Skip to main content

ogeom_intersect/
curve_surface.rs

1//! Where a curve pierces a surface.
2//!
3//! *Elsewhere* this is `GeomAPI_IntCS` and the line/quadric half of `IntAna`.
4//! Two consumers drive it: edge/face interference in the boolean's pave
5//! filler, and the exact point-in-solid classifier, which is a ray/surface
6//! query per face, the very use `docs/PLAN.md` carries what remains exact.
7//!
8//! # Well-posed, for once
9//!
10//! `C(t) = S(u, v)` is three equations in three unknowns; unlike the
11//! surface/surface system, nothing has to be pinned for Newton to converge to
12//! a point. The analytic cases are still answered in closed form first: a line
13//! against a plane or a quadric is a linear or quadratic equation, and solving
14//! a quadratic by iteration would be slower and less exact than writing down
15//! its roots.
16//!
17//! # A curve lying in the surface
18//!
19//! A line in a plane crosses it nowhere and everywhere. That is an overlap
20//! (the parameter range of the curve that lies in the surface), and it is a
21//! different answer from any list of points. Detected where the analytic
22//! forms can see it; the general path reports whatever isolated piercings its
23//! sampling resolves, and says so.
24
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::{Curve, Curve3d, Surface, SurfaceGeometry};
27use ogeom_math::{Point, solve};
28
29use crate::march::{Cell, sample_by, segment_meets_triangle};
30
31/// One piercing of a surface by a curve.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Piercing {
34    /// The parameter on the curve.
35    pub on_curve: f64,
36    /// The parameters on the surface.
37    pub on_surface: (f64, f64),
38    /// Where, taken from the curve.
39    pub point: Point,
40    /// The distance between the two evaluations there.
41    pub gap: f64,
42}
43
44/// What a curve does to a surface.
45#[derive(Debug, Clone, PartialEq)]
46pub struct CurveSurfaceIntersection {
47    /// Isolated piercings, in order along the curve.
48    pub crossings: Vec<Piercing>,
49    /// Parameter ranges of the curve that lie *in* the surface.
50    ///
51    /// Detected for the analytic cases: a line in a plane. The general path
52    /// cannot see lying-on and reports whatever isolated piercings its
53    /// sampling resolves.
54    pub lying: Vec<(f64, f64)>,
55}
56
57impl CurveSurfaceIntersection {
58    /// No contact found.
59    #[must_use]
60    pub fn is_empty(&self) -> bool {
61        self.crossings.is_empty() && self.lying.is_empty()
62    }
63
64    const fn empty() -> Self {
65        Self {
66            crossings: Vec::new(),
67            lying: Vec::new(),
68        }
69    }
70}
71
72/// How hard the general path looks.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct CurveSurfaceOptions {
75    /// How many segments the curve is sampled into for seeding.
76    pub samples: usize,
77    /// How finely the surface is sampled, per direction.
78    pub grid: usize,
79    /// The widest gap that still counts as a piercing.
80    pub gap: f64,
81}
82
83impl Default for CurveSurfaceOptions {
84    fn default() -> Self {
85        Self {
86            samples: 128,
87            grid: 24,
88            gap: 1e-7,
89        }
90    }
91}
92
93/// Where a curve pierces a surface.
94///
95/// Analytic line/plane, line/sphere and line/cylinder are answered in closed
96/// form; everything else is seeded polyhedrally and polished by Newton on the
97/// well-posed three-by-three system.
98///
99/// # Errors
100///
101/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the options
102/// are unusable.
103pub fn intersect_curve_surface(
104    curve: &Curve,
105    surface: &SurfaceGeometry,
106    options: CurveSurfaceOptions,
107    tol: Tolerances,
108) -> OgeomResult<CurveSurfaceIntersection> {
109    if options.samples < 2 || options.grid < 2 {
110        ogeom_bail!(Construction, "seeding needs at least two steps each way");
111    }
112    if !options.gap.is_finite() || options.gap <= 0.0 {
113        ogeom_bail!(Construction, "a gap of {} is not a distance", options.gap);
114    }
115
116    match (curve, surface) {
117        (Curve::Line(line), SurfaceGeometry::Plane(p)) => {
118            Ok(line_plane(line, p.plane(), curve, surface, tol))
119        }
120        (Curve::Line(line), SurfaceGeometry::Sphere(s)) => Ok(line_quadric(
121            line,
122            curve,
123            surface,
124            sphere_roots(line, s.sphere()),
125            options,
126            tol,
127        )),
128        (Curve::Line(line), SurfaceGeometry::Cylinder(c)) => Ok(line_quadric(
129            line,
130            curve,
131            surface,
132            cylinder_roots(line, c.cylinder()),
133            options,
134            tol,
135        )),
136        _ => general(curve, surface, options, tol),
137    }
138}
139
140// --- analytic ----------------------------------------------------------------
141
142fn line_plane(
143    line: &ogeom_geom::LineCurve,
144    plane: ogeom_math::Plane,
145    curve: &Curve,
146    surface: &SurfaceGeometry,
147    tol: Tolerances,
148) -> CurveSurfaceIntersection {
149    let axis = line.axis();
150    let along = plane.normal().dot(axis.direction);
151    let height = plane.signed_distance_to(axis.location);
152
153    if along.abs() <= tol.angular() {
154        // Parallel: in the plane, or never touching it.
155        if height.abs() <= tol.confusion() {
156            return CurveSurfaceIntersection {
157                crossings: Vec::new(),
158                lying: vec![line.domain()],
159            };
160        }
161        return CurveSurfaceIntersection::empty();
162    }
163
164    let t = -height / along;
165    let (lo, hi) = line.domain();
166    if t < lo - tol.parametric() || t > hi + tol.parametric() {
167        return CurveSurfaceIntersection::empty();
168    }
169    let point = axis.location + axis.direction.vector() * t;
170    let Some(found) = invert(surface, point, curve, t, tol) else {
171        return CurveSurfaceIntersection::empty();
172    };
173    if found.gap > tol.confusion() {
174        // The crossing is real on the unbounded plane but outside this
175        // surface's stated extents; the clamped polish says so as a gap.
176        return CurveSurfaceIntersection::empty();
177    }
178    CurveSurfaceIntersection {
179        crossings: vec![found],
180        lying: Vec::new(),
181    }
182}
183
184/// The line parameters at which a line meets a sphere.
185fn sphere_roots(line: &ogeom_geom::LineCurve, sphere: ogeom_math::Sphere) -> Vec<f64> {
186    let axis = line.axis();
187    let d = axis.direction.vector();
188    let m = axis.location - sphere.centre();
189    // |m + t d|^2 = r^2, with |d| = 1.
190    let b = m.dot(d);
191    let c = sphere.radius().mul_add(-sphere.radius(), m.dot(m));
192    let discriminant = b.mul_add(b, -c);
193    if discriminant < 0.0 {
194        return Vec::new();
195    }
196    let root = discriminant.sqrt();
197    if root == 0.0 {
198        vec![-b]
199    } else {
200        vec![-b - root, -b + root]
201    }
202}
203
204/// The line parameters at which a line meets a cylinder.
205fn cylinder_roots(line: &ogeom_geom::LineCurve, cylinder: ogeom_math::Cylinder) -> Vec<f64> {
206    let axis = line.axis();
207    let w = cylinder.axis().direction.vector();
208    // Strip the components along the cylinder's axis; what is left is a 2D
209    // circle problem in the perpendicular plane.
210    let d = axis.direction.vector();
211    let m = axis.location - cylinder.axis().location;
212    let d_perp = d - w * d.dot(w);
213    let m_perp = m - w * m.dot(w);
214    let a = d_perp.dot(d_perp);
215    if a <= f64::MIN_POSITIVE {
216        // The line runs along the axis direction: on the wall it would lie,
217        // not pierce, and lying is not detected here.
218        return Vec::new();
219    }
220    let b = d_perp.dot(m_perp);
221    let c = cylinder
222        .radius()
223        .mul_add(-cylinder.radius(), m_perp.dot(m_perp));
224    let discriminant = b.mul_add(b, -(a * c));
225    if discriminant < 0.0 {
226        return Vec::new();
227    }
228    let root = discriminant.sqrt();
229    if root == 0.0 {
230        vec![-b / a]
231    } else {
232        vec![(-b - root) / a, (-b + root) / a]
233    }
234}
235
236/// Roots dressed as piercings, filtered by the line's own range and the
237/// surface's extents.
238fn line_quadric(
239    line: &ogeom_geom::LineCurve,
240    curve: &Curve,
241    surface: &SurfaceGeometry,
242    roots: Vec<f64>,
243    options: CurveSurfaceOptions,
244    tol: Tolerances,
245) -> CurveSurfaceIntersection {
246    let axis = line.axis();
247    let (lo, hi) = line.domain();
248    let mut crossings = Vec::new();
249    for t in roots {
250        if t < lo - tol.parametric() || t > hi + tol.parametric() {
251            continue;
252        }
253        let point = axis.location + axis.direction.vector() * t;
254        let Some(found) = invert(surface, point, curve, t, tol) else {
255            continue;
256        };
257        // The extent check is the gap. The polish clamps the surface
258        // parameters into the stated domain, so a root beyond the cylinder's
259        // height converges to the rim with a gap of exactly how far past it
260        // was: a piercing of the unbounded geometry, not of this surface.
261        // Discarding the polish's gap and writing zero here was the bug this
262        // comment replaces.
263        if found.gap > tol.confusion() {
264            continue;
265        }
266        let _ = options;
267        crossings.push(Piercing {
268            on_curve: t,
269            on_surface: found.on_surface,
270            point,
271            gap: found.gap,
272        });
273    }
274    crossings.sort_by(|a, b| {
275        a.on_curve
276            .partial_cmp(&b.on_curve)
277            .unwrap_or(core::cmp::Ordering::Equal)
278    });
279    CurveSurfaceIntersection {
280        crossings,
281        lying: Vec::new(),
282    }
283}
284
285/// Surface parameters of a point known to lie on an analytic surface.
286///
287/// Closed-form inversion for the quadrics; refined by one Newton pass so the
288/// reported parameters evaluate back onto the point to rounding.
289fn invert(
290    surface: &SurfaceGeometry,
291    point: Point,
292    curve: &Curve,
293    on_curve: f64,
294    tol: Tolerances,
295) -> Option<Piercing> {
296    let guess = match surface {
297        SurfaceGeometry::Plane(p) => {
298            let local = p.plane().frame().to_local(point);
299            (local.x, local.y)
300        }
301        SurfaceGeometry::Sphere(s) => {
302            let local = s.sphere().frame().to_local(point);
303            let latitude = (local.z / s.sphere().radius()).clamp(-1.0, 1.0).asin();
304            (
305                local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU),
306                latitude,
307            )
308        }
309        SurfaceGeometry::Cylinder(c) => {
310            let local = c.cylinder().frame().to_local(point);
311            (
312                local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU),
313                local.z,
314            )
315        }
316        _ => return None,
317    };
318    // One polish step against the curve point, so parameter rounding in the
319    // inversion does not survive into the result, and the gap comes with it,
320    // because the polish clamps into the surface's extents and the gap is
321    // what says whether the clamped answer still touches the curve.
322    polish(curve, surface, on_curve, guess, tol)
323}
324
325// --- general -----------------------------------------------------------------
326
327fn general(
328    curve: &Curve,
329    surface: &SurfaceGeometry,
330    options: CurveSurfaceOptions,
331    tol: Tolerances,
332) -> OgeomResult<CurveSurfaceIntersection> {
333    let cells = sample_by(surface, seeding(surface, options.grid), tol);
334    let (lo, hi) = curve.domain();
335
336    let mut points = Vec::with_capacity(options.samples + 1);
337    for i in 0..=options.samples {
338        #[allow(clippy::cast_precision_loss)]
339        let t = lo + (hi - lo) * i as f64 / options.samples as f64;
340        if let Ok(p) = curve.point_at(t, tol) {
341            points.push((t, p));
342        }
343    }
344
345    let mut crossings: Vec<Piercing> = Vec::new();
346    for pair in points.windows(2) {
347        let (t0, p0) = pair[0];
348        let (t1, p1) = pair[1];
349        for cell in &cells {
350            // Near the cell within the surface's own bow from it: a curve
351            // crossing the surface in the gap between the flat cell and
352            // the curved patch it stands for (a ray starting a few microns
353            // from the wall it leaves by) meets no cell, and is seeded by
354            // its nearness instead.
355            if !segment_near_cell(p0, p1, cell, options.gap.max(cell.sag)) {
356                continue;
357            }
358            if segment_meets_triangle(p0, p1, cell.corners).is_none()
359                && !(cell.sag > options.gap && segment_near_cell(p0, p1, cell, cell.sag))
360            {
361                continue;
362            }
363            // Newton from where the segment meets the cell, not from the
364            // cell's corner: on a wall bowing a few hundredths of a
365            // millimetre over a cell, the corner can stand far enough off
366            // that the first step leaves the chart, and clamped at its edge
367            // the solve stalls there. The corner stays a second try.
368            let (near_t, near_uv) = seed_in(cell, p0, p1, t0, t1);
369            let Some(found) = [(near_t, near_uv), (f64::midpoint(t0, t1), cell.at)]
370                .into_iter()
371                .filter_map(|(t, uv)| polish(curve, surface, t, uv, tol))
372                .find(|found| found.gap <= options.gap)
373            else {
374                continue;
375            };
376            let reach = tol.confusion() * 100.0;
377            if !crossings
378                .iter()
379                .any(|c| c.point.distance(found.point) <= reach)
380            {
381                crossings.push(found);
382            }
383        }
384    }
385    crossings.sort_by(|a, b| {
386        a.on_curve
387            .partial_cmp(&b.on_curve)
388            .unwrap_or(core::cmp::Ordering::Equal)
389    });
390    Ok(CurveSurfaceIntersection {
391        crossings,
392        lying: Vec::new(),
393    })
394}
395
396/// How many seed cells to lay along each direction of a surface: the
397/// asked grid, and for a spline at least two per knot span. A patch swept
398/// several turns round an axis (a thread's flank) spans dozens of knots
399/// along its length, and a grid of the asked size lays flat cells a turn's
400/// fraction wide whose chords stand a tenth of a millimetre off the wall;
401/// a curve crossing the wall inside that gap meets no cell and is missed.
402fn seeding(surface: &SurfaceGeometry, grid: usize) -> (usize, usize) {
403    const CAP: usize = 1024;
404    let SurfaceGeometry::BSpline(spline) = surface else {
405        return (grid, grid);
406    };
407    let spans = |knots: &ogeom_math::KnotVector| knots.distinct().len().saturating_sub(1);
408    (
409        grid.max(2 * spans(spline.u_knots())).min(CAP.max(grid)),
410        grid.max(2 * spans(spline.v_knots())).min(CAP.max(grid)),
411    )
412}
413
414/// Where to start Newton for a segment near a cell: the point of the cell
415/// the segment passes through, or failing that the one nearest its middle,
416/// with its parameters on the curve and on the surface read off the cell's
417/// corners.
418fn seed_in(cell: &Cell, p0: Point, p1: Point, t0: f64, t1: f64) -> (f64, (f64, f64)) {
419    let [a, b, c] = cell.corners;
420    let (t, at) = segment_meets_triangle(p0, p1, cell.corners).map_or_else(
421        || (f64::midpoint(t0, t1), p0.midpoint(p1)),
422        |x| {
423            let length = p0.distance(p1);
424            let f = if length > 0.0 {
425                p0.distance(x) / length
426            } else {
427                0.5
428            };
429            (t0 + (t1 - t0) * f, x)
430        },
431    );
432    // Barycentric weights of the point's foot in the cell's plane, pulled
433    // back inside the cell.
434    let (e1, e2, d) = (b - a, c - a, at - a);
435    let (d11, d12, d22) = (e1.dot(e1), e1.dot(e2), e2.dot(e2));
436    let (d1, d2) = (d.dot(e1), d.dot(e2));
437    let det = d11 * d22 - d12 * d12;
438    let (mut wb, mut wc) = if det > 0.0 {
439        ((d22 * d1 - d12 * d2) / det, (d11 * d2 - d12 * d1) / det)
440    } else {
441        (1.0 / 3.0, 1.0 / 3.0)
442    };
443    wb = wb.clamp(0.0, 1.0);
444    wc = wc.clamp(0.0, 1.0);
445    if wb + wc > 1.0 {
446        let sum = wb + wc;
447        wb /= sum;
448        wc /= sum;
449    }
450    let wa = 1.0 - wb - wc;
451    let [pa, pb, pc] = cell.params;
452    (
453        t,
454        (
455            wa * pa.0 + wb * pb.0 + wc * pc.0,
456            wa * pa.1 + wb * pb.1 + wc * pc.1,
457        ),
458    )
459}
460
461/// Whether a segment's box comes near a cell's.
462fn segment_near_cell(a: Point, b: Point, cell: &Cell, margin: f64) -> bool {
463    let low = Point::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z));
464    let high = Point::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z));
465    low.x <= cell.high.x + margin
466        && cell.low.x <= high.x + margin
467        && low.y <= cell.high.y + margin
468        && cell.low.y <= high.y + margin
469        && low.z <= cell.high.z + margin
470        && cell.low.z <= high.z + margin
471}
472
473/// Newton on the well-posed system `C(t) = S(u, v)`.
474fn polish(
475    curve: &Curve,
476    surface: &SurfaceGeometry,
477    seed_t: f64,
478    seed_uv: (f64, f64),
479    tol: Tolerances,
480) -> Option<Piercing> {
481    let clamp_t = |t: f64| {
482        let (lo, hi) = curve.domain();
483        if curve.is_periodic() {
484            let span = hi - lo;
485            if span > 0.0 {
486                return lo + (t - lo).rem_euclid(span);
487            }
488        }
489        t.clamp(lo, hi)
490    };
491    let clamp_uv = |u: f64, v: f64| {
492        let ((ua, ub), (va, vb)) = surface.domain();
493        let fold = |x: f64, lo: f64, hi: f64, periodic: bool| {
494            if periodic {
495                let span = hi - lo;
496                if span > 0.0 {
497                    return lo + (x - lo).rem_euclid(span);
498                }
499            }
500            x.clamp(lo, hi)
501        };
502        (
503            fold(u, ua, ub, surface.is_periodic_u()),
504            fold(v, va, vb, surface.is_periodic_v()),
505        )
506    };
507
508    let system = |x: &[f64]| {
509        let t = clamp_t(x[0]);
510        let (u, v) = clamp_uv(x[1], x[2]);
511        let pc = curve.point_at(t, tol).unwrap_or(Point::ORIGIN);
512        let ps = surface.point_at(u, v, tol).unwrap_or(Point::ORIGIN);
513        let dc = curve.d1_at(t, tol).unwrap_or(ogeom_math::Vector::ZERO);
514        let (du, dv) = surface
515            .d1_at(u, v, tol)
516            .unwrap_or((ogeom_math::Vector::ZERO, ogeom_math::Vector::ZERO));
517        let gap = pc - ps;
518        (
519            vec![gap.x, gap.y, gap.z],
520            vec![
521                vec![dc.x, -du.x, -dv.x],
522                vec![dc.y, -du.y, -dv.y],
523                vec![dc.z, -du.z, -dv.z],
524            ],
525        )
526    };
527    let criteria = solve::Criteria {
528        residual: tol.confusion() * 0.01,
529        step: tol.parametric(),
530        max_iterations: 40,
531    };
532    let found = solve::newton_system(system, &[seed_t, seed_uv.0, seed_uv.1], criteria).ok()?;
533    let t = clamp_t(found.value[0]);
534    let (u, v) = clamp_uv(found.value[1], found.value[2]);
535    let pc = curve.point_at(t, tol).ok()?;
536    let ps = surface.point_at(u, v, tol).ok()?;
537    Some(Piercing {
538        on_curve: t,
539        on_surface: (u, v),
540        point: pc,
541        gap: pc.distance(ps),
542    })
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used)]
547mod tests {
548    use super::*;
549    use ogeom_geom::{
550        BSplineCurve, CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
551    };
552    use ogeom_math::{Circle, Cylinder, Direction, Frame, KnotVector, Plane, Sphere, Vector};
553
554    const T: Tolerances = Tolerances::millimetres();
555
556    fn sphere(radius: f64) -> SurfaceGeometry {
557        SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
558    }
559
560    fn cylinder(radius: f64, height: (f64, f64)) -> SurfaceGeometry {
561        CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), height)
562            .unwrap()
563            .into()
564    }
565
566    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
567        PlaneSurface::over(
568            Plane::through(origin, Direction::new(normal, T).unwrap()),
569            (-6.0, 6.0),
570            (-6.0, 6.0),
571        )
572        .unwrap()
573        .into()
574    }
575
576    fn segment(from: Point, to: Point) -> Curve {
577        LineCurve::segment(from, to, T).unwrap().into()
578    }
579
580    #[test]
581    fn a_line_through_a_sphere_pierces_it_where_the_quadratic_says() {
582        let ball = sphere(2.0);
583        let ray = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
584        let found =
585            intersect_curve_surface(&ray, &ball, CurveSurfaceOptions::default(), T).unwrap();
586        assert_eq!(found.crossings.len(), 2);
587        assert!(
588            found.crossings[0]
589                .point
590                .is_equal(Point::new(-2.0, 0.0, 0.0), T)
591        );
592        assert!(
593            found.crossings[1]
594                .point
595                .is_equal(Point::new(2.0, 0.0, 0.0), T)
596        );
597        for hit in &found.crossings {
598            assert!(hit.gap < 1e-12);
599            // The surface parameters evaluate back onto the point.
600            let lifted = ball
601                .point_at(hit.on_surface.0, hit.on_surface.1, T)
602                .unwrap();
603            assert!(lifted.is_equal(hit.point, T));
604        }
605
606        // Tangent: one root. Missing: none.
607        let grazing = segment(Point::new(-5.0, 0.0, 2.0), Point::new(5.0, 0.0, 2.0));
608        assert_eq!(
609            intersect_curve_surface(&grazing, &ball, CurveSurfaceOptions::default(), T)
610                .unwrap()
611                .crossings
612                .len(),
613            1
614        );
615        let missing = segment(Point::new(-5.0, 0.0, 3.0), Point::new(5.0, 0.0, 3.0));
616        assert!(
617            intersect_curve_surface(&missing, &ball, CurveSurfaceOptions::default(), T)
618                .unwrap()
619                .is_empty()
620        );
621    }
622
623    #[test]
624    fn a_line_through_a_cylinder_respects_its_height() {
625        let drum = cylinder(2.0, (-1.0, 1.0));
626        // Crosses the infinite cylinder at z = 0: inside the height, two hits.
627        let level = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
628        assert_eq!(
629            intersect_curve_surface(&level, &drum, CurveSurfaceOptions::default(), T)
630                .unwrap()
631                .crossings
632                .len(),
633            2
634        );
635        // Crosses at z = 3: the unbounded geometry meets it, this surface
636        // does not reach there.
637        let high = segment(Point::new(-5.0, 0.0, 3.0), Point::new(5.0, 0.0, 3.0));
638        assert!(
639            intersect_curve_surface(&high, &drum, CurveSurfaceOptions::default(), T)
640                .unwrap()
641                .is_empty()
642        );
643    }
644
645    #[test]
646    fn a_line_lying_in_a_plane_is_an_overlap_not_a_crossing_list() {
647        let ground = plane(Point::ORIGIN, Vector::Z);
648        let lying = segment(Point::new(-3.0, 1.0, 0.0), Point::new(3.0, 1.0, 0.0));
649        let found =
650            intersect_curve_surface(&lying, &ground, CurveSurfaceOptions::default(), T).unwrap();
651        assert!(found.crossings.is_empty());
652        assert_eq!(found.lying.len(), 1);
653
654        let crossing = segment(Point::new(0.0, 0.0, -1.0), Point::new(0.0, 0.0, 1.0));
655        let found =
656            intersect_curve_surface(&crossing, &ground, CurveSurfaceOptions::default(), T).unwrap();
657        assert_eq!(found.crossings.len(), 1);
658        assert!(found.crossings[0].point.is_equal(Point::ORIGIN, T));
659
660        let parallel = segment(Point::new(-3.0, 0.0, 1.0), Point::new(3.0, 0.0, 1.0));
661        assert!(
662            intersect_curve_surface(&parallel, &ground, CurveSurfaceOptions::default(), T)
663                .unwrap()
664                .is_empty()
665        );
666    }
667
668    #[test]
669    fn a_circle_pierces_a_plane_twice_through_the_general_path() {
670        // A circle in the xz plane against the ground: no analytic case
671        // handles circle/plane here, so this is the seeded Newton path, and
672        // the answer is known exactly anyway.
673        let ring: Curve = CircleCurve::new(
674            Circle::new(
675                Frame::new(Point::new(0.0, 0.0, 0.0), -Direction::Y, Direction::X, T).unwrap(),
676                2.0,
677                T,
678            )
679            .unwrap(),
680        )
681        .into();
682        let ground = plane(Point::ORIGIN, Vector::Z);
683        let found =
684            intersect_curve_surface(&ring, &ground, CurveSurfaceOptions::default(), T).unwrap();
685        assert_eq!(found.crossings.len(), 2);
686        for hit in &found.crossings {
687            assert!(hit.gap < 1e-9);
688            assert!(hit.point.z.abs() < 1e-9);
689            assert!((hit.point.to_vector().magnitude() - 2.0).abs() < 1e-9);
690        }
691    }
692
693    #[test]
694    fn a_spline_through_a_sphere_is_found_and_polished() {
695        // A spline wandering through the ball: piercings with no closed form
696        // anywhere, verified implicitly: each reported point is on the
697        // sphere to the gap it claims.
698        let wander: Curve = BSplineCurve::new(
699            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
700            vec![
701                Point::new(-4.0, -1.0, -1.0),
702                Point::new(-1.0, 2.0, 1.0),
703                Point::new(1.0, -2.0, -1.0),
704                Point::new(4.0, 1.0, 1.0),
705            ],
706            T,
707        )
708        .unwrap()
709        .into();
710        let ball = sphere(2.0);
711        let found =
712            intersect_curve_surface(&wander, &ball, CurveSurfaceOptions::default(), T).unwrap();
713        assert!(!found.crossings.is_empty(), "the spline passes through");
714        for hit in &found.crossings {
715            assert!(hit.gap < 1e-9);
716            let SurfaceGeometry::Sphere(s) = &ball else {
717                unreachable!()
718            };
719            assert!(s.sphere().distance_to(hit.point).abs() < 1e-9);
720        }
721    }
722
723    #[test]
724    fn unusable_options_are_refused() {
725        let ball = sphere(1.0);
726        let ray = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
727        for options in [
728            CurveSurfaceOptions {
729                samples: 1,
730                ..CurveSurfaceOptions::default()
731            },
732            CurveSurfaceOptions {
733                grid: 1,
734                ..CurveSurfaceOptions::default()
735            },
736            CurveSurfaceOptions {
737                gap: 0.0,
738                ..CurveSurfaceOptions::default()
739            },
740        ] {
741            assert!(intersect_curve_surface(&ray, &ball, options, T).is_err());
742        }
743    }
744}