Skip to main content

brep_kernel/intersect/
tangent_curve.rs

1//! TANGENT-CURVE LANE — march the curve along which two carriers TOUCH.
2//!
3//! Where two surfaces are tangent along a whole curve (a cylinder resting on
4//! a plane, a torus standing on a table, a sphere inscribed in its bore) the
5//! ordinary marcher has nothing to walk: its step direction `n_a × n_b`
6//! vanishes everywhere on the contact and its corrector, which solves
7//! `S_a = S_b` alone, is free to drift ACROSS the contact band — the
8//! surfaces agree to first order there, so the gap only grows with the SQUARE
9//! of the across-offset and a gap tolerance resolves nothing. The imprint
10//! therefore refuses such pairs by name (`csg/imprint/tangent_contact.rs`), or
11//! worse, an exact lane reports them "provably empty" and the contact is lost
12//! without a word.
13//!
14//! * **The direction.** Write both carriers as graphs over their common
15//!   tangent plane at a contact point. The height difference `g = f_a − f_b`
16//!   is stationary there and its Hessian is `H = II_a − II_b`, the difference
17//!   of the two second fundamental forms against ONE normal. Along a contact
18//!   curve the two surfaces agree to second order, so `H` is rank-deficient
19//!   and its null eigenvector IS the curve's tangent.
20//! * **The corrector.** Every station solves the CONTACT system
21//!   `{S_a = S_b, n_a × n_b = 0}` (five residuals, the two cross components in
22//!   `n_a`'s tangent basis) plus the section-plane constraint
23//!   `(S_a − predicted) · d = 0`, by damped Gauss–Newton. The cross-normal
24//!   residual is LINEAR in the across-offset where the gap is quadratic, so
25//!   the station is pinned onto the contact to the same precision an ordinary
26//!   station is pinned onto its curve.
27//! * **The step.** The reactive controller of the ordinary march (turn of the
28//!   direction between stations) under the §4.12 normal-turn ceiling read
29//!   along the null direction, a stop at a non-closed domain boundary of
30//!   either carrier, and closure when the trace returns to its start.
31//! * **The type.** The curve comes back as a [`SurfaceIntersectionCurve`]
32//!   with `tangent = true`, so a consumer can tell a crease that separates
33//!   nothing from a section that separates inside from outside.
34//!
35//! What this module does NOT decide: whether the contact lies inside the
36//! faces' trims, and what the boolean should DO with a tangent section (a
37//! tangent edge both of whose sides survive is a non-manifold incidence the
38//! assembler cannot represent, see `csg/boolean/mod.rs`'s pinch gate). Those
39//! are the imprint's and the boolean's questions; this lane only finds the
40//! curve and names it.
41
42use crate::curve::interior_knot_count;
43use crate::fit::solve_small;
44use crate::surface_surface_intersection::{SurfaceIntersectionCurve, SurfaceIntersectionOptions};
45use crate::{project_point_to_surface, NurbsSurface, Vec3};
46
47const EPSILON: f64 = 1e-12;
48
49/// Smallest `min|λ| / κ` of `H = II_a − II_b` at which the contact counts as
50/// an isolated node or point rather than EXTENDED along `H`'s null direction.
51/// The same number `csg/imprint/tangent_contact.rs` reads: genuine nodes
52/// measure `0.7..1.0`, extended contacts `1e-15` and below, so this is a rank
53/// test, not a tolerance band.
54pub const EXTENDED_RANK_FLOOR: f64 = 1e-6;
55
56/// Gauss–Newton iterations refining a witness onto the contact.
57const REFINE_ITERATIONS: usize = 60;
58/// Corrector iterations per station.
59const CORRECT_ITERATIONS: usize = 30;
60/// Relative residual improvement that counts as progress, and the number of
61/// consecutive iterations without it that end a refinement.
62const RESIDUAL_PROGRESS: f64 = 1e-3;
63const REFINE_PATIENCE: usize = 4;
64/// Levenberg diagonal relative to the largest normal-equation diagonal: large
65/// enough to clear the solver's pivot floor on the rank-deficient system an
66/// extended contact produces, small enough to leave a well-conditioned step
67/// unchanged to nine figures.
68const LEVENBERG_DAMPING: f64 = 1e-10;
69/// Relative finite-difference step for the Jacobian, in each parameter span.
70const JACOBIAN_STEP: f64 = 1e-7;
71/// Direction turn across a step that pops the step (re-stepped at half), the
72/// turn under which the step grows, and the normal-turn budget per step —
73/// the ordinary march's values.
74const TURN_POP: f64 = 0.35;
75const TURN_GROW: f64 = 0.03;
76const TURN_SHRINK: f64 = 0.15;
77const NORMAL_TURN_BUDGET: f64 = 0.1;
78/// Reach floor of the trace in carrier extents, as the ordinary march's.
79const MARCH_REACH_EXTENTS: f64 = 4.0;
80/// Distance gate (fraction of the carriers' extent) inside which a grid sample
81/// is a contact candidate at all.
82const CANDIDATE_GATE_FRACTION: f64 = 1e-2;
83/// How many ranked grid samples the detection tries to refine before giving up.
84const CANDIDATE_ATTEMPTS: usize = 4;
85
86#[derive(Clone, Copy)]
87struct Domain {
88    u0: f64,
89    u1: f64,
90    v0: f64,
91    v1: f64,
92    closed_u: bool,
93    closed_v: bool,
94}
95
96impl Domain {
97    fn of(surface: &NurbsSurface) -> Result<Self, String> {
98        let [u0, u1] = surface.domain_u()?;
99        let [v0, v1] = surface.domain_v()?;
100        let mut closed_u = true;
101        let mut closed_v = true;
102        for fraction in [0.17, 0.5, 0.83] {
103            let v = v0 + (v1 - v0) * fraction;
104            if surface.evaluate(u0, v)?.sub(surface.evaluate(u1, v)?).length() > 1e-6 {
105                closed_u = false;
106            }
107            let u = u0 + (u1 - u0) * fraction;
108            if surface.evaluate(u, v0)?.sub(surface.evaluate(u, v1)?).length() > 1e-6 {
109                closed_v = false;
110            }
111        }
112        Ok(Self { u0, u1, v0, v1, closed_u, closed_v })
113    }
114
115    fn spans(self) -> [f64; 2] {
116        [self.u1 - self.u0, self.v1 - self.v0]
117    }
118
119    /// `value` brought into the domain: wrapped when closed, clamped
120    /// otherwise. The flag says whether a clamp actually moved it.
121    fn fit(self, value: f64, direction_u: bool) -> (f64, bool) {
122        let (minimum, maximum, closed) = if direction_u {
123            (self.u0, self.u1, self.closed_u)
124        } else {
125            (self.v0, self.v1, self.closed_v)
126        };
127        if closed {
128            ((value - minimum).rem_euclid(maximum - minimum) + minimum, false)
129        } else {
130            let fitted = value.clamp(minimum, maximum);
131            (fitted, fitted != value)
132        }
133    }
134}
135
136/// One point of the contact on both carriers.
137#[derive(Clone, Copy, Debug)]
138struct Station {
139    ua: f64,
140    va: f64,
141    ub: f64,
142    vb: f64,
143    point: Vec3,
144}
145
146impl Station {
147    fn parameter(&self, index: usize) -> f64 {
148        match index {
149            0 => self.ua,
150            1 => self.va,
151            2 => self.ub,
152            _ => self.vb,
153        }
154    }
155
156    fn with_parameter(mut self, index: usize, value: f64) -> Self {
157        match index {
158            0 => self.ua = value,
159            1 => self.va = value,
160            2 => self.ub = value,
161            _ => self.vb = value,
162        }
163        self
164    }
165}
166
167/// The tangent-plane frame at a station: `n_a`, `n_b`, and an orthonormal
168/// basis of `n_a`'s tangent plane in which the cross-normal residual and the
169/// fundamental forms are written.
170#[derive(Clone, Copy)]
171struct Frame {
172    normal_a: Vec3,
173    normal_b: Vec3,
174    e1: Vec3,
175    e2: Vec3,
176}
177
178fn frame_at(first: &NurbsSurface, second: &NurbsSurface, station: Station) -> Option<Frame> {
179    let normal_a = first.normal(station.ua, station.va).ok()?;
180    let normal_b = second.normal(station.ub, station.vb).ok()?;
181    let e1 = normal_a.perpendicular().ok()?;
182    let e2 = normal_a.cross(e1).normalized().ok()?;
183    Some(Frame { normal_a, normal_b, e1, e2 })
184}
185
186/// The five contact residuals at `station`, the cross-normal components in
187/// the FIXED basis `(e1, e2)` so a finite difference across the station reads
188/// one smooth function and not a basis choice.
189fn contact_residual(
190    first: &NurbsSurface,
191    second: &NurbsSurface,
192    station: Station,
193    e1: Vec3,
194    e2: Vec3,
195) -> Option<[f64; 5]> {
196    let point_a = first.evaluate(station.ua, station.va).ok()?;
197    let point_b = second.evaluate(station.ub, station.vb).ok()?;
198    let normal_a = first.normal(station.ua, station.va).ok()?;
199    let normal_b = second.normal(station.ub, station.vb).ok()?;
200    let cross = normal_a.cross(normal_b);
201    let gap = point_a.sub(point_b);
202    Some([gap.x, gap.y, gap.z, cross.dot(e1), cross.dot(e2)])
203}
204
205/// One damped Gauss–Newton step for the `free` parameters of a station,
206/// from a residual vector and its finite-difference Jacobian.
207fn gauss_newton_step<const R: usize>(
208    residual: [f64; R],
209    jacobian: [[f64; 4]; R],
210    free: [bool; 4],
211) -> Option<[f64; 4]> {
212    let mut matrix = [[0.0f64; 4]; 4];
213    let mut rhs = [0.0f64; 4];
214    for row in 0..4 {
215        if !free[row] {
216            // A frozen parameter solves to zero: unit diagonal, zero right side.
217            matrix[row][row] = 1.0;
218            continue;
219        }
220        for column in 0..4 {
221            if !free[column] {
222                continue;
223            }
224            matrix[row][column] = (0..R).map(|k| jacobian[k][row] * jacobian[k][column]).sum();
225        }
226        rhs[row] = -(0..R).map(|k| jacobian[k][row] * residual[k]).sum::<f64>();
227    }
228    let diagonal = (0..4)
229        .filter(|&index| free[index])
230        .fold(0.0f64, |largest, index| largest.max(matrix[index][index]));
231    if diagonal <= 0.0 {
232        return None;
233    }
234    for index in 0..4 {
235        if free[index] {
236            matrix[index][index] += LEVENBERG_DAMPING * diagonal;
237        }
238    }
239    solve_small(matrix, rhs, 4).ok()
240}
241
242/// Finite-difference Jacobian of `residual_at` over the four parameters, each
243/// stepped by `JACOBIAN_STEP` of its own span; frozen parameters get a zero
244/// column.
245fn jacobian_of<const R: usize>(
246    station: Station,
247    spans: [f64; 4],
248    free: [bool; 4],
249    fit: impl Fn(Station) -> Station,
250    residual_at: impl Fn(Station) -> Option<[f64; R]>,
251) -> Option<[[f64; 4]; R]> {
252    let mut jacobian = [[0.0f64; 4]; R];
253    for index in 0..4 {
254        if !free[index] {
255            continue;
256        }
257        let step = spans[index] * JACOBIAN_STEP;
258        if step <= 0.0 {
259            return None;
260        }
261        let plus = residual_at(fit(station.with_parameter(index, station.parameter(index) + step)))?;
262        let minus = residual_at(fit(station.with_parameter(index, station.parameter(index) - step)))?;
263        for row in 0..R {
264            jacobian[row][index] = (plus[row] - minus[row]) / (2.0 * step);
265        }
266    }
267    Some(jacobian)
268}
269
270/// Refine `seed` onto `{S_a = S_b, n_a × n_b = 0}`: Gauss–Newton on the
271/// overdetermined 5×4 system, iterated to round-off because the caller reads a
272/// RANK off the result and an extended contact stopped a few microns short of
273/// the curve reads as a node (`csg/imprint/tangent_contact.rs` explains the
274/// one-sided error). Returns the station and the cross-normal residual it
275/// reached; `None` when the seed is not near any contact.
276fn refine_contact(
277    first: &NurbsSurface,
278    second: &NurbsSurface,
279    domain_a: Domain,
280    domain_b: Domain,
281    seed: Station,
282    tolerance: f64,
283) -> Result<Option<(Station, f64)>, String> {
284    let spans = [domain_a.spans()[0], domain_a.spans()[1], domain_b.spans()[0], domain_b.spans()[1]];
285    let fit = |station: Station| Station {
286        ua: domain_a.fit(station.ua, true).0,
287        va: domain_a.fit(station.va, false).0,
288        ub: domain_b.fit(station.ub, true).0,
289        vb: domain_b.fit(station.vb, false).0,
290        point: station.point,
291    };
292    let free = [true; 4];
293    let mut current = fit(seed);
294    let mut best = current;
295    let mut best_residual = f64::INFINITY;
296    let mut best_tangency = f64::INFINITY;
297    let mut without_progress = 0usize;
298    for _ in 0..REFINE_ITERATIONS {
299        let Some(frame) = frame_at(first, second, current) else {
300            return Ok(None);
301        };
302        let residual_at = |station: Station| contact_residual(first, second, station, frame.e1, frame.e2);
303        let Some(residual) = residual_at(current) else {
304            return Ok(None);
305        };
306        let gap = (residual[0] * residual[0] + residual[1] * residual[1] + residual[2] * residual[2]).sqrt();
307        let tangency = (residual[3] * residual[3] + residual[4] * residual[4]).sqrt();
308        let worst = gap.max(tangency);
309        if worst < best_residual * (1.0 - RESIDUAL_PROGRESS) {
310            without_progress = 0;
311        } else {
312            without_progress += 1;
313        }
314        if worst < best_residual {
315            best_residual = worst;
316            best_tangency = tangency;
317            best = current;
318        }
319        if worst == 0.0 || without_progress >= REFINE_PATIENCE {
320            break;
321        }
322        let Some(jacobian) = jacobian_of::<5>(current, spans, free, fit, residual_at) else {
323            return Ok(None);
324        };
325        let Some(changes) = gauss_newton_step(residual, jacobian, free) else {
326            return Ok(None);
327        };
328        // Never more than a quarter of a span at once: near a tangency the
329        // system is ill-conditioned by construction and an unbounded step
330        // lands on an unrelated sheet.
331        let mut scale = 1.0f64;
332        for index in 0..4 {
333            let limit = spans[index] / 4.0;
334            if changes[index].abs() > limit {
335                scale = scale.min(limit / changes[index].abs());
336            }
337        }
338        let next = fit(Station {
339            ua: current.ua + changes[0] * scale,
340            va: current.va + changes[1] * scale,
341            ub: current.ub + changes[2] * scale,
342            vb: current.vb + changes[3] * scale,
343            point: current.point,
344        });
345        let moved = (0..4)
346            .map(|index| (next.parameter(index) - current.parameter(index)).abs() / spans[index])
347            .fold(0.0f64, f64::max);
348        current = next;
349        if moved <= 1e-12 {
350            break;
351        }
352    }
353    if best_residual > tolerance {
354        return Ok(None);
355    }
356    let point_a = first.evaluate(best.ua, best.va)?;
357    let point_b = second.evaluate(best.ub, best.vb)?;
358    best.point = point_a.add(point_b).scale(0.5);
359    Ok(Some((best, best_tangency)))
360}
361
362/// The second fundamental form of `surface` at `(u, v)` in the orthonormal
363/// tangent basis `(e1, e2)`, measured against `normal`.
364fn second_fundamental_form(
365    surface: &NurbsSurface,
366    u: f64,
367    v: f64,
368    e1: Vec3,
369    e2: Vec3,
370    normal: Vec3,
371) -> Option<[[f64; 2]; 2]> {
372    let derivatives = surface.derivatives(u, v, 2).ok()?;
373    let s_u = derivatives[1][0];
374    let s_v = derivatives[0][1];
375    let s_uu = derivatives[2][0];
376    let s_uv = derivatives[1][1];
377    let s_vv = derivatives[0][2];
378    let a11 = s_u.dot(s_u);
379    let a12 = s_u.dot(s_v);
380    let a22 = s_v.dot(s_v);
381    let determinant = a11 * a22 - a12 * a12;
382    if determinant.abs() <= f64::MIN_POSITIVE {
383        return None;
384    }
385    let coefficients = |target: Vec3| -> (f64, f64) {
386        let b1 = s_u.dot(target);
387        let b2 = s_v.dot(target);
388        ((b1 * a22 - b2 * a12) / determinant, (a11 * b2 - a12 * b1) / determinant)
389    };
390    let along_1 = coefficients(e1);
391    let along_2 = coefficients(e2);
392    let l = s_uu.dot(normal);
393    let m = s_uv.dot(normal);
394    let n = s_vv.dot(normal);
395    let form = |x: (f64, f64), y: (f64, f64)| l * x.0 * y.0 + m * (x.0 * y.1 + x.1 * y.0) + n * x.1 * y.1;
396    Some([
397        [form(along_1, along_1), form(along_1, along_2)],
398        [form(along_2, along_1), form(along_2, along_2)],
399    ])
400}
401
402fn eigenvalues(matrix: [[f64; 2]; 2]) -> (f64, f64) {
403    let trace = matrix[0][0] + matrix[1][1];
404    let determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
405    let discriminant = (trace * trace / 4.0 - determinant).max(0.0).sqrt();
406    (trace / 2.0 + discriminant, trace / 2.0 - discriminant)
407}
408
409fn spectral_norm(matrix: [[f64; 2]; 2]) -> f64 {
410    let (first, second) = eigenvalues(matrix);
411    first.abs().max(second.abs())
412}
413
414/// Unit eigenvector of a symmetric 2×2 matrix for the eigenvalue `lambda`.
415fn eigenvector(matrix: [[f64; 2]; 2], lambda: f64) -> [f64; 2] {
416    let candidates = [
417        [matrix[0][1], lambda - matrix[0][0]],
418        [lambda - matrix[1][1], matrix[0][1]],
419    ];
420    let mut best = [1.0, 0.0];
421    let mut best_length = 0.0;
422    for candidate in candidates {
423        let length = (candidate[0] * candidate[0] + candidate[1] * candidate[1]).sqrt();
424        if length > best_length {
425            best_length = length;
426            best = [candidate[0] / length, candidate[1] / length];
427        }
428    }
429    if best_length == 0.0 {
430        // The matrix is a multiple of the identity: every direction is an
431        // eigenvector, and none is a null direction unless the multiple is 0.
432        return [1.0, 0.0];
433    }
434    best
435}
436
437/// The local structure of the contact at a station: the eigenvalues of
438/// `H = II_a − II_b`, the rank verdict and the null direction in 3D.
439#[derive(Clone, Copy, Debug)]
440struct HeightDifference {
441    rank_ratio: f64,
442    curvature_scale: f64,
443    saddle: bool,
444    /// The null eigenvector in 3D, meaningful only where `rank_ratio` is
445    /// under [`EXTENDED_RANK_FLOOR`].
446    null_direction: Vec3,
447    /// `|II_a(d, d)|` and `|II_b(d, d)|` along the null direction — the
448    /// normal curvatures the §4.12 ceiling reads.
449    normal_curvature_along: f64,
450}
451
452fn height_difference(
453    first: &NurbsSurface,
454    second: &NurbsSurface,
455    station: Station,
456    frame: Frame,
457) -> Option<HeightDifference> {
458    // Both forms against the SAME normal, or their difference is the SUM of
459    // the curvatures and every contact reads as definite.
460    let orientation = if frame.normal_a.dot(frame.normal_b) < 0.0 { -1.0 } else { 1.0 };
461    let form_a = second_fundamental_form(first, station.ua, station.va, frame.e1, frame.e2, frame.normal_a)?;
462    let form_b = second_fundamental_form(
463        second,
464        station.ub,
465        station.vb,
466        frame.e1,
467        frame.e2,
468        frame.normal_b.scale(orientation),
469    )?;
470    let mut difference = [[0.0f64; 2]; 2];
471    for row in 0..2 {
472        for column in 0..2 {
473            difference[row][column] = form_a[row][column] - form_b[row][column];
474        }
475    }
476    let curvature_scale = spectral_norm(form_a).max(spectral_norm(form_b));
477    if curvature_scale <= 0.0 {
478        // Two planes: a 2-D coincidence, which the cosurface path owns.
479        return None;
480    }
481    let (lambda_first, lambda_second) = eigenvalues(difference);
482    let (smallest, largest) = if lambda_first.abs() <= lambda_second.abs() {
483        (lambda_first, lambda_second)
484    } else {
485        (lambda_second, lambda_first)
486    };
487    let _ = largest;
488    let [x, y] = eigenvector(difference, smallest);
489    let null_direction = frame.e1.scale(x).add(frame.e2.scale(y)).normalized().ok()?;
490    let along = |form: [[f64; 2]; 2]| form[0][0] * x * x + 2.0 * form[0][1] * x * y + form[1][1] * y * y;
491    Some(HeightDifference {
492        rank_ratio: smallest.abs() / curvature_scale,
493        curvature_scale,
494        saddle: lambda_first * lambda_second < 0.0,
495        null_direction,
496        normal_curvature_along: along(form_a).abs().max(along(form_b).abs()),
497    })
498}
499
500/// A tangential contact between two carriers, refined onto the contact and
501/// classified by the rank of the height-difference Hessian there.
502#[derive(Clone, Copy, Debug)]
503pub struct TangentContact {
504    /// The refined contact point.
505    pub point: Vec3,
506    /// Its parameters on the first and second carrier.
507    pub params_a: [f64; 2],
508    pub params_b: [f64; 2],
509    /// `min|λ| / κ` of `H = II_a − II_b`: under [`EXTENDED_RANK_FLOOR`] the
510    /// contact extends along a curve.
511    pub rank_ratio: f64,
512    /// The local curvature scale `κ` the ratio is read against.
513    pub curvature_scale: f64,
514    /// `|n_a × n_b|` the refinement actually reached.
515    pub tangency_residual: f64,
516    /// The two eigenvalues have opposite signs: two transverse branches cross
517    /// here (an isolated node), which the ordinary march owns.
518    pub saddle: bool,
519    /// The contact curve's unit tangent at `point`, present only for an
520    /// extended contact.
521    pub direction: Option<Vec3>,
522}
523
524impl TangentContact {
525    /// The carriers touch along a CURVE through this point.
526    pub fn is_extended(&self) -> bool {
527        self.direction.is_some()
528    }
529}
530
531fn classify_station(
532    first: &NurbsSurface,
533    second: &NurbsSurface,
534    station: Station,
535    tangency_residual: f64,
536) -> Option<TangentContact> {
537    let frame = frame_at(first, second, station)?;
538    let difference = height_difference(first, second, station, frame)?;
539    let extended = difference.rank_ratio < EXTENDED_RANK_FLOOR;
540    Some(TangentContact {
541        point: station.point,
542        params_a: [station.ua, station.va],
543        params_b: [station.ub, station.vb],
544        rank_ratio: difference.rank_ratio,
545        curvature_scale: difference.curvature_scale,
546        tangency_residual,
547        saddle: difference.saddle,
548        direction: extended.then_some(difference.null_direction),
549    })
550}
551
552/// Refine `witness` onto the tangential contact of the two carriers and
553/// classify it. `None` when the witness is not near any contact (the
554/// refinement leaves a residual above `tolerance`) or the carriers have no
555/// curvature to classify by (two planes).
556pub fn classify_tangent_contact_at(
557    first: &NurbsSurface,
558    second: &NurbsSurface,
559    witness: Vec3,
560    tolerance: f64,
561) -> Result<Option<TangentContact>, String> {
562    let domain_a = Domain::of(first)?;
563    let domain_b = Domain::of(second)?;
564    let on_a = project_point_to_surface(first, witness)?;
565    let on_b = project_point_to_surface(second, witness)?;
566    let seed = Station { ua: on_a.u, va: on_a.v, ub: on_b.u, vb: on_b.v, point: witness };
567    let Some((station, tangency)) = refine_contact(first, second, domain_a, domain_b, seed, tolerance)? else {
568        return Ok(None);
569    };
570    Ok(classify_station(first, second, station, tangency))
571}
572
573fn extent(surface: &NurbsSurface) -> Result<f64, String> {
574    let mut minimum = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
575    let mut maximum = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
576    for point in surface.control_points.iter().flatten() {
577        let point = point.point()?;
578        minimum = Vec3::new(minimum.x.min(point.x), minimum.y.min(point.y), minimum.z.min(point.z));
579        maximum = Vec3::new(maximum.x.max(point.x), maximum.y.max(point.y), maximum.z.max(point.z));
580    }
581    Ok(maximum.sub(minimum).length())
582}
583
584fn grid_count(surface: &NurbsSurface, direction_u: bool) -> usize {
585    let (knots, degree) = if direction_u {
586        (&surface.knots_u, surface.degree_u)
587    } else {
588        (&surface.knots_v, surface.degree_v)
589    };
590    (8.0_f64)
591        .max(((interior_knot_count(knots, degree) + 1) * (degree + 1)) as f64)
592        .ceil()
593        .min(24.0) as usize
594}
595
596/// Find a tangential contact between two carriers without a witness: grid the
597/// first carrier, project each sample onto the second, rank the samples by
598/// their distance and the angle between the normals, and refine the best few
599/// onto the contact. The grid is then repeated from the second carrier, so a
600/// contact one carrier samples poorly is still reached from the other.
601///
602/// `None` means no sample refines onto a contact — the carriers do not touch
603/// tangentially anywhere the grids see.
604pub fn find_tangent_contact(
605    first: &NurbsSurface,
606    second: &NurbsSurface,
607    tolerance: f64,
608) -> Result<Option<TangentContact>, String> {
609    let diagonal = extent(first)?.min(extent(second)?);
610    let gate = CANDIDATE_GATE_FRACTION * diagonal + tolerance * 100.0;
611    for swapped in [false, true] {
612        let (grid_surface, other) = if swapped { (second, first) } else { (first, second) };
613        let domain_grid = Domain::of(grid_surface)?;
614        let nu = grid_count(grid_surface, true);
615        let nv = grid_count(grid_surface, false);
616        let mut candidates: Vec<(f64, Station)> = Vec::new();
617        for i in 0..=nu {
618            for j in 0..=nv {
619                let u = domain_grid.u0 + (domain_grid.u1 - domain_grid.u0) * i as f64 / nu as f64;
620                let v = domain_grid.v0 + (domain_grid.v1 - domain_grid.v0) * j as f64 / nv as f64;
621                let point = grid_surface.evaluate(u, v)?;
622                let projection = project_point_to_surface(other, point)?;
623                if projection.distance > gate {
624                    continue;
625                }
626                let (Ok(normal_grid), Ok(normal_other)) =
627                    (grid_surface.normal(u, v), other.normal(projection.u, projection.v))
628                else {
629                    continue;
630                };
631                let score = projection.distance / diagonal.max(EPSILON) + normal_grid.cross(normal_other).length();
632                let station = if swapped {
633                    Station { ua: projection.u, va: projection.v, ub: u, vb: v, point }
634                } else {
635                    Station { ua: u, va: v, ub: projection.u, vb: projection.v, point }
636                };
637                candidates.push((score, station));
638            }
639        }
640        candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
641        let domain_a = Domain::of(first)?;
642        let domain_b = Domain::of(second)?;
643        for (_, seed) in candidates.into_iter().take(CANDIDATE_ATTEMPTS) {
644            if let Some((station, tangency)) = refine_contact(first, second, domain_a, domain_b, seed, tolerance)? {
645                if let Some(contact) = classify_station(first, second, station, tangency) {
646                    return Ok(Some(contact));
647                }
648            }
649        }
650    }
651    Ok(None)
652}
653
654/// Correct the predicted station onto the contact curve inside the section
655/// plane through `predicted` normal to `direction`. A parameter that clamps at
656/// a non-closed domain end is frozen there and the plane constraint is
657/// dropped — the boundary is then what pins the station along the curve — and
658/// the flag reports that the trace has reached a boundary.
659fn correct(
660    first: &NurbsSurface,
661    second: &NurbsSurface,
662    domain_a: Domain,
663    domain_b: Domain,
664    from: Station,
665    predicted: Vec3,
666    direction: Vec3,
667    tolerance: f64,
668) -> Result<Option<(Station, bool)>, String> {
669    let spans = [domain_a.spans()[0], domain_a.spans()[1], domain_b.spans()[0], domain_b.spans()[1]];
670    let fit = |station: Station| Station {
671        ua: domain_a.fit(station.ua, true).0,
672        va: domain_a.fit(station.va, false).0,
673        ub: domain_b.fit(station.ub, true).0,
674        vb: domain_b.fit(station.vb, false).0,
675        point: station.point,
676    };
677    let bar = tolerance * 0.01 + EPSILON;
678    let mut current = from;
679    let mut free = [true; 4];
680    let mut boundary = false;
681    for _ in 0..CORRECT_ITERATIONS {
682        let Some(frame) = frame_at(first, second, current) else {
683            return Ok(None);
684        };
685        let residual_at = |station: Station| -> Option<[f64; 6]> {
686            let contact = contact_residual(first, second, station, frame.e1, frame.e2)?;
687            let point_a = first.evaluate(station.ua, station.va).ok()?;
688            let plane = if boundary { 0.0 } else { point_a.sub(predicted).dot(direction) };
689            Some([contact[0], contact[1], contact[2], contact[3], contact[4], plane])
690        };
691        let Some(residual) = residual_at(current) else {
692            return Ok(None);
693        };
694        let gap = (residual[0] * residual[0] + residual[1] * residual[1] + residual[2] * residual[2]).sqrt();
695        let tangency = (residual[3] * residual[3] + residual[4] * residual[4]).sqrt();
696        if gap <= bar && tangency <= bar && residual[5].abs() <= bar {
697            let point_a = first.evaluate(current.ua, current.va)?;
698            let point_b = second.evaluate(current.ub, current.vb)?;
699            current.point = point_a.add(point_b).scale(0.5);
700            return Ok(Some((current, boundary)));
701        }
702        let Some(jacobian) = jacobian_of::<6>(current, spans, free, fit, residual_at) else {
703            return Ok(None);
704        };
705        let Some(changes) = gauss_newton_step(residual, jacobian, free) else {
706            return Ok(None);
707        };
708        let mut scale = 1.0f64;
709        for index in 0..4 {
710            let limit = spans[index] / 4.0;
711            if changes[index].abs() > limit {
712                scale = scale.min(limit / changes[index].abs());
713            }
714        }
715        let mut next = current;
716        let mut moved = 0.0f64;
717        for index in 0..4 {
718            if !free[index] {
719                continue;
720            }
721            let raw = current.parameter(index) + changes[index] * scale;
722            let (fitted, clamped) = match index {
723                0 => domain_a.fit(raw, true),
724                1 => domain_a.fit(raw, false),
725                2 => domain_b.fit(raw, true),
726                _ => domain_b.fit(raw, false),
727            };
728            if clamped {
729                free[index] = false;
730                boundary = true;
731            }
732            moved = moved.max((fitted - current.parameter(index)).abs() / spans[index]);
733            next = next.with_parameter(index, fitted);
734        }
735        current = next;
736        if !free.iter().any(|&f| f) {
737            return Ok(None);
738        }
739        if moved <= 1e-14 {
740            return Ok(None);
741        }
742    }
743    Ok(None)
744}
745
746struct Trace {
747    stations: Vec<Station>,
748    tangents: Vec<Vec3>,
749    closed: bool,
750}
751
752fn vector_angle(a: Vec3, b: Vec3) -> f64 {
753    (a.dot(b) / (a.length() * b.length())).clamp(-1.0, 1.0).acos()
754}
755
756/// §4.12 ceiling along the contact direction: the advance that turns either
757/// carrier's normal by [`NORMAL_TURN_BUDGET`]; `None` where neither carrier
758/// curves along the direction.
759fn normal_turn_ceiling(difference: &HeightDifference) -> Option<f64> {
760    (difference.normal_curvature_along > 1e-12).then(|| NORMAL_TURN_BUDGET / difference.normal_curvature_along)
761}
762
763#[allow(clippy::too_many_arguments)]
764fn trace(
765    first: &NurbsSurface,
766    second: &NurbsSurface,
767    domain_a: Domain,
768    domain_b: Domain,
769    start: Station,
770    start_direction: Vec3,
771    tolerance: f64,
772    initial_step: f64,
773    minimum_step: f64,
774    maximum_step: f64,
775    maximum_steps: usize,
776) -> Result<Trace, String> {
777    let mut stations = vec![start];
778    let mut tangents = vec![start_direction];
779    let mut current = start;
780    let mut direction = start_direction;
781    let mut step_size = initial_step.min(maximum_step);
782    if let Some(frame) = frame_at(first, second, start) {
783        if let Some(difference) = height_difference(first, second, start, frame) {
784            if let Some(ceiling) = normal_turn_ceiling(&difference) {
785                step_size = step_size.min(ceiling).clamp(minimum_step, maximum_step);
786            }
787        }
788    }
789    let debug = std::env::var_os("BREP_DEBUG_SSI_TRACE").is_some();
790    let mut terminated = false;
791    for _ in 0..maximum_steps {
792        let mut accepted = None;
793        while step_size >= minimum_step {
794            let predicted = current.point.add(direction.scale(step_size));
795            if let Some((corrected, boundary)) =
796                correct(first, second, domain_a, domain_b, current, predicted, direction, tolerance)?
797            {
798                if corrected.point.sub(current.point).length() <= 2.5 * step_size {
799                    accepted = Some((corrected, boundary));
800                    break;
801                }
802            }
803            step_size /= 2.0;
804        }
805        let Some((candidate, boundary)) = accepted else {
806            terminated = true;
807            break;
808        };
809        let next = frame_at(first, second, candidate)
810            .and_then(|frame| height_difference(first, second, candidate, frame));
811        let next_direction = match next {
812            Some(difference) if difference.rank_ratio < EXTENDED_RANK_FLOOR => {
813                let null = difference.null_direction;
814                Some((if null.dot(direction) < 0.0 { null.scale(-1.0) } else { null }, difference))
815            }
816            _ => None,
817        };
818        if debug {
819            let at = candidate.point;
820            eprintln!(
821                "TANGENT-TRACE step {step_size:.4e} jump {:.4e} boundary {boundary} extended {} p ({:.6},{:.6},{:.6})",
822                at.sub(current.point).length(),
823                next_direction.is_some(),
824                at.x,
825                at.y,
826                at.z
827            );
828        }
829        let Some((next_direction, difference)) = next_direction else {
830            stations.push(candidate);
831            tangents.push(direction);
832            terminated = true;
833            break;
834        };
835        if boundary {
836            stations.push(candidate);
837            tangents.push(next_direction);
838            terminated = true;
839            break;
840        }
841        let angle = vector_angle(direction, next_direction);
842        if angle > TURN_POP && step_size > minimum_step * 1.01 {
843            step_size = minimum_step.max(step_size / 2.0);
844            continue;
845        }
846        stations.push(candidate);
847        tangents.push(next_direction);
848        let reactive = if angle < TURN_GROW {
849            step_size * 1.5
850        } else if angle > TURN_SHRINK {
851            step_size * 0.6
852        } else {
853            step_size
854        };
855        step_size = match normal_turn_ceiling(&difference) {
856            Some(ceiling) => reactive.min(ceiling),
857            None => reactive,
858        }
859        .clamp(minimum_step, maximum_step);
860        if stations.len() > 4 && candidate.point.sub(start.point).length() <= step_size * 1.2 {
861            let to_start = start.point.sub(candidate.point);
862            if to_start.length() <= EPSILON || to_start.dot(next_direction) >= 0.0 {
863                stations.push(start);
864                tangents.push(tangents[0]);
865                return Ok(Trace { stations, tangents, closed: true });
866            }
867        }
868        current = candidate;
869        direction = next_direction;
870    }
871    if !terminated {
872        return Err(format!(
873            "tangent-curve trace exhausted {maximum_steps} steps ({} stations, step {step_size:.3e}) \
874             without reaching a boundary or closure",
875            stations.len()
876        ));
877    }
878    Ok(Trace { stations, tangents, closed: false })
879}
880
881/// March the tangent curve through an EXTENDED contact, both ways from the
882/// contact point, and return it typed as a tangent section. `None` when the
883/// contact is not extended (an isolated node or point has no curve to march).
884///
885/// Step sizes are the ordinary march's (`initial = extent / 100`,
886/// `maximum = extent / 15` or the caller's ceiling, `minimum = max(100 tol,
887/// 1e-6 extent)`), with the corrector run at `options.tolerance`.
888pub fn trace_tangent_curve(
889    first: &NurbsSurface,
890    second: &NurbsSurface,
891    contact: &TangentContact,
892    options: &SurfaceIntersectionOptions,
893) -> Result<Option<SurfaceIntersectionCurve>, String> {
894    let Some(direction) = contact.direction else {
895        return Ok(None);
896    };
897    let domain_a = Domain::of(first)?;
898    let domain_b = Domain::of(second)?;
899    let diagonal = extent(first)?.min(extent(second)?);
900    let initial_step = diagonal / 100.0;
901    let minimum_step = (options.tolerance * 100.0).max(diagonal * 1e-6);
902    let maximum_step = (diagonal / 15.0).min(options.maximum_step.unwrap_or(f64::INFINITY));
903    let needed = (MARCH_REACH_EXTENTS * diagonal / maximum_step).ceil();
904    let maximum_steps = if needed.is_finite() && needed > options.maximum_steps as f64 {
905        needed as usize
906    } else {
907        options.maximum_steps
908    };
909    let start = Station {
910        ua: contact.params_a[0],
911        va: contact.params_a[1],
912        ub: contact.params_b[0],
913        vb: contact.params_b[1],
914        point: contact.point,
915    };
916    let forward = trace(
917        first, second, domain_a, domain_b, start, direction, options.tolerance,
918        initial_step, minimum_step, maximum_step, maximum_steps,
919    )?;
920    let (stations, tangents, closed) = if forward.closed {
921        (forward.stations, forward.tangents, true)
922    } else {
923        let backward = trace(
924            first, second, domain_a, domain_b, start, direction.scale(-1.0), options.tolerance,
925            initial_step, minimum_step, maximum_step, maximum_steps,
926        )?;
927        let mut stations: Vec<Station> = backward.stations.into_iter().skip(1).rev().collect();
928        let mut tangents: Vec<Vec3> = backward
929            .tangents
930            .into_iter()
931            .skip(1)
932            .rev()
933            .map(|tangent| tangent.scale(-1.0))
934            .collect();
935        stations.extend(forward.stations);
936        tangents.extend(forward.tangents);
937        (stations, tangents, false)
938    };
939    if stations.len() < 2 {
940        return Ok(None);
941    }
942    Ok(Some(SurfaceIntersectionCurve {
943        points: stations.iter().map(|station| station.point).collect(),
944        params_a: stations.iter().map(|station| [station.ua, station.va]).collect(),
945        params_b: stations.iter().map(|station| [station.ub, station.vb]).collect(),
946        closed,
947        tangents: Some(tangents),
948        span_deviation: None,
949        tangent: true,
950    }))
951}
952