Skip to main content

brep_kernel/intersect/
curve_surface_intersection.rs

1use crate::curve::interior_knot_count;
2use crate::{project_point_to_surface, NurbsCurve, NurbsSurface, Vec3};
3use serde::Serialize;
4
5const EPSILON: f64 = 1e-12;
6
7#[derive(Clone, Copy)]
8struct Bounds {
9    minimum: Vec3,
10    maximum: Vec3,
11}
12
13impl Bounds {
14    fn from_points(points: impl IntoIterator<Item = Vec3>) -> Self {
15        let mut bounds = Self {
16            minimum: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
17            maximum: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
18        };
19        for point in points {
20            bounds.minimum.x = bounds.minimum.x.min(point.x);
21            bounds.minimum.y = bounds.minimum.y.min(point.y);
22            bounds.minimum.z = bounds.minimum.z.min(point.z);
23            bounds.maximum.x = bounds.maximum.x.max(point.x);
24            bounds.maximum.y = bounds.maximum.y.max(point.y);
25            bounds.maximum.z = bounds.maximum.z.max(point.z);
26        }
27        bounds
28    }
29
30    fn expanded(self, amount: f64) -> Self {
31        let delta = Vec3::new(amount, amount, amount);
32        Self {
33            minimum: self.minimum.sub(delta),
34            maximum: self.maximum.add(delta),
35        }
36    }
37
38    fn intersects(self, other: Self) -> bool {
39        self.minimum.x <= other.maximum.x
40            && self.maximum.x >= other.minimum.x
41            && self.minimum.y <= other.maximum.y
42            && self.maximum.y >= other.minimum.y
43            && self.minimum.z <= other.maximum.z
44            && self.maximum.z >= other.minimum.z
45    }
46}
47
48/// The box of the carrier's control points, in their order; the first control
49/// point that is not a point is the error. Read without collecting them: it
50/// is paid on every call, and a view ray through a fitted carrier of a gear
51/// asks it thousands of times.
52fn surface_bounds(surface: &NurbsSurface) -> Result<Bounds, String> {
53    let mut bounds = Bounds::from_points([]);
54    for point in surface.control_points.iter().flatten() {
55        let point = point.point()?;
56        bounds.minimum.x = bounds.minimum.x.min(point.x);
57        bounds.minimum.y = bounds.minimum.y.min(point.y);
58        bounds.minimum.z = bounds.minimum.z.min(point.z);
59        bounds.maximum.x = bounds.maximum.x.max(point.x);
60        bounds.maximum.y = bounds.maximum.y.max(point.y);
61        bounds.maximum.z = bounds.maximum.z.max(point.z);
62    }
63    Ok(bounds)
64}
65
66
67/// The box of each knot-span patch's control points: the surface over span
68/// `[u_i, u_i+1) x [v_j, v_j+1)` lies in the convex hull of the
69/// `(p + 1) x (q + 1)` control points that support it (positive weights), so
70/// a curve segment clear of every span box is clear of the surface.
71///
72/// The whole-net box cannot say that for a carrier that winds: an eight-turn
73/// helical tube's box is the whole coil, every sample segment of a ray through
74/// the coil passes it, and every one seeded a Newton that starts from a global
75/// projection onto a 1280-point net — 21 of the 26 s the 2026-09-26 coil
76/// fillet report's interference check took, for about one hit in forty seeds.
77fn span_bounds(surface: &NurbsSurface) -> Result<Vec<Bounds>, String> {
78    let (p, q) = (surface.degree_u, surface.degree_v);
79    let rows = surface.control_points.len();
80    let columns = surface.control_points.first().map_or(0, Vec::len);
81    let spans = |knots: &[f64], degree: usize, count: usize| -> Vec<usize> {
82        (degree..count).filter(|&i| i + 1 < knots.len() && knots[i] < knots[i + 1]).collect()
83    };
84    let (spans_u, spans_v) = (spans(&surface.knots_u, p, rows), spans(&surface.knots_v, q, columns));
85    // A malformed net has no span structure to trust: the whole-net box stands.
86    if spans_u.is_empty() || spans_v.is_empty() {
87        return Ok(vec![surface_bounds(surface)?]);
88    }
89    let mut boxes = Vec::with_capacity(spans_u.len() * spans_v.len());
90    for &i in &spans_u {
91        for &j in &spans_v {
92            let mut bounds = Bounds::from_points([]);
93            for row in &surface.control_points[i - p..=i] {
94                for control in &row[j - q..=j] {
95                    let point = control.point()?;
96                    bounds.minimum.x = bounds.minimum.x.min(point.x);
97                    bounds.minimum.y = bounds.minimum.y.min(point.y);
98                    bounds.minimum.z = bounds.minimum.z.min(point.z);
99                    bounds.maximum.x = bounds.maximum.x.max(point.x);
100                    bounds.maximum.y = bounds.maximum.y.max(point.y);
101                    bounds.maximum.z = bounds.maximum.z.max(point.z);
102                }
103            }
104            boxes.push(bounds);
105        }
106    }
107    Ok(boxes)
108}
109
110fn fit_parameter(value: f64, minimum: f64, maximum: f64, closed: bool) -> f64 {
111    if closed {
112        (value - minimum).rem_euclid(maximum - minimum) + minimum
113    } else {
114        value.clamp(minimum, maximum)
115    }
116}
117
118fn solve_three(matrix: [[f64; 3]; 3], rhs: Vec3) -> Option<[f64; 3]> {
119    let a = matrix;
120    let determinant = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
121        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
122        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
123    let scale: f64 = a.iter().flatten().map(|value| value.abs()).sum();
124    if determinant.abs() <= 1e-14 * scale.max(1.0).powi(3) {
125        return None;
126    }
127    let x = (rhs.x * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
128        - a[0][1] * (rhs.y * a[2][2] - a[1][2] * rhs.z)
129        + a[0][2] * (rhs.y * a[2][1] - a[1][1] * rhs.z))
130        / determinant;
131    let y = (a[0][0] * (rhs.y * a[2][2] - a[1][2] * rhs.z)
132        - rhs.x * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
133        + a[0][2] * (a[1][0] * rhs.z - rhs.y * a[2][0]))
134        / determinant;
135    let z = (a[0][0] * (a[1][1] * rhs.z - rhs.y * a[2][1])
136        - a[0][1] * (a[1][0] * rhs.z - rhs.y * a[2][0])
137        + rhs.x * (a[1][0] * a[2][1] - a[1][1] * a[2][0]))
138        / determinant;
139    Some([x, y, z])
140}
141
142fn newton_curve_surface(
143    curve: &NurbsCurve,
144    surface: &NurbsSurface,
145    seed: f64,
146) -> Result<[f64; 3], String> {
147    let [t0, t1] = curve.domain()?;
148    let [u0, u1] = surface.domain_u()?;
149    let [v0, v1] = surface.domain_v()?;
150    let (closed_u, closed_v) = surface.closed_directions()?;
151    let mut t = seed;
152    let projection = project_point_to_surface(surface, curve.evaluate(t)?)?;
153    let mut u = projection.u;
154    let mut v = projection.v;
155    for _ in 0..60 {
156        let (curve_point, tangent) = curve.deriv1(t)?;
157        let (surface_point, du, dv) = surface.deriv1(u, v)?;
158        let residual = curve_point.sub(surface_point);
159        if residual.length() <= EPSILON {
160            return Ok([t, u, v]);
161        }
162        let Some([mut step_t, mut step_u, mut step_v]) = solve_three(
163            [
164                [tangent.x, -du.x, -dv.x],
165                [tangent.y, -du.y, -dv.y],
166                [tangent.z, -du.z, -dv.z],
167            ],
168            residual.scale(-1.0),
169        ) else {
170            if tangent.length_squared() <= EPSILON {
171                return Ok([t, u, v]);
172            }
173            let next_t = (t - residual.dot(tangent) / tangent.length_squared()).clamp(t0, t1);
174            if (next_t - t).abs() <= 1e-15 * (t1 - t0) {
175                return Ok([t, u, v]);
176            }
177            t = next_t;
178            // The next iterate's foot, continued from this one's: the Newton
179            // is on ONE sheet, and the nearest point of the whole surface to
180            // the stepped curve point can be on another.
181            let projection =
182                crate::projection::project_point_to_surface_from_seed(surface, curve.evaluate(t)?, [u, v])?;
183            u = projection.u;
184            v = projection.v;
185            continue;
186        };
187        step_t = step_t.clamp(-(t1 - t0) / 4.0, (t1 - t0) / 4.0);
188        step_u = step_u.clamp(-(u1 - u0) / 4.0, (u1 - u0) / 4.0);
189        step_v = step_v.clamp(-(v1 - v0) / 4.0, (v1 - v0) / 4.0);
190        let next_t = (t + step_t).clamp(t0, t1);
191        let next_u = fit_parameter(u + step_u, u0, u1, closed_u);
192        let next_v = fit_parameter(v + step_v, v0, v1, closed_v);
193        let stalled = (next_t - t).abs() <= 1e-15 * (t1 - t0)
194            && (next_u - u).abs() <= 1e-15 * (u1 - u0)
195            && (next_v - v).abs() <= 1e-15 * (v1 - v0);
196        t = next_t;
197        u = next_u;
198        v = next_v;
199        if stalled {
200            return Ok([t, u, v]);
201        }
202    }
203    Ok([t, u, v])
204}
205
206#[derive(Clone, Copy, Debug, Serialize)]
207pub struct CurveSurfaceIntersection {
208    pub t: f64,
209    pub u: f64,
210    pub v: f64,
211    pub point: Vec3,
212    pub gap: f64,
213    pub tangential: bool,
214}
215
216/// Whether the duplicate guard's parameter tolerance is capped by the curve's
217/// domain. `BREP_CS_DUPLICATE_CAP=0` restores the uncapped tolerance, which at
218/// a stationary parameter is 1e6 and swallows every other hit on the curve.
219fn duplicate_cap_enabled() -> bool {
220    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
221    *ENABLED.get_or_init(|| std::env::var("BREP_CS_DUPLICATE_CAP").as_deref() != Ok("0"))
222}
223
224pub fn intersect_curve_surface(
225    curve: &NurbsCurve,
226    surface: &NurbsSurface,
227    tolerance: f64,
228) -> Result<Vec<CurveSurfaceIntersection>, String> {
229    let seeds = sampled_seeds(curve, surface, tolerance)?;
230    intersect_curve_surface_from_seeds(curve, surface, tolerance, seeds)
231}
232
233/// The general search's Newton seeds: the midpoint of every sample segment
234/// of the curve whose box meets the surface's box and one of its span boxes.
235fn sampled_seeds(curve: &NurbsCurve, surface: &NurbsSurface, tolerance: f64) -> Result<Vec<f64>, String> {
236    let [t0, t1] = curve.domain()?;
237    let surface_box = surface_bounds(surface)?.expanded(tolerance * 10.0);
238    let sample_count =
239        32usize.max((interior_knot_count(&curve.knots, curve.degree) + 1) * (curve.degree + 1) * 4);
240    let mut samples = Vec::with_capacity(sample_count + 1);
241    for index in 0..=sample_count {
242        let t = t0 + (t1 - t0) * index as f64 / sample_count as f64;
243        samples.push((t, curve.evaluate(t)?));
244    }
245    let span_boxes = span_bounds(surface)?;
246    let mut seeds = Vec::new();
247    for pair in samples.windows(2) {
248        let segment_box = Bounds::from_points([pair[0].1, pair[1].1]).expanded(tolerance * 10.0);
249        if segment_box.intersects(surface_box)
250            && span_boxes.iter().any(|span_box| segment_box.intersects(span_box.expanded(tolerance * 10.0)))
251        {
252            seeds.push((pair[0].0 + pair[1].0) * 0.5);
253        }
254    }
255    Ok(seeds)
256}
257
258/// [`intersect_curve_surface`] from the caller's own Newton seeds (curve
259/// parameters): the same Newton, gap filter, duplicate guard and tangency
260/// reading as the general search, only the seeds differ. The classifier's
261/// exact ray lane (`crate::exact_line_surface`) seeds it with the closed-form
262/// roots of a straight segment against an analytic carrier.
263pub(crate) fn intersect_curve_surface_from_seeds(
264    curve: &NurbsCurve,
265    surface: &NurbsSurface,
266    tolerance: f64,
267    seeds: Vec<f64>,
268) -> Result<Vec<CurveSurfaceIntersection>, String> {
269    let [t0, t1] = curve.domain()?;
270    let mut results: Vec<CurveSurfaceIntersection> = Vec::new();
271    for seed in seeds {
272        let [t, u, v] = newton_curve_surface(curve, surface, seed)?;
273        let on_curve = curve.evaluate(t)?;
274        let on_surface = surface.evaluate(u, v)?;
275        let gap = on_curve.sub(on_surface).length();
276        if gap > tolerance {
277            continue;
278        }
279        let tangent = curve.deriv1(t)?.1;
280        // Two hits are the SAME hit when they are at the same PLACE, and the
281        // chord test below is what measures that. This parameter test only has
282        // to absorb two seeds landing on one root a few ulps apart — and it
283        // converts the caller's 3D tolerance into a parameter tolerance by
284        // dividing by the curve's speed, a quotient that is unbounded where the
285        // speed is not. At a stationary parameter the `EPSILON` floor makes it
286        // `1e-7 / 1e-12 * 10 = 1e6`, wider than any curve's domain, so every
287        // earlier hit on the curve reads as a duplicate and a legitimate
288        // intersection AT a cusp is discarded unless it happens to be first.
289        // An involute flank's speed is `r_base * t`, so a gear profile carries
290        // one such cusp per flank per tooth: this is the 2026-09-14 herringbone
291        // report's root cause one lane over, where it is silent rather than
292        // loud.
293        //
294        // The cap is the curve's OWN domain, which is the only scale this test
295        // has: 1e-6 of it keeps the ulp-absorbing job (nine orders above the
296        // `1e-15 * (t1 - t0)` stall the Newton above exits on, three above the
297        // 1e-9 floor this rule already had) and drops the unbounded one. Two
298        // seeds on the same cusp root can land further apart than that in
299        // parameter, but not in space, and the chord test catches them.
300        // Escape hatch for tamper-verification:
301        // BREP_CS_DUPLICATE_CAP=0 restores the uncapped tolerance.
302        let uncapped = ((tolerance / tangent.length().max(EPSILON)) * 10.0).max((t1 - t0) * 1e-9);
303        let capped = uncapped.min((t1 - t0) * 1e-6);
304        let parameter_tolerance = if duplicate_cap_enabled() { capped } else { uncapped };
305        if let Some(result) = results.iter().find(|result| {
306            (result.t - t).abs() <= parameter_tolerance
307                || result.point.sub(on_curve).length() <= tolerance * 10.0
308        }) {
309            // The only drops the cap changes: a PARAMETER-only match (the two
310            // hits are apart in space) that the capped tolerance would keep. A
311            // hit dropped here leaves no trace anywhere else, so name it when
312            // asked: `BREP_DEBUG_CS_DUPLICATE=1`. With the cap on this prints
313            // nothing by construction; under `BREP_CS_DUPLICATE_CAP=0` it counts
314            // what the cap recovers.
315            let chord = result.point.sub(on_curve).length();
316            let gap = (result.t - t).abs();
317            if chord > tolerance * 10.0
318                && gap > capped
319                && std::env::var("BREP_DEBUG_CS_DUPLICATE").is_ok()
320            {
321                eprintln!(
322                    "cs-duplicate: dropped t={t:.12} against t={:.12} gap={gap:.6e} ptol={parameter_tolerance:.6e} \
323                     chord={chord:.6e} |tangent|={:.6e} domain=[{t0:.6},{t1:.6}] tolerance={tolerance:.3e}",
324                    result.t,
325                    tangent.length(),
326                );
327            }
328            continue;
329        }
330        let normal = surface.normal(u, v).ok();
331        let tangential = normal
332            .and_then(|normal| {
333                tangent
334                    .normalized()
335                    .ok()
336                    .map(|unit| normal.dot(unit).abs() <= 1e-3)
337            })
338            .unwrap_or(false);
339        results.push(CurveSurfaceIntersection {
340            t,
341            u,
342            v,
343            point: on_curve.add(on_surface).scale(0.5),
344            gap,
345            tangential,
346        });
347    }
348    results.sort_by(|a, b| a.t.total_cmp(&b.t));
349    Ok(results)
350}
351