Skip to main content

geomcore/
intersect.rs

1//! Analytic intersection result types.
2//!
3//! The `intersect_*` methods on the analytic curves and surfaces return
4//! these enums: the intersecting geometry on success, or an explicit
5//! classification when the pair is tangent, disjoint, parallel, or
6//! coincident. Tangency and coincidence are decided with
7//! [`crate::Tolerance`], so near-degenerate configurations classify
8//! robustly instead of collapsing to noise.
9
10use crate::curves::{BSplineCurve3D, InterpParametrization, ParametricCurve3D};
11use crate::math::{gauss_newton_2d, newton_3d};
12use crate::surfaces::ParametricSurface;
13use crate::{Circle3D, Ellipse3D, Hyperbola3D, Line3D, Parabola3D, Point2D, Point3D, Tolerance};
14
15/// Result of intersecting two planes.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum PlanePlaneIntersection {
18    /// The planes meet in a line.
19    Line(Line3D),
20    /// The planes are parallel but distinct.
21    Parallel,
22    /// The planes coincide.
23    Coincident,
24}
25
26/// Result of intersecting a plane with a sphere.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum PlaneSphereIntersection {
29    /// The plane cuts the sphere in a circle.
30    Circle(Circle3D),
31    /// The plane grazes the sphere in a single point.
32    TangentPoint(Point3D),
33    /// The plane misses the sphere.
34    Empty,
35}
36
37/// Result of intersecting two spheres.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum SphereSphereIntersection {
40    /// The spheres meet in a circle.
41    Circle(Circle3D),
42    /// The spheres touch in a single point.
43    TangentPoint(Point3D),
44    /// The spheres are separate, one inside the other without contact, or
45    /// concentric with different radii.
46    Empty,
47    /// The spheres coincide (same center and radius within tolerance).
48    Coincident,
49}
50
51/// Result of intersecting a plane with a cylinder.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum PlaneCylinderIntersection {
54    /// The plane is perpendicular to the axis: a circle.
55    Circle(Circle3D),
56    /// The general oblique section: an ellipse.
57    Ellipse(Ellipse3D),
58    /// The plane is parallel to the axis and cuts two generators.
59    TwoLines(Line3D, Line3D),
60    /// The plane is parallel to the axis and grazes one generator.
61    TangentLine(Line3D),
62    /// The plane is parallel to the axis and misses the cylinder.
63    Empty,
64}
65
66/// Result of intersecting a plane with a cone.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum PlaneConeIntersection {
69    /// The plane is perpendicular to the axis: a circle.
70    Circle(Circle3D),
71    /// The plane meets the cone in an ellipse.
72    Ellipse(Ellipse3D),
73    /// The plane is parallel to a generator: a parabola.
74    Parabola(Parabola3D),
75    /// The plane cuts both nappes' worth of angle: a hyperbola branch.
76    Hyperbola(Hyperbola3D),
77    /// The plane passes through the apex and cuts two generators.
78    TwoLines(Line3D, Line3D),
79    /// The plane passes through the apex along a single generator.
80    TangentLine(Line3D),
81    /// The plane passes through the apex without meeting a generator.
82    ApexPoint(Point3D),
83    /// The section lies entirely behind the apex, off the single nappe.
84    Empty,
85}
86
87/// Result of intersecting a line with a plane.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum LinePlaneIntersection {
90    /// Transversal hit: the line parameter and the point.
91    Point(f64, Point3D),
92    /// The line is parallel to the plane but offset from it.
93    Parallel,
94    /// The line lies in the plane.
95    Coincident,
96}
97
98/// Result of intersecting a line with a sphere, cylinder, or cone.
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub enum LineQuadricIntersection {
101    /// Two transversal hits: `(parameter, point)` pairs, ordered.
102    TwoPoints((f64, Point3D), (f64, Point3D)),
103    /// A single transversal hit: the linear case (line parallel to a
104    /// cylinder axis or cone generator) or a nappe-filtered root.
105    OnePoint(f64, Point3D),
106    /// Grazing contact: the line parameter and the point.
107    Tangent(f64, Point3D),
108    /// No intersection.
109    Empty,
110    /// The line lies entirely on the surface (a cylinder or cone
111    /// generator; a line in a plane reports through
112    /// [`LinePlaneIntersection::Coincident`] instead).
113    Coincident,
114}
115
116/// Result of intersecting a sphere with a cylinder.
117///
118/// The analytic path requires the cylinder axis through the sphere
119/// center (within `tol.confusion`); otherwise the section is a space
120/// quartic and reports [`SphereCylinderIntersection::NotAnalytic`],
121/// reserved for the numeric surface-surface path.
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub enum SphereCylinderIntersection {
124    /// Latitude circle(s): one tangent ring or a symmetric pair.
125    Circle(Circle3D),
126    /// Two latitude rings symmetric about the equatorial plane.
127    TwoCircles(Circle3D, Circle3D),
128    /// The cylinder misses the sphere.
129    Empty,
130    /// No closed form: the pair is not in analytic configuration.
131    NotAnalytic,
132}
133
134/// Result of intersecting a sphere with a cone.
135///
136/// The analytic path requires the sphere center on the cone axis
137/// (within `tol.confusion`); otherwise the section is a space quartic
138/// and reports [`SphereConeIntersection::NotAnalytic`], reserved for the
139/// numeric surface-surface path.
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum SphereConeIntersection {
142    /// Latitude circle on the nappe.
143    Circle(Circle3D),
144    /// Two latitude circles on the nappe.
145    TwoCircles(Circle3D, Circle3D),
146    /// Grazing contact ring on the nappe.
147    TangentCircle(Circle3D),
148    /// No latitude circle lies on the single nappe.
149    Empty,
150    /// No closed form: the pair is not in analytic configuration.
151    NotAnalytic,
152}
153
154/// Result of intersecting two cylinders.
155///
156/// The analytic path requires parallel axes (within `tol.angular`);
157/// skew or crossing axes give a space quartic and report
158/// [`CylinderCylinderIntersection::NotAnalytic`], reserved for the
159/// numeric surface-surface path.
160#[derive(Debug, Clone, Copy, PartialEq)]
161pub enum CylinderCylinderIntersection {
162    /// Two shared generators.
163    TwoLines(Line3D, Line3D),
164    /// One shared grazing generator.
165    TangentLine(Line3D),
166    /// Separate, nested without contact, or concentric with different radii.
167    Empty,
168    /// Same axis and radius within tolerance.
169    Coincident,
170    /// No closed form: the pair is not in analytic configuration.
171    NotAnalytic,
172}
173
174/// Result of intersecting a cone with a cylinder.
175///
176/// The analytic path needs coaxial axes (within tolerance): the tube of
177/// radius `r` meets the nappe at axial `r/tan(phi)`, always a single
178/// latitude circle (or the apex for a degenerate tube). Anything else is
179/// a space quartic and reports
180/// [`ConeCylinderIntersection::NotAnalytic`], reserved for the numeric
181/// surface-surface path.
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum ConeCylinderIntersection {
184    /// Latitude circle on the nappe.
185    Circle(Circle3D),
186    /// Degenerate tube (its axis) meeting the nappe at the apex.
187    ApexPoint(Point3D),
188    /// No closed form: the pair is not in analytic configuration.
189    NotAnalytic,
190}
191
192/// Result of intersecting a torus with a plane.
193///
194/// The analytic path needs the plane through the axis (two meridian
195/// circles) or perpendicular to it (latitude circles). Anything else is
196/// a quartic spiric section and reports
197/// [`TorusPlaneIntersection::NotAnalytic`].
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub enum TorusPlaneIntersection {
200    /// Two meridian circles (axis-containing plane).
201    TwoCircles(Circle3D, Circle3D),
202    /// Grazing ring (perpendicular plane at tube top/bottom).
203    TangentCircle(Circle3D),
204    /// Single latitude ring (spindle case).
205    Circle(Circle3D),
206    /// The plane misses the torus.
207    Empty,
208    /// No closed form: the pair is not in analytic configuration.
209    NotAnalytic,
210}
211
212/// Result of intersecting a torus with a sphere.
213///
214/// The analytic path needs the sphere center on the torus axis, reducing
215/// to a quadratic in the axial coordinate. Anything else is high-degree
216/// and reports [`TorusSphereIntersection::NotAnalytic`].
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub enum TorusSphereIntersection {
219    /// Latitude ring.
220    Circle(Circle3D),
221    /// Two latitude rings.
222    TwoCircles(Circle3D, Circle3D),
223    /// Grazing ring.
224    TangentCircle(Circle3D),
225    /// No ring.
226    Empty,
227    /// No closed form: the pair is not in analytic configuration.
228    NotAnalytic,
229}
230
231/// Result of intersecting a torus with a cylinder.
232///
233/// The analytic path needs coaxial axes. Anything else reports
234/// [`TorusCylinderIntersection::NotAnalytic`].
235#[derive(Debug, Clone, Copy, PartialEq)]
236pub enum TorusCylinderIntersection {
237    /// Two latitude rings symmetric about the equatorial plane.
238    TwoCircles(Circle3D, Circle3D),
239    /// Grazing ring in the equatorial plane.
240    TangentCircle(Circle3D),
241    /// The tube misses the torus.
242    Empty,
243    /// No closed form: the pair is not in analytic configuration.
244    NotAnalytic,
245}
246
247/// Result of intersecting a torus with a cone.
248///
249/// The analytic path needs coaxial axes, reducing to a quadratic in the
250/// axial coordinate with nappe filtering. Anything else reports
251/// [`TorusConeIntersection::NotAnalytic`].
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub enum TorusConeIntersection {
254    /// Latitude ring on the nappe.
255    Circle(Circle3D),
256    /// Two latitude rings on the nappe.
257    TwoCircles(Circle3D, Circle3D),
258    /// Grazing ring on the nappe.
259    TangentCircle(Circle3D),
260    /// No ring on the single nappe.
261    Empty,
262    /// No closed form: the pair is not in analytic configuration.
263    NotAnalytic,
264}
265
266/// Result of intersecting two tori.
267///
268/// The analytic path needs collinear axes, reducing to quadratics in the
269/// axial coordinate. Anything else (up to degree 16) reports
270/// [`TorusTorusIntersection::NotAnalytic`].
271#[derive(Debug, Clone, Copy, PartialEq)]
272pub enum TorusTorusIntersection {
273    /// Latitude ring.
274    Circle(Circle3D),
275    /// Two latitude rings.
276    TwoCircles(Circle3D, Circle3D),
277    /// Grazing ring.
278    TangentCircle(Circle3D),
279    /// No rings.
280    Empty,
281    /// Same center and radii within tolerance.
282    Coincident,
283    /// No closed form: the pair is not in analytic configuration.
284    NotAnalytic,
285}
286
287/// Result of intersecting two cones.
288///
289/// The analytic path needs coaxial axes: equal latitude-ring radii give
290/// one axial position (or apex/coincident degenerates). Anything else is
291/// a space quartic and reports [`ConeConeIntersection::NotAnalytic`].
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub enum ConeConeIntersection {
294    /// Latitude ring on both nappes.
295    Circle(Circle3D),
296    /// The rings collapse to the shared apex.
297    ApexPoint(Point3D),
298    /// No ring on the nappes (parallel distinct nappes, or valid roots
299    /// behind an apex).
300    Empty,
301    /// Same apex, axis, and semi-angle within tolerance.
302    Coincident,
303    /// No closed form: the pair is not in analytic configuration.
304    NotAnalytic,
305}
306
307/// Result of intersecting two 2D lines.
308#[derive(Debug, Clone, Copy, PartialEq)]
309pub enum LineLine2DIntersection {
310    /// Transversal hit: line parameter on self, point, line parameter other.
311    Point(f64, Point2D, f64),
312    /// Parallel distinct lines.
313    Parallel,
314    /// Coincident lines.
315    Coincident,
316}
317
318/// Result of intersecting a 2D line with a 2D circle.
319#[derive(Debug, Clone, Copy, PartialEq)]
320pub enum LineCircle2DIntersection {
321    /// Two hits: `(line parameter, point)` pairs, ordered.
322    Points((f64, Point2D), (f64, Point2D)),
323    /// Grazing contact: line parameter and point.
324    Tangent(f64, Point2D),
325    /// No intersection.
326    Empty,
327}
328
329/// Result of intersecting two 2D circles.
330///
331/// Parameters are intentionally omitted (no natural primary curve);
332/// recover them with [`Circle2D::parameter_of`].
333#[derive(Debug, Clone, Copy, PartialEq)]
334pub enum CircleCircle2DIntersection {
335    /// Two meeting points.
336    Points(Point2D, Point2D),
337    /// Grazing contact point.
338    Tangent(Point2D),
339    /// Separate, nested, or concentric with different radii.
340    Empty,
341    /// Same center and radius within tolerance.
342    Coincident,
343}
344
345/// Result of intersecting two 3D lines.
346#[derive(Debug, Clone, Copy, PartialEq)]
347pub enum LineLine3DIntersection {
348    /// Transversal hit: parameter on self, point, parameter on other.
349    Point(f64, Point3D, f64),
350    /// Parallel distinct lines, with their distance.
351    Parallel(f64),
352    /// Coincident lines.
353    Coincident,
354    /// Skew lines: parameters and distance of the closest pair.
355    Skew {
356        /// Parameter on the first line.
357        s: f64,
358        /// Parameter on the second line.
359        t: f64,
360        /// Distance between the closest points.
361        distance: f64,
362    },
363}
364
365/// Result of intersecting a 3D line with a 3D circle.
366///
367/// The line meets the circle's plane in at most one point (transversal
368/// graze or miss); a line lying in the plane reduces to the 2D problem.
369#[derive(Debug, Clone, Copy, PartialEq)]
370pub enum LineCircle3DIntersection {
371    /// Two hits: `(line parameter, point)` pairs, ordered.
372    Points((f64, Point3D), (f64, Point3D)),
373    /// Grazing contact: line parameter and point.
374    Tangent(f64, Point3D),
375    /// No intersection.
376    Empty,
377}
378
379/// Result of intersecting two 3D circles.
380///
381/// Only coplanar pairs admit a closed form (via 2D images in the shared
382/// plane); anything else reports [`CircleCircle3DIntersection::NotAnalytic`].
383/// Parameters are omitted (no natural primary curve); recover them with
384/// [`Circle3D::parameter_of`].
385#[derive(Debug, Clone, Copy, PartialEq)]
386pub enum CircleCircle3DIntersection {
387    /// Two meeting points.
388    Points(Point3D, Point3D),
389    /// Grazing contact point.
390    Tangent(Point3D),
391    /// No intersection.
392    Empty,
393    /// Same center, radius, and plane within tolerance.
394    Coincident,
395    /// No closed form: the circles are not coplanar.
396    NotAnalytic,
397}
398
399/// Solution classification for `a*t^2 + b*t + c = 0` with tolerance.
400#[derive(Debug, Clone, Copy, PartialEq)]
401pub(crate) enum QuadraticSolution {
402    /// Two distinct roots, ordered.
403    Two(f64, f64),
404    /// A double root (discriminant within tolerance of zero).
405    One(f64),
406    /// Degenerate linear equation with a single root.
407    Linear(f64),
408    /// Degenerate `0 = 0` (caller decides coincidence separately).
409    Degenerate,
410    /// No (real) roots.
411    Empty,
412}
413
414/// Solve `a*t^2 + b*t + c = 0`, classifying with `tol`.
415///
416/// Near-zero `a` (against `tol.angular`, the coefficients being unit
417/// scale for unit-direction lines) falls back to the linear equation.
418/// Otherwise the root half-separation `sqrt(|disc|) / (2|a|)` decides:
419/// separations within `tol.confusion * (1 + |t0|)` of the double root
420/// report grazing contact, wider positive discriminants two ordered
421/// roots, wider negative ones empty.
422pub(crate) fn solve_quadratic(a: f64, b: f64, c: f64, tol: Tolerance) -> QuadraticSolution {
423    if a.abs() <= tol.angular {
424        if b.abs() <= tol.confusion {
425            return QuadraticSolution::Degenerate;
426        }
427        return QuadraticSolution::Linear(-c / b);
428    }
429    let disc = b * b - 4.0 * a * c;
430    let mid = -b / (2.0 * a);
431    let sep = disc.abs().sqrt() / (2.0 * a.abs());
432    if sep <= tol.confusion * (1.0 + mid.abs()) {
433        QuadraticSolution::One(mid)
434    } else if disc < 0.0 {
435        QuadraticSolution::Empty
436    } else {
437        let (t1, t2) = (mid - sep, mid + sep);
438        QuadraticSolution::Two(t1, t2)
439    }
440}
441
442/// One stationary point of the distance between two 3D curves.
443#[derive(Debug, Clone, Copy, PartialEq)]
444pub struct CurveCurveExtremum {
445    /// Parameter on the first curve.
446    pub first_param: f64,
447    /// Parameter on the second curve.
448    pub second_param: f64,
449    /// Distance between the two points.
450    pub distance: f64,
451}
452
453/// One transversal meeting of two 3D curves.
454#[derive(Debug, Clone, Copy, PartialEq)]
455pub struct CurveCurveHit {
456    /// Parameter on the first curve.
457    pub first_param: f64,
458    /// The meeting point (on the first curve; within tolerance of both).
459    pub point: Point3D,
460    /// Parameter on the second curve.
461    pub second_param: f64,
462}
463
464/// Clamp `t` into finite bounds (periodic and unbounded curves evaluate
465/// anywhere, so only finite windows clamp).
466fn clamp_param(t: f64, bounds: (f64, f64)) -> f64 {
467    if bounds.0.is_finite() || bounds.1.is_finite() {
468        t.clamp(bounds.0, bounds.1)
469    } else {
470        t
471    }
472}
473
474/// Generic closest-point enumeration between two 3D curves.
475///
476/// A coarse parameter grid seeds Gauss-Newton on
477/// `|C1(s) - C2(t)|^2`; distinct converged stationary points report
478/// ordered by distance. Unbounded curves seed `[-10, 10]`. Overlapping
479/// curves yield dense sets — use the analytic methods when
480/// classification matters.
481///
482/// # Examples
483///
484/// ```
485/// use geomcore::{Line3D, Point3D, Tolerance, Vector3D, curve_curve_extrema};
486/// let l1 = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
487/// let l2 = Line3D::new(Point3D::new(0.0, 0.0, 1.0), Vector3D::Y).unwrap();
488/// let ext = curve_curve_extrema(&l1, &l2, Tolerance::DEFAULT);
489/// assert_eq!(ext.len(), 1);
490/// assert_eq!(ext[0].distance, 1.0);
491/// ```
492pub fn curve_curve_extrema<A, B>(a: &A, b: &B, tol: Tolerance) -> Vec<CurveCurveExtremum>
493where
494    A: ParametricCurve3D,
495    B: ParametricCurve3D,
496{
497    const WINDOW: f64 = 10.0;
498    let (ba, bb) = (a.bounds(), b.bounds());
499    let (la, ha) = (
500        if ba.0.is_finite() { ba.0 } else { -WINDOW },
501        if ba.1.is_finite() { ba.1 } else { WINDOW },
502    );
503    let (lb, hb) = (
504        if bb.0.is_finite() { bb.0 } else { -WINDOW },
505        if bb.1.is_finite() { bb.1 } else { WINDOW },
506    );
507    const NS: usize = 32;
508    const NT: usize = 32;
509    const KEEP: usize = 24;
510    let mut samples: Vec<(f64, f64, f64)> = Vec::with_capacity(NS * NT);
511    for i in 0..NS {
512        for j in 0..NT {
513            let s = la + (ha - la) * i as f64 / (NS - 1) as f64;
514            let t = lb + (hb - lb) * j as f64 / (NT - 1) as f64;
515            samples.push((s, t, a.eval_point(s).distance(b.eval_point(t))));
516        }
517    }
518    samples.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap());
519    let mut out: Vec<CurveCurveExtremum> = Vec::new();
520    for (s, t, _) in samples.into_iter().take(KEEP) {
521        let residual = |s: f64, t: f64| {
522            let d = a.eval_point(clamp_param(s, ba)) - b.eval_point(clamp_param(t, bb));
523            [d.x, d.y, d.z]
524        };
525        let jac_s = |s: f64, _t: f64| {
526            let v = a.eval_derivative(clamp_param(s, ba), 1);
527            [v.x, v.y, v.z]
528        };
529        let jac_t = |_s: f64, t: f64| {
530            let v = b.eval_derivative(clamp_param(t, bb), 1);
531            [-v.x, -v.y, -v.z]
532        };
533        if let Some((rs, rt)) = gauss_newton_2d(residual, jac_s, jac_t, s, t, tol.confusion, 50) {
534            let (cs, ct) = (clamp_param(rs, ba), clamp_param(rt, bb));
535            let distance = a.eval_point(cs).distance(b.eval_point(ct));
536            if !out.iter().any(|e: &CurveCurveExtremum| {
537                (a.eval_point(e.first_param).distance(a.eval_point(cs))
538                    + b.eval_point(e.second_param).distance(b.eval_point(ct)))
539                    <= tol.confusion
540            }) {
541                out.push(CurveCurveExtremum {
542                    first_param: cs,
543                    second_param: ct,
544                    distance,
545                });
546            }
547        }
548    }
549    out.sort_by(|x, y| x.distance.partial_cmp(&y.distance).unwrap());
550    out
551}
552
553/// Generic 3D curve-curve intersection: [`curve_curve_extrema`] filtered
554/// to meetings within `tol.confusion`, ordered by first-curve parameter.
555///
556/// # Examples
557///
558/// ```
559/// use geomcore::{Circle3D, Line3D, Point3D, Tolerance, Vector3D, intersect_curve_curve};
560/// let line = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
561/// let circle = Circle3D::new(Point3D::ORIGIN, Vector3D::Z, 2.0).unwrap();
562/// let hits = intersect_curve_curve(&line, &circle, Tolerance::DEFAULT);
563/// assert_eq!(hits.len(), 2);
564/// ```
565pub fn intersect_curve_curve<A, B>(a: &A, b: &B, tol: Tolerance) -> Vec<CurveCurveHit>
566where
567    A: ParametricCurve3D,
568    B: ParametricCurve3D,
569{
570    let mut hits: Vec<CurveCurveHit> = curve_curve_extrema(a, b, tol)
571        .into_iter()
572        .filter(|e| e.distance <= tol.confusion)
573        .map(|e| CurveCurveHit {
574            first_param: e.first_param,
575            point: a.eval_point(e.first_param),
576            second_param: e.second_param,
577        })
578        .collect();
579    hits.sort_by(|x, y| x.first_param.partial_cmp(&y.first_param).unwrap());
580    hits
581}
582
583/// One transversal meeting of a curve and a surface.
584#[derive(Debug, Clone, Copy, PartialEq)]
585pub struct CurveSurfaceHit {
586    /// Parameter on the curve.
587    pub curve_param: f64,
588    /// Parameters on the surface.
589    pub surface_params: (f64, f64),
590    /// The meeting point (on the curve; within tolerance of the surface).
591    pub point: Point3D,
592}
593
594/// Generic curve-surface intersection for any parametric curve and surface.
595///
596/// Curve samples projected onto the surface seed Newton iteration on
597/// `C(t) - S(u, v) = 0` (first derivatives only); converged roots with a
598/// residual within `tol.confusion` report, ordered by curve parameter.
599/// Unbounded curves seed `[-10, 10]`. Overlapping pairs yield dense hit
600/// sets along the overlap — use the analytic `intersect_*` methods when
601/// classification (tangent, coincident) matters.
602///
603/// # Examples
604///
605/// ```
606/// use geomcore::{Line3D, Point3D, Sphere, Tolerance, Vector3D, intersect_curve_surface};
607/// let line = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
608/// let sphere = Sphere::new(Point3D::ORIGIN, 2.0).unwrap();
609/// let hits = intersect_curve_surface(&line, &sphere, Tolerance::DEFAULT);
610/// assert_eq!(hits.len(), 2);
611/// assert!((hits[0].curve_param + 2.0).abs() < 1e-9);
612/// assert!((hits[1].curve_param - 2.0).abs() < 1e-9);
613/// ```
614pub fn intersect_curve_surface<C, S>(curve: &C, surface: &S, tol: Tolerance) -> Vec<CurveSurfaceHit>
615where
616    C: ParametricCurve3D,
617    S: ParametricSurface,
618{
619    let (t0, t1) = curve.bounds();
620    // Unbounded curves seed a documented default window.
621    const WINDOW: f64 = 10.0;
622    let (lo, hi) = (
623        if t0.is_finite() { t0 } else { -WINDOW },
624        if t1.is_finite() { t1 } else { WINDOW },
625    );
626    const SEEDS: usize = 128;
627    const KEEP: usize = 24;
628    let mut samples: Vec<(f64, f64)> = (0..SEEDS)
629        .map(|i| {
630            let t = lo + (hi - lo) * i as f64 / (SEEDS - 1) as f64;
631            (t, surface.project_point(curve.eval_point(t), tol).distance)
632        })
633        .collect();
634    samples.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
635    let mut hits: Vec<CurveSurfaceHit> = Vec::new();
636    for (t, _) in samples.into_iter().take(KEEP) {
637        let proj = surface.project_point(curve.eval_point(t), tol);
638        let f = |x: [f64; 3]| {
639            let d = curve.eval_point(x[0]) - surface.eval_point(x[1], x[2]);
640            [d.x, d.y, d.z]
641        };
642        let jac = |x: [f64; 3]| {
643            let ct = curve.eval_derivative(x[0], 1);
644            let su = surface.eval_derivative(x[1], x[2], 1, 0);
645            let sv = surface.eval_derivative(x[1], x[2], 0, 1);
646            [
647                [ct.x, -su.x, -sv.x],
648                [ct.y, -su.y, -sv.y],
649                [ct.z, -su.z, -sv.z],
650            ]
651        };
652        if let Some([rt, ru, rv]) = newton_3d(f, jac, [t, proj.u, proj.v], tol.confusion, 50) {
653            let p = curve.eval_point(rt);
654            let q = surface.eval_point(ru, rv);
655            if p.distance(q) <= tol.confusion
656                && !hits
657                    .iter()
658                    .any(|h: &CurveSurfaceHit| h.point.distance(p) <= tol.confusion)
659            {
660                hits.push(CurveSurfaceHit {
661                    curve_param: rt,
662                    surface_params: (ru, rv),
663                    point: p,
664                });
665            }
666        }
667    }
668    hits.sort_by(|a, b| a.curve_param.partial_cmp(&b.curve_param).unwrap());
669    hits
670}
671
672/// Marching surface-surface intersection for any two parametric surfaces.
673///
674/// Seeds come from both grids projected onto the other surface; each seed
675/// traces along the `N1 x N2` tangent with adaptive steps, corrected by
676/// alternating projections, until it closes a loop, leaves the parameter
677/// bounds, or stalls. Traces fit [`BSplineCurve3D`] interpolants
678/// (degree 3, closed loops by repeated end points).
679///
680/// The fits are APPROXIMATIONS (interpolation error, typically ~1e-4 at
681/// default tolerance — assert residuals, not containment): exact
682/// classification stays with the analytic `intersect_*` methods. Likewise
683/// no tangency classification (grazing pairs may trace partially or not
684/// at all), no marching through singular points (poles, apexes), and a
685/// fixed seed density — v1 heuristics, documented so callers can judge
686/// fit.
687///
688/// # Examples
689///
690/// ```
691/// use geomcore::{Plane, Point3D, Tolerance, Vector3D, marching_intersection};
692/// let xy = Plane::new(Point3D::ORIGIN, Vector3D::Z).unwrap();
693/// let zy = Plane::new(Point3D::ORIGIN, Vector3D::X).unwrap();
694/// let curves = marching_intersection(&xy, &zy, Tolerance::DEFAULT);
695/// assert_eq!(curves.len(), 1);
696/// ```
697pub fn marching_intersection<A, B>(a: &A, b: &B, tol: Tolerance) -> Vec<BSplineCurve3D>
698where
699    A: ParametricSurface,
700    B: ParametricSurface,
701{
702    // Seed both directions: grid samples projected onto the other surface.
703    // Unbounded directions seed a documented default window.
704    let mut seeds: Vec<(Point3D, f64)> = Vec::new();
705    {
706        let (u0, u1) = seed_window(a.u_bounds());
707        let (v0, v1) = seed_window(a.v_bounds());
708        const NU: usize = 24;
709        const NV: usize = 24;
710        for j in 0..=NV {
711            for i in 0..=NU {
712                let (u, v) = (
713                    u0 + (u1 - u0) * i as f64 / NU as f64,
714                    v0 + (v1 - v0) * j as f64 / NV as f64,
715                );
716                let p = a.eval_point(u, v);
717                let d = b.project_point(p, tol).distance;
718                if d <= 100.0 * tol.confusion {
719                    seeds.push((p, d));
720                }
721            }
722        }
723    }
724    {
725        let (u0, u1) = seed_window(b.u_bounds());
726        let (v0, v1) = seed_window(b.v_bounds());
727        const NU: usize = 24;
728        const NV: usize = 24;
729        for j in 0..=NV {
730            for i in 0..=NU {
731                let (u, v) = (
732                    u0 + (u1 - u0) * i as f64 / NU as f64,
733                    v0 + (v1 - v0) * j as f64 / NV as f64,
734                );
735                let p = b.eval_point(u, v);
736                let d = a.project_point(p, tol).distance;
737                if d <= 100.0 * tol.confusion {
738                    seeds.push((p, d));
739                }
740            }
741        }
742    }
743    // Union-find chaining so each connected seed set traces once: a seed
744    // joins any cluster with a member in radius (5% of the seed bbox
745    // diagonal), transitively linking extended structures like lines.
746    let mut leaders: Vec<Point3D> = Vec::new();
747    if !seeds.is_empty() {
748        let mut lo = seeds[0].0;
749        let mut hi = seeds[0].0;
750        for &(p, _) in &seeds[1..] {
751            lo = Point3D::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
752            hi = Point3D::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
753        }
754        let radius = (0.05 * lo.distance(hi)).max(100.0 * tol.confusion);
755        let mut parent: Vec<usize> = (0..seeds.len()).collect();
756        fn find(parent: &mut [usize], mut x: usize) -> usize {
757            while parent[x] != x {
758                parent[x] = parent[parent[x]];
759                x = parent[x];
760            }
761            x
762        }
763        for i in 0..seeds.len() {
764            for j in (i + 1)..seeds.len() {
765                if seeds[i].0.distance(seeds[j].0) <= radius {
766                    let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
767                    parent[ri] = rj;
768                }
769            }
770        }
771        // Best (closest) seed per cluster, clusters ordered by it.
772        let mut best: std::collections::HashMap<usize, (f64, Point3D)> =
773            std::collections::HashMap::new();
774        for (i, &(p, d)) in seeds.iter().enumerate() {
775            let r = find(&mut parent, i);
776            best.entry(r)
777                .and_modify(|e| {
778                    if d < e.0 {
779                        *e = (d, p);
780                    }
781                })
782                .or_insert((d, p));
783        }
784        let mut ordered: Vec<(f64, Point3D)> = best.into_values().collect();
785        ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
786        leaders = ordered.into_iter().map(|(_, p)| p).collect();
787    }
788    let mut curves: Vec<BSplineCurve3D> = Vec::new();
789    // Seeds near an already traced loop are suppressed by projection
790    // onto its fit (raw spacing and fit error both exceed confusion, so
791    // neither raw points nor tight containment can decide duplication).
792    // Near-tangent distinct loops (closer than this) are out of scope.
793    let suppress = 1000.0 * tol.confusion;
794    let mut step = 0.02f64;
795    for seed in leaders {
796        if curves
797            .iter()
798            .any(|c| c.project_point(seed, tol).distance <= suppress)
799        {
800            continue;
801        }
802        if let Some(loop_pts) = trace_loop(a, b, seed, &mut step, tol)
803            && loop_pts.len() >= 4
804        {
805            let mut closed = loop_pts;
806            closed.push(closed[0]);
807            let degree = 3.min(closed.len() - 1);
808            if let Ok(fit) =
809                BSplineCurve3D::interpolate(&closed, degree, InterpParametrization::Centripetal)
810            {
811                curves.push(fit);
812            }
813        }
814    }
815    curves
816}
817
818/// Trace one loop (or open arc) through `seed`; adaptive step in `step`.
819fn trace_loop<A, B>(
820    a: &A,
821    b: &B,
822    seed: Point3D,
823    step: &mut f64,
824    tol: Tolerance,
825) -> Option<Vec<Point3D>>
826where
827    A: ParametricSurface,
828    B: ParametricSurface,
829{
830    let normal_at = |s: &dyn ParametricSurface, u: f64, v: f64| {
831        let su = s.eval_derivative(u, v, 1, 0);
832        let sv = s.eval_derivative(u, v, 0, 1);
833        let n = su.cross(sv);
834        let scale = su.magnitude() * sv.magnitude();
835        if n.magnitude() <= tol.angular * scale.max(f64::MIN_POSITIVE) {
836            None
837        } else {
838            Some(n * (1.0 / n.magnitude()))
839        }
840    };
841    // Seed must project cleanly onto both surfaces for tangents.
842    let mut fwd: Vec<Point3D> = vec![seed];
843    let mut bwd: Vec<Point3D> = Vec::new();
844    // March both directions from the seed.
845    for (chain, dir) in [(&mut fwd, 1.0), (&mut bwd, -1.0)] {
846        let mut p = seed;
847        let mut h = *step;
848        for _ in 0..2000 {
849            let qa = a.project_point(p, tol);
850            let qb = b.project_point(p, tol);
851            let (Some(na), Some(nb)) = (normal_at(a, qa.u, qa.v), normal_at(b, qb.u, qb.v)) else {
852                break;
853            };
854            let t = na.cross(nb);
855            if t.magnitude() <= tol.angular {
856                break;
857            }
858            let t = t * (dir / t.magnitude());
859            // Adaptive predict-correct.
860            let mut accepted = false;
861            for _ in 0..10 {
862                let q = correct(a, b, p + t * h, tol);
863                match q {
864                    Some(q) if b.project_point(q, tol).distance <= 10.0 * tol.confusion => {
865                        p = q;
866                        h = (h * 1.25).min(0.1);
867                        accepted = true;
868                        break;
869                    }
870                    _ => {
871                        h *= 0.5;
872                        if h < tol.confusion {
873                            break;
874                        }
875                    }
876                }
877            }
878            if !accepted {
879                break;
880            }
881            // Closed loop?
882            if chain.len() > 10 && p.distance(chain[0]) <= 10.0 * tol.confusion {
883                chain.push(chain[0]);
884                break;
885            }
886            // Left the parameter domain on an open direction?
887            let ra = a.project_point(p, tol);
888            let rb = b.project_point(p, tol);
889            if off_bounds(a, ra.u, ra.v) || off_bounds(b, rb.u, rb.v) {
890                chain.push(p);
891                break;
892            }
893            chain.push(p);
894        }
895    }
896    *step = (*step).clamp(tol.confusion, 1.0);
897    // Stitch backward (reversed, seed duplicates dropped) + forward.
898    bwd.reverse();
899    let mut pts = bwd;
900    pts.extend(fwd.into_iter().skip(1));
901    if pts.len() < 4 { None } else { Some(pts) }
902}
903
904/// Alternating-projection correction onto both surfaces.
905fn correct<A, B>(a: &A, b: &B, p: Point3D, tol: Tolerance) -> Option<Point3D>
906where
907    A: ParametricSurface,
908    B: ParametricSurface,
909{
910    let mut q = p;
911    for _ in 0..8 {
912        let pa = a.project_point(q, tol);
913        let qa = a.eval_point(pa.u, pa.v);
914        let pb = b.project_point(qa, tol);
915        let qb = b.eval_point(pb.u, pb.v);
916        if qb.distance(q) <= tol.confusion {
917            return Some(qb);
918        }
919        q = qb;
920    }
921    let residual = a.project_point(q, tol).distance + b.project_point(q, tol).distance;
922    if residual <= 10.0 * tol.confusion {
923        Some(q)
924    } else {
925        None
926    }
927}
928
929/// Clamp a parameter bound pair to a finite seed window.
930fn seed_window((lo, hi): (f64, f64)) -> (f64, f64) {
931    const WINDOW: f64 = 10.0;
932    (
933        if lo.is_finite() { lo } else { -WINDOW },
934        if hi.is_finite() { hi } else { WINDOW },
935    )
936}
937
938/// Whether `(u, v)` left a non-periodic direction's bounds.
939fn off_bounds<S: ParametricSurface + ?Sized>(s: &S, u: f64, v: f64) -> bool {
940    let (u0, u1) = s.u_bounds();
941    let (v0, v1) = s.v_bounds();
942    (s.u_period().is_none() && (u < u0 || u > u1)) || (s.v_period().is_none() && (v < v0 || v > v1))
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948    use crate::Tolerance;
949    use crate::{Line3D, LineCircle3DIntersection, Point3D, Sphere, Vector3D};
950
951    #[test]
952    fn test_solve_quadratic_two_roots_ordered() {
953        // t^2 - 5t + 6 = 0 -> 2, 3.
954        assert_eq!(
955            solve_quadratic(1.0, -5.0, 6.0, Tolerance::DEFAULT),
956            QuadraticSolution::Two(2.0, 3.0)
957        );
958    }
959
960    #[test]
961    fn test_solve_quadratic_tangent_band() {
962        // Exact double root.
963        assert_eq!(
964            solve_quadratic(1.0, -4.0, 4.0, Tolerance::DEFAULT),
965            QuadraticSolution::One(2.0)
966        );
967        // Miss by sqrt(c) = 1e-7, i.e. exactly at tolerance: still grazing.
968        assert_eq!(
969            solve_quadratic(1.0, 0.0, 1e-14, Tolerance::DEFAULT),
970            QuadraticSolution::One(0.0)
971        );
972        // Miss by sqrt(c) = 3e-5, beyond tolerance: empty.
973        assert_eq!(
974            solve_quadratic(1.0, 0.0, 1e-9, Tolerance::DEFAULT),
975            QuadraticSolution::Empty
976        );
977        // Clearly negative discriminant misses.
978        assert_eq!(
979            solve_quadratic(1.0, 0.0, 1.0, Tolerance::DEFAULT),
980            QuadraticSolution::Empty
981        );
982    }
983
984    #[test]
985    fn test_solve_quadratic_degenerate_arms() {
986        // Linear: 2t + 4 = 0.
987        assert_eq!(
988            solve_quadratic(0.0, 2.0, 4.0, Tolerance::DEFAULT),
989            QuadraticSolution::Linear(-2.0)
990        );
991        // Fully degenerate.
992        assert_eq!(
993            solve_quadratic(0.0, 0.0, 1.0, Tolerance::DEFAULT),
994            QuadraticSolution::Degenerate
995        );
996    }
997
998    #[test]
999    fn test_generic_line_sphere_matches_analytic() {
1000        let tol = Tolerance::DEFAULT;
1001        let line = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
1002        let sphere = Sphere::new(Point3D::ORIGIN, 2.0).unwrap();
1003        let hits = intersect_curve_surface(&line, &sphere, tol);
1004        assert_eq!(hits.len(), 2);
1005        assert!((hits[0].curve_param + 2.0).abs() < 1e-9);
1006        assert!((hits[1].curve_param - 2.0).abs() < 1e-9);
1007        // Cross-check against the analytic solver.
1008        match line.intersect_sphere(&sphere, tol) {
1009            LineQuadricIntersection::TwoPoints((t1, _), (t2, _)) => {
1010                assert!((hits[0].curve_param - t1).abs() < 1e-9);
1011                assert!((hits[1].curve_param - t2).abs() < 1e-9);
1012            }
1013            _ => panic!("analytic expected two points"),
1014        }
1015    }
1016
1017    #[test]
1018    fn test_generic_line_sphere_miss() {
1019        let tol = Tolerance::DEFAULT;
1020        let line = Line3D::new(Point3D::new(0.0, 0.0, 3.0), Vector3D::X).unwrap();
1021        let sphere = Sphere::new(Point3D::ORIGIN, 2.0).unwrap();
1022        assert!(intersect_curve_surface(&line, &sphere, tol).is_empty());
1023    }
1024
1025    #[test]
1026    fn test_generic_segment_plane_hit() {
1027        use crate::{BSplineCurve3D, Plane};
1028        let tol = Tolerance::DEFAULT;
1029        let poles = vec![Point3D::new(0.0, 0.0, 0.0), Point3D::new(2.0, 0.0, 0.0)];
1030        let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
1031        let plane = Plane::new(Point3D::new(1.0, 0.0, 0.0), Vector3D::X).unwrap();
1032        let hits = intersect_curve_surface(&curve, &plane, tol);
1033        assert_eq!(hits.len(), 1);
1034        assert!((hits[0].curve_param - 0.5).abs() < 1e-9);
1035        assert_eq!(hits[0].point, Point3D::new(1.0, 0.0, 0.0));
1036    }
1037
1038    #[test]
1039    fn test_marching_plane_plane_line() {
1040        use crate::Plane;
1041        let tol = Tolerance::DEFAULT;
1042        let xy = Plane::new(Point3D::ORIGIN, Vector3D::Z).unwrap();
1043        let zy = Plane::new(Point3D::ORIGIN, Vector3D::X).unwrap();
1044        let curves = marching_intersection(&xy, &zy, tol);
1045        assert_eq!(curves.len(), 1);
1046        // The traced line runs along Y through the origin.
1047        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1048            let p = curves[0].eval_point(t);
1049            assert!(p.x.abs() < 1e-6, "{p:?}");
1050            assert!(p.z.abs() < 1e-6, "{p:?}");
1051        }
1052    }
1053
1054    #[test]
1055    fn test_marching_plane_sphere_circle() {
1056        use crate::{Plane, Sphere};
1057        let tol = Tolerance::DEFAULT;
1058        let plane = Plane::new(Point3D::ORIGIN, Vector3D::Z).unwrap();
1059        let sphere = Sphere::new(Point3D::ORIGIN, 2.0).unwrap();
1060        let curves = marching_intersection(&plane, &sphere, tol);
1061        assert_eq!(curves.len(), 1);
1062        // Approximate fit: residuals within marching scale (not confusion).
1063        for t in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] {
1064            let p = curves[0].eval_point(t);
1065            assert!(plane.project_point(p, tol).distance < 1e-3, "{p:?}");
1066            assert!(sphere.project_point(p, tol).distance < 1e-3, "{p:?}");
1067        }
1068    }
1069
1070    #[test]
1071    fn test_marching_miss_is_empty() {
1072        use crate::{Plane, Sphere};
1073        let tol = Tolerance::DEFAULT;
1074        let plane = Plane::new(Point3D::new(0.0, 0.0, 5.0), Vector3D::Z).unwrap();
1075        let sphere = Sphere::new(Point3D::ORIGIN, 2.0).unwrap();
1076        assert!(marching_intersection(&plane, &sphere, tol).is_empty());
1077    }
1078
1079    #[test]
1080    fn test_curve_curve_extrema_skew_matches_analytic() {
1081        use crate::{Line3D, LineLine3DIntersection};
1082        let tol = Tolerance::DEFAULT;
1083        let l1 = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
1084        let l2 = Line3D::new(Point3D::new(0.0, 0.0, 1.0), Vector3D::Y).unwrap();
1085        let ext = curve_curve_extrema(&l1, &l2, tol);
1086        assert_eq!(ext.len(), 1);
1087        assert!((ext[0].distance - 1.0).abs() < 1e-9);
1088        match l1.intersect_line(&l2, tol) {
1089            LineLine3DIntersection::Skew { s, t, distance } => {
1090                assert!((ext[0].first_param - s).abs() < 1e-9);
1091                assert!((ext[0].second_param - t).abs() < 1e-9);
1092                assert!((ext[0].distance - distance).abs() < 1e-12);
1093            }
1094            _ => panic!("analytic expected skew"),
1095        }
1096    }
1097
1098    #[test]
1099    fn test_intersect_curve_curve_line_circle() {
1100        use crate::{Circle3D, Line3D};
1101        let tol = Tolerance::DEFAULT;
1102        let line = Line3D::new(Point3D::ORIGIN, Vector3D::X).unwrap();
1103        let circle = Circle3D::new(Point3D::ORIGIN, Vector3D::Z, 2.0).unwrap();
1104        let hits = intersect_curve_curve(&line, &circle, tol);
1105        assert_eq!(hits.len(), 2);
1106        assert!((hits[0].first_param + 2.0).abs() < 1e-9);
1107        assert!((hits[1].first_param - 2.0).abs() < 1e-9);
1108        // Cross-check against the analytic solver.
1109        match line.intersect_circle(&circle, tol) {
1110            LineCircle3DIntersection::Points((t1, _), (t2, _)) => {
1111                assert!((hits[0].first_param - t1).abs() < 1e-9);
1112                assert!((hits[1].first_param - t2).abs() < 1e-9);
1113            }
1114            _ => panic!("analytic expected two points"),
1115        }
1116    }
1117}