geop_core_geometry/intersection/branch.rs
1//! The branch where two surfaces meet, followed from point to point: a
2//! corrector that brings a predicted point onto both surfaces at once
3//! ([`predictor_corrector_step`]), and a curve fitted through points of a
4//! branch that encloses the branch between them too ([`fit_branch`]).
5//!
6//! Where the branch runs and where it ends is the caller's to decide: the
7//! booleans march from a piercing point to the vertex they reach
8//! (`geop-lib-booleans`' `trace_one_side`); a shell knows both ends, the
9//! moved vertices, and the edge whose inner copy the branch is.
10
11use geop_core_math::{
12 geop_error::{GeopError, GeopResult, WithContext},
13 matrix::Matrix,
14 scalars::Scalar,
15 solvers::{
16 iterate::{Repeat, Step, iterate},
17 linear::solve_linear_system,
18 },
19 vector::{Vector, Vector3},
20 with_context,
21};
22
23use crate::{
24 nurb_curve::{NurbCurve, WidestBetween},
25 nurb_surface::NurbSurface3D,
26};
27
28/// Where a point of an intersection branch is on both surfaces:
29/// `(u_a, v_a, u_b, v_b)`.
30pub type OnBoth = (Scalar, Scalar, Scalar, Scalar);
31
32/// How many damped-Newton iterations the corrector may take to bring a
33/// predicted point onto *both* surfaces at once. Like [`NEWTON_ITERATIONS`]
34/// this bounds effort, not correctness: the loop exits early the moment the
35/// two surfaces' enclosures of the point overlap, and failing to converge
36/// within the budget is reported as an error rather than accepted.
37const CORRECTOR_ITERATIONS: usize = 20;
38
39/// Levenberg-Marquardt damping for the corrector's linear solve. Purely a
40/// conditioning term — it keeps the step finite where the 4x4 system is
41/// rank-deficient (the two surfaces locally parallel, or a parametric pole
42/// where one surface's `Su`/`Sv` collapse), at the cost of a shorter step
43/// that the next iteration simply continues. It cannot affect what a
44/// converged answer *means*: the `could_be_equal` test below is what
45/// decides whether the corrector actually landed on the curve.
46const CORRECTOR_DAMPING: f64 = 1e-12;
47
48/// One predictor-corrector step from `point` (with known `(u_a, v_a)` /
49/// `(u_b, v_b)` on `surf_a`/`surf_b`) along `dir`: predicts `point + dir *
50/// step_size`, then solves for the point that lies on *both* surfaces at
51/// once by damped Newton, returning the union of the two surfaces'
52/// enclosures of it plus each surface's own resulting `(u, v)`.
53///
54/// The corrector solves one system in the four unknowns `(u_a, v_a, u_b,
55/// v_b)` rather than projecting onto each surface separately. Separate
56/// projections each enforce their own surface's constraint while ignoring
57/// the other's, so they land a full prediction-error apart and stay there
58/// no matter how good the predictor is — and that error is not small: the
59/// marching point inherits the start vertex's own interval width (a vertex
60/// found by `curve_surface_intersect` is located only to within
61/// `min_subdivision_size`), which `sharpen` turns into a concrete offset.
62///
63/// Three of the four residuals are `S_a(u_a, v_a) - S_b(u_b, v_b) = 0`.
64/// Those alone are underdetermined by exactly one degree of freedom — the
65/// intersection curve is a curve, so sliding along it satisfies them
66/// equally well — and that free direction is what the damper has to pin
67/// down. It pins it *biased to the last direction*: the fourth residual
68/// holds the solution on the plane through the prediction with normal
69/// `dir`, so the corrector may move freely onto the curve but not drift
70/// along it, and the step stays the one the predictor asked for.
71///
72/// A step that would leave a patch is *not* rejected: `project` clamps into
73/// the domain, so such a step lands exactly on the patch boundary — which
74/// is precisely where an intersection curve ends, and therefore where the
75/// vertex it must connect to lives. Refusing it instead stops the march a
76/// full `step_size` short of that vertex, which is how a trace ends up
77/// never reaching anything. Deciding whether a direction is viable at all
78/// is `face_contains`' job on the first step (see a trace's), not
79/// something for every step to re-litigate via raw domain bounds.
80#[allow(clippy::too_many_arguments)]
81pub fn predictor_corrector_step(
82 surf_a: &NurbSurface3D,
83 surf_b: &NurbSurface3D,
84 point: Vector3,
85 dir: Vector3,
86 u_a: Scalar,
87 v_a: Scalar,
88 u_b: Scalar,
89 v_b: Scalar,
90 step_size: Scalar,
91) -> GeopResult<(Vector3, Scalar, Scalar, Scalar, Scalar)> {
92 let ctx = with_context!(
93 "predictor_corrector_step(point={point:?}, dir={dir:?}, u_a={u_a:?}, v_a={v_a:?}, u_b={u_b:?}, v_b={v_b:?}, step_size={step_size:?})"
94 );
95 // Sharpened exactly once, here. The prediction is a seed and a bias for
96 // the damped Newton below, free to be any point along the step, so
97 // collapsing it costs no accuracy — and it fixes the plane the corrector
98 // solves against. Nothing downstream of this line sharpens.
99 //
100 // The plane's normal `dir` is as free as its point: any plane crossing
101 // the curve pins the corrector's free direction equally well. Left wide,
102 // it is the one wide row of an otherwise sharp Jacobian — and where the
103 // two surfaces meet at a shallow angle, `dir` (a normalized cross product
104 // of nearly parallel normals, taken at the previous step's honest
105 // `(u, v)`) is wide by percents, enough to put zero inside a pivot of the
106 // nearly singular system and fail the solve.
107 let dir = dir.sharpen();
108 let target = point.add(&dir.prod_scalar(step_size)).sharpen();
109 let (u_a_lo, u_a_hi) = surf_a.domain_u();
110 let (v_a_lo, v_a_hi) = surf_a.domain_v();
111 let (u_b_lo, u_b_hi) = surf_b.domain_u();
112 let (v_b_lo, v_b_hi) = surf_b.domain_v();
113 let lambda = Scalar::from_f64(CORRECTOR_DAMPING);
114
115 // Two copies of the iterate ([`iterate`]): the sharp *seed* the next
116 // Newton step starts from, and the *answer*, that same value with the
117 // last update's width kept, which is what gets returned.
118 //
119 // Sharpening the seed is legitimate (any point inside it is an equally
120 // good place to start) and necessary (left unsharpened, 20 steps compound
121 // width until `evaluate` fails outright — measured at 36/175 scenes).
122 // Sharpening the *answer* is not: callers ask `face_contains` where this
123 // point is, and a sharp `(u, v)` sitting 6e-17 off a trim boundary reports
124 // `Inside` a face it is actually on the edge of, because `could_be_equal`
125 // has no width to work with. That produced traced curves running along a
126 // face's own boundary, spliced in as zero-area slivers.
127 //
128 // Seeding sharp while returning honest is what the wart needed: the
129 // returned width is that of a *single* step from a sharp seed — bounded
130 // per step rather than merely not discarded — so it states how well this
131 // iterate is pinned down without compounding.
132 //
133 // That holds for the first seed too. The `(u, v)` handed in are the
134 // previous step's honest answer, as wide as that step left them; used
135 // as the seed unsharpened, each step starts from the last one's width,
136 // carries it through the residual into its own, and the march compounds
137 // it step after step. On a tool whose corners are only known to 1e-12 —
138 // computed from a solid's own vertices — that reached a whole patch's
139 // width within a dozen steps (`cube_minus_box_with_wide_corners`).
140 let clamp = |[u_a, v_a, u_b, v_b]: [Scalar; 4]| {
141 [
142 u_a.clamped(u_a_lo, u_a_hi),
143 v_a.clamped(v_a_lo, v_a_hi),
144 u_b.clamped(u_b_lo, u_b_hi),
145 v_b.clamped(v_b_lo, v_b_hi),
146 ]
147 };
148 let sharp = |x: [Scalar; 4]| x.map(|s| s.sharpen());
149 let given = [u_a, v_a, u_b, v_b];
150 // Where the two surfaces were found to meet.
151 let mut met = None;
152 let corrected = iterate(
153 clamp(sharp(given)),
154 given,
155 CORRECTOR_ITERATIONS,
156 Repeat::Never,
157 // Each update is kept twice: honestly, and sharpened as the next
158 // seed — see above for why the two differ. Sharpen *before*
159 // clamping, as `NurbSurface::project` does: `clamped` cannot pull
160 // back an interval that merely straddles a domain bound, and
161 // collapsing such an interval to its midpoint can land just past
162 // the bound, where the next `evaluate` rejects it. On a sharp value
163 // `clamped`'s comparisons are exact, so the seed is always in domain.
164 |&next| {
165 let honest = clamp(next);
166 (clamp(sharp(honest)), honest)
167 },
168 |&[u_a1, v_a1, u_b1, v_b1]| -> GeopResult<Step<[Scalar; 4]>> {
169 let p_a = surf_a
170 .evaluate(u_a1, v_a1)
171 .with_context(&|e: GeopError| e.with_context("evaluate surf_a"))
172 .with_context(&ctx)?;
173 let p_b = surf_b
174 .evaluate(u_b1, v_b1)
175 .with_context(&|e: GeopError| e.with_context("evaluate surf_b"))
176 .with_context(&ctx)?;
177 // All *four* residuals have to be satisfied, not just the three
178 // that put the point on both surfaces. The corrector is handed
179 // the previous step's converged `(u, v)`, which already lies on
180 // both surfaces — testing only those three would report success
181 // without ever moving, leaving the march pinned at its first
182 // step forever. The fourth residual is what says the point has
183 // actually advanced to the plane the predictor stepped to.
184 let on_plane = dir.prod_dot(&p_a.sub(&target));
185 if p_a.could_be_equal(&p_b) && on_plane.could_be_equal(Scalar::ZERO) {
186 // The union of the two surfaces' enclosures, not their
187 // average: both enclose the *same* point on the
188 // intersection curve, so the smallest interval containing
189 // both is the honest answer. Averaging instead invents a
190 // sharp midpoint claiming more precision than either had —
191 // and hides how far apart they were, exactly the signal
192 // that the two surfaces have started to disagree about
193 // where the curve is.
194 met = Some(p_a.union(&p_b));
195 return Ok(Step::Done);
196 }
197
198 let (sau, sav) = surf_a
199 .derivatives(u_a1, v_a1)
200 .with_context(&|e: GeopError| e.with_context("derivatives of surf_a"))
201 .with_context(&ctx)?;
202 let (sbu, sbv) = surf_b
203 .derivatives(u_b1, v_b1)
204 .with_context(&|e: GeopError| e.with_context("derivatives of surf_b"))
205 .with_context(&ctx)?;
206
207 // Residuals: the two surfaces must meet (rows 0..3), and the
208 // meeting point must sit on the plane the predictor stepped to
209 // (row 3).
210 let delta = p_a.sub(&p_b);
211 let mut f = [Scalar::ZERO; 4];
212 let mut j = [[Scalar::ZERO; 4]; 4];
213 for k in 0..3 {
214 f[k] = delta[k];
215 j[k] = [sau[k], sav[k], sbu[k].neg(), sbv[k].neg()];
216 }
217 f[3] = on_plane;
218 j[3] = [
219 dir.prod_dot(&sau),
220 dir.prod_dot(&sav),
221 Scalar::ZERO,
222 Scalar::ZERO,
223 ];
224
225 // Damped normal equations `(J^T J + lambda I) d = -J^T f`. Going
226 // through `J^T J` rather than solving `J d = -f` directly is
227 // what lets the damping act at all, and keeps the step finite
228 // where `J` loses rank instead of erroring out of an otherwise
229 // fine trace.
230 //
231 // The matrix is sharpened: it only steers the step, and any
232 // matrix near the true Jacobian leads Newton to the same fixed
233 // point — the residual `f` alone decides where that is, so the
234 // step's honest width is the residual's, carried through a sharp
235 // preconditioner (as in Krawczyk's `Y = mid(J)^-1`). Left as
236 // intervals, the elimination amplifies the surfaces' own width by
237 // the system's condition, which `J^T J` squares: where the
238 // surfaces meet at a shallow angle, that put zero inside the last
239 // pivot of a solvable system.
240 let mut ata = [[Scalar::ZERO; 4]; 4];
241 let mut atf = [Scalar::ZERO; 4];
242 for r in 0..4 {
243 for c in 0..4 {
244 let mut sum = Scalar::ZERO;
245 for row in &j {
246 sum = sum.add(row[r].mul(row[c]));
247 }
248 ata[r][c] = if r == c { sum.add(lambda) } else { sum }.sharpen();
249 }
250 let mut sum = Scalar::ZERO;
251 for k in 0..4 {
252 sum = sum.add(j[k][r].mul(f[k]));
253 }
254 atf[r] = sum.neg();
255 }
256
257 let step = solve_linear_system(&Matrix::from_rows(ata), &Vector::from_array(atf))
258 .with_context(&ctx)?;
259 Ok(Step::Next([
260 u_a1.add(step[0]),
261 v_a1.add(step[1]),
262 u_b1.add(step[2]),
263 v_b1.add(step[3]),
264 ]))
265 },
266 )?;
267 if let Some(point) = met {
268 let [u_a, v_a, u_b, v_b] = corrected.answer;
269 return Ok((point, u_a, v_a, u_b, v_b));
270 }
271 let [u_a1, v_a1, u_b1, v_b1] = corrected.seed;
272
273 Err(ctx(GeopError::new(format!(
274 "the corrector did not bring the two surfaces onto a common point within {CORRECTOR_ITERATIONS} iterations: face_a lands at (u={u_a1:?}, v={v_a1:?}) and face_b at (u={u_b1:?}, v={v_b1:?}), which cannot be the same point — at least one of them has left its surface"
275 ))))
276}
277
278/// The curve of a traced branch whose shape nothing names: a cubic through
279/// the marched `points` (each on both surfaces at its `uvs`), widened to
280/// enclose the branch between them.
281///
282/// `widest_between` is told the widest point of the branch located between
283/// the marched ones — leg, fraction, point, width — of the last attempt to
284/// fit it: the curve is widened to hold it, so one that is wide makes the
285/// whole curve wide, and this says which.
286pub fn fit_branch(
287 surf_a: &NurbSurface3D,
288 surf_b: &NurbSurface3D,
289 points: &[Vector3],
290 uvs: &[OnBoth],
291 widest_between: &mut WidestBetween,
292 ctx: &dyn Fn(GeopError) -> GeopError,
293) -> GeopResult<NurbCurve<4>> {
294 // The edge's curve interpolates the marched points, and must
295 // enclose the intersection branch *between* them too — a later
296 // trace of the same branch from its other end tests its own
297 // points against this curve (the duplicate check in
298 // a trace's), and they lie on the branch, not on an
299 // interpolant.
300 //
301 // Located with the same corrector the march uses, at a fraction
302 // `frac` of the way along the leg from `from` (whose surface
303 // parameters are `(u_a, v_a, u_b, v_b)`) to `to`.
304 let on_branch = |from: Vector3,
305 (u_a, v_a, u_b, v_b): OnBoth,
306 to: Vector3,
307 frac: Scalar|
308 -> GeopResult<(Vector3, OnBoth)> {
309 let leg_ctx = with_context!("on_branch(from={from:?}, to={to:?}, frac={frac:?})");
310 let chord = to.sub(&from);
311 let step = chord.norm().mul(frac);
312 let (p, na, va, nb, vb) = predictor_corrector_step(
313 surf_a,
314 surf_b,
315 from,
316 chord.normalize().with_context(&leg_ctx)?,
317 u_a,
318 v_a,
319 u_b,
320 v_b,
321 step,
322 )
323 .with_context(&leg_ctx)?;
324 Ok((p, (na, va, nb, vb)))
325 };
326 // The march's stride leaves a cubic through its points drifting ~1e-5
327 // from the branch; a branch crossed in only a stride or two would be
328 // left with too few points for a cubic at all — a single stride,
329 // halved, is three, and a quadratic through three points of an ellipse
330 // drifts ~4e-6 from it. That drift is enclosed as width, honestly, but
331 // only in the directions it points in, and a pcurve fitted by
332 // projecting the curve onto a surface tilted against them inherits it
333 // as an offset its own width does not cover. And a branch whose
334 // curvature runs from gentle to tight along it — the involute flank of
335 // a gear tooth crossing a plane, its curvature radius growing tenfold
336 // from root to tip — leaves the cubic drifting a hundred times the
337 // usual. So the legs are split finer (see `fit_enclosing`).
338 let stations: Vec<(Vector3, OnBoth)> =
339 points.iter().copied().zip(uvs.iter().copied()).collect();
340 NurbCurve::<4>::fit_enclosing(
341 &stations,
342 |(from, on_both), (to, _), frac| on_branch(*from, *on_both, *to, frac),
343 widest_between,
344 ctx,
345 )
346}