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