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