Skip to main content

brep_kernel/intersect/
surface_surface_intersection.rs

1use crate::curve::interior_knot_count;
2use crate::fit::solve_small;
3use crate::projection::project_point_to_surface_from_seed;
4use crate::{project_point_to_surface, KnotVector, NurbsSurface, Vec3};
5use serde::{Deserialize, Serialize};
6
7const EPSILON: f64 = crate::tolerance::FP_FLOOR;
8const LINEAR_TOLERANCE: f64 = crate::tolerance::DEFAULT_MODEL;
9
10/// Minimum `|n_a × n_b|` at a start point for a trace to begin there — the
11/// gate `intersect_surfaces` applies to explicit seeds and grid starts alike,
12/// and `intersect_surfaces_supplemental` to its own seeds.
13///
14/// Below it the carriers are tangent there to working precision, so the point
15/// sits in a tangency BAND — the set of points within the coincidence
16/// tolerance of both surfaces, about `√(2·R·tol)` wide across a contact of
17/// curvature radius `R` — rather than on a transverse curve.  A trace started
18/// inside the band walks the band's noise: it neither closes nor reaches a
19/// boundary and exhausts its step budget (a corner blend's sphere kissing a
20/// mirrored copy's face plane at one point; the same sphere inscribed in its
21/// own fillet cylinder, kissing it along a circle).  Shared topology at a
22/// tangential contact comes from imprint's boundary-curve exchange, never
23/// from marching ("similar faces we do not intersect" — Golovanov §6.2).
24const TRANSVERSE_START_CROSS: f64 = 1e-2;
25
26/// [`TRANSVERSE_START_CROSS`] under its crate-wide name: the bound at which a
27/// contact stops being a graze and becomes a crossing. `csg::imprint`'s
28/// tangential-only gate asks the same question of a marched point, and must ask
29/// it with the same number the seeds were accepted by.
30pub(crate) const TRANSVERSE_SEED_CROSS: f64 = TRANSVERSE_START_CROSS;
31
32/// How much CURVE a trace must be able to reach, as a multiple of the
33/// carriers' shared extent, whatever step ceiling the caller asked for.
34///
35/// `maximum_steps` bounds corrector CALLS; multiplied by the ceiling in force
36/// it bounds the ARC a trace can reach, and that product is the only thing
37/// that decides whether an ordinary section is reachable at all.  At the
38/// marcher's own ceiling (`diagonal / 15`) the default budget reaches 266
39/// extents — no bound in practice.  A caller that asks for a FINER ceiling is
40/// asking for RESOLUTION, and its request silently shortened the reach in the
41/// same proportion: `offset_shell`'s `0.0005 · scale` leaves 2.0 extents, less
42/// than one turn of an ordinary closed section on a part that size, so a
43/// perfectly marched circle died at 4000 steps (2026-09-13: the apex-cone
44/// carrier against its base plane — 42.714 of curve against a 28.844 reach,
45/// every step at the cap, the §4.12 curvature budget permitting 113× more).
46///
47/// That is the 2026-08-06 rule one level up: what a marcher can reach must be
48/// a fixed fraction of the MODEL, never a function of the step the caller
49/// asked for.  So the ceiling is honoured as resolution and the step COUNT is
50/// derived from it — the trace takes however many steps this much curve needs.
51/// Nothing that completes today changes by a single step: the derived count
52/// only ever exceeds the caller's own, and only on the fine-ceiling lane.
53///
54/// It is a FLOOR on the reach, not a bound on how long a section may be — the
55/// default lane keeps its 266 extents.  Sized for the fine-ceiling lane alone,
56/// where the longest section measured over the kernel's 37 631 traces is 1.76
57/// extents (the 2026-09-13 census; 4.43 is the most any capped ceiling reached
58/// before this and 0.13 the least).
59const MARCH_REACH_EXTENTS: f64 = 4.0;
60
61/// Corrector calls a trace may spend: the caller's own budget, or however many
62/// [`MARCH_REACH_EXTENTS`] of curve need at `maximum_step`, whichever is more.
63fn step_budget(maximum_steps: usize, maximum_step: f64, diagonal: f64) -> usize {
64    if !(maximum_step > 0.0) || !diagonal.is_finite() {
65        return maximum_steps;
66    }
67    let needed = (MARCH_REACH_EXTENTS * diagonal / maximum_step).ceil();
68    if !needed.is_finite() || needed <= maximum_steps as f64 {
69        return maximum_steps;
70    }
71    needed as usize
72}
73
74use crate::spatial::Aabb as Bounds;
75
76
77#[derive(Clone, Copy)]
78struct SurfaceInfo<'a> {
79    surface: &'a NurbsSurface,
80    u0: f64,
81    u1: f64,
82    v0: f64,
83    v1: f64,
84    closed_u: bool,
85    closed_v: bool,
86}
87
88fn surface_info(surface: &NurbsSurface) -> Result<SurfaceInfo<'_>, String> {
89    let ku = KnotVector::new(surface.knots_u.clone(), surface.degree_u)?;
90    let kv = KnotVector::new(surface.knots_v.clone(), surface.degree_v)?;
91    let [u0, u1] = ku.domain();
92    let [v0, v1] = kv.domain();
93    let mut closed_u = true;
94    let mut closed_v = true;
95    for fraction in [0.17, 0.5, 0.83] {
96        let v = v0 + (v1 - v0) * fraction;
97        if surface
98            .evaluate(u0, v)?
99            .sub(surface.evaluate(u1, v)?)
100            .length()
101            > LINEAR_TOLERANCE * 10.0
102        {
103            closed_u = false;
104        }
105        let u = u0 + (u1 - u0) * fraction;
106        if surface
107            .evaluate(u, v0)?
108            .sub(surface.evaluate(u, v1)?)
109            .length()
110            > LINEAR_TOLERANCE * 10.0
111        {
112            closed_v = false;
113        }
114    }
115    Ok(SurfaceInfo {
116        surface,
117        u0,
118        u1,
119        v0,
120        v1,
121        closed_u,
122        closed_v,
123    })
124}
125
126fn fit(value: f64, minimum: f64, maximum: f64, closed: bool) -> f64 {
127    crate::projection::fit_parameter(value, minimum, maximum, closed)
128}
129
130
131#[derive(Clone, Copy)]
132struct IntersectionPoint {
133    ua: f64,
134    va: f64,
135    ub: f64,
136    vb: f64,
137    point: Vec3,
138}
139
140fn refine_point(
141    first: SurfaceInfo<'_>,
142    second: SurfaceInfo<'_>,
143    mut ua: f64,
144    mut va: f64,
145    mut ub: f64,
146    mut vb: f64,
147    tolerance: f64,
148) -> Result<Option<IntersectionPoint>, String> {
149    for _ in 0..40 {
150        let da = first.surface.derivatives(ua, va, 1)?;
151        let db = second.surface.derivatives(ub, vb, 1)?;
152        let residual = da[0][0].sub(db[0][0]);
153        if residual.length() <= tolerance * 0.01 + EPSILON {
154            return Ok(Some(IntersectionPoint {
155                ua,
156                va,
157                ub,
158                vb,
159                point: da[0][0].add(db[0][0]).scale(0.5),
160            }));
161        }
162        let columns = [
163            da[1][0],
164            da[0][1],
165            db[1][0].scale(-1.0),
166            db[0][1].scale(-1.0),
167        ];
168        let mut matrix = [[0.0f64; 3]; 3];
169        for column in columns {
170            let values = [column.x, column.y, column.z];
171            for row in 0..3 {
172                for col in 0..3 {
173                    matrix[row][col] += values[row] * values[col];
174                }
175            }
176        }
177        let lambda = match solve_small(matrix, [-residual.x, -residual.y, -residual.z], 3) {
178            Ok(value) => value,
179            Err(_) => return Ok(None),
180        };
181        let multiplier = Vec3::new(lambda[0], lambda[1], lambda[2]);
182        let mut changes = columns.map(|column| column.dot(multiplier));
183        let domains = [
184            first.u1 - first.u0,
185            first.v1 - first.v0,
186            second.u1 - second.u0,
187            second.v1 - second.v0,
188        ];
189        for index in 0..4 {
190            changes[index] = changes[index].clamp(-domains[index] / 4.0, domains[index] / 4.0);
191        }
192        ua = fit(ua + changes[0], first.u0, first.u1, first.closed_u);
193        va = fit(va + changes[1], first.v0, first.v1, first.closed_v);
194        ub = fit(ub + changes[2], second.u0, second.u1, second.closed_u);
195        vb = fit(vb + changes[3], second.v0, second.v1, second.closed_v);
196    }
197    let a = first.surface.evaluate(ua, va)?;
198    let b = second.surface.evaluate(ub, vb)?;
199    if a.sub(b).length() <= tolerance {
200        Ok(Some(IntersectionPoint {
201            ua,
202            va,
203            ub,
204            vb,
205            point: a.add(b).scale(0.5),
206        }))
207    } else {
208        Ok(None)
209    }
210}
211
212fn find_start_points(
213    first: SurfaceInfo<'_>,
214    second: SurfaceInfo<'_>,
215    tolerance: f64,
216    density: f64,
217) -> Result<Vec<IntersectionPoint>, String> {
218    let grid_count = |info: SurfaceInfo<'_>, direction_u: bool| {
219        let (knots, degree) = if direction_u {
220            (&info.surface.knots_u, info.surface.degree_u)
221        } else {
222            (&info.surface.knots_v, info.surface.degree_v)
223        };
224        (8.0_f64)
225            .max(((interior_knot_count(knots, degree) + 1) * (degree + 1)) as f64 * density)
226            .ceil()
227            .min(24.0) as usize
228    };
229    let nu = grid_count(first, true);
230    let nv = grid_count(first, false);
231    let first_box = Bounds::from_surface_controls(first.surface)?;
232    let second_box = Bounds::from_surface_controls(second.surface)?;
233    let gate = first_box.diagonal().min(second_box.diagonal()) * 0.15 + tolerance;
234    let expanded_second = second_box.expanded((tolerance * 100.0).max(gate));
235    let mut starts = Vec::new();
236    for i in 0..=nu {
237        for j in 0..=nv {
238            let ua = first.u0 + (first.u1 - first.u0) * i as f64 / nu as f64;
239            let va = first.v0 + (first.v1 - first.v0) * j as f64 / nv as f64;
240            let point = first.surface.evaluate(ua, va)?;
241            if !expanded_second.contains(point) {
242                continue;
243            }
244            let projection = project_point_to_surface(second.surface, point)?;
245            if projection.distance > gate {
246                continue;
247            }
248            if let Some(refined) =
249                refine_point(first, second, ua, va, projection.u, projection.v, tolerance)?
250            {
251                starts.push(refined);
252            }
253        }
254    }
255    Ok(starts)
256}
257
258/// One knot-span leaf of a carrier: the control box of the span (the patch
259/// lies in it, weights being positive) and the span's parametric centre.
260struct SpanLeaf {
261    bounds: Bounds,
262    centre: [f64; 2],
263}
264
265/// The knot-span leaves of a carrier (A4): one per non-empty span pair, each
266/// boxed by the `(p+1)×(q+1)` control points that influence it. A net whose
267/// span structure cannot be read is one leaf, the whole box.
268fn span_leaves(info: SurfaceInfo<'_>) -> Result<Vec<SpanLeaf>, String> {
269    let surface = info.surface;
270    let (p, q) = (surface.degree_u, surface.degree_v);
271    let rows = surface.control_points.len();
272    let columns = surface.control_points.first().map_or(0, Vec::len);
273    let spans = |knots: &[f64], degree: usize, count: usize| -> Vec<usize> {
274        (degree..count).filter(|&i| i + 1 < knots.len() && knots[i] < knots[i + 1]).collect()
275    };
276    let (spans_u, spans_v) = (spans(&surface.knots_u, p, rows), spans(&surface.knots_v, q, columns));
277    if spans_u.is_empty() || spans_v.is_empty() {
278        return Ok(vec![SpanLeaf {
279            bounds: Bounds::from_surface_controls(surface)?,
280            centre: [0.5 * (info.u0 + info.u1), 0.5 * (info.v0 + info.v1)],
281        }]);
282    }
283    let mut leaves = Vec::with_capacity(spans_u.len() * spans_v.len());
284    for &i in &spans_u {
285        for &j in &spans_v {
286            let mut minimum = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
287            let mut maximum = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
288            for row in &surface.control_points[i - p..=i] {
289                for control in &row[j - q..=j] {
290                    let point = control.point()?;
291                    minimum.x = minimum.x.min(point.x);
292                    minimum.y = minimum.y.min(point.y);
293                    minimum.z = minimum.z.min(point.z);
294                    maximum.x = maximum.x.max(point.x);
295                    maximum.y = maximum.y.max(point.y);
296                    maximum.z = maximum.z.max(point.z);
297                }
298            }
299            leaves.push(SpanLeaf {
300                bounds: Bounds { minimum, maximum },
301                centre: [
302                    0.5 * (surface.knots_u[i] + surface.knots_u[i + 1]),
303                    0.5 * (surface.knots_v[j] + surface.knots_v[j + 1]),
304                ],
305            });
306        }
307    }
308    Ok(leaves)
309}
310
311/// Above this many overlapping leaf pairs the tree seeding hands back to the
312/// grid: two dense nets that overlap everywhere would otherwise refine tens
313/// of thousands of candidates where the grid's few hundred find the same
314/// curves.
315const TREE_SEED_PAIR_CAP: usize = 4096;
316
317/// A4: interior start candidates from the two carriers' knot-span leaves.
318/// For every pair of leaves whose boxes overlap (each grown by the same gate
319/// the grid uses) two candidates are refined onto both carriers: the first
320/// leaf's parametric centre projected onto the second carrier from the second
321/// leaf's centre, and the converse. `None` when the pair count is over
322/// [`TREE_SEED_PAIR_CAP`], which the caller answers with the grid.
323fn tree_start_points(
324    first: SurfaceInfo<'_>,
325    second: SurfaceInfo<'_>,
326    tolerance: f64,
327) -> Result<Option<(Vec<IntersectionPoint>, usize)>, String> {
328    let first_box = Bounds::from_surface_controls(first.surface)?;
329    let second_box = Bounds::from_surface_controls(second.surface)?;
330    let gate = first_box.diagonal().min(second_box.diagonal()) * 0.15 + tolerance;
331    let grow = (tolerance * 100.0).max(gate);
332    let leaves_a = span_leaves(first)?;
333    let leaves_b = span_leaves(second)?;
334    let mut pairs = Vec::new();
335    for (ia, leaf_a) in leaves_a.iter().enumerate() {
336        for (ib, leaf_b) in leaves_b.iter().enumerate() {
337            if leaf_a.bounds.intersects(leaf_b.bounds, grow) {
338                pairs.push((ia, ib));
339                if pairs.len() > TREE_SEED_PAIR_CAP {
340                    return Ok(None);
341                }
342            }
343        }
344    }
345    let mut starts = Vec::new();
346    for (ia, ib) in &pairs {
347        let (leaf_a, leaf_b) = (&leaves_a[*ia], &leaves_b[*ib]);
348        // A leaf's centre is at most a leaf diagonal from anything in its
349        // box, so a candidate that far from the other carrier is still the
350        // pair's own and is refined; the grid's gate (a fraction of the
351        // whole carrier) would drop every candidate of a leaf pair smaller
352        // than it — a sphere of radius `0.005 L` on an `L` sheet.
353        let reach = gate.max(leaf_a.bounds.diagonal().max(leaf_b.bounds.diagonal()));
354        // The first leaf's centre onto the second carrier, seeded from the
355        // second leaf's centre; then the converse.
356        for (from, onto, from_info, onto_info, from_first) in [
357            (leaf_a, leaf_b, first, second, true),
358            (leaf_b, leaf_a, second, first, false),
359        ] {
360            let point = from_info.surface.evaluate(from.centre[0], from.centre[1])?;
361            let projection = project_point_to_surface_from_seed(onto_info.surface, point, onto.centre)?;
362            if projection.distance > reach {
363                continue;
364            }
365            let refined = if from_first {
366                refine_point(first, second, from.centre[0], from.centre[1], projection.u, projection.v, tolerance)?
367            } else {
368                refine_point(first, second, projection.u, projection.v, from.centre[0], from.centre[1], tolerance)?
369            };
370            if let Some(refined) = refined {
371                starts.push(refined);
372            }
373        }
374    }
375    Ok(Some((starts, pairs.len())))
376}
377
378/// Whether the two carriers CROSS at `point` (`|n_a × n_b|` at or above
379/// [`TRANSVERSE_START_CROSS`]) rather than touch tangentially; a surface
380/// with no normal there counts as not crossing.
381fn transverse_at(
382    first: SurfaceInfo<'_>,
383    second: SurfaceInfo<'_>,
384    point: IntersectionPoint,
385) -> bool {
386    crossing_at(first, second, point) >= TRANSVERSE_START_CROSS
387}
388
389/// `|n_a × n_b|` at `point`; zero where either carrier has no normal there.
390fn crossing_at(
391    first: SurfaceInfo<'_>,
392    second: SurfaceInfo<'_>,
393    point: IntersectionPoint,
394) -> f64 {
395    match (
396        first.surface.normal(point.ua, point.va),
397        second.surface.normal(point.ub, point.vb),
398    ) {
399        (Ok(a), Ok(b)) => a.cross(b).length(),
400        _ => 0.0,
401    }
402}
403
404fn tangent_at(
405    first: SurfaceInfo<'_>,
406    second: SurfaceInfo<'_>,
407    point: IntersectionPoint,
408) -> Result<Option<Vec3>, String> {
409    let first_normal = first.surface.normal(point.ua, point.va).ok();
410    let second_normal = second.surface.normal(point.ub, point.vb).ok();
411    match (first_normal, second_normal) {
412        (Some(a), Some(b)) => {
413            let tangent = a.cross(b);
414            if tangent.length() <= 1e-9 {
415                Ok(None)
416            } else {
417                Ok(Some(tangent.normalized()?))
418            }
419        }
420        _ => Ok(None),
421    }
422}
423
424fn polish_boundary(
425    first: SurfaceInfo<'_>,
426    second: SurfaceInfo<'_>,
427    mut point: IntersectionPoint,
428    tolerance: f64,
429) -> Result<Option<IntersectionPoint>, String> {
430    let free = [
431        first.closed_u || (point.ua > first.u0 && point.ua < first.u1),
432        first.closed_v || (point.va > first.v0 && point.va < first.v1),
433        second.closed_u || (point.ub > second.u0 && point.ub < second.u1),
434        second.closed_v || (point.vb > second.v0 && point.vb < second.v1),
435    ];
436    for _ in 0..40 {
437        let da = first.surface.derivatives(point.ua, point.va, 1)?;
438        let db = second.surface.derivatives(point.ub, point.vb, 1)?;
439        let residual = da[0][0].sub(db[0][0]);
440        if residual.length() <= tolerance {
441            point.point = da[0][0].add(db[0][0]).scale(0.5);
442            return Ok(Some(point));
443        }
444        let all_columns = [
445            da[1][0],
446            da[0][1],
447            db[1][0].scale(-1.0),
448            db[0][1].scale(-1.0),
449        ];
450        let indices: Vec<usize> = (0..4).filter(|index| free[*index]).collect();
451        if indices.is_empty() {
452            return Ok(None);
453        }
454        let mut matrix = [[0.0f64; 4]; 4];
455        let mut rhs = [0.0f64; 4];
456        for (row, &r) in indices.iter().enumerate() {
457            rhs[row] = -all_columns[r].dot(residual);
458            for (column, &c) in indices.iter().enumerate() {
459                matrix[row][column] = all_columns[r].dot(all_columns[c]);
460            }
461        }
462        let changes = match solve_small(matrix, rhs, indices.len()) {
463            Ok(value) => value,
464            Err(_) => return Ok(None),
465        };
466        let mut update = [0.0; 4];
467        for (index, &parameter_index) in indices.iter().enumerate() {
468            update[parameter_index] = changes[index];
469        }
470        point.ua = fit(point.ua + update[0], first.u0, first.u1, first.closed_u);
471        point.va = fit(point.va + update[1], first.v0, first.v1, first.closed_v);
472        point.ub = fit(point.ub + update[2], second.u0, second.u1, second.closed_u);
473        point.vb = fit(point.vb + update[3], second.v0, second.v1, second.closed_v);
474    }
475    Ok(None)
476}
477
478fn correct(
479    first: SurfaceInfo<'_>,
480    second: SurfaceInfo<'_>,
481    from: IntersectionPoint,
482    predicted: Vec3,
483    tangent: Vec3,
484    tolerance: f64,
485) -> Result<Option<(IntersectionPoint, bool)>, String> {
486    let mut point = from;
487    let mut boundary = false;
488    for _ in 0..30 {
489        let da = first.surface.derivatives(point.ua, point.va, 1)?;
490        let db = second.surface.derivatives(point.ub, point.vb, 1)?;
491        let residual = da[0][0].sub(db[0][0]);
492        let plane_residual = da[0][0].sub(predicted).dot(tangent);
493        if residual.length() <= tolerance * 0.01 + EPSILON
494            && plane_residual.abs() <= tolerance * 0.01 + EPSILON
495        {
496            point.point = da[0][0].add(db[0][0]).scale(0.5);
497            return Ok(Some((point, boundary)));
498        }
499        let matrix = [
500            [da[1][0].x, da[0][1].x, -db[1][0].x, -db[0][1].x],
501            [da[1][0].y, da[0][1].y, -db[1][0].y, -db[0][1].y],
502            [da[1][0].z, da[0][1].z, -db[1][0].z, -db[0][1].z],
503            [da[1][0].dot(tangent), da[0][1].dot(tangent), 0.0, 0.0],
504        ];
505        let changes = match solve_small(
506            matrix,
507            [-residual.x, -residual.y, -residual.z, -plane_residual],
508            4,
509        ) {
510            Ok(value) => value,
511            Err(_) => return Ok(None),
512        };
513        let scale = [
514            changes[0].abs() / ((first.u1 - first.u0) / 4.0),
515            changes[1].abs() / ((first.v1 - first.v0) / 4.0),
516            changes[2].abs() / ((second.u1 - second.u0) / 4.0),
517            changes[3].abs() / ((second.v1 - second.v0) / 4.0),
518            1.0,
519        ]
520        .into_iter()
521        .fold(1.0_f64, f64::max);
522        let raw = [
523            point.ua + changes[0] / scale,
524            point.va + changes[1] / scale,
525            point.ub + changes[2] / scale,
526            point.vb + changes[3] / scale,
527        ];
528        let domains = [
529            (first.u0, first.u1, first.closed_u),
530            (first.v0, first.v1, first.closed_v),
531            (second.u0, second.u1, second.closed_u),
532            (second.v0, second.v1, second.closed_v),
533        ];
534        boundary = false;
535        let mut next = [0.0; 4];
536        for index in 0..4 {
537            next[index] = fit(
538                raw[index],
539                domains[index].0,
540                domains[index].1,
541                domains[index].2,
542            );
543            if !domains[index].2
544                && (next[index] == domains[index].0 || next[index] == domains[index].1)
545                && raw[index] != next[index]
546            {
547                boundary = true;
548            }
549        }
550        let old = [point.ua, point.va, point.ub, point.vb];
551        let spans = [
552            first.u1 - first.u0,
553            first.v1 - first.v0,
554            second.u1 - second.u0,
555            second.v1 - second.v0,
556        ];
557        let stalled = (0..4).all(|index| (next[index] - old[index]).abs() <= 1e-14 * spans[index]);
558        point.ua = next[0];
559        point.va = next[1];
560        point.ub = next[2];
561        point.vb = next[3];
562        if stalled {
563            if boundary {
564                if let Some(polished) = polish_boundary(first, second, point, tolerance)? {
565                    return Ok(Some((polished, true)));
566                }
567            }
568            return Ok(None);
569        }
570    }
571    Ok(None)
572}
573
574struct Trace {
575    points: Vec<IntersectionPoint>,
576    /// One unit tangent per point, along the trace direction.
577    tangents: Vec<Vec3>,
578    closed: bool,
579    /// The largest span deviation measured, `0.0` when none was asked for.
580    deviation: f64,
581}
582
583/// A branch assembled from its traces: stations, one tangent per station
584/// along the branch, and the largest span deviation either trace measured.
585struct Spliced {
586    points: Vec<IntersectionPoint>,
587    tangents: Vec<Vec3>,
588    deviation: f64,
589}
590
591impl From<Trace> for Spliced {
592    fn from(trace: Trace) -> Self {
593        Self {
594            points: trace.points,
595            tangents: trace.tangents,
596            deviation: trace.deviation,
597        }
598    }
599}
600
601/// `reverse(backward[1..]) ++ forward`: the backward trace walked the other
602/// way, so its tangents are negated to run along the branch.
603fn splice(backward: Trace, forward: Trace) -> Spliced {
604    let mut points: Vec<_> = backward.points.into_iter().skip(1).rev().collect();
605    let mut tangents: Vec<_> = backward
606        .tangents
607        .into_iter()
608        .skip(1)
609        .rev()
610        .map(|tangent| tangent.scale(-1.0))
611        .collect();
612    points.extend(forward.points);
613    tangents.extend(forward.tangents);
614    Spliced {
615        points,
616        tangents,
617        deviation: backward.deviation.max(forward.deviation),
618    }
619}
620
621/// Preserve complete tangents for UV reuse without asking the marcher to
622/// measure or change its spans. Deviation remains absent unless measured.
623fn curve_from(all: Spliced, closed: bool, measured: bool) -> SurfaceIntersectionCurve {
624    let complete = all.tangents.len() == all.points.len();
625    SurfaceIntersectionCurve {
626        points: all.points.iter().map(|point| point.point).collect(),
627        params_a: all.points.iter().map(|point| [point.ua, point.va]).collect(),
628        params_b: all.points.iter().map(|point| [point.ub, point.vb]).collect(),
629        closed,
630        tangents: (complete && (measured || (crate::imprint::carried_uv_enabled() && all.points.len() >= crate::imprint::carried_uv_min_stations()))).then_some(all.tangents),
631        span_deviation: (complete && measured).then_some(all.deviation),
632        tangent: false,
633    }
634}
635
636/// The cubic Hermite through `p0` and `p1` with end derivatives `d0`, `d1`,
637/// at `s` in `[0, 1]`.
638fn hermite_at(p0: Vec3, d0: Vec3, p1: Vec3, d1: Vec3, s: f64) -> Vec3 {
639    let s2 = s * s;
640    let s3 = s2 * s;
641    p0.scale(2.0 * s3 - 3.0 * s2 + 1.0)
642        .add(d0.scale(s3 - 2.0 * s2 + s))
643        .add(p1.scale(-2.0 * s3 + 3.0 * s2))
644        .add(d1.scale(s3 - s2))
645}
646
647/// A cubic Hermite in one carrier's parameter plane between two stations:
648/// end parameters, and end derivatives that are the least-squares images of
649/// the chord-scaled 3D tangents through `[S_u S_v]`. Closed directions are
650/// unwrapped so the Hermite runs the short way round; [`fit`] wraps the
651/// samples back.
652struct ParameterHermite {
653    p0: [f64; 2],
654    d0: [f64; 2],
655    p1: [f64; 2],
656    d1: [f64; 2],
657}
658
659impl ParameterHermite {
660    fn new(
661        info: SurfaceInfo<'_>,
662        p0: [f64; 2],
663        t0: Vec3,
664        p1: [f64; 2],
665        t1: Vec3,
666        chord: f64,
667    ) -> Result<Self, String> {
668        let image = |uv: [f64; 2], tangent: Vec3| -> Result<[f64; 2], String> {
669            let d = info.surface.derivatives(uv[0], uv[1], 1)?;
670            let (su, sv) = (d[1][0], d[0][1]);
671            let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
672            let (bu, bv) = (su.dot(tangent), sv.dot(tangent));
673            let det = e * g - f * f;
674            if det.abs() <= EPSILON * (1.0 + e * g) {
675                return Ok([0.0, 0.0]);
676            }
677            Ok([
678                (g * bu - f * bv) / det * chord,
679                (e * bv - f * bu) / det * chord,
680            ])
681        };
682        let mut p1 = p1;
683        let unwrap = |value: f64, near: f64, closed: bool, minimum: f64, maximum: f64| {
684            let period = maximum - minimum;
685            if closed && period > 0.0 && (value - near).abs() > 0.5 * period {
686                value - period * ((value - near) / period).round()
687            } else {
688                value
689            }
690        };
691        p1[0] = unwrap(p1[0], p0[0], info.closed_u, info.u0, info.u1);
692        p1[1] = unwrap(p1[1], p0[1], info.closed_v, info.v0, info.v1);
693        Ok(Self {
694            p0,
695            d0: image(p0, t0)?,
696            p1,
697            d1: image([fit(p1[0], info.u0, info.u1, info.closed_u), fit(p1[1], info.v0, info.v1, info.closed_v)], t1)?,
698        })
699    }
700
701    fn at(&self, s: f64) -> [f64; 2] {
702        let s2 = s * s;
703        let s3 = s2 * s;
704        let (h00, h10, h01, h11) = (
705            2.0 * s3 - 3.0 * s2 + 1.0,
706            s3 - 2.0 * s2 + s,
707            -2.0 * s3 + 3.0 * s2,
708            s3 - s2,
709        );
710        [
711            h00 * self.p0[0] + h10 * self.d0[0] + h01 * self.p1[0] + h11 * self.d1[0],
712            h00 * self.p0[1] + h10 * self.d0[1] + h01 * self.p1[1] + h11 * self.d1[1],
713        ]
714    }
715}
716
717/// It is what the emitted span would miss by if the stations were
718/// interpolated with these tangents, measured against the carriers
719/// themselves, which no test on the stations alone can see.
720fn span_deviation(
721    first: SurfaceInfo<'_>,
722    second: SurfaceInfo<'_>,
723    from: IntersectionPoint,
724    from_tangent: Vec3,
725    to: IntersectionPoint,
726    to_tangent: Vec3,
727) -> Result<f64, String> {
728    let chord = to.point.sub(from.point).length();
729    if chord <= EPSILON {
730        return Ok(0.0);
731    }
732    let on_a = ParameterHermite::new(first, [from.ua, from.va], from_tangent, [to.ua, to.va], to_tangent, chord)?;
733    let on_b = ParameterHermite::new(second, [from.ub, from.vb], from_tangent, [to.ub, to.vb], to_tangent, chord)?;
734    let (d0, d1) = (from_tangent.scale(chord), to_tangent.scale(chord));
735    let mut worst: f64 = 0.0;
736    for s in [0.17, 0.33, 0.5, 0.67, 0.83] {
737        let c = hermite_at(from.point, d0, to.point, d1, s);
738        let [ua, va] = on_a.at(s);
739        let a = first.surface.evaluate(fit(ua, first.u0, first.u1, first.closed_u), fit(va, first.v0, first.v1, first.closed_v))?;
740        let [ub, vb] = on_b.at(s);
741        let b = second.surface.evaluate(fit(ub, second.u0, second.u1, second.closed_u), fit(vb, second.v0, second.v1, second.closed_v))?;
742        worst = worst
743            .max(a.sub(b).length())
744            .max(a.sub(c).length())
745            .max(b.sub(c).length());
746    }
747    Ok(worst)
748}
749
750fn vector_angle(a: Vec3, b: Vec3) -> f64 {
751    (a.dot(b) / (a.length() * b.length()))
752        .clamp(-1.0, 1.0)
753        .acos()
754}
755
756/// Predictive step size from the surface-normal turn budget (Golovanov
757/// §4.12): for each parameter of each surface the permissible parametric
758/// step is Δα·√g/b (g, b = first/second fundamental form coefficients);
759/// its 3D advance is Δα·g/b, and the usable step is the smallest advance
760/// PROJECTED onto the march direction (the displacement sphere of §4.8),
761/// ignoring parameters that barely move along the curve.  Returns None
762/// when curvature data is unavailable (degenerate normals) — the caller
763/// keeps its reactive step then.
764fn normal_turn_step(
765    first: SurfaceInfo<'_>,
766    second: SurfaceInfo<'_>,
767    point: IntersectionPoint,
768    tangent: Vec3,
769    budget: f64,
770) -> Option<f64> {
771    let mut best: Option<f64> = None;
772    for (surface, u, v) in [
773        (first.surface, point.ua, point.va),
774        (second.surface, point.ub, point.vb),
775    ] {
776        let derivatives = surface.derivatives(u, v, 2).ok()?;
777        let normal = derivatives[1][0].cross(derivatives[0][1]);
778        let normal_length = normal.length();
779        if normal_length <= 1e-12 {
780            continue;
781        }
782        let unit_normal = normal.scale(1.0 / normal_length);
783        for (first_derivative, second_derivative) in [
784            (derivatives[1][0], derivatives[2][0]),
785            (derivatives[0][1], derivatives[0][2]),
786        ] {
787            let metric = first_derivative.dot(first_derivative);
788            if metric <= 1e-16 {
789                continue;
790            }
791            let curvature = second_derivative.dot(unit_normal).abs();
792            if curvature <= 1e-12 {
793                continue; // flat along this parameter: no budget limit
794            }
795            let advance = budget * metric / curvature;
796            let alignment = first_derivative
797                .scale(1.0 / metric.sqrt())
798                .dot(tangent)
799                .abs();
800            if alignment <= 0.1 {
801                continue; // barely moves along the curve (§4.8)
802            }
803            let along = advance * alignment;
804            if best.map(|value| along < value).unwrap_or(true) {
805                best = Some(along);
806            }
807        }
808    }
809    best
810}
811
812/// The switches a trace reads, taken from the environment once per
813/// intersection call rather than once per trace.
814#[derive(Clone, Copy)]
815struct TraceSwitches {
816    /// `BREP_SSI_FIRST_STEP_BUDGET=0` lets the first step skip the §4.12
817    /// ceiling, as every trace did before 2026-09-15.
818    first_step_ceiling: bool,
819    /// `BREP_DEBUG_SSI_TRACE=1`: every start and every accepted station.
820    debug: bool,
821}
822
823impl TraceSwitches {
824    fn from_env() -> Self {
825        Self {
826            first_step_ceiling: std::env::var("BREP_SSI_FIRST_STEP_BUDGET").as_deref() != Ok("0"),
827            debug: std::env::var_os("BREP_DEBUG_SSI_TRACE").is_some(),
828        }
829    }
830}
831
832#[allow(clippy::too_many_arguments)]
833fn trace(
834    first: SurfaceInfo<'_>,
835    second: SurfaceInfo<'_>,
836    start: IntersectionPoint,
837    direction: f64,
838    tolerance: f64,
839    initial_step: f64,
840    minimum_step: f64,
841    maximum_step: f64,
842    maximum_steps: usize,
843    switches: TraceSwitches,
844    span_budget: Option<f64>,
845) -> Result<Trace, String> {
846    let mut points = vec![start];
847    let mut current = start;
848    let Some(mut tangent) = tangent_at(first, second, start)? else {
849        return Ok(Trace {
850            points,
851            tangents: Vec::new(),
852            closed: false,
853            deviation: 0.0,
854        });
855    };
856    tangent = tangent.scale(direction);
857    let mut tangents = vec![tangent];
858    let mut deviation: f64 = 0.0;
859    // The FIRST step is held to the same §4.12 normal-turn ceiling as every
860    // later one. The ceiling used to be applied only after a step was accepted,
861    // so step zero was `diagonal / 100` whatever the curvature — and a step
862    // that lands on a trim-window boundary ends the branch before the turn
863    // check below ever reads it. On the rescue pair
864    // (`boolean_fuzz_corpus/23_section_pcurve_fold_t222`, cone × a 90° pin-wall
865    // window) that one step was the whole 0.69 and 0.77 of section, turning the
866    // tangent 0.97 rad where the ceiling allows 1.0e-2 of advance: two
867    // stations, a four-point fit, and trims 3.0e-2 and 3.8e-2 apart. Escape
868    // hatch: BREP_SSI_FIRST_STEP_BUDGET=0.
869    let mut step_size = initial_step.min(maximum_step);
870    if switches.first_step_ceiling {
871        if let Some(budget) = normal_turn_step(first, second, start, tangent, 0.1) {
872            step_size = step_size.min(budget).clamp(minimum_step, maximum_step);
873        }
874    }
875    let mut terminated = false;
876    for _ in 0..maximum_steps {
877        let mut accepted = None;
878        while step_size >= minimum_step {
879            let predicted = current.point.add(tangent.scale(step_size));
880            if let Some((corrected, boundary)) =
881                correct(first, second, current, predicted, tangent, tolerance)?
882            {
883                if corrected.point.sub(current.point).length() <= 2.5 * step_size {
884                    accepted = Some((corrected, boundary));
885                    break;
886                }
887            }
888            step_size /= 2.0;
889        }
890        let Some((candidate, boundary)) = accepted else {
891            terminated = true;
892            break;
893        };
894        points.push(candidate);
895        if switches.debug {
896            // One line per accepted station: the tangent's turn across the
897            // step, the normal-turn ceiling where it started, the step asked
898            // for and the distance actually covered.
899            let turn = tangent_at(first, second, candidate)
900                .ok()
901                .flatten()
902                .map(|next| vector_angle(tangent, if next.dot(tangent) < 0.0 { next.scale(-1.0) } else { next }))
903                .unwrap_or(f64::NAN);
904            let budget = normal_turn_step(first, second, current, tangent, 0.1).unwrap_or(f64::NAN);
905            let at = candidate.point;
906            eprintln!(
907                "SSI-TRACE dir {direction} step {step_size:.4e} jump {:.4e} turn {turn:.4} ceiling {budget:.4e} boundary {boundary} p ({:.6},{:.6},{:.6})",
908                at.sub(current.point).length(),
909                at.x,
910                at.y,
911                at.z
912            );
913        }
914        // A station that ends the trace still gets a tangent (its own where
915        // one exists, else the previous one carried forward), so the tangent
916        // list stays parallel to the stations.
917        let next = tangent_at(first, second, candidate)?.map(|next| {
918            if next.dot(tangent) < 0.0 { next.scale(-1.0) } else { next }
919        });
920        if boundary {
921            tangents.push(next.unwrap_or(tangent));
922            terminated = true;
923            break;
924        }
925        let Some(next_tangent) = next else {
926            tangents.push(tangent);
927            terminated = true;
928            break;
929        };
930        let angle = vector_angle(tangent, next_tangent);
931        if angle > 0.35 {
932            points.pop();
933            step_size = minimum_step.max(step_size / 2.0);
934            if step_size <= minimum_step * 1.01 {
935                points.push(candidate);
936            } else {
937                continue;
938            }
939        }
940        // The span test (A1): the span between `current` and `candidate`
941        // is measured against both carriers; over budget, it is re-stepped
942        // at half the step, like a turn over 0.35, and accepted at the
943        // minimum step regardless.
944        let mut span_fine = true;
945        if let Some(budget) = span_budget {
946            let measured = span_deviation(first, second, current, tangent, candidate, next_tangent)?;
947            if measured > budget && step_size > minimum_step * 1.01 {
948                points.pop();
949                step_size = minimum_step.max(step_size / 2.0);
950                continue;
951            }
952            deviation = deviation.max(measured);
953            span_fine = measured < budget / 8.0;
954        }
955        tangents.push(next_tangent);
956        // §4.12 normal-turn budget on BOTH surfaces as a hard CEILING on
957        // the reactive controller: growth stays reactive (accelerating
958        // into under-sampled flats regressed downstream curve fits), but
959        // the budget stops the step from outrunning surface curvature.
960        let reactive = if angle < 0.03 && span_fine {
961            step_size * 1.5
962        } else if angle > 0.15 {
963            step_size * 0.6
964        } else {
965            step_size
966        };
967        step_size = match normal_turn_step(first, second, candidate, next_tangent, 0.1) {
968            Some(budget) => reactive.min(budget),
969            None => reactive,
970        }
971        .clamp(minimum_step, maximum_step);
972        if points.len() > 4 && candidate.point.sub(start.point).length() <= step_size * 1.2 {
973            let to_start = start.point.sub(candidate.point);
974            if to_start.length() <= EPSILON || to_start.dot(next_tangent) >= 0.0 {
975                points.push(start);
976                tangents.push(tangents[0]);
977                return Ok(Trace {
978                    points,
979                    tangents,
980                    closed: true,
981                    deviation,
982                });
983            }
984        }
985        current = candidate;
986        tangent = next_tangent;
987    }
988    if !terminated {
989        // The step budget ran out with the curve still going: a silently
990        // truncated branch flows into topology as if complete, which is far
991        // worse than a loud failure here.
992        return Err(format!(
993            "surface intersection trace exhausted {maximum_steps} steps \
994             ({} points, step {step_size:.3e}) without reaching a boundary or closure",
995            points.len()
996        ));
997    }
998    Ok(Trace {
999        points,
1000        tangents,
1001        closed: false,
1002        deviation,
1003    })
1004}
1005
1006fn point_segment_distance(point: Vec3, start: Vec3, end: Vec3) -> f64 {
1007    point.distance_to_segment(start, end, EPSILON)
1008}
1009
1010#[derive(Clone, Debug, Serialize)]
1011pub struct SurfaceIntersectionCurve {
1012    pub points: Vec<Vec3>,
1013    pub params_a: Vec<[f64; 2]>,
1014    pub params_b: Vec<[f64; 2]>,
1015    pub closed: bool,
1016    /// The section's unit tangent at each station, along the curve's
1017    /// direction; retained for trace-UV reuse or when the march ran with a
1018    /// [`SurfaceIntersectionOptions::span_budget`].
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub tangents: Option<Vec<Vec3>>,
1021    /// The largest span deviation the march measured on this curve;
1022    /// present only when it ran with a `span_budget`.
1023    #[serde(skip_serializing_if = "Option::is_none")]
1024    pub span_deviation: Option<f64>,
1025    /// The curve is a TANGENT section: the carriers touch along it with
1026    /// parallel normals rather than crossing it, so it separates nothing on
1027    /// either face. Only `intersect::tangent_curve` produces one; every
1028    /// marched or supplemental curve reads `false`.
1029    #[serde(skip_serializing_if = "std::ops::Not::not")]
1030    pub tangent: bool,
1031}
1032
1033#[derive(Clone, Debug, Deserialize)]
1034pub struct SurfaceIntersectionOptions {
1035    #[serde(default = "default_tolerance")]
1036    pub tolerance: f64,
1037    #[serde(default = "default_density")]
1038    pub seed_density: f64,
1039    #[serde(default = "default_maximum_steps")]
1040    pub maximum_steps: usize,
1041    pub maximum_step: Option<f64>,
1042    #[serde(default)]
1043    pub seed_points: Vec<Vec3>,
1044    #[serde(default)]
1045    pub seed_only: bool,
1046    pub seed_gate: Option<f64>,
1047    /// When set, every accepted span is re-sampled as the cubic Hermite
1048    /// through its two stations — chord-scaled tangents, in 3D and in each
1049    /// carrier's parameter plane — at five interior parameters, and the
1050    /// span's deviation is the largest distance among the 3D Hermite and
1051    /// the two carriers' images of their parameter Hermites. A span over
1052    /// the budget is rejected and re-stepped at half the step (accepted at
1053    /// the minimum step regardless), and the step grows only while the
1054    /// deviation stays under an eighth of the budget. The curve then
1055    /// reports its station tangents and its largest span deviation.
1056    ///
1057    /// `None`, the default, measures nothing and marches exactly as before.
1058    /// The budget a lane carries is what its FIT can absorb (the imprint's
1059    /// `intersection_fit`), never the corrector's `tolerance`: a cubic
1060    /// Hermite over a circle of radius `r` with step `h` misses by about
1061    /// `r (h/r)^4 / 384`, so a budget of `1e-7` would hold the step near
1062    /// `r/15` and multiply stations on every curved section.
1063    #[serde(default)]
1064    pub span_budget: Option<f64>,
1065    /// Off, the default, keeps the grid-then-seeds order byte for byte.
1066    #[serde(default)]
1067    pub ordered_starts: bool,
1068    /// A3's second half: a start an earlier trace ran through is consumed
1069    /// with a station snapped to it (the start is a refined intersection
1070    /// point, the chord it lies by is not), instead of merely dropped. Off,
1071    /// the default, keeps the first-claimant rule. Measured apart from the
1072    /// ordering because it inserts stations into traced curves.
1073    #[serde(default)]
1074    pub consume_starts: bool,
1075    /// The transverse gate stays. Off, the default, grids as before.
1076    #[serde(default)]
1077    pub tree_seeds: bool,
1078    /// SUB-GATE STARTS (2026-10-17, the revolve-into-bore report): the
1079    /// `|n_a × n_b|` an EXPLICIT seed is admitted at, in place of
1080    /// `TRANSVERSE_START_CROSS`. Grid and tree starts keep the gate: the
1081    /// tangency-band runaway it exists for is theirs (a refined start inside
1082    /// a band is a point no transverse curve passes through). An explicit
1083    /// seed the caller has PROVEN to lie on a crossing — a trim edge of one
1084    /// face that leaves one side of the other carrier for the other at the
1085    /// seed — is a point of a transverse section whatever the angle, and the
1086    /// march from it is sound down to where the corrector can still tell the
1087    /// section from the tangency band: the lateral uncertainty `tol / α` of
1088    /// a crossing at angle `α` against the band's half-width `√(2 R tol)`
1089    /// across a contact of curvature radius `R`, so `α ≥ √(tol / 2R)`; the
1090    /// caller derives the bound from its own tolerance and scale. A seed
1091    /// below it is dropped exactly as before. `None`, the default, keeps the
1092    /// gate byte for byte.
1093    #[serde(default)]
1094    pub seed_start_cross: Option<f64>,
1095}
1096
1097fn default_tolerance() -> f64 {
1098    LINEAR_TOLERANCE
1099}
1100fn default_density() -> f64 {
1101    1.0
1102}
1103fn default_maximum_steps() -> usize {
1104    4000
1105}
1106
1107impl Default for SurfaceIntersectionOptions {
1108    fn default() -> Self {
1109        Self {
1110            tolerance: default_tolerance(),
1111            seed_density: default_density(),
1112            maximum_steps: default_maximum_steps(),
1113            maximum_step: None,
1114            seed_points: Vec::new(),
1115            seed_only: false,
1116            seed_gate: None,
1117            span_budget: None,
1118            ordered_starts: false,
1119            consume_starts: false,
1120            tree_seeds: false,
1121            seed_start_cross: None,
1122        }
1123    }
1124}
1125
1126/// A station used only to construct an emitted section. Refinement preserves
1127/// every supplied station; it never replaces the topology-bearing trace.
1128#[derive(Clone, Copy, Debug)]
1129pub(crate) struct EmissionSample {
1130    pub point: Vec3,
1131    pub uv_a: [f64; 2],
1132    pub uv_b: [f64; 2],
1133    pub tangent: Vec3,
1134}
1135
1136/// Subdivide smooth spans locally until their joint 3D/UV Hermites pass the
1137/// existing span check. Failure is a conservative request for the fit route.
1138/// No seed search, branch tracing, clipping, station motion or topology edit.
1139pub(crate) fn refine_emission_samples(
1140    first_surface: &NurbsSurface,
1141    second_surface: &NurbsSurface,
1142    samples: &[EmissionSample],
1143    budget: f64,
1144) -> Result<Option<Vec<EmissionSample>>, String> {
1145    if samples.len() < 2 || !(budget > 0.0 && budget.is_finite()) { return Ok(None); }
1146    let first = surface_info(first_surface)?;
1147    let second = surface_info(second_surface)?;
1148    let point = |s: EmissionSample| IntersectionPoint {
1149        point: s.point, ua: s.uv_a[0], va: s.uv_a[1], ub: s.uv_b[0], vb: s.uv_b[1],
1150    };
1151    // Fixed endpoints must themselves meet the emission floor. Refining an
1152    // interior cannot repair an inaccurate endpoint without moving topology.
1153    for s in samples {
1154        if !(first_surface.evaluate(s.uv_a[0], s.uv_a[1])?.sub(s.point).length() <= budget)
1155            || !(second_surface.evaluate(s.uv_b[0], s.uv_b[1])?.sub(s.point).length() <= budget)
1156        { return Ok(None); }
1157    }
1158    let mut pending: Vec<_> = samples.windows(2).rev().map(|p| (p[0], p[1], 0usize)).collect();
1159    let mut out = vec![samples[0]];
1160    let mut inserted = 0;
1161    while let Some((a, b, depth)) = pending.pop() {
1162        let pa = point(a);
1163        let pb = point(b);
1164        if span_deviation(first, second, pa, a.tangent, pb, b.tangent)? <= budget {
1165            out.push(b);
1166            continue;
1167        }
1168        let chord_vector = b.point.sub(a.point);
1169        let chord = chord_vector.length();
1170        if depth >= 8 || inserted >= 4096 || chord <= 4.0 * crate::KNOT_IDENTITY_TOL
1171            || crossing_at(first, second, pa) < 0.05 || crossing_at(first, second, pb) < 0.05
1172            || a.tangent.dot(b.tangent) < 0.5
1173            || a.tangent.dot(chord_vector) <= 0.5 * chord || b.tangent.dot(chord_vector) <= 0.5 * chord
1174        { return Ok(None); }
1175        let ha = ParameterHermite::new(first, a.uv_a, a.tangent, b.uv_a, b.tangent, chord)?;
1176        let hb = ParameterHermite::new(second, a.uv_b, a.tangent, b.uv_b, b.tangent, chord)?;
1177        let ua = ha.at(0.5);
1178        let ub = hb.at(0.5);
1179        let predicted = hermite_at(a.point, a.tangent.scale(chord), b.point, b.tangent.scale(chord), 0.5);
1180        let direction = a.tangent.add(b.tangent).normalized()?;
1181        let seed = IntersectionPoint {
1182            point: predicted,
1183            ua: fit(ua[0], first.u0, first.u1, first.closed_u),
1184            va: fit(ua[1], first.v0, first.v1, first.closed_v),
1185            ub: fit(ub[0], second.u0, second.u1, second.closed_u),
1186            vb: fit(ub[1], second.v0, second.v1, second.closed_v),
1187        };
1188        let Some((mid, boundary)) = correct(first, second, seed, predicted, direction, budget * 0.1)? else { return Ok(None); };
1189        let along = mid.point.sub(a.point).dot(chord_vector) / (chord * chord);
1190        if boundary || mid.point.sub(predicted).length() > 0.25 * chord
1191            || !(0.2..=0.8).contains(&along) || crossing_at(first, second, mid) < 0.05
1192        { return Ok(None); }
1193        let Some(mut tangent) = tangent_at(first, second, mid)? else { return Ok(None); };
1194        if tangent.dot(direction) < 0.0 { tangent = tangent.scale(-1.0); }
1195        if tangent.dot(a.tangent) < 0.5 || tangent.dot(b.tangent) < 0.5 { return Ok(None); }
1196        let m = EmissionSample { point: mid.point, uv_a: [mid.ua, mid.va], uv_b: [mid.ub, mid.vb], tangent };
1197        inserted += 1;
1198        pending.push((m, b, depth + 1));
1199        pending.push((a, m, depth + 1));
1200    }
1201    Ok(Some(out))
1202}
1203
1204/// Refine `seed` onto the intersection of `first_surface` and `second_surface`:
1205/// the closest point on each carrier seeds the same Newton the march uses
1206/// ([`refine_point`]), so the answer is an INTERSECTION point at the march's own
1207/// residual (`tolerance * 0.01`), not a projection onto one surface — a point
1208/// merely projected onto one carrier would carry the very bias a section
1209/// refiner exists to remove (2026-09-26, the sphere/bore rim of `BadBoolean`).
1210/// `None` when the Newton does not converge or lands further than `reach` from
1211/// the seed: a refiner must not pull a station onto another branch.
1212pub(crate) fn refine_to_intersection(
1213    first_surface: &NurbsSurface,
1214    second_surface: &NurbsSurface,
1215    seed: Vec3,
1216    tolerance: f64,
1217    reach: f64,
1218) -> Result<Option<Vec3>, String> {
1219    let first = surface_info(first_surface)?;
1220    let second = surface_info(second_surface)?;
1221    let on_first = project_point_to_surface(first_surface, seed)?;
1222    let on_second = project_point_to_surface(second_surface, seed)?;
1223    let Some(refined) = refine_point(
1224        first,
1225        second,
1226        on_first.u,
1227        on_first.v,
1228        on_second.u,
1229        on_second.v,
1230        tolerance,
1231    )?
1232    else {
1233        return Ok(None);
1234    };
1235    if refined.point.sub(seed).length() > reach {
1236        return Ok(None);
1237    }
1238    Ok(Some(refined.point))
1239}
1240
1241pub fn intersect_surfaces(
1242    first_surface: &NurbsSurface,
1243    second_surface: &NurbsSurface,
1244    options: &SurfaceIntersectionOptions,
1245) -> Result<Vec<SurfaceIntersectionCurve>, String> {
1246    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::MARCH);
1247    let _lap = crate::projection_profile::Lap::enter_pair(first_surface, second_surface);
1248    let first_box = Bounds::from_surface_controls(first_surface)?;
1249    let second_box = Bounds::from_surface_controls(second_surface)?;
1250    if !first_box.intersects(second_box, options.tolerance * 10.0) {
1251        return Ok(Vec::new());
1252    }
1253    let first = surface_info(first_surface)?;
1254    let second = surface_info(second_surface)?;
1255    let diagonal = first_box.diagonal().min(second_box.diagonal());
1256    let initial_step = diagonal / 100.0;
1257    let minimum_step = (options.tolerance * 100.0).max(diagonal * 1e-6);
1258    let maximum_step = (diagonal / 15.0).min(options.maximum_step.unwrap_or(f64::INFINITY));
1259    let maximum_steps = step_budget(options.maximum_steps, maximum_step, diagonal);
1260    let mut starts = if options.seed_only {
1261        Vec::new()
1262    } else if options.tree_seeds {
1263        // A4: leaf-pair candidates; the grid only when the pair count is
1264        // over the cap.
1265        match tree_start_points(first, second, options.tolerance)? {
1266            Some((starts, _pairs)) => starts,
1267            None => find_start_points(first, second, options.tolerance, options.seed_density)?,
1268        }
1269    } else {
1270        find_start_points(first, second, options.tolerance, options.seed_density)?
1271    };
1272    // A grid start is refined onto both carriers exactly like an explicit
1273    // seed and is held to the same transversality gate: inside a tangency
1274    // band the refinement converges (the band IS within tolerance of both
1275    // surfaces) to a point no transverse curve passes through.
1276    starts.retain(|start| transverse_at(first, second, *start));
1277    let seed_gate = options.seed_gate.unwrap_or(options.tolerance * 100.0);
1278    // A3: explicit seeds are kept apart so they can be traced first.
1279    let grid_count = starts.len();
1280    for seed in &options.seed_points {
1281        let projection_a = project_point_to_surface(first_surface, *seed)?;
1282        if projection_a.distance > seed_gate {
1283            continue;
1284        }
1285        let projection_b = project_point_to_surface(second_surface, *seed)?;
1286        if projection_b.distance > seed_gate {
1287            continue;
1288        }
1289        let Some(refined) = refine_point(
1290            first,
1291            second,
1292            projection_a.u,
1293            projection_a.v,
1294            projection_b.u,
1295            projection_b.v,
1296            options.tolerance,
1297        )?
1298        else {
1299            continue;
1300        };
1301        // The seed's own gate: the caller's sub-gate bound where it vouches
1302        // for the seed, the transversality gate otherwise.
1303        let admitted = match options.seed_start_cross {
1304            Some(bound) => crossing_at(first, second, refined) >= bound.min(TRANSVERSE_START_CROSS),
1305            None => transverse_at(first, second, refined),
1306        };
1307        if !admitted {
1308            continue;
1309        }
1310        starts.push(refined);
1311    }
1312    if options.ordered_starts {
1313        // A3: seeds (trim-boundary pierces) before the interior candidates,
1314        // and within each group the most transverse first; stable, so equal
1315        // crossings keep their discovery order.
1316        let crossing = |start: &IntersectionPoint| -> f64 {
1317            match (
1318                first.surface.normal(start.ua, start.va),
1319                second.surface.normal(start.ub, start.vb),
1320            ) {
1321                (Ok(a), Ok(b)) => a.cross(b).length(),
1322                _ => 0.0,
1323            }
1324        };
1325        let mut seeds = starts.split_off(grid_count);
1326        seeds.sort_by(|a, b| crossing(b).total_cmp(&crossing(a)));
1327        starts.sort_by(|a, b| crossing(b).total_cmp(&crossing(a)));
1328        seeds.append(&mut starts);
1329        starts = seeds;
1330    }
1331
1332    let switches = TraceSwitches::from_env();
1333    let mut curves: Vec<SurfaceIntersectionCurve> = Vec::new();
1334    for start in starts {
1335        let mut claimed = false;
1336        for curve in &mut curves {
1337            let Some((index, distance)) = curve
1338                .points
1339                .windows(2)
1340                .enumerate()
1341                .map(|(index, segment)| (index, point_segment_distance(start.point, segment[0], segment[1])))
1342                .filter(|(_, distance)| *distance <= initial_step * 1.5)
1343                .min_by(|a, b| a.1.total_cmp(&b.1))
1344            else {
1345                continue;
1346            };
1347            claimed = true;
1348            // A3: a start an earlier trace ran through is consumed with a
1349            // station snapped to it — the start is a refined intersection
1350            // point, the segment it lies by is a chord — unless it sits on a
1351            // station already.
1352            if options.consume_starts
1353                && distance > 0.0
1354                && start.point.sub(curve.points[index]).length() > minimum_step
1355                && start.point.sub(curve.points[index + 1]).length() > minimum_step
1356            {
1357                curve.points.insert(index + 1, start.point);
1358                if let Some(tangents) = curve.tangents.as_mut() {
1359                    let along = curve.points[index + 2].sub(curve.points[index]);
1360                    let tangent = match tangent_at(first, second, start)? {
1361                        Some(tangent) if tangent.dot(along) < 0.0 => tangent.scale(-1.0),
1362                        Some(tangent) => tangent,
1363                        None => tangents[index],
1364                    };
1365                    tangents.insert(index + 1, tangent);
1366                }
1367                if curve.params_a.len() > index + 1 && curve.params_b.len() > index + 1 {
1368                    curve.params_a.insert(index + 1, [start.ua, start.va]);
1369                    curve.params_b.insert(index + 1, [start.ub, start.vb]);
1370                }
1371            }
1372            break;
1373        }
1374        if switches.debug {
1375            let at = start.point;
1376            eprintln!(
1377                "SSI-START claimed {claimed} p ({:.6},{:.6},{:.6}) initial {initial_step:.4e} minimum {minimum_step:.4e} maximum {maximum_step:.4e}",
1378                at.x, at.y, at.z
1379            );
1380        }
1381        if claimed {
1382            continue;
1383        }
1384        let forward = trace(
1385            first,
1386            second,
1387            start,
1388            1.0,
1389            options.tolerance,
1390            initial_step,
1391            minimum_step,
1392            maximum_step,
1393            maximum_steps,
1394            switches,
1395            options.span_budget,
1396        )?;
1397        let (all, closed) = if forward.closed {
1398            (Spliced::from(forward), true)
1399        } else {
1400            let backward = trace(
1401                first,
1402                second,
1403                start,
1404                -1.0,
1405                options.tolerance,
1406                initial_step,
1407                minimum_step,
1408                maximum_step,
1409                maximum_steps,
1410                switches,
1411                options.span_budget,
1412            )?;
1413            (splice(backward, forward), false)
1414        };
1415        if all.points.len() < 2 {
1416            continue;
1417        }
1418        curves.push(curve_from(all, closed, options.span_budget.is_some()));
1419    }
1420    Ok(curves)
1421}
1422
1423/// Minimum |n_a × n_b| for a seed/branch to count as a *transverse* crossing
1424/// rather than a tangential graze — the same threshold `intersect_surfaces`
1425/// applies to its start points.
1426const SUPPLEMENTAL_TRANSVERSE_CROSS: f64 = TRANSVERSE_START_CROSS;
1427
1428/// Gated supplemental SSI **detector** for a pair the coarse pair-classifier
1429/// culled as "tangential-only" (`NearTangent` + `tangential_only`).
1430///
1431/// The 5×5 classification grid can land only on an incidental tangential KISS
1432/// between two curved carriers (e.g. the inner walls / tube tops of two
1433/// overlapping tori) and never sample the transverse loop where their tubes
1434/// actually CROSS, so `tangential_only` is a false positive that would drop a
1435/// real intersection. This routine re-seeds a DENSER grid on BOTH carriers,
1436/// keeps only *transverse* seeds (near-parallel normals rejected up front, so
1437/// the tangency band is never walked), SWALLOWS per-branch trace-exhaustion (a
1438/// genuine tangency band never closes/exits and would otherwise error — here
1439/// it simply means "no transverse curve from this seed"), and returns only
1440/// branches that are genuinely transverse along their length.
1441///
1442/// A *genuinely* tangential pair yields an empty result (every seed rejected /
1443/// every trace fruitless), so the caller skips exactly as before — the common
1444/// intersection path is never touched.
1445///
1446/// NOTE — deferred capability: when this DOES return branches, the true
1447/// intersection is *singular* (the transverse loops meet at tangent nodes,
1448/// e.g. equal-radius torus-torus at `(1.5, ±3.708, ±1.5)`). Imprinting them
1449/// requires 4-valent tangent-vertex ("checkerboard") singular assembly, which
1450/// the kernel does not yet do; until then the caller REFUSES such a pair with
1451/// a clear error rather than silently dropping the geometry or emitting the
1452/// cryptic downstream pcurve/open-loop error. See imprint.rs (tangential-only
1453/// skip) and the project singular-assembly roadmap.
1454pub fn intersect_surfaces_supplemental(
1455    first_surface: &NurbsSurface,
1456    second_surface: &NurbsSurface,
1457    options: &SurfaceIntersectionOptions,
1458) -> Result<Vec<SurfaceIntersectionCurve>, String> {
1459    let _wall = crate::wall_profile::Scope::enter(crate::wall_profile::MARCH);
1460    let first_box = Bounds::from_surface_controls(first_surface)?;
1461    let second_box = Bounds::from_surface_controls(second_surface)?;
1462    if !first_box.intersects(second_box, options.tolerance * 10.0) {
1463        return Ok(Vec::new());
1464    }
1465    let first = surface_info(first_surface)?;
1466    let second = surface_info(second_surface)?;
1467    let diagonal = first_box.diagonal().min(second_box.diagonal());
1468    let initial_step = diagonal / 100.0;
1469    let minimum_step = (options.tolerance * 100.0).max(diagonal * 1e-6);
1470    let maximum_step = (diagonal / 15.0).min(options.maximum_step.unwrap_or(f64::INFINITY));
1471    let maximum_steps = step_budget(options.maximum_steps, maximum_step, diagonal);
1472
1473    // Denser self-seed (≥2× density pushes the ≤24 grid toward its cap so a
1474    // thin transverse overlap the classifier stepped over still gets a start),
1475    // gridding BOTH carriers so an interior loop touching neither trim boundary
1476    // is still hit from whichever surface samples it best.
1477    let density = options.seed_density.max(2.0);
1478    let mut starts: Vec<IntersectionPoint> = Vec::new();
1479    for gridded_second in [false, true] {
1480        let (a, b) = if gridded_second {
1481            (second, first)
1482        } else {
1483            (first, second)
1484        };
1485        for start in find_start_points(a, b, options.tolerance, density)? {
1486            // find_start_points expresses params as (grid surface, projected
1487            // surface); normalise every start to (first, second).
1488            let start = if gridded_second {
1489                IntersectionPoint {
1490                    ua: start.ub,
1491                    va: start.vb,
1492                    ub: start.ua,
1493                    vb: start.va,
1494                    point: start.point,
1495                }
1496            } else {
1497                start
1498            };
1499            starts.push(start);
1500        }
1501    }
1502    // Reject tangential seeds outright: a start whose two surface normals are
1503    // (near-)parallel is on a graze, not a crossing, and marching it walks the
1504    // tangency band.
1505    starts.retain(|start| {
1506        match (
1507            first.surface.normal(start.ua, start.va),
1508            second.surface.normal(start.ub, start.vb),
1509        ) {
1510            (Ok(na), Ok(nb)) => na.cross(nb).length() > SUPPLEMENTAL_TRANSVERSE_CROSS,
1511            _ => false,
1512        }
1513    });
1514
1515    let switches = TraceSwitches::from_env();
1516    let mut curves: Vec<SurfaceIntersectionCurve> = Vec::new();
1517    for start in starts {
1518        let claimed = curves.iter().any(|curve| {
1519            curve.points.windows(2).any(|segment| {
1520                point_segment_distance(start.point, segment[0], segment[1]) <= initial_step * 1.5
1521            })
1522        });
1523        if claimed {
1524            continue;
1525        }
1526        // Swallow trace-exhaustion here (unlike the common path, which errors
1527        // loudly): on this gated path a non-closing branch means the seed sat
1528        // on a tangency band, which we simply drop.
1529        let Ok(forward) = trace(
1530            first,
1531            second,
1532            start,
1533            1.0,
1534            options.tolerance,
1535            initial_step,
1536            minimum_step,
1537            maximum_step,
1538            maximum_steps,
1539            switches,
1540            options.span_budget,
1541        ) else {
1542            continue;
1543        };
1544        let (all, closed) = if forward.closed {
1545            (Spliced::from(forward), true)
1546        } else {
1547            let Ok(backward) = trace(
1548                first,
1549                second,
1550                start,
1551                -1.0,
1552                options.tolerance,
1553                initial_step,
1554                minimum_step,
1555                maximum_step,
1556                maximum_steps,
1557                switches,
1558                options.span_budget,
1559            ) else {
1560                continue;
1561            };
1562            (splice(backward, forward), false)
1563        };
1564        if all.points.len() < 2 {
1565            continue;
1566        }
1567        // Keep only a branch that is genuinely transverse somewhere along its
1568        // length — a final guard against a tangency-band polyline that happened
1569        // to close.
1570        let transverse = all.points.iter().step_by((all.points.len() / 5).max(1)).any(|point| {
1571            match (
1572                first.surface.normal(point.ua, point.va),
1573                second.surface.normal(point.ub, point.vb),
1574            ) {
1575                (Ok(na), Ok(nb)) => na.cross(nb).length() > SUPPLEMENTAL_TRANSVERSE_CROSS,
1576                _ => false,
1577            }
1578        });
1579        if !transverse {
1580            continue;
1581        }
1582        curves.push(curve_from(all, closed, options.span_budget.is_some()));
1583    }
1584    Ok(curves)
1585}
1586