Skip to main content

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}