Skip to main content

brep_kernel/intersect/
surface_surface_intersection.rs

1use crate::curve::interior_knot_count;
2use crate::fit::solve_small;
3use crate::{project_point_to_surface, KnotVector, NurbsSurface, Vec3};
4use serde::{Deserialize, Serialize};
5
6const EPSILON: f64 = 1e-12;
7const LINEAR_TOLERANCE: f64 = 1e-7;
8
9/// Minimum `|n_a × n_b|` at a start point for a trace to begin there — the
10/// gate `intersect_surfaces` applies to explicit seeds and grid starts alike,
11/// and `intersect_surfaces_supplemental` to its own seeds.
12///
13/// Below it the carriers are tangent there to working precision, so the point
14/// sits in a tangency BAND — the set of points within the coincidence
15/// tolerance of both surfaces, about `√(2·R·tol)` wide across a contact of
16/// curvature radius `R` — rather than on a transverse curve.  A trace started
17/// inside the band walks the band's noise: it neither closes nor reaches a
18/// boundary and exhausts its step budget (a corner blend's sphere kissing a
19/// mirrored copy's face plane at one point; the same sphere inscribed in its
20/// own fillet cylinder, kissing it along a circle).  Shared topology at a
21/// tangential contact comes from imprint's boundary-curve exchange, never
22/// from marching ("similar faces we do not intersect" — Golovanov §6.2).
23const TRANSVERSE_START_CROSS: f64 = 1e-2;
24
25/// [`TRANSVERSE_START_CROSS`] under its crate-wide name: the bound at which a
26/// contact stops being a graze and becomes a crossing. `csg::imprint`'s
27/// tangential-only gate asks the same question of a marched point, and must ask
28/// it with the same number the seeds were accepted by.
29pub(crate) const TRANSVERSE_SEED_CROSS: f64 = TRANSVERSE_START_CROSS;
30
31/// How much CURVE a trace must be able to reach, as a multiple of the
32/// carriers' shared extent, whatever step ceiling the caller asked for.
33///
34/// `maximum_steps` bounds corrector CALLS; multiplied by the ceiling in force
35/// it bounds the ARC a trace can reach, and that product is the only thing
36/// that decides whether an ordinary section is reachable at all.  At the
37/// marcher's own ceiling (`diagonal / 15`) the default budget reaches 266
38/// extents — no bound in practice.  A caller that asks for a FINER ceiling is
39/// asking for RESOLUTION, and its request silently shortened the reach in the
40/// same proportion: `offset_shell`'s `0.0005 · scale` leaves 2.0 extents, less
41/// than one turn of an ordinary closed section on a part that size, so a
42/// perfectly marched circle died at 4000 steps (2026-09-13: the apex-cone
43/// carrier against its base plane — 42.714 of curve against a 28.844 reach,
44/// every step at the cap, the §4.12 curvature budget permitting 113× more).
45///
46/// That is the 2026-08-06 rule one level up: what a marcher can reach must be
47/// a fixed fraction of the MODEL, never a function of the step the caller
48/// asked for.  So the ceiling is honoured as resolution and the step COUNT is
49/// derived from it — the trace takes however many steps this much curve needs.
50/// Nothing that completes today changes by a single step: the derived count
51/// only ever exceeds the caller's own, and only on the fine-ceiling lane.
52///
53/// It is a FLOOR on the reach, not a bound on how long a section may be — the
54/// default lane keeps its 266 extents.  Sized for the fine-ceiling lane alone,
55/// where the longest section measured over the kernel's 37 631 traces is 1.76
56/// extents (the 2026-09-13 census; 4.43 is the most any capped ceiling reached
57/// before this and 0.13 the least).
58const MARCH_REACH_EXTENTS: f64 = 4.0;
59
60/// Corrector calls a trace may spend: the caller's own budget, or however many
61/// [`MARCH_REACH_EXTENTS`] of curve need at `maximum_step`, whichever is more.
62fn step_budget(maximum_steps: usize, maximum_step: f64, diagonal: f64) -> usize {
63    if !(maximum_step > 0.0) || !diagonal.is_finite() {
64        return maximum_steps;
65    }
66    let needed = (MARCH_REACH_EXTENTS * diagonal / maximum_step).ceil();
67    if !needed.is_finite() || needed <= maximum_steps as f64 {
68        return maximum_steps;
69    }
70    needed as usize
71}
72
73#[derive(Clone, Copy)]
74struct Bounds {
75    minimum: Vec3,
76    maximum: Vec3,
77}
78
79impl Bounds {
80    fn from_surface(surface: &NurbsSurface) -> Result<Self, String> {
81        let mut minimum = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
82        let mut maximum = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
83        for point in surface.control_points.iter().flatten() {
84            let point = point.point()?;
85            minimum.x = minimum.x.min(point.x);
86            minimum.y = minimum.y.min(point.y);
87            minimum.z = minimum.z.min(point.z);
88            maximum.x = maximum.x.max(point.x);
89            maximum.y = maximum.y.max(point.y);
90            maximum.z = maximum.z.max(point.z);
91        }
92        Ok(Self { minimum, maximum })
93    }
94
95    fn diagonal(self) -> f64 {
96        self.maximum.sub(self.minimum).length()
97    }
98
99    fn expanded(self, amount: f64) -> Self {
100        let delta = Vec3::new(amount, amount, amount);
101        Self {
102            minimum: self.minimum.sub(delta),
103            maximum: self.maximum.add(delta),
104        }
105    }
106
107    fn contains(self, point: Vec3) -> bool {
108        point.x >= self.minimum.x
109            && point.x <= self.maximum.x
110            && point.y >= self.minimum.y
111            && point.y <= self.maximum.y
112            && point.z >= self.minimum.z
113            && point.z <= self.maximum.z
114    }
115
116    fn intersects(self, other: Self, tolerance: f64) -> bool {
117        self.expanded(tolerance).minimum.x <= other.maximum.x
118            && self.maximum.x + tolerance >= other.minimum.x
119            && self.minimum.y - tolerance <= other.maximum.y
120            && self.maximum.y + tolerance >= other.minimum.y
121            && self.minimum.z - tolerance <= other.maximum.z
122            && self.maximum.z + tolerance >= other.minimum.z
123    }
124}
125
126#[derive(Clone, Copy)]
127struct SurfaceInfo<'a> {
128    surface: &'a NurbsSurface,
129    u0: f64,
130    u1: f64,
131    v0: f64,
132    v1: f64,
133    closed_u: bool,
134    closed_v: bool,
135}
136
137fn surface_info(surface: &NurbsSurface) -> Result<SurfaceInfo<'_>, String> {
138    let ku = KnotVector::new(surface.knots_u.clone(), surface.degree_u)?;
139    let kv = KnotVector::new(surface.knots_v.clone(), surface.degree_v)?;
140    let [u0, u1] = ku.domain();
141    let [v0, v1] = kv.domain();
142    let mut closed_u = true;
143    let mut closed_v = true;
144    for fraction in [0.17, 0.5, 0.83] {
145        let v = v0 + (v1 - v0) * fraction;
146        if surface
147            .evaluate(u0, v)?
148            .sub(surface.evaluate(u1, v)?)
149            .length()
150            > LINEAR_TOLERANCE * 10.0
151        {
152            closed_u = false;
153        }
154        let u = u0 + (u1 - u0) * fraction;
155        if surface
156            .evaluate(u, v0)?
157            .sub(surface.evaluate(u, v1)?)
158            .length()
159            > LINEAR_TOLERANCE * 10.0
160        {
161            closed_v = false;
162        }
163    }
164    Ok(SurfaceInfo {
165        surface,
166        u0,
167        u1,
168        v0,
169        v1,
170        closed_u,
171        closed_v,
172    })
173}
174
175fn fit(value: f64, minimum: f64, maximum: f64, closed: bool) -> f64 {
176    if closed {
177        (value - minimum).rem_euclid(maximum - minimum) + minimum
178    } else {
179        value.clamp(minimum, maximum)
180    }
181}
182
183
184#[derive(Clone, Copy)]
185struct IntersectionPoint {
186    ua: f64,
187    va: f64,
188    ub: f64,
189    vb: f64,
190    point: Vec3,
191}
192
193fn refine_point(
194    first: SurfaceInfo<'_>,
195    second: SurfaceInfo<'_>,
196    mut ua: f64,
197    mut va: f64,
198    mut ub: f64,
199    mut vb: f64,
200    tolerance: f64,
201) -> Result<Option<IntersectionPoint>, String> {
202    for _ in 0..40 {
203        let da = first.surface.derivatives(ua, va, 1)?;
204        let db = second.surface.derivatives(ub, vb, 1)?;
205        let residual = da[0][0].sub(db[0][0]);
206        if residual.length() <= tolerance * 0.01 + EPSILON {
207            return Ok(Some(IntersectionPoint {
208                ua,
209                va,
210                ub,
211                vb,
212                point: da[0][0].add(db[0][0]).scale(0.5),
213            }));
214        }
215        let columns = [
216            da[1][0],
217            da[0][1],
218            db[1][0].scale(-1.0),
219            db[0][1].scale(-1.0),
220        ];
221        let mut matrix = [[0.0f64; 3]; 3];
222        for column in columns {
223            let values = [column.x, column.y, column.z];
224            for row in 0..3 {
225                for col in 0..3 {
226                    matrix[row][col] += values[row] * values[col];
227                }
228            }
229        }
230        let lambda = match solve_small(matrix, [-residual.x, -residual.y, -residual.z], 3) {
231            Ok(value) => value,
232            Err(_) => return Ok(None),
233        };
234        let multiplier = Vec3::new(lambda[0], lambda[1], lambda[2]);
235        let mut changes = columns.map(|column| column.dot(multiplier));
236        let domains = [
237            first.u1 - first.u0,
238            first.v1 - first.v0,
239            second.u1 - second.u0,
240            second.v1 - second.v0,
241        ];
242        for index in 0..4 {
243            changes[index] = changes[index].clamp(-domains[index] / 4.0, domains[index] / 4.0);
244        }
245        ua = fit(ua + changes[0], first.u0, first.u1, first.closed_u);
246        va = fit(va + changes[1], first.v0, first.v1, first.closed_v);
247        ub = fit(ub + changes[2], second.u0, second.u1, second.closed_u);
248        vb = fit(vb + changes[3], second.v0, second.v1, second.closed_v);
249    }
250    let a = first.surface.evaluate(ua, va)?;
251    let b = second.surface.evaluate(ub, vb)?;
252    if a.sub(b).length() <= tolerance {
253        Ok(Some(IntersectionPoint {
254            ua,
255            va,
256            ub,
257            vb,
258            point: a.add(b).scale(0.5),
259        }))
260    } else {
261        Ok(None)
262    }
263}
264
265fn find_start_points(
266    first: SurfaceInfo<'_>,
267    second: SurfaceInfo<'_>,
268    tolerance: f64,
269    density: f64,
270) -> Result<Vec<IntersectionPoint>, String> {
271    let grid_count = |info: SurfaceInfo<'_>, direction_u: bool| {
272        let (knots, degree) = if direction_u {
273            (&info.surface.knots_u, info.surface.degree_u)
274        } else {
275            (&info.surface.knots_v, info.surface.degree_v)
276        };
277        (8.0_f64)
278            .max(((interior_knot_count(knots, degree) + 1) * (degree + 1)) as f64 * density)
279            .ceil()
280            .min(24.0) as usize
281    };
282    let nu = grid_count(first, true);
283    let nv = grid_count(first, false);
284    let first_box = Bounds::from_surface(first.surface)?;
285    let second_box = Bounds::from_surface(second.surface)?;
286    let gate = first_box.diagonal().min(second_box.diagonal()) * 0.15 + tolerance;
287    let expanded_second = second_box.expanded((tolerance * 100.0).max(gate));
288    let mut starts = Vec::new();
289    for i in 0..=nu {
290        for j in 0..=nv {
291            let ua = first.u0 + (first.u1 - first.u0) * i as f64 / nu as f64;
292            let va = first.v0 + (first.v1 - first.v0) * j as f64 / nv as f64;
293            let point = first.surface.evaluate(ua, va)?;
294            if !expanded_second.contains(point) {
295                continue;
296            }
297            let projection = project_point_to_surface(second.surface, point)?;
298            if projection.distance > gate {
299                continue;
300            }
301            if let Some(refined) =
302                refine_point(first, second, ua, va, projection.u, projection.v, tolerance)?
303            {
304                starts.push(refined);
305            }
306        }
307    }
308    Ok(starts)
309}
310
311/// Whether the two carriers CROSS at `point` (`|n_a × n_b|` at or above
312/// [`TRANSVERSE_START_CROSS`]) rather than touch tangentially; a surface
313/// with no normal there counts as not crossing.
314fn transverse_at(
315    first: SurfaceInfo<'_>,
316    second: SurfaceInfo<'_>,
317    point: IntersectionPoint,
318) -> bool {
319    match (
320        first.surface.normal(point.ua, point.va),
321        second.surface.normal(point.ub, point.vb),
322    ) {
323        (Ok(a), Ok(b)) => a.cross(b).length() >= TRANSVERSE_START_CROSS,
324        _ => false,
325    }
326}
327
328fn tangent_at(
329    first: SurfaceInfo<'_>,
330    second: SurfaceInfo<'_>,
331    point: IntersectionPoint,
332) -> Result<Option<Vec3>, String> {
333    let first_normal = first.surface.normal(point.ua, point.va).ok();
334    let second_normal = second.surface.normal(point.ub, point.vb).ok();
335    match (first_normal, second_normal) {
336        (Some(a), Some(b)) => {
337            let tangent = a.cross(b);
338            if tangent.length() <= 1e-9 {
339                Ok(None)
340            } else {
341                Ok(Some(tangent.normalized()?))
342            }
343        }
344        _ => Ok(None),
345    }
346}
347
348fn polish_boundary(
349    first: SurfaceInfo<'_>,
350    second: SurfaceInfo<'_>,
351    mut point: IntersectionPoint,
352    tolerance: f64,
353) -> Result<Option<IntersectionPoint>, String> {
354    let free = [
355        first.closed_u || (point.ua > first.u0 && point.ua < first.u1),
356        first.closed_v || (point.va > first.v0 && point.va < first.v1),
357        second.closed_u || (point.ub > second.u0 && point.ub < second.u1),
358        second.closed_v || (point.vb > second.v0 && point.vb < second.v1),
359    ];
360    for _ in 0..40 {
361        let da = first.surface.derivatives(point.ua, point.va, 1)?;
362        let db = second.surface.derivatives(point.ub, point.vb, 1)?;
363        let residual = da[0][0].sub(db[0][0]);
364        if residual.length() <= tolerance {
365            point.point = da[0][0].add(db[0][0]).scale(0.5);
366            return Ok(Some(point));
367        }
368        let all_columns = [
369            da[1][0],
370            da[0][1],
371            db[1][0].scale(-1.0),
372            db[0][1].scale(-1.0),
373        ];
374        let indices: Vec<usize> = (0..4).filter(|index| free[*index]).collect();
375        if indices.is_empty() {
376            return Ok(None);
377        }
378        let mut matrix = [[0.0f64; 4]; 4];
379        let mut rhs = [0.0f64; 4];
380        for (row, &r) in indices.iter().enumerate() {
381            rhs[row] = -all_columns[r].dot(residual);
382            for (column, &c) in indices.iter().enumerate() {
383                matrix[row][column] = all_columns[r].dot(all_columns[c]);
384            }
385        }
386        let changes = match solve_small(matrix, rhs, indices.len()) {
387            Ok(value) => value,
388            Err(_) => return Ok(None),
389        };
390        let mut update = [0.0; 4];
391        for (index, &parameter_index) in indices.iter().enumerate() {
392            update[parameter_index] = changes[index];
393        }
394        point.ua = fit(point.ua + update[0], first.u0, first.u1, first.closed_u);
395        point.va = fit(point.va + update[1], first.v0, first.v1, first.closed_v);
396        point.ub = fit(point.ub + update[2], second.u0, second.u1, second.closed_u);
397        point.vb = fit(point.vb + update[3], second.v0, second.v1, second.closed_v);
398    }
399    Ok(None)
400}
401
402fn correct(
403    first: SurfaceInfo<'_>,
404    second: SurfaceInfo<'_>,
405    from: IntersectionPoint,
406    predicted: Vec3,
407    tangent: Vec3,
408    tolerance: f64,
409) -> Result<Option<(IntersectionPoint, bool)>, String> {
410    let mut point = from;
411    let mut boundary = false;
412    for _ in 0..30 {
413        let da = first.surface.derivatives(point.ua, point.va, 1)?;
414        let db = second.surface.derivatives(point.ub, point.vb, 1)?;
415        let residual = da[0][0].sub(db[0][0]);
416        let plane_residual = da[0][0].sub(predicted).dot(tangent);
417        if residual.length() <= tolerance * 0.01 + EPSILON
418            && plane_residual.abs() <= tolerance * 0.01 + EPSILON
419        {
420            point.point = da[0][0].add(db[0][0]).scale(0.5);
421            return Ok(Some((point, boundary)));
422        }
423        let matrix = [
424            [da[1][0].x, da[0][1].x, -db[1][0].x, -db[0][1].x],
425            [da[1][0].y, da[0][1].y, -db[1][0].y, -db[0][1].y],
426            [da[1][0].z, da[0][1].z, -db[1][0].z, -db[0][1].z],
427            [da[1][0].dot(tangent), da[0][1].dot(tangent), 0.0, 0.0],
428        ];
429        let changes = match solve_small(
430            matrix,
431            [-residual.x, -residual.y, -residual.z, -plane_residual],
432            4,
433        ) {
434            Ok(value) => value,
435            Err(_) => return Ok(None),
436        };
437        let scale = [
438            changes[0].abs() / ((first.u1 - first.u0) / 4.0),
439            changes[1].abs() / ((first.v1 - first.v0) / 4.0),
440            changes[2].abs() / ((second.u1 - second.u0) / 4.0),
441            changes[3].abs() / ((second.v1 - second.v0) / 4.0),
442            1.0,
443        ]
444        .into_iter()
445        .fold(1.0_f64, f64::max);
446        let raw = [
447            point.ua + changes[0] / scale,
448            point.va + changes[1] / scale,
449            point.ub + changes[2] / scale,
450            point.vb + changes[3] / scale,
451        ];
452        let domains = [
453            (first.u0, first.u1, first.closed_u),
454            (first.v0, first.v1, first.closed_v),
455            (second.u0, second.u1, second.closed_u),
456            (second.v0, second.v1, second.closed_v),
457        ];
458        boundary = false;
459        let mut next = [0.0; 4];
460        for index in 0..4 {
461            next[index] = fit(
462                raw[index],
463                domains[index].0,
464                domains[index].1,
465                domains[index].2,
466            );
467            if !domains[index].2
468                && (next[index] == domains[index].0 || next[index] == domains[index].1)
469                && raw[index] != next[index]
470            {
471                boundary = true;
472            }
473        }
474        let old = [point.ua, point.va, point.ub, point.vb];
475        let spans = [
476            first.u1 - first.u0,
477            first.v1 - first.v0,
478            second.u1 - second.u0,
479            second.v1 - second.v0,
480        ];
481        let stalled = (0..4).all(|index| (next[index] - old[index]).abs() <= 1e-14 * spans[index]);
482        point.ua = next[0];
483        point.va = next[1];
484        point.ub = next[2];
485        point.vb = next[3];
486        if stalled {
487            if boundary {
488                if let Some(polished) = polish_boundary(first, second, point, tolerance)? {
489                    return Ok(Some((polished, true)));
490                }
491            }
492            return Ok(None);
493        }
494    }
495    Ok(None)
496}
497
498struct Trace {
499    points: Vec<IntersectionPoint>,
500    closed: bool,
501}
502
503fn vector_angle(a: Vec3, b: Vec3) -> f64 {
504    (a.dot(b) / (a.length() * b.length()))
505        .clamp(-1.0, 1.0)
506        .acos()
507}
508
509/// Predictive step size from the surface-normal turn budget (Golovanov
510/// §4.12): for each parameter of each surface the permissible parametric
511/// step is Δα·√g/b (g, b = first/second fundamental form coefficients);
512/// its 3D advance is Δα·g/b, and the usable step is the smallest advance
513/// PROJECTED onto the march direction (the displacement sphere of §4.8),
514/// ignoring parameters that barely move along the curve.  Returns None
515/// when curvature data is unavailable (degenerate normals) — the caller
516/// keeps its reactive step then.
517fn normal_turn_step(
518    first: SurfaceInfo<'_>,
519    second: SurfaceInfo<'_>,
520    point: IntersectionPoint,
521    tangent: Vec3,
522    budget: f64,
523) -> Option<f64> {
524    let mut best: Option<f64> = None;
525    for (surface, u, v) in [
526        (first.surface, point.ua, point.va),
527        (second.surface, point.ub, point.vb),
528    ] {
529        let derivatives = surface.derivatives(u, v, 2).ok()?;
530        let normal = derivatives[1][0].cross(derivatives[0][1]);
531        let normal_length = normal.length();
532        if normal_length <= 1e-12 {
533            continue;
534        }
535        let unit_normal = normal.scale(1.0 / normal_length);
536        for (first_derivative, second_derivative) in [
537            (derivatives[1][0], derivatives[2][0]),
538            (derivatives[0][1], derivatives[0][2]),
539        ] {
540            let metric = first_derivative.dot(first_derivative);
541            if metric <= 1e-16 {
542                continue;
543            }
544            let curvature = second_derivative.dot(unit_normal).abs();
545            if curvature <= 1e-12 {
546                continue; // flat along this parameter: no budget limit
547            }
548            let advance = budget * metric / curvature;
549            let alignment = first_derivative
550                .scale(1.0 / metric.sqrt())
551                .dot(tangent)
552                .abs();
553            if alignment <= 0.1 {
554                continue; // barely moves along the curve (§4.8)
555            }
556            let along = advance * alignment;
557            if best.map(|value| along < value).unwrap_or(true) {
558                best = Some(along);
559            }
560        }
561    }
562    best
563}
564
565/// The switches a trace reads, taken from the environment once per
566/// intersection call rather than once per trace.
567#[derive(Clone, Copy)]
568struct TraceSwitches {
569    /// `BREP_SSI_FIRST_STEP_BUDGET=0` lets the first step skip the §4.12
570    /// ceiling, as every trace did before 2026-09-15.
571    first_step_ceiling: bool,
572    /// `BREP_DEBUG_SSI_TRACE=1`: every start and every accepted station.
573    debug: bool,
574}
575
576impl TraceSwitches {
577    fn from_env() -> Self {
578        Self {
579            first_step_ceiling: std::env::var("BREP_SSI_FIRST_STEP_BUDGET").as_deref() != Ok("0"),
580            debug: std::env::var_os("BREP_DEBUG_SSI_TRACE").is_some(),
581        }
582    }
583}
584
585#[allow(clippy::too_many_arguments)]
586fn trace(
587    first: SurfaceInfo<'_>,
588    second: SurfaceInfo<'_>,
589    start: IntersectionPoint,
590    direction: f64,
591    tolerance: f64,
592    initial_step: f64,
593    minimum_step: f64,
594    maximum_step: f64,
595    maximum_steps: usize,
596    switches: TraceSwitches,
597) -> Result<Trace, String> {
598    let mut points = vec![start];
599    let mut current = start;
600    let Some(mut tangent) = tangent_at(first, second, start)? else {
601        return Ok(Trace {
602            points,
603            closed: false,
604        });
605    };
606    tangent = tangent.scale(direction);
607    // The FIRST step is held to the same §4.12 normal-turn ceiling as every
608    // later one. The ceiling used to be applied only after a step was accepted,
609    // so step zero was `diagonal / 100` whatever the curvature — and a step
610    // that lands on a trim-window boundary ends the branch before the turn
611    // check below ever reads it. On the rescue pair
612    // (`boolean_fuzz_corpus/23_section_pcurve_fold_t222`, cone × a 90° pin-wall
613    // window) that one step was the whole 0.69 and 0.77 of section, turning the
614    // tangent 0.97 rad where the ceiling allows 1.0e-2 of advance: two
615    // stations, a four-point fit, and trims 3.0e-2 and 3.8e-2 apart. Escape
616    // hatch: BREP_SSI_FIRST_STEP_BUDGET=0.
617    let mut step_size = initial_step.min(maximum_step);
618    if switches.first_step_ceiling {
619        if let Some(budget) = normal_turn_step(first, second, start, tangent, 0.1) {
620            step_size = step_size.min(budget).clamp(minimum_step, maximum_step);
621        }
622    }
623    let mut terminated = false;
624    for _ in 0..maximum_steps {
625        let mut accepted = None;
626        while step_size >= minimum_step {
627            let predicted = current.point.add(tangent.scale(step_size));
628            if let Some((corrected, boundary)) =
629                correct(first, second, current, predicted, tangent, tolerance)?
630            {
631                if corrected.point.sub(current.point).length() <= 2.5 * step_size {
632                    accepted = Some((corrected, boundary));
633                    break;
634                }
635            }
636            step_size /= 2.0;
637        }
638        let Some((candidate, boundary)) = accepted else {
639            terminated = true;
640            break;
641        };
642        points.push(candidate);
643        if switches.debug {
644            // One line per accepted station: the tangent's turn across the
645            // step, the normal-turn ceiling where it started, the step asked
646            // for and the distance actually covered.
647            let turn = tangent_at(first, second, candidate)
648                .ok()
649                .flatten()
650                .map(|next| vector_angle(tangent, if next.dot(tangent) < 0.0 { next.scale(-1.0) } else { next }))
651                .unwrap_or(f64::NAN);
652            let budget = normal_turn_step(first, second, current, tangent, 0.1).unwrap_or(f64::NAN);
653            let at = candidate.point;
654            eprintln!(
655                "SSI-TRACE dir {direction} step {step_size:.4e} jump {:.4e} turn {turn:.4} ceiling {budget:.4e} boundary {boundary} p ({:.6},{:.6},{:.6})",
656                at.sub(current.point).length(),
657                at.x,
658                at.y,
659                at.z
660            );
661        }
662        if boundary {
663            terminated = true;
664            break;
665        }
666        let Some(mut next_tangent) = tangent_at(first, second, candidate)? else {
667            terminated = true;
668            break;
669        };
670        if next_tangent.dot(tangent) < 0.0 {
671            next_tangent = next_tangent.scale(-1.0);
672        }
673        let angle = vector_angle(tangent, next_tangent);
674        if angle > 0.35 {
675            points.pop();
676            step_size = minimum_step.max(step_size / 2.0);
677            if step_size <= minimum_step * 1.01 {
678                points.push(candidate);
679            } else {
680                continue;
681            }
682        }
683        // §4.12 normal-turn budget on BOTH surfaces as a hard CEILING on
684        // the reactive controller: growth stays reactive (accelerating
685        // into under-sampled flats regressed downstream curve fits), but
686        // the budget stops the step from outrunning surface curvature.
687        let reactive = if angle < 0.03 {
688            step_size * 1.5
689        } else if angle > 0.15 {
690            step_size * 0.6
691        } else {
692            step_size
693        };
694        step_size = match normal_turn_step(first, second, candidate, next_tangent, 0.1) {
695            Some(budget) => reactive.min(budget),
696            None => reactive,
697        }
698        .clamp(minimum_step, maximum_step);
699        if points.len() > 4 && candidate.point.sub(start.point).length() <= step_size * 1.2 {
700            let to_start = start.point.sub(candidate.point);
701            if to_start.length() <= EPSILON || to_start.dot(next_tangent) >= 0.0 {
702                points.push(start);
703                return Ok(Trace {
704                    points,
705                    closed: true,
706                });
707            }
708        }
709        current = candidate;
710        tangent = next_tangent;
711    }
712    if !terminated {
713        // The step budget ran out with the curve still going: a silently
714        // truncated branch flows into topology as if complete, which is far
715        // worse than a loud failure here.
716        return Err(format!(
717            "surface intersection trace exhausted {maximum_steps} steps \
718             ({} points, step {step_size:.3e}) without reaching a boundary or closure",
719            points.len()
720        ));
721    }
722    Ok(Trace {
723        points,
724        closed: false,
725    })
726}
727
728fn point_segment_distance(point: Vec3, start: Vec3, end: Vec3) -> f64 {
729    let segment = end.sub(start);
730    let length_squared = segment.length_squared();
731    if length_squared <= EPSILON {
732        return point.sub(start).length();
733    }
734    let parameter = point.sub(start).dot(segment) / length_squared;
735    let parameter = parameter.clamp(0.0, 1.0);
736    point.sub(start.add(segment.scale(parameter))).length()
737}
738
739#[derive(Clone, Debug, Serialize)]
740pub struct SurfaceIntersectionCurve {
741    pub points: Vec<Vec3>,
742    pub params_a: Vec<[f64; 2]>,
743    pub params_b: Vec<[f64; 2]>,
744    pub closed: bool,
745}
746
747#[derive(Clone, Debug, Deserialize)]
748pub struct SurfaceIntersectionOptions {
749    #[serde(default = "default_tolerance")]
750    pub tolerance: f64,
751    #[serde(default = "default_density")]
752    pub seed_density: f64,
753    #[serde(default = "default_maximum_steps")]
754    pub maximum_steps: usize,
755    pub maximum_step: Option<f64>,
756    #[serde(default)]
757    pub seed_points: Vec<Vec3>,
758    #[serde(default)]
759    pub seed_only: bool,
760    pub seed_gate: Option<f64>,
761}
762
763fn default_tolerance() -> f64 {
764    LINEAR_TOLERANCE
765}
766fn default_density() -> f64 {
767    1.0
768}
769fn default_maximum_steps() -> usize {
770    4000
771}
772
773impl Default for SurfaceIntersectionOptions {
774    fn default() -> Self {
775        Self {
776            tolerance: default_tolerance(),
777            seed_density: default_density(),
778            maximum_steps: default_maximum_steps(),
779            maximum_step: None,
780            seed_points: Vec::new(),
781            seed_only: false,
782            seed_gate: None,
783        }
784    }
785}
786
787pub fn intersect_surfaces(
788    first_surface: &NurbsSurface,
789    second_surface: &NurbsSurface,
790    options: &SurfaceIntersectionOptions,
791) -> Result<Vec<SurfaceIntersectionCurve>, String> {
792    let first_box = Bounds::from_surface(first_surface)?;
793    let second_box = Bounds::from_surface(second_surface)?;
794    if !first_box.intersects(second_box, options.tolerance * 10.0) {
795        return Ok(Vec::new());
796    }
797    let first = surface_info(first_surface)?;
798    let second = surface_info(second_surface)?;
799    let diagonal = first_box.diagonal().min(second_box.diagonal());
800    let initial_step = diagonal / 100.0;
801    let minimum_step = (options.tolerance * 100.0).max(diagonal * 1e-6);
802    let maximum_step = (diagonal / 15.0).min(options.maximum_step.unwrap_or(f64::INFINITY));
803    let maximum_steps = step_budget(options.maximum_steps, maximum_step, diagonal);
804    let mut starts = if options.seed_only {
805        Vec::new()
806    } else {
807        find_start_points(first, second, options.tolerance, options.seed_density)?
808    };
809    // A grid start is refined onto both carriers exactly like an explicit
810    // seed and is held to the same transversality gate: inside a tangency
811    // band the refinement converges (the band IS within tolerance of both
812    // surfaces) to a point no transverse curve passes through.
813    starts.retain(|start| transverse_at(first, second, *start));
814    let seed_gate = options.seed_gate.unwrap_or(options.tolerance * 100.0);
815    for seed in &options.seed_points {
816        let projection_a = project_point_to_surface(first_surface, *seed)?;
817        if projection_a.distance > seed_gate {
818            continue;
819        }
820        let projection_b = project_point_to_surface(second_surface, *seed)?;
821        if projection_b.distance > seed_gate {
822            continue;
823        }
824        let Some(refined) = refine_point(
825            first,
826            second,
827            projection_a.u,
828            projection_a.v,
829            projection_b.u,
830            projection_b.v,
831            options.tolerance,
832        )?
833        else {
834            continue;
835        };
836        if !transverse_at(first, second, refined) {
837            continue;
838        }
839        starts.push(refined);
840    }
841
842    let switches = TraceSwitches::from_env();
843    let mut curves: Vec<SurfaceIntersectionCurve> = Vec::new();
844    for start in starts {
845        let claimed = curves.iter().any(|curve| {
846            curve.points.windows(2).any(|segment| {
847                point_segment_distance(start.point, segment[0], segment[1]) <= initial_step * 1.5
848            })
849        });
850        if switches.debug {
851            let at = start.point;
852            eprintln!(
853                "SSI-START claimed {claimed} p ({:.6},{:.6},{:.6}) initial {initial_step:.4e} minimum {minimum_step:.4e} maximum {maximum_step:.4e}",
854                at.x, at.y, at.z
855            );
856        }
857        if claimed {
858            continue;
859        }
860        let forward = trace(
861            first,
862            second,
863            start,
864            1.0,
865            options.tolerance,
866            initial_step,
867            minimum_step,
868            maximum_step,
869            maximum_steps,
870            switches,
871        )?;
872        let (all, closed) = if forward.closed {
873            (forward.points, true)
874        } else {
875            let backward = trace(
876                first,
877                second,
878                start,
879                -1.0,
880                options.tolerance,
881                initial_step,
882                minimum_step,
883                maximum_step,
884                maximum_steps,
885                switches,
886            )?;
887            let mut all: Vec<_> = backward.points.into_iter().skip(1).rev().collect();
888            all.extend(forward.points);
889            (all, false)
890        };
891        if all.len() < 2 {
892            continue;
893        }
894        curves.push(SurfaceIntersectionCurve {
895            points: all.iter().map(|point| point.point).collect(),
896            params_a: all.iter().map(|point| [point.ua, point.va]).collect(),
897            params_b: all.iter().map(|point| [point.ub, point.vb]).collect(),
898            closed,
899        });
900    }
901    Ok(curves)
902}
903
904/// Minimum |n_a × n_b| for a seed/branch to count as a *transverse* crossing
905/// rather than a tangential graze — the same threshold `intersect_surfaces`
906/// applies to its start points.
907const SUPPLEMENTAL_TRANSVERSE_CROSS: f64 = TRANSVERSE_START_CROSS;
908
909/// Gated supplemental SSI **detector** for a pair the coarse pair-classifier
910/// culled as "tangential-only" (`NearTangent` + `tangential_only`).
911///
912/// The 5×5 classification grid can land only on an incidental tangential KISS
913/// between two curved carriers (e.g. the inner walls / tube tops of two
914/// overlapping tori) and never sample the transverse loop where their tubes
915/// actually CROSS, so `tangential_only` is a false positive that would drop a
916/// real intersection. This routine re-seeds a DENSER grid on BOTH carriers,
917/// keeps only *transverse* seeds (near-parallel normals rejected up front, so
918/// the tangency band is never walked), SWALLOWS per-branch trace-exhaustion (a
919/// genuine tangency band never closes/exits and would otherwise error — here
920/// it simply means "no transverse curve from this seed"), and returns only
921/// branches that are genuinely transverse along their length.
922///
923/// A *genuinely* tangential pair yields an empty result (every seed rejected /
924/// every trace fruitless), so the caller skips exactly as before — the common
925/// intersection path is never touched.
926///
927/// NOTE — deferred capability: when this DOES return branches, the true
928/// intersection is *singular* (the transverse loops meet at tangent nodes,
929/// e.g. equal-radius torus-torus at `(1.5, ±3.708, ±1.5)`). Imprinting them
930/// requires 4-valent tangent-vertex ("checkerboard") singular assembly, which
931/// the kernel does not yet do; until then the caller REFUSES such a pair with
932/// a clear error rather than silently dropping the geometry or emitting the
933/// cryptic downstream pcurve/open-loop error. See imprint.rs (tangential-only
934/// skip) and the project singular-assembly roadmap.
935pub fn intersect_surfaces_supplemental(
936    first_surface: &NurbsSurface,
937    second_surface: &NurbsSurface,
938    options: &SurfaceIntersectionOptions,
939) -> Result<Vec<SurfaceIntersectionCurve>, String> {
940    let first_box = Bounds::from_surface(first_surface)?;
941    let second_box = Bounds::from_surface(second_surface)?;
942    if !first_box.intersects(second_box, options.tolerance * 10.0) {
943        return Ok(Vec::new());
944    }
945    let first = surface_info(first_surface)?;
946    let second = surface_info(second_surface)?;
947    let diagonal = first_box.diagonal().min(second_box.diagonal());
948    let initial_step = diagonal / 100.0;
949    let minimum_step = (options.tolerance * 100.0).max(diagonal * 1e-6);
950    let maximum_step = (diagonal / 15.0).min(options.maximum_step.unwrap_or(f64::INFINITY));
951    let maximum_steps = step_budget(options.maximum_steps, maximum_step, diagonal);
952
953    // Denser self-seed (≥2× density pushes the ≤24 grid toward its cap so a
954    // thin transverse overlap the classifier stepped over still gets a start),
955    // gridding BOTH carriers so an interior loop touching neither trim boundary
956    // is still hit from whichever surface samples it best.
957    let density = options.seed_density.max(2.0);
958    let mut starts: Vec<IntersectionPoint> = Vec::new();
959    for gridded_second in [false, true] {
960        let (a, b) = if gridded_second {
961            (second, first)
962        } else {
963            (first, second)
964        };
965        for start in find_start_points(a, b, options.tolerance, density)? {
966            // find_start_points expresses params as (grid surface, projected
967            // surface); normalise every start to (first, second).
968            let start = if gridded_second {
969                IntersectionPoint {
970                    ua: start.ub,
971                    va: start.vb,
972                    ub: start.ua,
973                    vb: start.va,
974                    point: start.point,
975                }
976            } else {
977                start
978            };
979            starts.push(start);
980        }
981    }
982    // Reject tangential seeds outright: a start whose two surface normals are
983    // (near-)parallel is on a graze, not a crossing, and marching it walks the
984    // tangency band.
985    starts.retain(|start| {
986        match (
987            first.surface.normal(start.ua, start.va),
988            second.surface.normal(start.ub, start.vb),
989        ) {
990            (Ok(na), Ok(nb)) => na.cross(nb).length() > SUPPLEMENTAL_TRANSVERSE_CROSS,
991            _ => false,
992        }
993    });
994
995    let switches = TraceSwitches::from_env();
996    let mut curves: Vec<SurfaceIntersectionCurve> = Vec::new();
997    for start in starts {
998        let claimed = curves.iter().any(|curve| {
999            curve.points.windows(2).any(|segment| {
1000                point_segment_distance(start.point, segment[0], segment[1]) <= initial_step * 1.5
1001            })
1002        });
1003        if claimed {
1004            continue;
1005        }
1006        // Swallow trace-exhaustion here (unlike the common path, which errors
1007        // loudly): on this gated path a non-closing branch means the seed sat
1008        // on a tangency band, which we simply drop.
1009        let Ok(forward) = trace(
1010            first,
1011            second,
1012            start,
1013            1.0,
1014            options.tolerance,
1015            initial_step,
1016            minimum_step,
1017            maximum_step,
1018            maximum_steps,
1019            switches,
1020        ) else {
1021            continue;
1022        };
1023        let (all, closed) = if forward.closed {
1024            (forward.points, true)
1025        } else {
1026            let Ok(backward) = trace(
1027                first,
1028                second,
1029                start,
1030                -1.0,
1031                options.tolerance,
1032                initial_step,
1033                minimum_step,
1034                maximum_step,
1035                maximum_steps,
1036                switches,
1037            ) else {
1038                continue;
1039            };
1040            let mut all: Vec<_> = backward.points.into_iter().skip(1).rev().collect();
1041            all.extend(forward.points);
1042            (all, false)
1043        };
1044        if all.len() < 2 {
1045            continue;
1046        }
1047        // Keep only a branch that is genuinely transverse somewhere along its
1048        // length — a final guard against a tangency-band polyline that happened
1049        // to close.
1050        let transverse = all.iter().step_by((all.len() / 5).max(1)).any(|point| {
1051            match (
1052                first.surface.normal(point.ua, point.va),
1053                second.surface.normal(point.ub, point.vb),
1054            ) {
1055                (Ok(na), Ok(nb)) => na.cross(nb).length() > SUPPLEMENTAL_TRANSVERSE_CROSS,
1056                _ => false,
1057            }
1058        });
1059        if !transverse {
1060            continue;
1061        }
1062        curves.push(SurfaceIntersectionCurve {
1063            points: all.iter().map(|point| point.point).collect(),
1064            params_a: all.iter().map(|point| [point.ua, point.va]).collect(),
1065            params_b: all.iter().map(|point| [point.ub, point.vb]).collect(),
1066            closed,
1067        });
1068    }
1069    Ok(curves)
1070}
1071