Skip to main content

ogeom_intersect/
curves.rs

1//! Where two curves meet, in the plane and in space.
2//!
3//! *Elsewhere* these are `Geom2dAPI_InterCurveCurve` and `IntCurve` for the
4//! plane, and extrema-based crossing for space. The planar case is the
5//! load-bearing one: boolean face splitting happens in a surface's parameter
6//! space, and the curves it splits with are pcurves, so 2D curve/curve is the
7//! operation the whole boolean pipeline stands on.
8//!
9//! # Two curves in space generically miss
10//!
11//! In the plane, two curves that cross, cross. In space they pass by: a
12//! crossing is two points closer than a tolerance, not an exact common point,
13//! and pretending otherwise would make every 3D result empty. So the 3D
14//! answer reports the *gap* it achieved at each crossing, and the caller's
15//! tolerance decides what counts. The 2D answer reports gaps too (a solved
16//! crossing is still a pair of floats), but there the gap is rounding, not
17//! geometry.
18//!
19//! # Overlap is an answer, not a failure
20//!
21//! Two collinear lines, two arcs of one circle: where the supports coincide,
22//! "the intersection points" do not exist; the intersection is a stretch of
23//! curve. That is reported as an overlap with the parameter ranges involved.
24//! Detected for the analytic same-support cases; two B-splines that happen to
25//! trace the same path are *not* detected as overlapping, and that limit is
26//! recorded rather than discovered.
27//!
28//! # The general path is honest about resolution
29//!
30//! Non-analytic pairs are seeded by sampling both curves into segments and
31//! testing the pairs, then polished by Newton onto the true crossing. Like the
32//! surface seeding it mirrors, it finds what the sampling resolves: two
33//! crossings closer together than a sample step can read as one. The sampling
34//! density is a stated knob, not a hidden constant.
35
36use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_geom::{Curve, Curve2d, Curve3d, PlanarCurve};
38use ogeom_math::{Point, Point2, solve};
39
40/// One crossing of two curves.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Crossing<P> {
43    /// The parameter on the first curve.
44    pub on_a: f64,
45    /// The parameter on the second.
46    pub on_b: f64,
47    /// Where, taken from the first curve.
48    pub point: P,
49    /// How far apart the two curves are there.
50    ///
51    /// Rounding for a planar crossing; real geometry for a spatial one, where
52    /// two curves generically miss and "crossing" means passing within the
53    /// caller's tolerance.
54    pub gap: f64,
55    /// How far along the curves this contact could honestly sit: zero for a
56    /// transversal crossing, the length of the touching run where the curves
57    /// meet tangentially: there the closest approach is anywhere in a
58    /// valley the width of the gap, and a consumer placing a vertex at it
59    /// owns that much doubt.
60    pub reach: f64,
61}
62
63/// A stretch where two curves share their support.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Overlap {
66    /// The parameter range on the first curve.
67    pub on_a: (f64, f64),
68    /// The corresponding range on the second.
69    pub on_b: (f64, f64),
70}
71
72/// What two curves do to each other.
73#[derive(Debug, Clone, PartialEq)]
74pub struct CurveIntersection<P> {
75    /// Isolated crossings, in order along the first curve.
76    pub crossings: Vec<Crossing<P>>,
77    /// Stretches of shared support.
78    ///
79    /// The analytic same-support cases (collinear lines, arcs of one
80    /// circle) come back exactly. In space, the sampling path also reports
81    /// a stretch along which the first curve's samples stay within the gap
82    /// of the second, its ends bisected to parametric resolution and its
83    /// correspondence stated by those ends alone: a fitted section tracing
84    /// the arc it was cut along is one overlap, not a row of crossings. A
85    /// stretch shorter than two samples of the first curve is still read as
86    /// whatever crossings the sampling finds; in the plane, only the
87    /// analytic cases are detected.
88    pub overlaps: Vec<Overlap>,
89}
90
91impl<P> CurveIntersection<P> {
92    /// No contact at all.
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.crossings.is_empty() && self.overlaps.is_empty()
96    }
97
98    const fn empty() -> Self {
99        Self {
100            crossings: Vec::new(),
101            overlaps: Vec::new(),
102        }
103    }
104}
105
106/// How hard the general path looks.
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct CurveCurveOptions {
109    /// How many segments each curve is sampled into when seeding.
110    ///
111    /// The resolution knob: two crossings inside one segment read as one.
112    pub samples: usize,
113    /// The widest gap that still counts as a crossing, in space.
114    ///
115    /// Meaningful for 3D, where curves generically miss. In 2D a genuine
116    /// crossing converges to rounding and this only rejects near-misses.
117    pub gap: f64,
118}
119
120impl Default for CurveCurveOptions {
121    fn default() -> Self {
122        Self {
123            samples: 128,
124            gap: 1e-7,
125        }
126    }
127}
128
129/// Where two planar curves meet.
130///
131/// Analytic pairs (lines and circles) are answered in closed form, overlaps
132/// included. Everything else goes through sampling and Newton.
133///
134/// # Errors
135///
136/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the options
137/// are unusable.
138pub fn intersect_curves_2d(
139    a: &PlanarCurve,
140    b: &PlanarCurve,
141    options: CurveCurveOptions,
142    tol: Tolerances,
143) -> OgeomResult<CurveIntersection<Point2>> {
144    check(options)?;
145    // Through any trim to the basis the closed forms answer for, as in
146    // space; the answer is clipped to the trims' windows after.
147    let (basis_a, window_a) = through_trim_2d(a);
148    let (basis_b, window_b) = through_trim_2d(b);
149    let found = match (basis_a, basis_b) {
150        (PlanarCurve::Line(x), PlanarCurve::Line(y)) => line_line_2d(x, y, tol),
151        (PlanarCurve::Line(x), PlanarCurve::Circle(y)) => line_circle_2d(x, y, false, tol),
152        (PlanarCurve::Circle(x), PlanarCurve::Line(y)) => line_circle_2d(y, x, true, tol),
153        (PlanarCurve::Circle(x), PlanarCurve::Circle(y)) => circle_circle_2d(x, y, tol),
154        _ => return general_2d(a, b, options, tol),
155    };
156    let side = |curve: &PlanarCurve, window: Option<(f64, f64)>| Side {
157        period: {
158            let (lo, hi) = curve.domain();
159            (curve.is_periodic() && hi > lo).then_some(hi - lo)
160        },
161        domain: curve.domain(),
162        window,
163    };
164    Ok(clipped_to_windows(
165        found,
166        side(basis_a, window_a),
167        side(basis_b, window_b),
168        tol,
169    ))
170}
171
172/// A planar curve seen through a trim, as [`through_trim`] sees one in
173/// space.
174fn through_trim_2d(curve: &PlanarCurve) -> (&PlanarCurve, Option<(f64, f64)>) {
175    match curve {
176        PlanarCurve::Trimmed(t) if !t.is_reversed() => (t.basis(), Some(t.domain())),
177        other => (other, None),
178    }
179}
180
181/// One side of a curve pair as clipping sees it: its basis's period and
182/// domain, and the window a trim restricts it to.
183#[derive(Debug, Clone, Copy)]
184struct Side {
185    period: Option<f64>,
186    domain: (f64, f64),
187    window: Option<(f64, f64)>,
188}
189
190impl Side {
191    fn of(curve: &Curve, window: Option<(f64, f64)>) -> Self {
192        let (lo, hi) = curve.domain();
193        Self {
194            period: (curve.is_periodic() && hi > lo).then_some(hi - lo),
195            domain: (lo, hi),
196            window,
197        }
198    }
199}
200
201/// Where two space curves pass within `options.gap` of each other.
202///
203/// # Errors
204///
205/// As [`intersect_curves_2d`].
206pub fn intersect_curves(
207    a: &Curve,
208    b: &Curve,
209    options: CurveCurveOptions,
210    tol: Tolerances,
211) -> OgeomResult<CurveIntersection<Point>> {
212    check(options)?;
213    let (basis_a, window_a) = through_trim(a);
214    let (basis_b, window_b) = through_trim(b);
215    if let Some(found) = same_curve_3d(basis_a, basis_b) {
216        return Ok(clipped_to_windows(
217            found,
218            Side::of(basis_a, window_a),
219            Side::of(basis_b, window_b),
220            tol,
221        ));
222    }
223    if let Some(found) = analytic_3d(basis_a, basis_b, options, tol) {
224        return Ok(clipped_to_windows(
225            found,
226            Side::of(basis_a, window_a),
227            Side::of(basis_b, window_b),
228            tol,
229        ));
230    }
231    general_3d(a, b, options, tol)
232}
233
234/// A curve seen through a trim: the curve the analytic path can answer for,
235/// and the window it is restricted to.
236///
237/// The trim shares its basis's parameterization, so the window is stated in
238/// the same numbers the analytic answer comes back in and clipping is an
239/// interval intersection rather than a change of variable. A *reversed* trim
240/// does renumber, so it is left to the sampling path rather than mis-read.
241fn through_trim(curve: &Curve) -> (&Curve, Option<(f64, f64)>) {
242    match curve {
243        Curve::Trimmed(t) if !t.is_reversed() => (t.basis(), Some(Curve3d::domain(&**t))),
244        other => (other, None),
245    }
246}
247
248/// The closed-form answers for a pair of space curves, or `None` where there
249/// is none and the sampling path is the honest route.
250fn analytic_3d(
251    a: &Curve,
252    b: &Curve,
253    options: CurveCurveOptions,
254    tol: Tolerances,
255) -> Option<CurveIntersection<Point>> {
256    match (a, b) {
257        (Curve::Line(x), Curve::Line(y)) => Some(line_line_3d(x, y, options, tol)),
258        (Curve::Circle(x), Curve::Circle(y)) => same_circle_3d(x, y, tol)
259            .or_else(|| coplanar_circles_3d(x, y, options, tol))
260            .or_else(|| skew_conics_3d(a, b, options, tol)),
261        (Curve::Ellipse(x), Curve::Ellipse(y)) => {
262            same_ellipse_3d(x, y, tol).or_else(|| skew_conics_3d(a, b, options, tol))
263        }
264        (Curve::Circle(_), Curve::Ellipse(_)) | (Curve::Ellipse(_), Curve::Circle(_)) => {
265            skew_conics_3d(a, b, options, tol)
266        }
267        (Curve::Line(x), Curve::Circle(_) | Curve::Ellipse(_)) => line_conic_3d(x, b, options, tol)
268            .map(|mut found| {
269                for c in &mut found.crossings {
270                    core::mem::swap(&mut c.on_a, &mut c.on_b);
271                }
272                found.crossings.sort_by(|x, y| x.on_a.total_cmp(&y.on_a));
273                found
274            }),
275        (Curve::Circle(_) | Curve::Ellipse(_), Curve::Line(y)) => line_conic_3d(y, a, options, tol),
276        _ => None,
277    }
278}
279
280/// A circle or ellipse, as its centre, the two semi-axis vectors its
281/// parameter turns between, and its plane's normal; `None` for any other
282/// curve, or one whose parameter runs backwards.
283fn conic_of(
284    curve: &Curve,
285) -> Option<(
286    Point,
287    ogeom_math::Vector,
288    ogeom_math::Vector,
289    ogeom_math::Vector,
290)> {
291    match curve {
292        Curve::Circle(c) if !c.is_reversed() => {
293            let circle = c.circle();
294            let f = circle.frame();
295            Some((
296                f.origin(),
297                f.x().vector() * circle.radius(),
298                f.y().vector() * circle.radius(),
299                f.z().vector(),
300            ))
301        }
302        Curve::Ellipse(e) if !e.is_reversed() => {
303            let ellipse = e.ellipse();
304            let f = ellipse.frame();
305            Some((
306                f.origin(),
307                f.x().vector() * ellipse.major_radius(),
308                f.y().vector() * ellipse.minor_radius(),
309                f.z().vector(),
310            ))
311        }
312        _ => None,
313    }
314}
315
316/// Two circles or ellipses in planes that are not one plane, in closed
317/// form: where the first meets the second's plane (`alpha cos t + beta sin
318/// t + gamma = 0`, at most twice), kept where the second curve passes
319/// within the gap. Parallel planes apart share no point. `None` for a
320/// coplanar pair, which the sampling path answers.
321fn skew_conics_3d(
322    a: &Curve,
323    b: &Curve,
324    options: CurveCurveOptions,
325    tol: Tolerances,
326) -> Option<CurveIntersection<Point>> {
327    let (ca, ua, va, na) = conic_of(a)?;
328    let (cb, _, _, nb) = conic_of(b)?;
329    if na.cross(nb).magnitude() <= tol.angular() {
330        return ((ca - cb).dot(nb).abs() > options.gap.max(tol.confusion()))
331            .then(CurveIntersection::empty);
332    }
333    let (alpha, beta, gamma) = (nb.dot(ua), nb.dot(va), nb.dot(ca - cb));
334    let size = alpha.hypot(beta);
335    // The first conic stands within the gap of the other's plane over a
336    // stretch as wide as the gap is against how far it rises from that
337    // plane. Planes all but parallel (a rim fitted to one facet group, a
338    // section through another) make that stretch wide, and the curves can
339    // pass within the gap anywhere along it, not only where one crosses the
340    // other's plane; the sampling path measures such a pass.
341    if options.gap.max(tol.confusion()) > size * 1e-3 {
342        return None;
343    }
344    let mut crossings: Vec<Crossing<Point>> = Vec::new();
345    if size > 0.0 && gamma.abs() <= size * (1.0 + 1e-12) {
346        let phase = beta.atan2(alpha);
347        let turn = (-gamma / size).clamp(-1.0, 1.0).acos();
348        let (a_lo, a_hi) = a.domain();
349        let (b_lo, b_hi) = b.domain();
350        let tau = core::f64::consts::TAU;
351        let into = |t: f64, lo: f64| lo + (t - lo).rem_euclid(tau);
352        let roots = if turn <= 1e-12 {
353            vec![phase]
354        } else {
355            vec![phase - turn, phase + turn]
356        };
357        for root in roots {
358            let t = into(root, a_lo);
359            if t > a_hi + tol.parametric() {
360                continue;
361            }
362            let point = a.point_at(t, tol).ok()?;
363            // The closed form reads the curve as its centre and semi-axes;
364            // a point that does not land on the other plane means it read
365            // it wrong, and the sampling path answers instead.
366            if (point - cb).dot(nb).abs() > tol.confusion() * 10.0 {
367                return None;
368            }
369            let s = match b {
370                Curve::Circle(c) => {
371                    ogeom_math::elementary::circle_parameter(&c.circle(), point, tol)
372                }
373                Curve::Ellipse(e) => {
374                    ogeom_math::elementary::ellipse_parameter(&e.ellipse(), point, tol)
375                }
376                _ => return None,
377            };
378            let Ok(s) = s else {
379                continue;
380            };
381            let s = into(s, b_lo);
382            if s > b_hi + tol.parametric() {
383                continue;
384            }
385            let gap = b.point_at(s, tol).ok()?.distance(point);
386            if gap > options.gap {
387                continue;
388            }
389            crossings.push(Crossing {
390                on_a: t,
391                on_b: s,
392                point,
393                gap,
394                reach: 0.0,
395            });
396        }
397    }
398    crossings.sort_by(|x, y| x.on_a.total_cmp(&y.on_a));
399    Some(CurveIntersection {
400        crossings,
401        overlaps: Vec::new(),
402    })
403}
404
405/// A circle or ellipse against a line, in closed form, the conic first.
406///
407/// A line in the conic's plane meets it where a quadratic along the line
408/// vanishes, at most twice; a line through the plane meets it at most where
409/// it pierces the plane. Kept where the two pass within the gap. A line
410/// running nearly along the plane without lying in it, or a crossing nearly
411/// tangent, is left to the sampling path, which measures how far such a
412/// touch reaches.
413fn line_conic_3d(
414    line: &ogeom_geom::LineCurve,
415    conic: &Curve,
416    options: CurveCurveOptions,
417    tol: Tolerances,
418) -> Option<CurveIntersection<Point>> {
419    let (centre, u, v, normal) = conic_of(conic)?;
420    let (origin, along) = (line.axis().location, line.axis().direction.vector());
421    let (a_len, b_len) = (u.magnitude(), v.magnitude());
422    if a_len <= tol.confusion() || b_len <= tol.confusion() {
423        return None;
424    }
425    let (ux, vy) = (u / a_len, v / b_len);
426    let lean = along.dot(normal);
427    let height = (origin - centre).dot(normal);
428    let mut ts: Vec<f64> = Vec::new();
429    if lean.abs() <= tol.angular() {
430        if height.abs() > options.gap.max(tol.confusion()) {
431            return Some(CurveIntersection::empty());
432        }
433        // (x0 + t dx)^2 / a^2 + (y0 + t dy)^2 / b^2 = 1, in the plane.
434        let (x0, y0) = ((origin - centre).dot(ux), (origin - centre).dot(vy));
435        let (dx, dy) = (along.dot(ux), along.dot(vy));
436        let qa = dx * dx / (a_len * a_len) + dy * dy / (b_len * b_len);
437        let qb = 2.0 * (x0 * dx / (a_len * a_len) + y0 * dy / (b_len * b_len));
438        let qc = x0 * x0 / (a_len * a_len) + y0 * y0 / (b_len * b_len) - 1.0;
439        let disc = qb.mul_add(qb, -4.0 * qa * qc);
440        if qa <= 0.0 {
441            return None;
442        }
443        if disc < 0.0 {
444            let t = -qb / (2.0 * qa);
445            let p = origin + along * t;
446            let foot = conic_parameter(conic, p, tol)?;
447            let gap = conic.point_at(foot, tol).ok()?.distance(p);
448            return (gap > options.gap).then(CurveIntersection::empty);
449        }
450        let root = disc.sqrt();
451        ts.push((-qb - root) / (2.0 * qa));
452        ts.push((-qb + root) / (2.0 * qa));
453    } else if lean.abs() >= 0.1 {
454        ts.push(-height / lean);
455    } else {
456        return None;
457    }
458    let (lo, hi) = line.domain();
459    let (c_lo, c_hi) = conic.domain();
460    let tau = core::f64::consts::TAU;
461    let mut crossings: Vec<Crossing<Point>> = Vec::new();
462    for t in ts {
463        if t < lo - tol.parametric() || t > hi + tol.parametric() {
464            continue;
465        }
466        let point = origin + along * t;
467        let s = conic_parameter(conic, point, tol)?;
468        let s = c_lo + (s - c_lo).rem_euclid(tau);
469        if s > c_hi + tol.parametric() {
470            continue;
471        }
472        let on_conic = conic.point_at(s, tol).ok()?;
473        let gap = on_conic.distance(point);
474        if gap > options.gap {
475            continue;
476        }
477        let tangent = conic.d1_at(s, tol).ok()?;
478        if tangent.cross(along).magnitude() <= 1e-3 * tangent.magnitude() {
479            return None;
480        }
481        crossings.push(Crossing {
482            on_a: s,
483            on_b: t,
484            point: on_conic,
485            gap,
486            reach: 0.0,
487        });
488    }
489    crossings.sort_by(|x, y| x.on_a.total_cmp(&y.on_a));
490    crossings.dedup_by(|x, y| (x.on_a - y.on_a).abs() <= tol.parametric());
491    Some(CurveIntersection {
492        crossings,
493        overlaps: Vec::new(),
494    })
495}
496
497/// Where a point lies along a circle or ellipse, by projection.
498fn conic_parameter(conic: &Curve, point: Point, tol: Tolerances) -> Option<f64> {
499    match conic {
500        Curve::Circle(c) => ogeom_math::elementary::circle_parameter(&c.circle(), point, tol).ok(),
501        Curve::Ellipse(e) => {
502            ogeom_math::elementary::ellipse_parameter(&e.ellipse(), point, tol).ok()
503        }
504        _ => None,
505    }
506}
507
508/// Restrict an answer about two whole curves to the windows their trims
509/// actually cover.
510///
511/// Both parts matter. A crossing is kept only where *both* parameters fall
512/// inside their window, on a periodic basis after whichever whole turn
513/// brings them there. An overlap is an interval on each side tied by an
514/// affine correspondence, so it is clipped on one side, carried across, and
515/// clipped again, and what comes back is the stretch both trims really share.
516fn clipped_to_windows<P>(
517    found: CurveIntersection<P>,
518    a: Side,
519    b: Side,
520    tol: Tolerances,
521) -> CurveIntersection<P> {
522    let (window_a, window_b) = (a.window, b.window);
523    if window_a.is_none() && window_b.is_none() {
524        return found;
525    }
526    let (pa, pb) = (a.period, b.period);
527    let slack = tol.parametric();
528    let placed = |t: f64, window: Option<(f64, f64)>, period: Option<f64>| -> Option<f64> {
529        let Some((lo, hi)) = window else {
530            return Some(t);
531        };
532        for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
533            let shifted = period.map_or(t, |p| p.mul_add(k, t));
534            if shifted >= lo - slack && shifted <= hi + slack {
535                return Some(shifted);
536            }
537            if period.is_none() {
538                break;
539            }
540        }
541        None
542    };
543
544    let mut crossings = Vec::with_capacity(found.crossings.len());
545    for crossing in found.crossings {
546        let (Some(on_a), Some(on_b)) = (
547            placed(crossing.on_a, window_a, pa),
548            placed(crossing.on_b, window_b, pb),
549        ) else {
550            continue;
551        };
552        crossings.push(Crossing {
553            on_a,
554            on_b,
555            ..crossing
556        });
557    }
558
559    let mut overlaps: Vec<Overlap> = Vec::with_capacity(found.overlaps.len());
560    let ordered = |r: (f64, f64)| if r.0 <= r.1 { r } else { (r.1, r.0) };
561    let meet = |x: (f64, f64), y: (f64, f64)| -> Option<(f64, f64)> {
562        let both = (x.0.max(y.0), x.1.min(y.1));
563        (both.1 - both.0 > slack).then_some(both)
564    };
565    let (domain_a, domain_b) = (a.domain, b.domain);
566    let shifts = |period: Option<f64>| -> Vec<f64> {
567        period.map_or_else(
568            || vec![0.0],
569            |p| [0.0, 1.0, -1.0, 2.0, -2.0].iter().map(|k| k * p).collect(),
570        )
571    };
572    for overlap in found.overlaps {
573        let span_a = overlap.on_a.1 - overlap.on_a.0;
574        let span_b = overlap.on_b.1 - overlap.on_b.0;
575        if span_a.abs() <= f64::MIN_POSITIVE || span_b.abs() <= f64::MIN_POSITIVE {
576            continue;
577        }
578        let rate = span_b / span_a;
579        let to_b = |t: f64| overlap.on_b.0 + rate * (t - overlap.on_a.0);
580        let to_a = |t: f64| overlap.on_a.0 + (t - overlap.on_b.0) / rate;
581        // An overlap a whole turn long is the same set traced twice: the
582        // correspondence holds round and round, and each side's window is
583        // all that limits it. Otherwise it holds on its own stretch, at
584        // whichever whole turn each window meets it.
585        let whole = pa.is_some_and(|p| span_a.abs() >= p - slack)
586            && pb.is_some_and(|p| span_b.abs() >= p - slack);
587        let window_a = window_a.map_or(domain_a, ordered);
588        let window_b = window_b.map_or(domain_b, ordered);
589        let mut pieces_a: Vec<(f64, f64, f64)> = Vec::new();
590        if whole {
591            pieces_a.push((window_a.0, window_a.1, 0.0));
592        } else {
593            for shift in shifts(pa) {
594                let stretch = ordered(overlap.on_a);
595                if let Some(piece) = meet((stretch.0 + shift, stretch.1 + shift), window_a) {
596                    pieces_a.push((piece.0, piece.1, shift));
597                }
598            }
599        }
600        for (lo, hi, shift_a) in pieces_a {
601            // On `b`, at whichever whole turn of it lands in its window.
602            let image = ordered((to_b(lo - shift_a), to_b(hi - shift_a)));
603            for shift_b in shifts(pb) {
604                let Some(on_b) = meet((image.0 + shift_b, image.1 + shift_b), window_b) else {
605                    continue;
606                };
607                let back = |t: f64| to_a(t - shift_b) + shift_a;
608                let on_a = ordered((back(on_b.0), back(on_b.1)));
609                if on_a.1 - on_a.0 <= slack {
610                    continue;
611                }
612                // Walked as `a` walks: `b`'s ends follow `a`'s.
613                let forward = |t: f64| to_b(t - shift_a) + shift_b;
614                let (on_a, on_b) = if span_a >= 0.0 {
615                    (on_a, (forward(on_a.0), forward(on_a.1)))
616                } else {
617                    ((on_a.1, on_a.0), (forward(on_a.1), forward(on_a.0)))
618                };
619                let repeated = overlaps.iter().any(|o| {
620                    (o.on_a.0 - on_a.0).abs() <= slack && (o.on_a.1 - on_a.1).abs() <= slack
621                });
622                if !repeated {
623                    overlaps.push(Overlap { on_a, on_b });
624                }
625            }
626        }
627    }
628    CurveIntersection {
629        crossings,
630        overlaps,
631    }
632}
633
634/// Two circles in one plane, in closed form: where they cross, from the
635/// radical line in the plane. Exact however shallow the crossing: two rims a
636/// micron apart meet at a fraction of a milliradian, and sampled, the touch
637/// would read as a run millimetres long, which is how far the two stay
638/// within tolerance of each other, not where they cross. `None` for circles
639/// in different planes, sharing a centre, or within the weld distance of
640/// touching, which the other paths answer.
641fn coplanar_circles_3d(
642    a: &ogeom_geom::CircleCurve,
643    b: &ogeom_geom::CircleCurve,
644    options: CurveCurveOptions,
645    tol: Tolerances,
646) -> Option<CurveIntersection<Point>> {
647    let (ca, cb) = (a.circle(), b.circle());
648    let normal = ca.frame().z().vector();
649    if normal.cross(cb.frame().z().vector()).magnitude() > tol.angular() {
650        return None;
651    }
652    let between = cb.centre() - ca.centre();
653    if between.dot(normal).abs() > tol.confusion() {
654        return None;
655    }
656    let in_plane = between - normal * between.dot(normal);
657    let distance = in_plane.magnitude();
658    let (ra, rb) = (ca.radius(), cb.radius());
659    if distance <= tol.confusion() {
660        return None;
661    }
662    if distance > ra + rb + options.gap || distance < (ra - rb).abs() - options.gap {
663        return Some(CurveIntersection::empty());
664    }
665    // Well within the weld distance of touching (half of it, as for two
666    // drums), the two crossings are one touch the root of a rounding error
667    // has pulled apart.
668    let weld = tol.confusion() * 50.0;
669    if (distance - (ra + rb)).abs() <= weld || (distance - (ra - rb).abs()).abs() <= weld {
670        return None;
671    }
672    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
673    let squared = ra.mul_add(ra, -(along * along));
674    if squared <= tol.confusion() * tol.confusion() {
675        return None;
676    }
677    let half = squared.sqrt();
678    let ux = in_plane / distance;
679    let uy = normal.cross(ux);
680    let parameter = |curve: &ogeom_geom::CircleCurve, p: Point| -> f64 {
681        let local = curve.circle().frame().to_local(p);
682        let angle = local.y.atan2(local.x);
683        let angle = if curve.is_reversed() { -angle } else { angle };
684        let (lo, _) = Curve3d::domain(curve);
685        lo + (angle - lo).rem_euclid(core::f64::consts::TAU)
686    };
687    let mut crossings: Vec<Crossing<Point>> = [half, -half]
688        .into_iter()
689        .map(|h| {
690            let point = ca.centre() + ux * along + uy * h;
691            Crossing {
692                on_a: parameter(a, point),
693                on_b: parameter(b, point),
694                point,
695                gap: 0.0,
696                reach: 0.0,
697            }
698        })
699        .collect();
700    sort_crossings(&mut crossings);
701    Some(CurveIntersection {
702        crossings,
703        overlaps: Vec::new(),
704    })
705}
706
707/// Two circles tracing the same point set in space: the circle counterpart of
708/// collinear lines, and the one 3D circle pair the sampling path cannot
709/// answer: every sample is a hit, and "the crossings" do not exist. Distinct
710/// circles return `None` and fall through to the general machinery, which
711/// handles genuinely crossing pairs.
712fn same_circle_3d(
713    a: &ogeom_geom::CircleCurve,
714    b: &ogeom_geom::CircleCurve,
715    tol: Tolerances,
716) -> Option<CurveIntersection<Point>> {
717    let (ca, cb) = (a.circle(), b.circle());
718    if ca.centre().distance(cb.centre()) > tol.confusion() {
719        return None;
720    }
721    if (ca.radius() - cb.radius()).abs() > tol.confusion() {
722        return None;
723    }
724    // Parallel or antiparallel axes both trace the same set, at whatever
725    // phase and winding each was written with.
726    let (za, zb) = (ca.frame().z().vector(), cb.frame().z().vector());
727    if za.cross(zb).magnitude() > tol.angular() {
728        return None;
729    }
730    // The ranges are a *correspondence*, which is what an overlap means and
731    // what a caller carrying a split across the pair relies on: `on_b`'s ends
732    // are the parameters at which `b` stands where `a`'s own ends do. Phase
733    // comes from where `a` starts on `b`, winding from whether the two run
734    // the same way there, and a pair written with opposite windings runs
735    // `on_b` backwards, which is exactly the truth about them.
736    let (lo, hi) = Curve3d::domain(a);
737    let start = a.point_at(lo, tol).ok()?;
738    let local = cb.frame().to_local(start);
739    let angle = local.y.atan2(local.x);
740    let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
741    let along_a = a.d1_at(lo, tol).ok()?;
742    let along_b = b.d1_at(phase, tol).ok()?;
743    let winding: f64 = if along_a.dot(along_b) >= 0.0 {
744        1.0
745    } else {
746        -1.0
747    };
748    Some(CurveIntersection {
749        crossings: Vec::new(),
750        overlaps: vec![Overlap {
751            on_a: (lo, hi),
752            on_b: (phase, winding.mul_add(hi - lo, phase)),
753        }],
754    })
755}
756
757/// The *same description* twice: one curve object meeting itself, forward
758/// or reversed. A fitted seam reused as a wedge's apex ring is exactly this
759/// pair, and the sampling path (every sample a hit) cannot answer it, for
760/// the same reason it cannot answer coincident circles. Equality here is
761/// structural, so two independent fits of one path still fall through to
762/// the general machinery, which is the honest place for them.
763fn same_curve_3d(a: &Curve, b: &Curve) -> Option<CurveIntersection<Point>> {
764    let (lo, hi) = Curve3d::domain(a);
765    if a == b {
766        return Some(CurveIntersection {
767            crossings: Vec::new(),
768            overlaps: vec![Overlap {
769                on_a: (lo, hi),
770                on_b: (lo, hi),
771            }],
772        });
773    }
774    use ogeom_geom::Reversible as _;
775    if *a == b.clone().reversed() {
776        let (blo, bhi) = Curve3d::domain(b);
777        return Some(CurveIntersection {
778            crossings: Vec::new(),
779            overlaps: vec![Overlap {
780                on_a: (lo, hi),
781                on_b: (bhi, blo),
782            }],
783        });
784    }
785    None
786}
787
788/// Two ellipses tracing the same point set in space: the ellipse counterpart
789/// of [`same_circle_3d`], and just as invisible to the sampling path. Unlike
790/// a circle, an ellipse's natural parameter is pinned to its major axis, so
791/// the correspondence is affine only when the two `x` axes line up (parallel
792/// or antiparallel) as well as the planes and radii; anything else falls
793/// through to the general machinery.
794fn same_ellipse_3d(
795    a: &ogeom_geom::EllipseCurve,
796    b: &ogeom_geom::EllipseCurve,
797    tol: Tolerances,
798) -> Option<CurveIntersection<Point>> {
799    let (ea, eb) = (a.ellipse(), b.ellipse());
800    if ea.frame().origin().distance(eb.frame().origin()) > tol.confusion() {
801        return None;
802    }
803    if (ea.major_radius() - eb.major_radius()).abs() > tol.confusion()
804        || (ea.minor_radius() - eb.minor_radius()).abs() > tol.confusion()
805    {
806        return None;
807    }
808    let (za, zb) = (ea.frame().z().vector(), eb.frame().z().vector());
809    if za.cross(zb).magnitude() > tol.angular() {
810        return None;
811    }
812    let (xa, xb) = (ea.frame().x().vector(), eb.frame().x().vector());
813    if xa.cross(xb).magnitude() > tol.angular() {
814        return None;
815    }
816    // As for circles: phase from where `a` starts on `b`, winding from
817    // whether the two run the same way there, and the ranges come back as
818    // the correspondence an overlap means.
819    let (lo, hi) = Curve3d::domain(a);
820    let start = a.point_at(lo, tol).ok()?;
821    let angle = ogeom_math::elementary::ellipse_parameter(&eb, start, tol).ok()?;
822    let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
823    let along_a = a.d1_at(lo, tol).ok()?;
824    let along_b = b.d1_at(phase, tol).ok()?;
825    let winding: f64 = if along_a.dot(along_b) >= 0.0 {
826        1.0
827    } else {
828        -1.0
829    };
830    Some(CurveIntersection {
831        crossings: Vec::new(),
832        overlaps: vec![Overlap {
833            on_a: (lo, hi),
834            on_b: (phase, winding.mul_add(hi - lo, phase)),
835        }],
836    })
837}
838
839fn check(options: CurveCurveOptions) -> OgeomResult<()> {
840    if options.samples < 2 {
841        ogeom_bail!(Construction, "seeding needs at least two segments");
842    }
843    if !options.gap.is_finite() || options.gap <= 0.0 {
844        ogeom_bail!(Construction, "a gap of {} is not a distance", options.gap);
845    }
846    Ok(())
847}
848
849// --- analytic, planar --------------------------------------------------------
850
851fn line_line_2d(
852    a: &ogeom_geom::Line2d,
853    b: &ogeom_geom::Line2d,
854    tol: Tolerances,
855) -> CurveIntersection<Point2> {
856    let (oa, da) = (a.axis().location, a.axis().direction.vector());
857    let (ob, db) = (b.axis().location, b.axis().direction.vector());
858    let cross = da.cross(db);
859
860    if cross.abs() <= tol.angular() {
861        // Parallel. Collinear if one origin is on the other line.
862        let between = ob - oa;
863        if between.cross(da).abs() > tol.confusion() {
864            return CurveIntersection::empty();
865        }
866        // The shared stretch, as each line's own parameter range.
867        let (a_lo, a_hi) = a.domain();
868        let (b_lo, b_hi) = b.domain();
869        // Where b's range lands on a's parameter: t_a = (p - oa)·da.
870        let project = |p: Point2| (p - oa).dot(da);
871        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
872        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
873        // And back onto b.
874        let back = |t: f64| (oa + da * t - ob).dot(db);
875        if hi - lo <= tol.confusion() {
876            // Segments meeting end to end share a point, not a stretch.
877            if lo - hi > tol.confusion() {
878                return CurveIntersection::empty();
879            }
880            let t = f64::midpoint(lo, hi).clamp(a_lo, a_hi);
881            return CurveIntersection {
882                crossings: vec![Crossing {
883                    on_a: t,
884                    on_b: back(t).clamp(b_lo, b_hi),
885                    point: oa + da * t,
886                    gap: 0.0,
887                    reach: 0.0,
888                }],
889                overlaps: Vec::new(),
890            };
891        }
892        return CurveIntersection {
893            crossings: Vec::new(),
894            overlaps: vec![Overlap {
895                on_a: (lo, hi),
896                // Paired end to end with `on_a`, not sorted: two lines written in
897                // opposite directions run `on_b` backwards, and a consumer
898                // carrying a stretch across by the correspondence (the
899                // boolean clipping a contact to the edge it runs along)
900                // reads a sorted pair as the reflected stretch.
901                on_b: (back(lo), back(hi)),
902            }],
903        };
904    }
905
906    let between = ob - oa;
907    let t = between.cross(db) / cross;
908    let s = between.cross(da) / cross;
909    let (a_lo, a_hi) = a.domain();
910    let (b_lo, b_hi) = b.domain();
911    if t < a_lo - tol.parametric()
912        || t > a_hi + tol.parametric()
913        || s < b_lo - tol.parametric()
914        || s > b_hi + tol.parametric()
915    {
916        return CurveIntersection::empty();
917    }
918    CurveIntersection {
919        crossings: vec![Crossing {
920            on_a: t,
921            on_b: s,
922            point: oa + da * t,
923            gap: 0.0,
924            reach: 0.0,
925        }],
926        overlaps: Vec::new(),
927    }
928}
929
930fn line_circle_2d(
931    line: &ogeom_geom::Line2d,
932    circle: &ogeom_geom::Circle2d,
933    swapped: bool,
934    tol: Tolerances,
935) -> CurveIntersection<Point2> {
936    let (o, d) = (line.axis().location, line.axis().direction.vector());
937    let c = circle.circle();
938    let centre = c.centre();
939    let radius = c.radius();
940
941    // Foot of the perpendicular from the centre onto the line.
942    let along = (centre - o).dot(d);
943    let foot = o + d * along;
944    let gap = foot.distance(centre);
945    if gap > radius + tol.confusion() {
946        return CurveIntersection::empty();
947    }
948    let half = radius.mul_add(radius, -(gap * gap)).max(0.0).sqrt();
949    let candidates = if half <= tol.confusion() {
950        vec![along]
951    } else {
952        vec![along - half, along + half]
953    };
954
955    let (l_lo, l_hi) = line.domain();
956    let mut crossings = Vec::new();
957    for t in candidates {
958        if t < l_lo - tol.parametric() || t > l_hi + tol.parametric() {
959            continue;
960        }
961        let p = o + d * t;
962        let Some(s) = circle_parameter(circle, p, tol) else {
963            continue;
964        };
965        let (on_a, on_b) = if swapped { (s, t) } else { (t, s) };
966        crossings.push(Crossing {
967            on_a,
968            on_b,
969            point: p,
970            gap: 0.0,
971            reach: 0.0,
972        });
973    }
974    sort_crossings(&mut crossings);
975    CurveIntersection {
976        crossings,
977        overlaps: Vec::new(),
978    }
979}
980
981fn circle_circle_2d(
982    a: &ogeom_geom::Circle2d,
983    b: &ogeom_geom::Circle2d,
984    tol: Tolerances,
985) -> CurveIntersection<Point2> {
986    let (ca, cb) = (a.circle(), b.circle());
987    let between = cb.centre() - ca.centre();
988    let distance = between.magnitude();
989    let (ra, rb) = (ca.radius(), cb.radius());
990
991    if distance <= tol.confusion() {
992        if (ra - rb).abs() <= tol.confusion() {
993            // The same circle, the whole turn of each. As in space: phase
994            // from where `a` starts on `b`, winding from whether the two run
995            // the same way there, so `on_b` names where `b` stands at
996            // `a`'s own ends.
997            use ogeom_geom::Curve2d as _;
998            let (lo, hi) = a.domain();
999            let correspondence = (|| {
1000                let start = a.point_at(lo, tol).ok()?;
1001                let phase = circle_parameter(b, start, tol)?;
1002                let along_a = a.d1_at(lo, tol).ok()?;
1003                let along_b = b.d1_at(phase, tol).ok()?;
1004                let winding: f64 = if along_a.dot(along_b) >= 0.0 {
1005                    1.0
1006                } else {
1007                    -1.0
1008                };
1009                Some((phase, winding.mul_add(hi - lo, phase)))
1010            })();
1011            return CurveIntersection {
1012                crossings: Vec::new(),
1013                overlaps: vec![Overlap {
1014                    on_a: (lo, hi),
1015                    on_b: correspondence.unwrap_or_else(|| b.domain()),
1016                }],
1017            };
1018        }
1019        return CurveIntersection::empty();
1020    }
1021    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
1022        return CurveIntersection::empty();
1023    }
1024
1025    // The radical line: where the two circles' equations agree.
1026    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
1027    let squared = ra.mul_add(ra, -(along * along));
1028    let direction = between * (1.0 / distance);
1029    let foot = ca.centre() + direction * along;
1030    let mut crossings = Vec::new();
1031    let mut push = |p: Point2| {
1032        if let (Some(s), Some(t)) = (circle_parameter(a, p, tol), circle_parameter(b, p, tol)) {
1033            crossings.push(Crossing {
1034                on_a: s,
1035                on_b: t,
1036                point: p,
1037                gap: 0.0,
1038                reach: 0.0,
1039            });
1040        }
1041    };
1042    if squared <= tol.confusion() * tol.confusion() {
1043        push(foot);
1044    } else {
1045        let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
1046        push(foot + offset);
1047        push(foot - offset);
1048    }
1049    sort_crossings(&mut crossings);
1050    CurveIntersection {
1051        crossings,
1052        overlaps: Vec::new(),
1053    }
1054}
1055
1056/// The parameter at which a circle passes through a point on it.
1057fn circle_parameter(curve: &ogeom_geom::Circle2d, p: Point2, tol: Tolerances) -> Option<f64> {
1058    let c = curve.circle();
1059    let local = p - c.centre();
1060    let x = local.dot(c.frame().x().vector());
1061    let y = local.dot(c.frame().y().vector());
1062    let mut angle = y.atan2(x);
1063    if curve.is_reversed() {
1064        angle = -angle;
1065    }
1066    let angle = angle.rem_euclid(core::f64::consts::TAU);
1067    let (lo, hi) = curve.domain();
1068    // Fold into the arc's own range where the arc covers it.
1069    if angle >= lo - tol.parametric() && angle <= hi + tol.parametric() {
1070        return Some(angle.clamp(lo, hi));
1071    }
1072    let shifted = angle - core::f64::consts::TAU;
1073    if shifted >= lo - tol.parametric() && shifted <= hi + tol.parametric() {
1074        return Some(shifted.clamp(lo, hi));
1075    }
1076    None
1077}
1078
1079// --- analytic, spatial -------------------------------------------------------
1080
1081fn line_line_3d(
1082    a: &ogeom_geom::LineCurve,
1083    b: &ogeom_geom::LineCurve,
1084    options: CurveCurveOptions,
1085    tol: Tolerances,
1086) -> CurveIntersection<Point> {
1087    let (oa, da) = (a.axis().location, a.axis().direction.vector());
1088    let (ob, db) = (b.axis().location, b.axis().direction.vector());
1089    let cross = da.cross(db);
1090    let denominator = cross.square_magnitude();
1091
1092    if denominator <= tol.angular() * tol.angular() {
1093        // Parallel: collinear overlap or nothing.
1094        let between = ob - oa;
1095        if between.cross(da).magnitude() > tol.confusion() {
1096            return CurveIntersection::empty();
1097        }
1098        let (a_lo, a_hi) = a.domain();
1099        let (b_lo, b_hi) = b.domain();
1100        let project = |p: Point| (p - oa).dot(da);
1101        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
1102        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
1103        let back = |t: f64| (oa + da * t - ob).dot(db);
1104        if hi - lo <= tol.confusion() {
1105            // Segments meeting end to end share a point, not a stretch.
1106            if lo - hi > tol.confusion() {
1107                return CurveIntersection::empty();
1108            }
1109            let t = f64::midpoint(lo, hi).clamp(a_lo, a_hi);
1110            let s = back(t).clamp(b_lo, b_hi);
1111            let point = oa + da * t;
1112            return CurveIntersection {
1113                crossings: vec![Crossing {
1114                    on_a: t,
1115                    on_b: s,
1116                    point,
1117                    gap: point.distance(ob + db * s),
1118                    reach: 0.0,
1119                }],
1120                overlaps: Vec::new(),
1121            };
1122        }
1123        return CurveIntersection {
1124            crossings: Vec::new(),
1125            overlaps: vec![Overlap {
1126                on_a: (lo, hi),
1127                // Paired end to end with `on_a`, not sorted: two lines written in
1128                // opposite directions run `on_b` backwards, and a consumer
1129                // carrying a stretch across by the correspondence (the
1130                // boolean clipping a contact to the edge it runs along)
1131                // reads a sorted pair as the reflected stretch.
1132                on_b: (back(lo), back(hi)),
1133            }],
1134        };
1135    }
1136
1137    // Closest approach of two skew lines, in closed form.
1138    let between = ob - oa;
1139    let t = between.cross(db).dot(cross) / denominator;
1140    let s = between.cross(da).dot(cross) / denominator;
1141    let pa = oa + da * t;
1142    let pb = ob + db * s;
1143    let gap = pa.distance(pb);
1144    let (a_lo, a_hi) = a.domain();
1145    let (b_lo, b_hi) = b.domain();
1146    if gap > options.gap
1147        || t < a_lo - tol.parametric()
1148        || t > a_hi + tol.parametric()
1149        || s < b_lo - tol.parametric()
1150        || s > b_hi + tol.parametric()
1151    {
1152        return CurveIntersection::empty();
1153    }
1154    CurveIntersection {
1155        crossings: vec![Crossing {
1156            on_a: t,
1157            on_b: s,
1158            point: pa,
1159            gap,
1160            reach: 0.0,
1161        }],
1162        overlaps: Vec::new(),
1163    }
1164}
1165
1166// --- the general path --------------------------------------------------------
1167
1168/// Sampled segments of one curve, with the parameters they span.
1169struct Sampled<P> {
1170    points: Vec<P>,
1171    parameters: Vec<f64>,
1172}
1173
1174fn sample_2d(curve: &PlanarCurve, n: usize, tol: Tolerances) -> Sampled<Point2> {
1175    let (lo, hi) = curve.domain();
1176    let mut points = Vec::with_capacity(n + 1);
1177    let mut parameters = Vec::with_capacity(n + 1);
1178    for i in 0..=n {
1179        #[allow(clippy::cast_precision_loss)]
1180        let t = lo + (hi - lo) * i as f64 / n as f64;
1181        if let Ok(p) = curve.point_at(t, tol) {
1182            points.push(p);
1183            parameters.push(t);
1184        }
1185    }
1186    Sampled { points, parameters }
1187}
1188
1189fn sample_3d(curve: &Curve, n: usize, tol: Tolerances) -> Sampled<Point> {
1190    let (lo, hi) = curve.domain();
1191    let mut points = Vec::with_capacity(n + 1);
1192    let mut parameters = Vec::with_capacity(n + 1);
1193    for i in 0..=n {
1194        #[allow(clippy::cast_precision_loss)]
1195        let t = lo + (hi - lo) * i as f64 / n as f64;
1196        if let Ok(p) = curve.point_at(t, tol) {
1197            points.push(p);
1198            parameters.push(t);
1199        }
1200    }
1201    Sampled { points, parameters }
1202}
1203
1204fn general_2d(
1205    a: &PlanarCurve,
1206    b: &PlanarCurve,
1207    options: CurveCurveOptions,
1208    tol: Tolerances,
1209) -> OgeomResult<CurveIntersection<Point2>> {
1210    let sa = sample_2d(a, options.samples, tol);
1211    let sb = sample_2d(b, options.samples, tol);
1212
1213    let mut crossings: Vec<Crossing<Point2>> = Vec::new();
1214    for i in 1..sa.points.len() {
1215        for j in 1..sb.points.len() {
1216            let Some((ta, tb)) = segments_cross_2d(
1217                (sa.points[i - 1], sa.points[i]),
1218                (sb.points[j - 1], sb.points[j]),
1219            ) else {
1220                continue;
1221            };
1222            let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
1223            let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
1224            if let Some(found) = polish_2d(a, b, seed_a, seed_b, options, tol) {
1225                push_unique_2d(&mut crossings, found, tol);
1226            }
1227        }
1228    }
1229    sort_crossings(&mut crossings);
1230    Ok(CurveIntersection {
1231        crossings,
1232        overlaps: Vec::new(),
1233    })
1234}
1235
1236fn general_3d(
1237    a: &Curve,
1238    b: &Curve,
1239    options: CurveCurveOptions,
1240    tol: Tolerances,
1241) -> OgeomResult<CurveIntersection<Point>> {
1242    let sa = sample_3d(a, options.samples, tol);
1243    let sb = sample_3d(b, options.samples, tol);
1244
1245    // Segment pairs whose closest approach is within reach seed the polish.
1246    // The threshold is the sampling sag plus the acceptable gap: what could
1247    // converge is seeded, what could not is skipped.
1248    let mut reach = options.gap;
1249    for s in [&sa, &sb] {
1250        let longest = s
1251            .points
1252            .windows(2)
1253            .map(|w| w[0].distance(w[1]))
1254            .fold(0.0_f64, f64::max);
1255        reach += longest;
1256    }
1257
1258    // Where the first curve runs along the second, every segment pair near
1259    // the stretch is in reach, and each seeds a polish that lands on the
1260    // same shared support the overlap pass reports whole and removes the
1261    // crossings of. So the samples' feet are found first, and a segment of
1262    // the first curve lying wholly in such a stretch seeds nothing: a
1263    // section along an edge would otherwise run thousands of polishes for
1264    // crossings that are all discarded.
1265    let feet: Vec<Option<(f64, f64)>> = sa
1266        .points
1267        .iter()
1268        .map(|p| foot_via_samples(b, &sb, *p, tol))
1269        .collect();
1270    let overlaps = if hugging_runs(&feet, options.gap).contains(&true) {
1271        shared_support_3d(a, b, &sa, &sb, &feet, options, tol)
1272    } else {
1273        Vec::new()
1274    };
1275    let shared = |lo: f64, hi: f64| {
1276        overlaps.iter().any(|o| {
1277            let (from, to) = order(o.on_a.0, o.on_a.1);
1278            lo >= from && hi <= to
1279        })
1280    };
1281
1282    // Every segment pair's closest approach, then seeds only where it is a
1283    // local minimum among the neighbouring pairs. Each crossing sits in
1284    // such a basin; the pairs round it would all polish to the same place.
1285    // Two curves running near each other (a section beside the edge it was
1286    // cut along) put hundreds of pairs in reach, and polishing every one
1287    // finds the same few crossings over and over.
1288    let (na, nb) = (sa.points.len(), sb.points.len());
1289    // Pairs whose boxes stand further apart than the reach cannot be in
1290    // it: they are not measured, and read as out of reach, which is what
1291    // measuring them would have said, for the reach test and for the
1292    // basin test alike.
1293    let boxes = |s: &Sampled<Point>| -> Vec<(Point, Point)> {
1294        s.points
1295            .windows(2)
1296            .map(|w| {
1297                (
1298                    Point::new(w[0].x.min(w[1].x), w[0].y.min(w[1].y), w[0].z.min(w[1].z)),
1299                    Point::new(w[0].x.max(w[1].x), w[0].y.max(w[1].y), w[0].z.max(w[1].z)),
1300                )
1301            })
1302            .collect()
1303    };
1304    let (boxes_a, boxes_b) = (boxes(&sa), boxes(&sb));
1305    let apart = |x: &(Point, Point), y: &(Point, Point)| {
1306        x.0.x - y.1.x > reach
1307            || y.0.x - x.1.x > reach
1308            || x.0.y - y.1.y > reach
1309            || y.0.y - x.1.y > reach
1310            || x.0.z - y.1.z > reach
1311            || y.0.z - x.1.z > reach
1312    };
1313    let mut approach: Vec<(f64, f64, f64)> =
1314        Vec::with_capacity(na.saturating_sub(1) * nb.saturating_sub(1));
1315    for i in 1..na {
1316        for j in 1..nb {
1317            approach.push(if apart(&boxes_a[i - 1], &boxes_b[j - 1]) {
1318                (0.0, 0.0, f64::INFINITY)
1319            } else {
1320                segments_approach_3d(
1321                    (sa.points[i - 1], sa.points[i]),
1322                    (sb.points[j - 1], sb.points[j]),
1323                )
1324            });
1325        }
1326    }
1327    let cols = nb.saturating_sub(1);
1328    let gap_at = |i: usize, j: usize| approach[(i - 1) * cols + (j - 1)].2;
1329    // Lowest along either curve: a pair nearer than both its neighbours in
1330    // the first curve's direction, or in the second's.
1331    let basin = |i: usize, j: usize| {
1332        let here = gap_at(i, j);
1333        let along_a = (i.saturating_sub(1).max(1)..=(i + 1).min(na - 1))
1334            .all(|p| p == i || here <= gap_at(p, j));
1335        let along_b = (j.saturating_sub(1).max(1)..=(j + 1).min(nb - 1))
1336            .all(|q| q == j || here <= gap_at(i, q));
1337        along_a || along_b
1338    };
1339    let mut crossings: Vec<Crossing<Point>> = Vec::new();
1340    for i in 1..na {
1341        let (lo, hi) = order(sa.parameters[i - 1], sa.parameters[i]);
1342        if shared(lo, hi) {
1343            continue;
1344        }
1345        for j in 1..nb {
1346            let (ta, tb, gap) = approach[(i - 1) * cols + (j - 1)];
1347            if gap > reach || !basin(i, j) {
1348                continue;
1349            }
1350            let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
1351            let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
1352            if let Some(found) = polish_3d(a, b, seed_a, seed_b, options, tol) {
1353                push_unique_3d(&mut crossings, found, tol);
1354            }
1355        }
1356    }
1357    sort_crossings(&mut crossings);
1358
1359    // A tangential contact is one crossing, however many the polish
1360    // returns. Where two curves touch, the stationarity conditions go flat
1361    // along the contact: every seed converges somewhere in a valley the
1362    // width of the gap, and an arc ending on the line it is tangent to
1363    // comes back as thirty crossings inside a micron or two. Consecutive
1364    // crossings with the first curve staying within the gap of the second
1365    // all the way between them are the same contact, and the nearest
1366    // approach among them speaks for it.
1367    if crossings.len() > 1 {
1368        let mut merged: Vec<Crossing<Point>> = Vec::with_capacity(crossings.len());
1369        let mut run_start: Option<Point> = None;
1370        for c in crossings {
1371            if let Some(last) = merged.last_mut()
1372                && contact_between_3d(a, b, last, &c, options, tol)
1373            {
1374                let start = run_start.get_or_insert(last.point);
1375                let reach = start.distance(c.point).max(last.reach);
1376                if c.gap < last.gap {
1377                    *last = c;
1378                }
1379                last.reach = reach;
1380                continue;
1381            }
1382            run_start = None;
1383            merged.push(c);
1384        }
1385        // A touch astride the first curve's period seam comes back as a
1386        // crossing at each end of the parameter range; the two are one
1387        // contact as well.
1388        if merged.len() > 1 && a.is_periodic() {
1389            let (lo, hi) = a.domain();
1390            let (first, last) = (merged[0], merged[merged.len() - 1]);
1391            let wrapped = Crossing {
1392                on_a: first.on_a + (hi - lo),
1393                ..first
1394            };
1395            if contact_between_3d(a, b, &last, &wrapped, options, tol) {
1396                let reach = last
1397                    .reach
1398                    .max(first.reach)
1399                    .max(last.point.distance(first.point));
1400                let keep = if first.gap <= last.gap {
1401                    0
1402                } else {
1403                    merged.len() - 1
1404                };
1405                merged[keep].reach = reach;
1406                if keep == 0 {
1407                    merged.pop();
1408                } else {
1409                    merged.remove(0);
1410                }
1411            }
1412        }
1413        crossings = merged;
1414    }
1415
1416    // Stretches where the first curve stays within the gap of the second
1417    // are shared support, not a row of crossings. A fitted section tracing
1418    // the arc it was cut along wobbles about it by less than the gap and
1419    // "crosses" it at every wobble; read as crossings, those shatter the
1420    // curve into hundreds of pieces and pave the edge at each. So every
1421    // sample of the first curve asks its foot on the second, a run of
1422    // consecutive samples within the gap is an overlap with its ends
1423    // bisected to parametric resolution, and the crossings inside it are
1424    // the overlap's, not the caller's.
1425    if !overlaps.is_empty() {
1426        crossings.retain(|c| {
1427            !overlaps.iter().any(|o| {
1428                let (lo, hi) = order(o.on_a.0, o.on_a.1);
1429                c.on_a >= lo - tol.parametric() && c.on_a <= hi + tol.parametric()
1430            })
1431        });
1432    }
1433    // Every surviving crossing owns the valley it sits in: how far along the
1434    // first curve the second stays within the caller's gap. A transversal
1435    // crossing leaves the gap within a gap's length and says nothing; a
1436    // tangential one (a line touching a fitted rim that wobbles about its
1437    // circle by the fit's budget) stays inside for the root of gap times
1438    // radius on either side, and the polish lands on whichever wobble's
1439    // floor it found. The consumer placing a vertex there owns that much
1440    // doubt, which the spread of several polished crossings only stated
1441    // when there were several. A valley longer than a tangency's (the
1442    // radius being at most the shorter curve's length) is a shared stretch
1443    // the overlap pass speaks for, not a crossing's to own.
1444    let gap = options.gap.max(tol.confusion());
1445    let extent = {
1446        let along = |s: &Sampled<Point>| {
1447            s.points
1448                .windows(2)
1449                .map(|w| w[0].distance(w[1]))
1450                .sum::<f64>()
1451        };
1452        along(&sa).min(along(&sb))
1453    };
1454    let cap = 4.0 * (gap * extent).sqrt();
1455    for c in &mut crossings {
1456        let valley = valley_extent_3d(a, b, &sb, c, gap, tol);
1457        if valley > gap * 8.0 && valley <= cap {
1458            c.reach = c.reach.max(valley);
1459        }
1460    }
1461    Ok(CurveIntersection {
1462        crossings,
1463        overlaps,
1464    })
1465}
1466
1467/// Whether the first curve stays within the gap of the second all the way
1468/// from one crossing to the next: three stations between them, each foot
1469/// seeded from the crossings' own parameters.
1470fn contact_between_3d(
1471    a: &Curve,
1472    b: &Curve,
1473    from: &Crossing<Point>,
1474    to: &Crossing<Point>,
1475    options: CurveCurveOptions,
1476    tol: Tolerances,
1477) -> bool {
1478    if (to.on_a - from.on_a).abs() <= tol.parametric() {
1479        return true;
1480    }
1481    (1..=3).all(|k| {
1482        let f = f64::from(k) / 4.0;
1483        let t = from.on_a + (to.on_a - from.on_a) * f;
1484        let seed = from.on_b + (to.on_b - from.on_b) * f;
1485        a.point_at(t, tol)
1486            .ok()
1487            .and_then(|p| foot_on_3d(b, p, seed, tol))
1488            .is_some_and(|(_, gap)| gap <= options.gap)
1489    })
1490}
1491
1492/// The foot of a point on the second curve, seeded from its sampled
1493/// polyline's nearest segment: the parameter and the distance there.
1494fn foot_via_samples(
1495    b: &Curve,
1496    sb: &Sampled<Point>,
1497    p: Point,
1498    tol: Tolerances,
1499) -> Option<(f64, f64)> {
1500    let mut seed = (f64::INFINITY, 0.0);
1501    for j in 1..sb.points.len() {
1502        let (_, tb, gap) = segments_approach_3d((p, p), (sb.points[j - 1], sb.points[j]));
1503        if gap < seed.0 {
1504            seed = (
1505                gap,
1506                sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb,
1507            );
1508        }
1509    }
1510    if !seed.0.is_finite() {
1511        return None;
1512    }
1513    foot_on_3d(b, p, seed.1, tol)
1514}
1515
1516/// Which samples of the first curve lie in a run of two or more whose feet
1517/// on the second are within the gap: the stretches [`shared_support_3d`]
1518/// reports as overlaps, sample by sample.
1519fn hugging_runs(feet: &[Option<(f64, f64)>], gap: f64) -> Vec<bool> {
1520    let within = |i: usize| feet[i].is_some_and(|(_, g)| g <= gap);
1521    let mut out = vec![false; feet.len()];
1522    let mut i = 0;
1523    while i < feet.len() {
1524        if !within(i) {
1525            i += 1;
1526            continue;
1527        }
1528        let start = i;
1529        while i + 1 < feet.len() && within(i + 1) {
1530            i += 1;
1531        }
1532        if i > start {
1533            out[start..=i].fill(true);
1534        }
1535        i += 1;
1536    }
1537    out
1538}
1539
1540/// Runs of the first curve's samples whose feet on the second lie within
1541/// the gap, each bisected to its parametric ends.
1542fn shared_support_3d(
1543    a: &Curve,
1544    b: &Curve,
1545    sa: &Sampled<Point>,
1546    sb: &Sampled<Point>,
1547    feet: &[Option<(f64, f64)>],
1548    options: CurveCurveOptions,
1549    tol: Tolerances,
1550) -> Vec<Overlap> {
1551    let hugs = |t: f64| -> Option<(f64, f64)> {
1552        let p = a.point_at(t, tol).ok()?;
1553        let (s, gap) = foot_via_samples(b, sb, p, tol)?;
1554        (gap <= options.gap).then_some((s, gap))
1555    };
1556    let within = |i: usize| feet[i].is_some_and(|(_, gap)| gap <= options.gap);
1557
1558    let mut overlaps = Vec::new();
1559    let mut i = 0;
1560    while i < sa.points.len() {
1561        if !within(i) {
1562            i += 1;
1563            continue;
1564        }
1565        let start = i;
1566        while i + 1 < sa.points.len() && within(i + 1) {
1567            i += 1;
1568        }
1569        let end = i;
1570        i += 1;
1571        if end == start {
1572            continue;
1573        }
1574        // The run's ends: where the samples stop hugging, bisected between
1575        // the last inside sample and the first outside one.
1576        let refine = |inside: usize, outside: Option<usize>| -> (f64, f64) {
1577            let (mut t_in, s_in) = (sa.parameters[inside], feet[inside].map_or(0.0, |f| f.0));
1578            let Some(out) = outside else {
1579                return (t_in, s_in);
1580            };
1581            let mut s_at = s_in;
1582            let mut t_out = sa.parameters[out];
1583            for _ in 0..48 {
1584                if (t_out - t_in).abs() <= tol.parametric() {
1585                    break;
1586                }
1587                let mid = f64::midpoint(t_in, t_out);
1588                match hugs(mid) {
1589                    Some((s, _)) => {
1590                        t_in = mid;
1591                        s_at = s;
1592                    }
1593                    None => t_out = mid,
1594                }
1595            }
1596            (t_in, s_at)
1597        };
1598        let (lo_a, lo_b) = refine(start, start.checked_sub(1));
1599        let (hi_a, hi_b) = refine(end, (end + 1 < sa.points.len()).then_some(end + 1));
1600        if hi_a - lo_a <= tol.parametric() || (hi_b - lo_b).abs() <= tol.parametric() {
1601            continue;
1602        }
1603        overlaps.push(Overlap {
1604            on_a: (lo_a, hi_a),
1605            on_b: (lo_b, hi_b),
1606        });
1607    }
1608    overlaps
1609}
1610
1611/// Newton on the foot-point condition `(c(s) - p) . c'(s) = 0` from a seed,
1612/// clamped to the curve's domain; the parameter and the distance there.
1613fn foot_on_3d(curve: &Curve, p: Point, seed: f64, tol: Tolerances) -> Option<(f64, f64)> {
1614    let mut s = clamp_3d(curve, seed);
1615    let mut best = (s, curve.point_at(s, tol).ok()?.distance(p));
1616    for _ in 0..30 {
1617        let d = curve.derivatives_at(s, 2, tol).ok()?;
1618        let zero = ogeom_math::Vector::ZERO;
1619        let (c, d1, d2) = (
1620            d.first().copied().unwrap_or(zero),
1621            d.get(1).copied().unwrap_or(zero),
1622            d.get(2).copied().unwrap_or(zero),
1623        );
1624        let gap = c - (p - Point::ORIGIN);
1625        let g = gap.dot(d1);
1626        let dg = d1.dot(d1) + gap.dot(d2);
1627        if dg.abs() <= f64::MIN_POSITIVE {
1628            break;
1629        }
1630        let next = clamp_3d(curve, s - g / dg);
1631        let dist = curve.point_at(next, tol).ok()?.distance(p);
1632        let moved = (next - s).abs();
1633        s = next;
1634        if dist < best.1 {
1635            best = (s, dist);
1636        }
1637        if moved <= tol.parametric() {
1638            break;
1639        }
1640    }
1641    Some(best)
1642}
1643
1644/// Newton on `c1(t) - c2(s) = 0` in the plane.
1645fn polish_2d(
1646    a: &PlanarCurve,
1647    b: &PlanarCurve,
1648    seed_a: f64,
1649    seed_b: f64,
1650    options: CurveCurveOptions,
1651    tol: Tolerances,
1652) -> Option<Crossing<Point2>> {
1653    let system = |x: &[f64; 2]| {
1654        let (t, s) = (clamp_2d(a, x[0]), clamp_2d(b, x[1]));
1655        let pa = a.point_at(t, tol).unwrap_or(Point2::ORIGIN);
1656        let pb = b.point_at(s, tol).unwrap_or(Point2::ORIGIN);
1657        let da = a
1658            .d1_at(t, tol)
1659            .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1660        let db = b
1661            .d1_at(s, tol)
1662            .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1663        ([pa.x - pb.x, pa.y - pb.y], [[da.x, -db.x], [da.y, -db.y]])
1664    };
1665    let criteria = solve::Criteria {
1666        residual: tol.confusion() * 0.01,
1667        step: tol.parametric(),
1668        max_iterations: 40,
1669    };
1670    let found = solve::newton_system_fixed(system, [seed_a, seed_b], criteria).ok()?;
1671    let (t, s) = (clamp_2d(a, found.0[0]), clamp_2d(b, found.0[1]));
1672    let pa = a.point_at(t, tol).ok()?;
1673    let pb = b.point_at(s, tol).ok()?;
1674    let gap = pa.distance(pb);
1675    if gap > options.gap {
1676        return None;
1677    }
1678    Some(Crossing {
1679        on_a: t,
1680        on_b: s,
1681        point: pa,
1682        gap,
1683        reach: 0.0,
1684    })
1685}
1686
1687/// Gauss-Newton on the closest approach of two space curves.
1688///
1689/// Three equations would be overdetermined for two unknowns, so the system is
1690/// the two *stationarity* conditions (the gap vector perpendicular to both
1691/// tangents), whose solutions are the local closest approaches. The gap test
1692/// afterwards decides whether the approach found is a crossing.
1693fn polish_3d(
1694    a: &Curve,
1695    b: &Curve,
1696    seed_a: f64,
1697    seed_b: f64,
1698    options: CurveCurveOptions,
1699    tol: Tolerances,
1700) -> Option<Crossing<Point>> {
1701    // Two unknowns: the allocation-free solver, and each curve's point
1702    // read from the same derivative table as its derivatives.
1703    let system = |x: [f64; 2]| {
1704        let (t, s) = (clamp_3d(a, x[0]), clamp_3d(b, x[1]));
1705        let (Ok(da), Ok(db)) = (a.derivatives_at(t, 2, tol), b.derivatives_at(s, 2, tol)) else {
1706            // A zero here would read as a root; infinite, the damped step
1707            // backs off instead.
1708            return ([f64::INFINITY; 2], [[0.0; 2]; 2]);
1709        };
1710        let zero = ogeom_math::Vector::ZERO;
1711        let at = |d: &[ogeom_math::Vector], k: usize| d.get(k).copied().unwrap_or(zero);
1712        let (pa, d1a, d2a) = (at(&da, 0), at(&da, 1), at(&da, 2));
1713        let (pb, d1b, d2b) = (at(&db, 0), at(&db, 1), at(&db, 2));
1714        let gap = pa - pb;
1715        (
1716            [gap.dot(d1a), -gap.dot(d1b)],
1717            [
1718                [d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
1719                [-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
1720            ],
1721        )
1722    };
1723    let criteria = solve::Criteria {
1724        residual: tol.confusion() * 0.01,
1725        step: tol.parametric(),
1726        max_iterations: 40,
1727    };
1728    let ([t, s], ..) = solve::newton_system_2(system, [seed_a, seed_b], criteria).ok()?;
1729    let (t, s) = (clamp_3d(a, t), clamp_3d(b, s));
1730    let pa = a.point_at(t, tol).ok()?;
1731    let pb = b.point_at(s, tol).ok()?;
1732    let gap = pa.distance(pb);
1733    if gap > options.gap {
1734        return None;
1735    }
1736    Some(Crossing {
1737        on_a: t,
1738        on_b: s,
1739        point: pa,
1740        gap,
1741        reach: 0.0,
1742    })
1743}
1744
1745// --- small helpers -----------------------------------------------------------
1746
1747fn clamp_2d(curve: &PlanarCurve, t: f64) -> f64 {
1748    let (lo, hi) = curve.domain();
1749    if curve.is_periodic() {
1750        let span = hi - lo;
1751        if span > 0.0 {
1752            return lo + (t - lo).rem_euclid(span);
1753        }
1754    }
1755    t.clamp(lo, hi)
1756}
1757
1758fn clamp_3d(curve: &Curve, t: f64) -> f64 {
1759    let (lo, hi) = curve.domain();
1760    if curve.is_periodic() {
1761        let span = hi - lo;
1762        if span > 0.0 {
1763            return lo + (t - lo).rem_euclid(span);
1764        }
1765    }
1766    t.clamp(lo, hi)
1767}
1768
1769/// Where two planar segments cross, as fractions along each.
1770fn segments_cross_2d(a: (Point2, Point2), b: (Point2, Point2)) -> Option<(f64, f64)> {
1771    let da = a.1 - a.0;
1772    let db = b.1 - b.0;
1773    let cross = da.cross(db);
1774    if cross.abs() <= f64::MIN_POSITIVE {
1775        return None;
1776    }
1777    let between = b.0 - a.0;
1778    let t = between.cross(db) / cross;
1779    let s = between.cross(da) / cross;
1780    if !(0.0..=1.0).contains(&t) || !(0.0..=1.0).contains(&s) {
1781        return None;
1782    }
1783    Some((t, s))
1784}
1785
1786/// The distance from `p` to the curve `b`, through its samples and a local
1787/// polish on the nearest segment's parameter span.
1788fn distance_to_curve_3d(b: &Curve, sb: &Sampled<Point>, p: Point, tol: Tolerances) -> f64 {
1789    let mut best = (0_usize, f64::INFINITY);
1790    for i in 1..sb.points.len() {
1791        let (q0, q1) = (sb.points[i - 1], sb.points[i]);
1792        let d = q1 - q0;
1793        let len2 = d.dot(d);
1794        let f = if len2 <= f64::MIN_POSITIVE {
1795            0.0
1796        } else {
1797            ((p - q0).dot(d) / len2).clamp(0.0, 1.0)
1798        };
1799        let dist = p.distance(q0 + d * f);
1800        if dist < best.1 {
1801            best = (i, dist);
1802        }
1803    }
1804    if best.0 == 0 {
1805        return best.1;
1806    }
1807    let (mut lo, mut hi) = (sb.parameters[best.0 - 1], sb.parameters[best.0]);
1808    let at = |t: f64| -> f64 { b.point_at(t, tol).map_or(f64::INFINITY, |q| q.distance(p)) };
1809    // Golden-section on the segment's span: the distance is unimodal there
1810    // at any sampling that resolved the curve at all.
1811    let phi = 0.5 * (3.0 - 5.0_f64.sqrt());
1812    let (mut x1, mut x2) = (lo + phi * (hi - lo), hi - phi * (hi - lo));
1813    let (mut f1, mut f2) = (at(x1), at(x2));
1814    for _ in 0..48 {
1815        if f1 < f2 {
1816            hi = x2;
1817            x2 = x1;
1818            f2 = f1;
1819            x1 = lo + phi * (hi - lo);
1820            f1 = at(x1);
1821        } else {
1822            lo = x1;
1823            x1 = x2;
1824            f1 = f2;
1825            x2 = hi - phi * (hi - lo);
1826            f2 = at(x2);
1827        }
1828    }
1829    f1.min(f2).min(best.1)
1830}
1831
1832/// How far from a crossing, along the first curve, the second curve stays
1833/// within `gap`: the larger of the two directions, in space.
1834fn valley_extent_3d(
1835    a: &Curve,
1836    b: &Curve,
1837    sb: &Sampled<Point>,
1838    crossing: &Crossing<Point>,
1839    gap: f64,
1840    tol: Tolerances,
1841) -> f64 {
1842    let (lo, hi) = a.domain();
1843    let span = hi - lo;
1844    if span <= 0.0 {
1845        return 0.0;
1846    }
1847    let mut extent = 0.0_f64;
1848    for direction in [-1.0, 1.0] {
1849        let inside = |t: f64| -> bool {
1850            if t < lo || t > hi {
1851                return false;
1852            }
1853            a.point_at(t, tol)
1854                .is_ok_and(|q| distance_to_curve_3d(b, sb, q, tol) <= gap)
1855        };
1856        let mut step = span * 1e-6;
1857        let mut last_in = crossing.on_a;
1858        let mut first_out: Option<f64> = None;
1859        while step <= span {
1860            let t = crossing.on_a + direction * step;
1861            if inside(t) {
1862                last_in = t;
1863                step *= 2.0;
1864            } else {
1865                first_out = Some(t);
1866                break;
1867            }
1868        }
1869        let edge = match first_out {
1870            Some(mut out) => {
1871                let mut r#in = last_in;
1872                for _ in 0..30 {
1873                    let mid = f64::midpoint(r#in, out);
1874                    if inside(mid) {
1875                        r#in = mid;
1876                    } else {
1877                        out = mid;
1878                    }
1879                }
1880                r#in
1881            }
1882            None => last_in,
1883        };
1884        if let Ok(q) = a.point_at(edge, tol) {
1885            extent = extent.max(q.distance(crossing.point));
1886        }
1887    }
1888    extent
1889}
1890
1891/// The closest approach of two spatial segments, as fractions and a distance.
1892fn segments_approach_3d(a: (Point, Point), b: (Point, Point)) -> (f64, f64, f64) {
1893    let da = a.1 - a.0;
1894    let db = b.1 - b.0;
1895    let between = a.0 - b.0;
1896    let (aa, bb, ab) = (da.dot(da), db.dot(db), da.dot(db));
1897    let (ad, bd) = (da.dot(between), db.dot(between));
1898    let denominator = ab.mul_add(-ab, aa * bb);
1899
1900    let (mut t, mut s) = if denominator.abs() <= f64::MIN_POSITIVE {
1901        (
1902            0.0,
1903            if bb > 0.0 {
1904                (bd / bb).clamp(0.0, 1.0)
1905            } else {
1906                0.0
1907            },
1908        )
1909    } else {
1910        (
1911            (ab.mul_add(bd, -(bb * ad)) / denominator).clamp(0.0, 1.0),
1912            (aa.mul_add(bd, -(ab * ad)) / denominator).clamp(0.0, 1.0),
1913        )
1914    };
1915    // One clamped end may pull the other; a single re-projection settles it.
1916    if bb > 0.0 {
1917        s = ((da.dot(between) + t * aa - 0.0).mul_add(0.0, db.dot(between + da * t)) / bb)
1918            .clamp(0.0, 1.0);
1919    }
1920    if aa > 0.0 {
1921        t = (da.dot(db * s - between) / aa).clamp(0.0, 1.0);
1922    }
1923    let pa = a.0 + da * t;
1924    let pb = b.0 + db * s;
1925    (t, s, pa.distance(pb))
1926}
1927
1928fn order(a: f64, b: f64) -> (f64, f64) {
1929    if a <= b { (a, b) } else { (b, a) }
1930}
1931
1932fn sort_crossings<P>(crossings: &mut [Crossing<P>]) {
1933    crossings.sort_by(|x, y| {
1934        x.on_a
1935            .partial_cmp(&y.on_a)
1936            .unwrap_or(core::cmp::Ordering::Equal)
1937    });
1938}
1939
1940fn push_unique_2d(crossings: &mut Vec<Crossing<Point2>>, found: Crossing<Point2>, tol: Tolerances) {
1941    let reach = tol.confusion() * 100.0;
1942    if crossings
1943        .iter()
1944        .any(|c| c.point.distance(found.point) <= reach)
1945    {
1946        return;
1947    }
1948    crossings.push(found);
1949}
1950
1951fn push_unique_3d(crossings: &mut Vec<Crossing<Point>>, found: Crossing<Point>, tol: Tolerances) {
1952    let reach = tol.confusion() * 100.0;
1953    if crossings
1954        .iter()
1955        .any(|c| c.point.distance(found.point) <= reach)
1956    {
1957        return;
1958    }
1959    crossings.push(found);
1960}
1961
1962#[cfg(test)]
1963#[allow(clippy::unwrap_used)]
1964mod tests {
1965    use super::*;
1966    use ogeom_geom::{BSpline2d, Circle2d, CircleCurve, Line2d, LineCurve};
1967    use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, KnotVector, Vector2};
1968
1969    const T: Tolerances = Tolerances::millimetres();
1970
1971    fn line2(from: Point2, to: Point2) -> PlanarCurve {
1972        Line2d::segment(from, to, T).unwrap().into()
1973    }
1974
1975    fn circle2(centre: Point2, radius: f64) -> PlanarCurve {
1976        Circle2d::new(
1977            Circle2::new(
1978                Frame2::new(centre, Direction2::new(Vector2::new(1.0, 0.0), T).unwrap()),
1979                radius,
1980                T,
1981            )
1982            .unwrap(),
1983        )
1984        .into()
1985    }
1986
1987    #[test]
1988    fn two_lines_cross_where_algebra_says() {
1989        let a = line2(Point2::new(0.0, 0.0), Point2::new(4.0, 4.0));
1990        let b = line2(Point2::new(0.0, 4.0), Point2::new(4.0, 0.0));
1991        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
1992        assert_eq!(found.crossings.len(), 1);
1993        let hit = &found.crossings[0];
1994        assert!(hit.point.is_equal(Point2::new(2.0, 2.0), T));
1995        // Parameters are arc length on a segment.
1996        approx::assert_relative_eq!(hit.on_a, 8.0_f64.sqrt(), epsilon = 1e-9);
1997
1998        // Segments that would cross beyond their ends do not.
1999        let short = line2(Point2::new(0.0, 4.0), Point2::new(1.0, 3.0));
2000        assert!(
2001            intersect_curves_2d(&a, &short, CurveCurveOptions::default(), T)
2002                .unwrap()
2003                .is_empty()
2004        );
2005    }
2006
2007    #[test]
2008    fn collinear_lines_overlap_rather_than_crossing_everywhere() {
2009        let a = line2(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0));
2010        let b = line2(Point2::new(4.0, 0.0), Point2::new(20.0, 0.0));
2011        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
2012        assert!(found.crossings.is_empty());
2013        assert_eq!(found.overlaps.len(), 1);
2014        let overlap = &found.overlaps[0];
2015        approx::assert_relative_eq!(overlap.on_a.0, 4.0, epsilon = 1e-9);
2016        approx::assert_relative_eq!(overlap.on_a.1, 10.0, epsilon = 1e-9);
2017        approx::assert_relative_eq!(overlap.on_b.0, 0.0, epsilon = 1e-9);
2018        approx::assert_relative_eq!(overlap.on_b.1, 6.0, epsilon = 1e-9);
2019
2020        // Parallel but apart: nothing.
2021        let above = line2(Point2::new(0.0, 1.0), Point2::new(10.0, 1.0));
2022        assert!(
2023            intersect_curves_2d(&a, &above, CurveCurveOptions::default(), T)
2024                .unwrap()
2025                .is_empty()
2026        );
2027    }
2028
2029    #[test]
2030    fn a_line_meets_a_circle_in_two_points_one_or_none() {
2031        let circle = circle2(Point2::new(0.0, 0.0), 2.0);
2032        let through = line2(Point2::new(-5.0, 0.0), Point2::new(5.0, 0.0));
2033        let found =
2034            intersect_curves_2d(&through, &circle, CurveCurveOptions::default(), T).unwrap();
2035        assert_eq!(found.crossings.len(), 2);
2036        for hit in &found.crossings {
2037            approx::assert_relative_eq!(
2038                hit.point.distance(Point2::new(0.0, 0.0)),
2039                2.0,
2040                epsilon = 1e-9
2041            );
2042            // The circle parameter really evaluates to the crossing point.
2043            let PlanarCurve::Circle(_) = &circle else {
2044                unreachable!()
2045            };
2046            let on_circle = circle.point_at(hit.on_b, T).unwrap();
2047            assert!(on_circle.is_equal(hit.point, T));
2048        }
2049
2050        let tangent = line2(Point2::new(-5.0, 2.0), Point2::new(5.0, 2.0));
2051        assert_eq!(
2052            intersect_curves_2d(&tangent, &circle, CurveCurveOptions::default(), T)
2053                .unwrap()
2054                .crossings
2055                .len(),
2056            1
2057        );
2058        let missing = line2(Point2::new(-5.0, 3.0), Point2::new(5.0, 3.0));
2059        assert!(
2060            intersect_curves_2d(&missing, &circle, CurveCurveOptions::default(), T)
2061                .unwrap()
2062                .is_empty()
2063        );
2064    }
2065
2066    #[test]
2067    fn two_circles_cross_touch_coincide_or_miss() {
2068        let a = circle2(Point2::new(0.0, 0.0), 2.0);
2069
2070        let crossing = circle2(Point2::new(3.0, 0.0), 2.0);
2071        let found = intersect_curves_2d(&a, &crossing, CurveCurveOptions::default(), T).unwrap();
2072        assert_eq!(found.crossings.len(), 2);
2073        for hit in &found.crossings {
2074            let on_a = a.point_at(hit.on_a, T).unwrap();
2075            let on_b = crossing.point_at(hit.on_b, T).unwrap();
2076            assert!(on_a.is_equal(hit.point, T));
2077            assert!(on_b.is_equal(hit.point, T));
2078        }
2079
2080        let touching = circle2(Point2::new(4.0, 0.0), 2.0);
2081        assert_eq!(
2082            intersect_curves_2d(&a, &touching, CurveCurveOptions::default(), T)
2083                .unwrap()
2084                .crossings
2085                .len(),
2086            1
2087        );
2088
2089        let same = circle2(Point2::new(0.0, 0.0), 2.0);
2090        let coincident = intersect_curves_2d(&a, &same, CurveCurveOptions::default(), T).unwrap();
2091        assert!(coincident.crossings.is_empty());
2092        assert_eq!(coincident.overlaps.len(), 1);
2093
2094        let apart = circle2(Point2::new(10.0, 0.0), 2.0);
2095        assert!(
2096            intersect_curves_2d(&a, &apart, CurveCurveOptions::default(), T)
2097                .unwrap()
2098                .is_empty()
2099        );
2100    }
2101
2102    #[test]
2103    fn the_general_path_handles_what_has_no_closed_form() {
2104        // A spline sine-ish wave against a line: three crossings, found by
2105        // sampling and polished by Newton to rounding.
2106        let wave: PlanarCurve = BSpline2d::new(
2107            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2108            vec![
2109                Point2::new(0.0, -1.0),
2110                Point2::new(1.0, 3.0),
2111                Point2::new(2.0, -3.0),
2112                Point2::new(3.0, 3.0),
2113                Point2::new(4.0, -1.0),
2114            ],
2115            T,
2116        )
2117        .unwrap()
2118        .into();
2119        let axis = line2(Point2::new(-1.0, 0.0), Point2::new(5.0, 0.0));
2120        let found = intersect_curves_2d(&wave, &axis, CurveCurveOptions::default(), T).unwrap();
2121        assert_eq!(found.crossings.len(), 3, "a wave crosses its axis thrice");
2122        for hit in &found.crossings {
2123            assert!(hit.gap < 1e-9);
2124            assert!(hit.point.y.abs() < 1e-9);
2125            let on_wave = wave.point_at(hit.on_a, T).unwrap();
2126            assert!(on_wave.is_equal(hit.point, T));
2127        }
2128    }
2129
2130    /// One circle written twice in the plane, a quarter turn apart and
2131    /// wound against each other: the overlap's ranges correspond.
2132    #[test]
2133    fn one_planar_circle_written_twice_states_the_correspondence() {
2134        let a = circle2(Point2::new(1.0, 2.0), 3.0);
2135        let quarter = Frame2::new(
2136            Point2::new(1.0, 2.0),
2137            Direction2::new(Vector2::new(0.0, 1.0), T).unwrap(),
2138        );
2139        let b: PlanarCurve = Circle2d::new(Circle2::new(quarter, 3.0, T).unwrap()).into();
2140        let flipped: PlanarCurve = ogeom_geom::Reversible::reversed(&b);
2141        for other in [b, flipped] {
2142            let found = intersect_curves_2d(&a, &other, CurveCurveOptions::default(), T).unwrap();
2143            assert_eq!(found.overlaps.len(), 1);
2144            let overlap = found.overlaps[0];
2145            for i in 0..=8 {
2146                let t = f64::from(i) / 8.0;
2147                let ta = (overlap.on_a.1 - overlap.on_a.0).mul_add(t, overlap.on_a.0);
2148                let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
2149                let pa = a.point_at(ta, T).unwrap();
2150                let pb = other
2151                    .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
2152                    .unwrap();
2153                assert!(pa.distance(pb) < 1e-9, "at {t}: {pa:?} against {pb:?}");
2154            }
2155        }
2156    }
2157
2158    /// Collinear segments meeting end to end share their end point, in the
2159    /// plane and in space, as a perpendicular pair meeting there does.
2160    #[test]
2161    fn collinear_segments_meeting_end_to_end_share_a_point() {
2162        let a = line2(Point2::new(0.0, 0.0), Point2::new(5.0, 0.0));
2163        let b = line2(Point2::new(5.0, 0.0), Point2::new(9.0, 0.0));
2164        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
2165        assert!(found.overlaps.is_empty());
2166        assert_eq!(found.crossings.len(), 1);
2167        assert!(found.crossings[0].point.distance(Point2::new(5.0, 0.0)) < 1e-12);
2168
2169        let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(0.0, 0.0, 5.0), T)
2170            .unwrap()
2171            .into();
2172        let b: Curve = LineCurve::segment(Point::new(0.0, 0.0, 5.0), Point::new(0.0, 0.0, 7.0), T)
2173            .unwrap()
2174            .into();
2175        let found = intersect_curves(&a, &b, CurveCurveOptions::default(), T).unwrap();
2176        assert!(found.overlaps.is_empty());
2177        assert_eq!(found.crossings.len(), 1);
2178        assert!(found.crossings[0].point.distance(Point::new(0.0, 0.0, 5.0)) < 1e-12);
2179    }
2180
2181    /// Two descriptions of one circle overlap over the whole turn, and the
2182    /// overlap's two ranges *correspond*: `on_b`'s ends are where `b` stands
2183    /// at `a`'s own ends. A caller carrying a split from one to the other
2184    /// (the boolean, pairing a hole's arcs against the disc that fills them)
2185    /// reads that correspondence and gets the same point back, whatever phase
2186    /// and winding the two were written with.
2187    #[test]
2188    fn one_circle_written_twice_states_the_correspondence_between_them() {
2189        use ogeom_math::{Direction, Vector};
2190        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
2191        // The same circle seen from underneath, started a third of a turn
2192        // round: opposite winding, arbitrary phase.
2193        let third = 2.0 * core::f64::consts::PI / 3.0;
2194        let flipped = Frame::new(
2195            Point::ORIGIN,
2196            -Direction::Z,
2197            Direction::new(Vector::new(third.cos(), third.sin(), 0.0), T).unwrap(),
2198            T,
2199        )
2200        .unwrap();
2201        let b: Curve = CircleCurve::new(Circle::new(flipped, 3.0, T).unwrap()).into();
2202        for pair in [(&a, &b), (&b, &a)] {
2203            let found = intersect_curves(pair.0, pair.1, CurveCurveOptions::default(), T).unwrap();
2204            assert!(
2205                found.crossings.is_empty(),
2206                "every point is a hit, so none is"
2207            );
2208            assert_eq!(found.overlaps.len(), 1);
2209            let overlap = &found.overlaps[0];
2210            let span = overlap.on_a.1 - overlap.on_a.0;
2211            for i in 0..=8 {
2212                let t = f64::from(i) / 8.0;
2213                let ta = span.mul_add(t, overlap.on_a.0);
2214                let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
2215                let pa = pair.0.point_at(ta, T).unwrap();
2216                let pb = pair
2217                    .1
2218                    .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
2219                    .unwrap();
2220                assert!(
2221                    pa.distance(pb) < 1e-9,
2222                    "at {t}: {pa:?} against {pb:?} (on_a {:?} on_b {:?})",
2223                    overlap.on_a,
2224                    overlap.on_b
2225                );
2226            }
2227        }
2228    }
2229
2230    #[test]
2231    fn space_curves_cross_within_a_gap_and_report_it() {
2232        // Two circles that would cross in a shared plane, with one lifted a
2233        // hair out of it: the crossings become passes with a real, small gap
2234        // that must be reported, not zeroed. (Not chain links: linked circles
2235        // never approach, since passing through each other's *disks* is what
2236        // linked means, and these radii hold the curves a constant two units
2237        // apart.)
2238        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2239        let lifted = Frame::new(
2240            Point::new(3.0, 0.0, 0.001),
2241            ogeom_math::Direction::Z,
2242            ogeom_math::Direction::X,
2243            T,
2244        )
2245        .unwrap();
2246        let b: Curve = CircleCurve::new(Circle::new(lifted, 2.0, T).unwrap()).into();
2247
2248        let options = CurveCurveOptions {
2249            gap: 1e-2,
2250            ..CurveCurveOptions::default()
2251        };
2252        let found = intersect_curves(&a, &b, options, T).unwrap();
2253        assert_eq!(found.crossings.len(), 2, "two near-crossings");
2254        for hit in &found.crossings {
2255            assert!(hit.gap > 1e-4, "the gap is real and must not be zeroed");
2256            assert!(hit.gap < 2e-3, "but small: {}", hit.gap);
2257        }
2258
2259        // Tighten the gap below the offset and the crossings vanish.
2260        let strict = CurveCurveOptions {
2261            gap: 1e-5,
2262            ..CurveCurveOptions::default()
2263        };
2264        assert!(intersect_curves(&a, &b, strict, T).unwrap().is_empty());
2265    }
2266
2267    #[test]
2268    fn skew_lines_in_space_miss_and_close_ones_meet() {
2269        let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2270            .unwrap()
2271            .into();
2272        let skew: Curve =
2273            LineCurve::segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0), T)
2274                .unwrap()
2275                .into();
2276        assert!(
2277            intersect_curves(&a, &skew, CurveCurveOptions::default(), T)
2278                .unwrap()
2279                .is_empty(),
2280            "a unit apart is not a crossing"
2281        );
2282
2283        let meeting: Curve =
2284            LineCurve::segment(Point::new(5.0, -5.0, 0.0), Point::new(5.0, 5.0, 0.0), T)
2285                .unwrap()
2286                .into();
2287        let found = intersect_curves(&a, &meeting, CurveCurveOptions::default(), T).unwrap();
2288        assert_eq!(found.crossings.len(), 1);
2289        assert!(
2290            found.crossings[0]
2291                .point
2292                .is_equal(Point::new(5.0, 0.0, 0.0), T)
2293        );
2294        assert!(found.crossings[0].gap < 1e-12);
2295
2296        // Collinear 3D lines overlap.
2297        let collinear: Curve =
2298            LineCurve::segment(Point::new(4.0, 0.0, 0.0), Point::new(20.0, 0.0, 0.0), T)
2299                .unwrap()
2300                .into();
2301        let shared = intersect_curves(&a, &collinear, CurveCurveOptions::default(), T).unwrap();
2302        assert_eq!(shared.overlaps.len(), 1);
2303    }
2304
2305    #[test]
2306    fn a_fitted_curve_tracing_an_arc_is_one_overlap_not_a_row_of_crossings() {
2307        // A spline fitted along a circle's arc sits within its fit budget
2308        // of the circle everywhere, and "crosses" it at every wobble. The
2309        // sampling path reports the stretch as one overlap and keeps no
2310        // crossing inside it. Read as crossings, a section tracing the arc
2311        // it was cut along would shatter into hundreds of pieces.
2312        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
2313        let points: Vec<Point> = (0..=40)
2314            .map(|i| {
2315                let a = 0.2 + 1.0 * f64::from(i) / 40.0;
2316                Point::new(4.0 * a.cos(), 4.0 * a.sin(), 0.0)
2317            })
2318            .collect();
2319        let fitted: Curve = ogeom_geom::fit::fit_points(&points, 3, 1e-7, T)
2320            .unwrap()
2321            .curve
2322            .into();
2323        let options = CurveCurveOptions {
2324            gap: 1e-5,
2325            ..CurveCurveOptions::default()
2326        };
2327        let found = intersect_curves(&fitted, &circle, options, T).unwrap();
2328        assert_eq!(found.overlaps.len(), 1, "one shared stretch: {found:?}");
2329        let (lo, hi) = found.overlaps[0].on_a;
2330        let (fa, fb) = fitted.domain();
2331        assert!(
2332            lo - fa < 1e-3 && fb - hi < 1e-3,
2333            "the whole fit runs along the circle"
2334        );
2335        assert!(
2336            found.crossings.is_empty(),
2337            "no crossing survives inside the overlap: {:?}",
2338            found.crossings
2339        );
2340    }
2341
2342    #[test]
2343    fn an_arc_ending_tangent_to_a_line_is_one_crossing_with_its_reach() {
2344        // A circle and its tangent line touch at one point, but the
2345        // stationarity conditions go flat along the touch and every seed
2346        // converges somewhere in a valley the width of the gap. One contact
2347        // comes back, at the touch, owning the valley's length as its reach.
2348        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
2349        let line: Curve =
2350            LineCurve::segment(Point::new(4.0, -3.0, 0.0), Point::new(4.0, 3.0, 0.0), T)
2351                .unwrap()
2352                .into();
2353        let options = CurveCurveOptions {
2354            gap: 1e-5,
2355            ..CurveCurveOptions::default()
2356        };
2357        let found = intersect_curves(&circle, &line, options, T).unwrap();
2358        assert_eq!(found.crossings.len(), 1, "one touch: {:?}", found.crossings);
2359        let touch = found.crossings[0];
2360        assert!(
2361            touch.point.distance(Point::new(4.0, 0.0, 0.0)) < 2e-2,
2362            "{touch:?}"
2363        );
2364        assert!(touch.reach < 5e-2, "the valley is short: {touch:?}");
2365        assert!(found.overlaps.is_empty());
2366    }
2367
2368    #[test]
2369    fn unusable_options_are_refused() {
2370        let a = line2(Point2::new(0.0, 0.0), Point2::new(1.0, 0.0));
2371        for options in [
2372            CurveCurveOptions {
2373                samples: 1,
2374                ..CurveCurveOptions::default()
2375            },
2376            CurveCurveOptions {
2377                gap: 0.0,
2378                ..CurveCurveOptions::default()
2379            },
2380            CurveCurveOptions {
2381                gap: f64::NAN,
2382                ..CurveCurveOptions::default()
2383            },
2384        ] {
2385            assert!(intersect_curves_2d(&a, &a.clone(), options, T).is_err());
2386        }
2387    }
2388
2389    /// Plane sections of one cylinder: a circle and two ellipses tilted
2390    /// different ways. Any two meet where their planes' common line pierces
2391    /// the cylinder, twice, and a circle lifted parallel to another meets
2392    /// it nowhere.
2393    #[test]
2394    fn circles_and_ellipses_in_different_planes_meet_where_the_planes_do() {
2395        use ogeom_geom::EllipseCurve;
2396        use ogeom_math::{Direction, Ellipse, Vector};
2397        let radius = 2.0;
2398        let tilted = |normal: Vector, major: Vector, lean: f64| -> Curve {
2399            let frame = Frame::new(
2400                Point::ORIGIN,
2401                Direction::new(normal, T).unwrap(),
2402                Direction::new(major, T).unwrap(),
2403                T,
2404            )
2405            .unwrap();
2406            EllipseCurve::new(Ellipse::new(frame, radius / lean.cos(), radius, T).unwrap()).into()
2407        };
2408        let (p, q) = (0.4_f64, 0.7_f64);
2409        let about_x = tilted(
2410            Vector::new(0.0, -p.sin(), p.cos()),
2411            Vector::new(0.0, p.cos(), p.sin()),
2412            p,
2413        );
2414        let about_y = tilted(
2415            Vector::new(-q.sin(), 0.0, q.cos()),
2416            Vector::new(q.cos(), 0.0, q.sin()),
2417            q,
2418        );
2419        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
2420        for (a, b) in [
2421            (&about_x, &about_y),
2422            (&about_y, &about_x),
2423            (&circle, &about_x),
2424            (&about_y, &circle),
2425        ] {
2426            let found = intersect_curves(a, b, CurveCurveOptions::default(), T).unwrap();
2427            assert_eq!(found.crossings.len(), 2, "{found:?}");
2428            for hit in &found.crossings {
2429                let on_a = a.point_at(hit.on_a, T).unwrap();
2430                let on_b = b.point_at(hit.on_b, T).unwrap();
2431                assert!(on_a.distance(on_b) < 1e-9, "{on_a:?} against {on_b:?}");
2432                assert!((on_a.x.hypot(on_a.y) - radius).abs() < 1e-9);
2433            }
2434        }
2435        let lifted = Frame::new(Point::new(0.0, 0.0, 1.0), Direction::Z, Direction::X, T).unwrap();
2436        let above: Curve = CircleCurve::new(Circle::new(lifted, radius, T).unwrap()).into();
2437        assert!(
2438            intersect_curves(&circle, &above, CurveCurveOptions::default(), T)
2439                .unwrap()
2440                .is_empty()
2441        );
2442    }
2443
2444    /// Two circles in planes a millionth of a radian apart, passing within
2445    /// the gap of each other where their shadows cross: the first crosses
2446    /// the second's plane a twentieth of a unit away from there, where the
2447    /// two are far apart. The near pass is found all the same.
2448    #[test]
2449    fn circles_in_all_but_one_plane_meet_where_they_pass() {
2450        use ogeom_math::{Direction, Vector};
2451        let flat: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2452        let lean = 1e-6;
2453        let normal = Direction::new(Vector::new(lean, 0.0, 1.0), T).unwrap();
2454        let centre = Point::new(1.0, 0.0, lean.mul_add(-0.5, 5e-8));
2455        let tilted_frame = Frame::new(
2456            centre,
2457            normal,
2458            Direction::new(Vector::new(1.0, 0.0, -lean), T).unwrap(),
2459            T,
2460        )
2461        .unwrap();
2462        let tilted: Curve = CircleCurve::new(Circle::new(tilted_frame, 2.0, T).unwrap()).into();
2463        let found = intersect_curves(&flat, &tilted, CurveCurveOptions::default(), T).unwrap();
2464        assert_eq!(found.crossings.len(), 2, "{found:?}");
2465        for hit in &found.crossings {
2466            assert!(hit.gap < 1e-7, "{hit:?}");
2467            let p = flat.point_at(hit.on_a, T).unwrap();
2468            assert!((p.x - 0.5).abs() < 1e-4, "{p:?}");
2469        }
2470    }
2471
2472    /// A line across an ellipse in its plane meets it twice, a line through
2473    /// a circle's plane meets it once where it pierces the circle, and a
2474    /// line through the plane inside the circle misses it. Either order of
2475    /// the pair answers the same, parameters swapped.
2476    #[test]
2477    fn lines_meet_circles_and_ellipses_in_closed_form() {
2478        use ogeom_geom::EllipseCurve;
2479        use ogeom_math::Ellipse;
2480        let ellipse: Curve =
2481            EllipseCurve::new(Ellipse::new(Frame::WORLD, 3.0, 2.0, T).unwrap()).into();
2482        let across: Curve =
2483            LineCurve::segment(Point::new(-5.0, 1.0, 0.0), Point::new(5.0, 1.0, 0.0), T)
2484                .unwrap()
2485                .into();
2486        for (a, b, line_first) in [(&ellipse, &across, false), (&across, &ellipse, true)] {
2487            let found = intersect_curves(a, b, CurveCurveOptions::default(), T).unwrap();
2488            assert_eq!(found.crossings.len(), 2, "{found:?}");
2489            for hit in &found.crossings {
2490                let p = a.point_at(hit.on_a, T).unwrap();
2491                let q = b.point_at(hit.on_b, T).unwrap();
2492                assert!(p.distance(q) < 1e-9, "{p:?} against {q:?}");
2493                let on_line = if line_first { p } else { q };
2494                assert!((on_line.y - 1.0).abs() < 1e-12);
2495            }
2496        }
2497        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2498        let through: Curve =
2499            LineCurve::segment(Point::new(2.0, 0.0, -1.0), Point::new(2.0, 0.0, 1.0), T)
2500                .unwrap()
2501                .into();
2502        let found = intersect_curves(&circle, &through, CurveCurveOptions::default(), T).unwrap();
2503        assert_eq!(found.crossings.len(), 1);
2504        assert!(found.crossings[0].on_a.abs() < 1e-9);
2505        assert!((found.crossings[0].on_b - 1.0).abs() < 1e-9);
2506        let inside: Curve =
2507            LineCurve::segment(Point::new(1.0, 0.0, -1.0), Point::new(1.0, 0.0, 1.0), T)
2508                .unwrap()
2509                .into();
2510        assert!(
2511            intersect_curves(&circle, &inside, CurveCurveOptions::default(), T)
2512                .unwrap()
2513                .is_empty()
2514        );
2515    }
2516
2517    /// Two trims of one circle share whatever stretch they share, across the
2518    /// seam or not, walked the same way or the other: every piece comes
2519    /// back inside both windows, and each end of it is the same point on
2520    /// both curves.
2521    #[test]
2522    fn arcs_of_one_circle_overlap_across_the_seam() {
2523        use ogeom_geom::{Curve3d as _, TrimmedCurve};
2524        let circle = |x: ogeom_math::Direction, z: ogeom_math::Direction| -> Curve {
2525            let frame = Frame::new(Point::new(10.0, 20.0, 30.0), z, x, T).unwrap();
2526            CircleCurve::new(Circle::new(frame, 50.0, T).unwrap()).into()
2527        };
2528        let trim = |c: &Curve, a: f64, b: f64| -> Curve {
2529            TrimmedCurve::new(c.clone(), a, b, T).unwrap().into()
2530        };
2531        let (x, y) = (ogeom_math::Direction::X, ogeom_math::Direction::Y);
2532        let (up, down) = (ogeom_math::Direction::Z, -ogeom_math::Direction::Z);
2533        let plain = circle(x, up);
2534        let turned = circle(y, up);
2535        let backwards = circle(x, down);
2536        // `a`'s share of its own length that `b` covers.
2537        for (a, b, share) in [
2538            (trim(&plain, 5.5, 7.0), trim(&plain, 0.2, 1.0), 0.517 / 1.5),
2539            (trim(&plain, 0.2, 1.0), trim(&plain, 5.5, 7.0), 0.517 / 0.8),
2540            (
2541                trim(&plain, 5.8, 6.8),
2542                trim(&backwards, 5.0, 6.2),
2543                0.4336 / 1.0,
2544            ),
2545            (trim(&plain, 5.0, 7.5), backwards.clone(), 1.0),
2546            (trim(&plain, 0.5, 6.0), trim(&turned, 4.0, 6.0), 1.218 / 5.5),
2547        ] {
2548            let found = intersect_curves(&a, &b, CurveCurveOptions::default(), T).unwrap();
2549            let (wa, wb) = (a.domain(), b.domain());
2550            let mut covered = 0.0;
2551            for o in &found.overlaps {
2552                for (t, w) in [
2553                    (o.on_a.0, wa),
2554                    (o.on_a.1, wa),
2555                    (o.on_b.0, wb),
2556                    (o.on_b.1, wb),
2557                ] {
2558                    assert!(t >= w.0 - 1e-9 && t <= w.1 + 1e-9, "{t} outside {w:?}");
2559                }
2560                for (ta, tb) in [(o.on_a.0, o.on_b.0), (o.on_a.1, o.on_b.1)] {
2561                    let gap = a
2562                        .point_at(ta, T)
2563                        .unwrap()
2564                        .distance(b.point_at(tb, T).unwrap());
2565                    assert!(gap < 1e-9, "ends {gap} apart");
2566                }
2567                covered += (o.on_a.1 - o.on_a.0).abs();
2568            }
2569            let want = share * (wa.1 - wa.0);
2570            assert!((covered - want).abs() < 2e-3, "{covered} against {want}");
2571        }
2572    }
2573
2574    /// A trimmed arc answers as its circle does inside its window: a line
2575    /// tangent to it, one crossing it twice a hair below the tangent, and a
2576    /// circle touching it from outside are all found.
2577    #[test]
2578    fn a_trimmed_arc_meets_tangents_as_its_circle_does() {
2579        use ogeom_geom::Trimmed2d;
2580        let options = CurveCurveOptions::default();
2581        let circle = circle2(Point2::new(0.0, 0.0), 10.0);
2582        let arc: PlanarCurve = Trimmed2d::new(circle, 0.3, 2.9, T).unwrap().into();
2583        let tangent = line2(Point2::new(-20.0, 10.0), Point2::new(20.0, 10.0));
2584        let found = intersect_curves_2d(&arc, &tangent, options, T).unwrap();
2585        assert_eq!(found.crossings.len(), 1);
2586        assert!(found.crossings[0].point.is_equal(Point2::new(0.0, 10.0), T));
2587        let grazing = line2(
2588            Point2::new(-20.0, 10.0 - 1e-4),
2589            Point2::new(20.0, 10.0 - 1e-4),
2590        );
2591        let found = intersect_curves_2d(&arc, &grazing, options, T).unwrap();
2592        assert_eq!(found.crossings.len(), 2);
2593        let beside: PlanarCurve =
2594            Trimmed2d::new(circle2(Point2::new(20.0, 0.0), 10.0), 2.0, 4.5, T)
2595                .unwrap()
2596                .into();
2597        let whole = circle2(Point2::new(0.0, 0.0), 10.0);
2598        let found = intersect_curves_2d(&whole, &beside, options, T).unwrap();
2599        assert_eq!(found.crossings.len(), 1);
2600        assert!(found.crossings[0].point.is_equal(Point2::new(10.0, 0.0), T));
2601        // Outside the window, nothing.
2602        let below = line2(Point2::new(-20.0, -10.0), Point2::new(20.0, -10.0));
2603        assert!(
2604            intersect_curves_2d(&arc, &below, options, T)
2605                .unwrap()
2606                .is_empty()
2607        );
2608    }
2609}