Skip to main content

brepkit_math/nurbs/
self_intersection.rs

1//! NURBS surface self-intersection detection.
2//!
3//! Detects regions where a single NURBS surface folds back on itself,
4//! producing `S(u1,v1) = S(u2,v2)` with `(u1,v1) ≠ (u2,v2)`.
5//!
6//! ## Algorithm
7//!
8//! 1. Sample the surface on a grid, build triangles with (u,v) parameter ranges
9//! 2. Build a BVH over triangle AABBs
10//! 3. Query overlapping pairs, filter out adjacent triangles
11//! 4. For non-adjacent close pairs: Newton-refine with constraint
12//!    `(u1,v1) ≠ (u2,v2)`, `S(u1,v1) = S(u2,v2)`
13//! 5. March along self-intersection curves
14
15#![allow(
16    clippy::many_single_char_names,
17    clippy::similar_names,
18    clippy::suboptimal_flops,
19    clippy::cast_precision_loss
20)]
21
22use crate::MathError;
23use crate::aabb::Aabb3;
24use crate::bvh::Bvh;
25use crate::nurbs::curve::NurbsCurve;
26use crate::nurbs::surface::NurbsSurface;
27use crate::vec::{Point3, Vec3};
28
29/// A self-intersection curve on a NURBS surface.
30///
31/// Contains the 3D intersection curve and the two sets of parameter
32/// values that map to the same 3D points.
33#[derive(Debug, Clone)]
34pub struct SelfIntersectionCurve {
35    /// The 3D self-intersection curve as a NURBS.
36    pub curve: NurbsCurve,
37    /// Parameter values on the "first sheet" of the surface.
38    pub params_a: Vec<(f64, f64)>,
39    /// Parameter values on the "second sheet" of the surface.
40    pub params_b: Vec<(f64, f64)>,
41}
42
43/// A triangle in the surface sampling grid, with parameter-space coordinates.
44struct SampleTriangle {
45    /// AABB of the triangle in 3D.
46    aabb: Aabb3,
47    /// Grid indices of the three corners.
48    indices: [(usize, usize); 3],
49    /// Parameter-space midpoint.
50    uv_mid: (f64, f64),
51}
52
53/// Detect self-intersections on a NURBS surface.
54///
55/// Samples the surface on a grid, finds non-adjacent regions where the
56/// surface folds back on itself, and traces the self-intersection curves.
57///
58/// # Parameters
59///
60/// - `surface`: The NURBS surface to test
61/// - `grid_res`: Grid resolution for sampling (e.g., 20)
62/// - `tolerance`: Distance tolerance for considering points as intersecting
63///
64/// # Errors
65///
66/// Returns an error if NURBS evaluation or curve fitting fails.
67pub fn detect_self_intersection(
68    surface: &NurbsSurface,
69    grid_res: usize,
70    tolerance: f64,
71) -> Result<Vec<SelfIntersectionCurve>, MathError> {
72    let n = grid_res.max(5);
73    let (u_min, u_max) = surface.domain_u();
74    let (v_min, v_max) = surface.domain_v();
75
76    // Step 1: Sample surface on grid.
77    let mut grid_pts: Vec<Vec<Point3>> = Vec::with_capacity(n + 1);
78    let mut grid_uv: Vec<Vec<(f64, f64)>> = Vec::with_capacity(n + 1);
79
80    for i in 0..=n {
81        let u = u_min + (u_max - u_min) * (i as f64 / n as f64);
82        let mut row_pts = Vec::with_capacity(n + 1);
83        let mut row_uv = Vec::with_capacity(n + 1);
84        for j in 0..=n {
85            let v = v_min + (v_max - v_min) * (j as f64 / n as f64);
86            row_pts.push(surface.evaluate(u, v));
87            row_uv.push((u, v));
88        }
89        grid_pts.push(row_pts);
90        grid_uv.push(row_uv);
91    }
92
93    // Step 2: Build triangles from grid and their AABBs.
94    let mut triangles: Vec<SampleTriangle> = Vec::with_capacity(2 * n * n);
95
96    for i in 0..n {
97        for j in 0..n {
98            // Lower-left triangle: (i,j), (i+1,j), (i+1,j+1)
99            let t1_pts = [grid_pts[i][j], grid_pts[i + 1][j], grid_pts[i + 1][j + 1]];
100            let t1_aabb = Aabb3::from_points(t1_pts.iter().copied());
101            let t1_uv_mid = (
102                (grid_uv[i][j].0 + grid_uv[i + 1][j].0 + grid_uv[i + 1][j + 1].0) / 3.0,
103                (grid_uv[i][j].1 + grid_uv[i + 1][j].1 + grid_uv[i + 1][j + 1].1) / 3.0,
104            );
105            triangles.push(SampleTriangle {
106                aabb: t1_aabb,
107                indices: [(i, j), (i + 1, j), (i + 1, j + 1)],
108                uv_mid: t1_uv_mid,
109            });
110
111            // Upper-right triangle: (i,j), (i+1,j+1), (i,j+1)
112            let t2_pts = [grid_pts[i][j], grid_pts[i + 1][j + 1], grid_pts[i][j + 1]];
113            let t2_aabb = Aabb3::from_points(t2_pts.iter().copied());
114            let t2_uv_mid = (
115                (grid_uv[i][j].0 + grid_uv[i + 1][j + 1].0 + grid_uv[i][j + 1].0) / 3.0,
116                (grid_uv[i][j].1 + grid_uv[i + 1][j + 1].1 + grid_uv[i][j + 1].1) / 3.0,
117            );
118            triangles.push(SampleTriangle {
119                aabb: t2_aabb,
120                indices: [(i, j), (i + 1, j + 1), (i, j + 1)],
121                uv_mid: t2_uv_mid,
122            });
123        }
124    }
125
126    // Step 3: Build BVH over triangles.
127    let bvh_entries: Vec<(usize, Aabb3)> = triangles
128        .iter()
129        .enumerate()
130        .map(|(i, t)| (i, t.aabb))
131        .collect();
132    let bvh = Bvh::build(&bvh_entries);
133
134    // Step 4: Find non-adjacent overlapping pairs.
135    let adjacency_threshold = 2; // Triangles within 2 grid steps are "adjacent"
136    let mut candidate_pairs: Vec<((f64, f64), (f64, f64))> = Vec::new();
137
138    for (i, tri_a) in triangles.iter().enumerate() {
139        let overlaps = bvh.query_overlap(&tri_a.aabb);
140        for &j in &overlaps {
141            if j <= i {
142                continue; // Skip self and already-processed pairs
143            }
144            let tri_b = &triangles[j];
145
146            // Check if triangles are non-adjacent in parameter space.
147            if are_adjacent(&tri_a.indices, &tri_b.indices, adjacency_threshold) {
148                continue;
149            }
150
151            candidate_pairs.push((tri_a.uv_mid, tri_b.uv_mid));
152        }
153    }
154
155    if candidate_pairs.is_empty() {
156        return Ok(Vec::new());
157    }
158
159    // Step 5: Refine candidate pairs to find actual self-intersection points.
160    #[allow(clippy::type_complexity)]
161    let mut self_int_points: Vec<((f64, f64), (f64, f64), Point3)> = Vec::new();
162
163    for &((u1, v1), (u2, v2)) in &candidate_pairs {
164        if let Some((pt, pa, pb)) =
165            refine_self_intersection_point(surface, u1, v1, u2, v2, tolerance)
166        {
167            // Verify parameters are actually distinct
168            let param_dist = ((pa.0 - pb.0).powi(2) + (pa.1 - pb.1).powi(2)).sqrt();
169            if param_dist > tolerance * 10.0 {
170                // Deduplicate
171                let is_dup = self_int_points.iter().any(|(existing, _, _)| {
172                    let dist = ((existing.0 - pa.0).powi(2) + (existing.1 - pa.1).powi(2)).sqrt();
173                    dist < tolerance * 100.0
174                });
175                if !is_dup {
176                    self_int_points.push((pa, pb, pt));
177                }
178            }
179        }
180    }
181
182    if self_int_points.is_empty() {
183        return Ok(Vec::new());
184    }
185
186    // Step 6: Build self-intersection curves from found points.
187    let points_3d: Vec<Point3> = self_int_points.iter().map(|(_, _, p)| *p).collect();
188    let params_a: Vec<(f64, f64)> = self_int_points.iter().map(|(a, _, _)| *a).collect();
189    let params_b: Vec<(f64, f64)> = self_int_points.iter().map(|(_, b, _)| *b).collect();
190
191    // Fit a curve through the points (if enough points).
192    if points_3d.len() < 2 {
193        // Single point self-intersection: create a degenerate curve.
194        let degree = 1;
195        let curve = crate::nurbs::interpolate(&[points_3d[0], points_3d[0]], degree)?;
196        return Ok(vec![SelfIntersectionCurve {
197            curve,
198            params_a,
199            params_b,
200        }]);
201    }
202
203    let degree = 3.min(points_3d.len() - 1);
204    let curve = if points_3d.len() > 50 {
205        let num_cps = (points_3d.len() / 3).max(degree + 1).min(points_3d.len());
206        crate::nurbs::fitting::approximate_lspia(&points_3d, degree, num_cps, 1e-6, 100)?
207    } else {
208        crate::nurbs::interpolate(&points_3d, degree)?
209    };
210
211    Ok(vec![SelfIntersectionCurve {
212        curve,
213        params_a,
214        params_b,
215    }])
216}
217
218/// Check if two triangles are adjacent in the grid (within `threshold` steps).
219fn are_adjacent(a: &[(usize, usize); 3], b: &[(usize, usize); 3], threshold: usize) -> bool {
220    for &(ai, aj) in a {
221        for &(bi, bj) in b {
222            let di = ai.abs_diff(bi);
223            let dj = aj.abs_diff(bj);
224            if di <= threshold && dj <= threshold {
225                return true;
226            }
227        }
228    }
229    false
230}
231
232/// Newton-refine a self-intersection point.
233///
234/// Solves `S(u1,v1) = S(u2,v2)` with the constraint that
235/// `(u1,v1) ≠ (u2,v2)`, using alternating projection on the
236/// single surface.
237#[allow(clippy::type_complexity)]
238fn refine_self_intersection_point(
239    surface: &NurbsSurface,
240    u1_guess: f64,
241    v1_guess: f64,
242    u2_guess: f64,
243    v2_guess: f64,
244    tolerance: f64,
245) -> Option<(Point3, (f64, f64), (f64, f64))> {
246    let (u_min, u_max) = surface.domain_u();
247    let (v_min, v_max) = surface.domain_v();
248    let eps = (u_max - u_min + v_max - v_min) * 0.01;
249
250    let mut u1 = u1_guess;
251    let mut v1 = v1_guess;
252    let mut u2 = u2_guess;
253    let mut v2 = v2_guess;
254
255    for _ in 0..50 {
256        let p1 = surface.evaluate(u1, v1);
257        let p2 = surface.evaluate(u2, v2);
258        let residual = p1 - p2;
259
260        if residual.length() < tolerance {
261            // Check that parameters are genuinely distinct
262            let param_dist = ((u1 - u2).powi(2) + (v1 - v2).powi(2)).sqrt();
263            if param_dist > eps {
264                return Some((p1, (u1, v1), (u2, v2)));
265            }
266            return None; // Same point, not a self-intersection
267        }
268
269        // Move (u2, v2) toward p1 using Newton step on the surface
270        let (du2, dv2) = surface_newton_step_self(surface, u2, v2, p1);
271        u2 = (u2 + du2).clamp(u_min, u_max);
272        v2 = (v2 + dv2).clamp(v_min, v_max);
273
274        // Move (u1, v1) toward updated p2
275        let p2_new = surface.evaluate(u2, v2);
276        let (du1, dv1) = surface_newton_step_self(surface, u1, v1, p2_new);
277        u1 = (u1 + du1).clamp(u_min, u_max);
278        v1 = (v1 + dv1).clamp(v_min, v_max);
279
280        // Push parameters apart if they're converging to the same point
281        let param_dist = ((u1 - u2).powi(2) + (v1 - v2).powi(2)).sqrt();
282        if param_dist < eps * 0.5 {
283            return None; // Parameters converging — not a real self-intersection
284        }
285    }
286
287    // Final check
288    let p1 = surface.evaluate(u1, v1);
289    let p2 = surface.evaluate(u2, v2);
290    if (p1 - p2).length() < tolerance * 100.0 {
291        let param_dist = ((u1 - u2).powi(2) + (v1 - v2).powi(2)).sqrt();
292        if param_dist > eps {
293            return Some((p1, (u1, v1), (u2, v2)));
294        }
295    }
296
297    None
298}
299
300/// Newton step to project (u,v) on a surface toward a target 3D point.
301fn surface_newton_step_self(surface: &NurbsSurface, u: f64, v: f64, target: Point3) -> (f64, f64) {
302    let pt = surface.evaluate(u, v);
303    let r = target - pt;
304    let r_vec = Vec3::new(r.x(), r.y(), r.z());
305
306    let derivs = surface.derivatives(u, v, 1);
307    let su = derivs[1][0];
308    let sv = derivs[0][1];
309
310    let a11 = su.dot(su);
311    let a12 = su.dot(sv);
312    let a22 = sv.dot(sv);
313    let b1 = su.dot(r_vec);
314    let b2 = sv.dot(r_vec);
315
316    let det = a11.mul_add(a22, -(a12 * a12));
317    if det.abs() < 1e-20 {
318        return (0.0, 0.0);
319    }
320
321    let du = b1.mul_add(a22, -(b2 * a12)) / det;
322    let dv = a11.mul_add(b2, -(a12 * b1)) / det;
323
324    (du, dv)
325}
326
327#[cfg(test)]
328mod tests {
329    #![allow(clippy::unwrap_used, clippy::expect_used)]
330
331    use super::*;
332    use crate::nurbs::surface::NurbsSurface;
333    use crate::vec::Point3;
334
335    /// A flat bilinear surface — no self-intersection.
336    fn flat_surface() -> NurbsSurface {
337        NurbsSurface::new(
338            1,
339            1,
340            vec![0.0, 0.0, 1.0, 1.0],
341            vec![0.0, 0.0, 1.0, 1.0],
342            vec![
343                vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)],
344                vec![Point3::new(0.0, 1.0, 0.0), Point3::new(1.0, 1.0, 0.0)],
345            ],
346            vec![vec![1.0, 1.0], vec![1.0, 1.0]],
347        )
348        .unwrap()
349    }
350
351    /// A surface with crossed control points that creates a self-intersection.
352    /// The control polygon folds over itself.
353    fn folded_surface() -> NurbsSurface {
354        NurbsSurface::new(
355            2,
356            2,
357            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
358            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
359            vec![
360                vec![
361                    Point3::new(0.0, 0.0, 0.0),
362                    Point3::new(0.5, 0.0, 0.0),
363                    Point3::new(1.0, 0.0, 0.0),
364                ],
365                vec![
366                    // Middle row crosses over: points at x=0 and x=1 swap z values
367                    Point3::new(0.0, 0.5, 1.0),
368                    Point3::new(0.5, 0.5, -1.0),
369                    Point3::new(1.0, 0.5, 1.0),
370                ],
371                vec![
372                    Point3::new(0.0, 1.0, 0.0),
373                    Point3::new(0.5, 1.0, 0.0),
374                    Point3::new(1.0, 1.0, 0.0),
375                ],
376            ],
377            vec![vec![1.0; 3]; 3],
378        )
379        .unwrap()
380    }
381
382    #[test]
383    fn flat_surface_clean() {
384        let surf = flat_surface();
385        let result = detect_self_intersection(&surf, 10, 1e-6).unwrap();
386        assert!(
387            result.is_empty(),
388            "flat surface should have no self-intersection"
389        );
390    }
391
392    #[test]
393    fn folded_surface_detected() {
394        let surf = folded_surface();
395        // The folded control polygon creates regions where S(u1,v1) ≈ S(u2,v2).
396        let result = detect_self_intersection(&surf, 15, 1e-4).unwrap();
397
398        // We may or may not find self-intersections depending on how strongly
399        // the surface folds. The key test is that it doesn't crash.
400        // If found, verify the detected points have distinct parameters.
401        for si in &result {
402            assert!(
403                si.params_a.len() == si.params_b.len(),
404                "param lists should have same length"
405            );
406            for (pa, pb) in si.params_a.iter().zip(si.params_b.iter()) {
407                let dist = ((pa.0 - pb.0).powi(2) + (pa.1 - pb.1).powi(2)).sqrt();
408                assert!(
409                    dist > 1e-6,
410                    "self-intersection params should be distinct: {pa:?} vs {pb:?}"
411                );
412            }
413        }
414    }
415}