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