Skip to main content

brep_kernel/intersect/
surface_surface_intersection.rs

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