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(a, &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.
1693///
1694/// Newton damped on the stationarity residual can settle where that residual
1695/// is least without being zero: a fitted curve that all but stops along its
1696/// parameter, as a fit through a sharp turn can, holds every seed nearby at
1697/// such a point, short of a crossing beside it. Where the solve ends there
1698/// without converging, the approach is walked on by alternating feet, each
1699/// curve's point dropped onto the other, which never lets the gap grow, and
1700/// polished again from where the walk stops.
1701fn polish_3d(
1702    a: &Curve,
1703    b: &Curve,
1704    seed_a: f64,
1705    seed_b: f64,
1706    options: CurveCurveOptions,
1707    tol: Tolerances,
1708) -> Option<Crossing<Point>> {
1709    let (t, s, converged) = stationary_3d(a, b, seed_a, seed_b, tol)?;
1710    let mut found = crossing_at_3d(a, b, t, s, tol)?;
1711    if found.gap > options.gap && !converged {
1712        let (t, s) = alternating_feet_3d(a, b, t, s, found.gap, tol)?;
1713        let walked = crossing_at_3d(a, b, t, s, tol)?;
1714        let polished = stationary_3d(a, b, t, s, tol)
1715            .and_then(|(t, s, _)| crossing_at_3d(a, b, t, s, tol))
1716            .filter(|p| p.gap < walked.gap);
1717        found = polished.unwrap_or(walked);
1718    }
1719    (found.gap <= options.gap).then_some(found)
1720}
1721
1722/// The two curves' points at `t` and `s` as a crossing, with their gap.
1723fn crossing_at_3d(
1724    a: &Curve,
1725    b: &Curve,
1726    t: f64,
1727    s: f64,
1728    tol: Tolerances,
1729) -> Option<Crossing<Point>> {
1730    let pa = a.point_at(t, tol).ok()?;
1731    let pb = b.point_at(s, tol).ok()?;
1732    Some(Crossing {
1733        on_a: t,
1734        on_b: s,
1735        point: pa,
1736        gap: pa.distance(pb),
1737        reach: 0.0,
1738    })
1739}
1740
1741/// From `t` on the first curve and `s` on the second, `gap` apart, each
1742/// point's foot on the other curve in turn while the gap falls by more than
1743/// a hundredth of the confusion distance a round, at most sixty-four rounds.
1744/// A foot that would widen the gap is not taken, so the pair returned is
1745/// never further apart than the pair given.
1746fn alternating_feet_3d(
1747    a: &Curve,
1748    b: &Curve,
1749    mut t: f64,
1750    mut s: f64,
1751    mut gap: f64,
1752    tol: Tolerances,
1753) -> Option<(f64, f64)> {
1754    for _ in 0..64 {
1755        let before = gap;
1756        let pa = a.point_at(t, tol).ok()?;
1757        if let Some((next, d)) = foot_on_3d(b, pa, s, tol)
1758            && d < gap
1759        {
1760            (s, gap) = (next, d);
1761        }
1762        let pb = b.point_at(s, tol).ok()?;
1763        if let Some((next, d)) = foot_on_3d(a, pb, t, tol)
1764            && d < gap
1765        {
1766            (t, gap) = (next, d);
1767        }
1768        if before - gap <= tol.confusion() * 0.01 {
1769            break;
1770        }
1771    }
1772    Some((t, s))
1773}
1774
1775/// Newton on the stationarity system from a seed: the parameters it ends
1776/// at, clamped to each curve's domain, and whether the residual met its
1777/// tolerance there.
1778fn stationary_3d(
1779    a: &Curve,
1780    b: &Curve,
1781    seed_a: f64,
1782    seed_b: f64,
1783    tol: Tolerances,
1784) -> Option<(f64, f64, bool)> {
1785    // Two unknowns: the allocation-free solver, and each curve's point
1786    // read from the same derivative table as its derivatives.
1787    let system = |x: [f64; 2]| {
1788        let (t, s) = (clamp_3d(a, x[0]), clamp_3d(b, x[1]));
1789        let (Ok(da), Ok(db)) = (a.derivatives_at(t, 2, tol), b.derivatives_at(s, 2, tol)) else {
1790            // A zero here would read as a root; infinite, the damped step
1791            // backs off instead.
1792            return ([f64::INFINITY; 2], [[0.0; 2]; 2]);
1793        };
1794        let zero = ogeom_math::Vector::ZERO;
1795        let at = |d: &[ogeom_math::Vector], k: usize| d.get(k).copied().unwrap_or(zero);
1796        let (pa, d1a, d2a) = (at(&da, 0), at(&da, 1), at(&da, 2));
1797        let (pb, d1b, d2b) = (at(&db, 0), at(&db, 1), at(&db, 2));
1798        let gap = pa - pb;
1799        (
1800            [gap.dot(d1a), -gap.dot(d1b)],
1801            [
1802                [d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
1803                [-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
1804            ],
1805        )
1806    };
1807    let criteria = solve::Criteria {
1808        residual: tol.confusion() * 0.01,
1809        step: tol.parametric(),
1810        max_iterations: 40,
1811    };
1812    let ([t, s], _, convergence, _) =
1813        solve::newton_system_2(system, [seed_a, seed_b], criteria).ok()?;
1814    Some((
1815        clamp_3d(a, t),
1816        clamp_3d(b, s),
1817        convergence == solve::Convergence::Residual,
1818    ))
1819}
1820
1821// --- small helpers -----------------------------------------------------------
1822
1823fn clamp_2d(curve: &PlanarCurve, t: f64) -> f64 {
1824    let (lo, hi) = curve.domain();
1825    if curve.is_periodic() {
1826        let span = hi - lo;
1827        if span > 0.0 {
1828            return lo + (t - lo).rem_euclid(span);
1829        }
1830    }
1831    t.clamp(lo, hi)
1832}
1833
1834fn clamp_3d(curve: &Curve, t: f64) -> f64 {
1835    let (lo, hi) = curve.domain();
1836    if curve.is_periodic() {
1837        let span = hi - lo;
1838        if span > 0.0 {
1839            return lo + (t - lo).rem_euclid(span);
1840        }
1841    }
1842    t.clamp(lo, hi)
1843}
1844
1845/// Where two planar segments cross, as fractions along each.
1846fn segments_cross_2d(a: (Point2, Point2), b: (Point2, Point2)) -> Option<(f64, f64)> {
1847    let da = a.1 - a.0;
1848    let db = b.1 - b.0;
1849    let cross = da.cross(db);
1850    if cross.abs() <= f64::MIN_POSITIVE {
1851        return None;
1852    }
1853    let between = b.0 - a.0;
1854    let t = between.cross(db) / cross;
1855    let s = between.cross(da) / cross;
1856    if !(0.0..=1.0).contains(&t) || !(0.0..=1.0).contains(&s) {
1857        return None;
1858    }
1859    Some((t, s))
1860}
1861
1862/// The distance from `p` to the curve `b`, through its samples and a local
1863/// polish on the nearest segment's parameter span.
1864fn distance_to_curve_3d(b: &Curve, sb: &Sampled<Point>, p: Point, tol: Tolerances) -> f64 {
1865    let mut best = (0_usize, f64::INFINITY);
1866    for i in 1..sb.points.len() {
1867        let (q0, q1) = (sb.points[i - 1], sb.points[i]);
1868        let d = q1 - q0;
1869        let len2 = d.dot(d);
1870        let f = if len2 <= f64::MIN_POSITIVE {
1871            0.0
1872        } else {
1873            ((p - q0).dot(d) / len2).clamp(0.0, 1.0)
1874        };
1875        let dist = p.distance(q0 + d * f);
1876        if dist < best.1 {
1877            best = (i, dist);
1878        }
1879    }
1880    if best.0 == 0 {
1881        return best.1;
1882    }
1883    let (mut lo, mut hi) = (sb.parameters[best.0 - 1], sb.parameters[best.0]);
1884    let at = |t: f64| -> f64 { b.point_at(t, tol).map_or(f64::INFINITY, |q| q.distance(p)) };
1885    // Golden-section on the segment's span: the distance is unimodal there
1886    // at any sampling that resolved the curve at all.
1887    let phi = 0.5 * (3.0 - 5.0_f64.sqrt());
1888    let (mut x1, mut x2) = (lo + phi * (hi - lo), hi - phi * (hi - lo));
1889    let (mut f1, mut f2) = (at(x1), at(x2));
1890    for _ in 0..48 {
1891        if f1 < f2 {
1892            hi = x2;
1893            x2 = x1;
1894            f2 = f1;
1895            x1 = lo + phi * (hi - lo);
1896            f1 = at(x1);
1897        } else {
1898            lo = x1;
1899            x1 = x2;
1900            f1 = f2;
1901            x2 = hi - phi * (hi - lo);
1902            f2 = at(x2);
1903        }
1904    }
1905    f1.min(f2).min(best.1)
1906}
1907
1908/// How far from a crossing, along the first curve, the second curve stays
1909/// within `gap`: the larger of the two directions, in space.
1910fn valley_extent_3d(
1911    a: &Curve,
1912    b: &Curve,
1913    sb: &Sampled<Point>,
1914    crossing: &Crossing<Point>,
1915    gap: f64,
1916    tol: Tolerances,
1917) -> f64 {
1918    let (lo, hi) = a.domain();
1919    let span = hi - lo;
1920    if span <= 0.0 {
1921        return 0.0;
1922    }
1923    let mut extent = 0.0_f64;
1924    for direction in [-1.0, 1.0] {
1925        let inside = |t: f64| -> bool {
1926            if t < lo || t > hi {
1927                return false;
1928            }
1929            a.point_at(t, tol)
1930                .is_ok_and(|q| distance_to_curve_3d(b, sb, q, tol) <= gap)
1931        };
1932        let mut step = span * 1e-6;
1933        let mut last_in = crossing.on_a;
1934        let mut first_out: Option<f64> = None;
1935        while step <= span {
1936            let t = crossing.on_a + direction * step;
1937            if inside(t) {
1938                last_in = t;
1939                step *= 2.0;
1940            } else {
1941                first_out = Some(t);
1942                break;
1943            }
1944        }
1945        let edge = match first_out {
1946            Some(mut out) => {
1947                let mut r#in = last_in;
1948                for _ in 0..30 {
1949                    let mid = f64::midpoint(r#in, out);
1950                    if inside(mid) {
1951                        r#in = mid;
1952                    } else {
1953                        out = mid;
1954                    }
1955                }
1956                r#in
1957            }
1958            None => last_in,
1959        };
1960        if let Ok(q) = a.point_at(edge, tol) {
1961            extent = extent.max(q.distance(crossing.point));
1962        }
1963    }
1964    extent
1965}
1966
1967/// The closest approach of two spatial segments, as fractions and a distance.
1968fn segments_approach_3d(a: (Point, Point), b: (Point, Point)) -> (f64, f64, f64) {
1969    let da = a.1 - a.0;
1970    let db = b.1 - b.0;
1971    let between = a.0 - b.0;
1972    let (aa, bb, ab) = (da.dot(da), db.dot(db), da.dot(db));
1973    let (ad, bd) = (da.dot(between), db.dot(between));
1974    let denominator = ab.mul_add(-ab, aa * bb);
1975
1976    let (mut t, mut s) = if denominator.abs() <= f64::MIN_POSITIVE {
1977        (
1978            0.0,
1979            if bb > 0.0 {
1980                (bd / bb).clamp(0.0, 1.0)
1981            } else {
1982                0.0
1983            },
1984        )
1985    } else {
1986        (
1987            (ab.mul_add(bd, -(bb * ad)) / denominator).clamp(0.0, 1.0),
1988            (aa.mul_add(bd, -(ab * ad)) / denominator).clamp(0.0, 1.0),
1989        )
1990    };
1991    // One clamped end may pull the other; a single re-projection settles it.
1992    if bb > 0.0 {
1993        s = ((da.dot(between) + t * aa - 0.0).mul_add(0.0, db.dot(between + da * t)) / bb)
1994            .clamp(0.0, 1.0);
1995    }
1996    if aa > 0.0 {
1997        t = (da.dot(db * s - between) / aa).clamp(0.0, 1.0);
1998    }
1999    let pa = a.0 + da * t;
2000    let pb = b.0 + db * s;
2001    (t, s, pa.distance(pb))
2002}
2003
2004fn order(a: f64, b: f64) -> (f64, f64) {
2005    if a <= b { (a, b) } else { (b, a) }
2006}
2007
2008fn sort_crossings<P>(crossings: &mut [Crossing<P>]) {
2009    crossings.sort_by(|x, y| {
2010        x.on_a
2011            .partial_cmp(&y.on_a)
2012            .unwrap_or(core::cmp::Ordering::Equal)
2013    });
2014}
2015
2016fn push_unique_2d(crossings: &mut Vec<Crossing<Point2>>, found: Crossing<Point2>, tol: Tolerances) {
2017    let reach = tol.confusion() * 100.0;
2018    if crossings
2019        .iter()
2020        .any(|c| c.point.distance(found.point) <= reach)
2021    {
2022        return;
2023    }
2024    crossings.push(found);
2025}
2026
2027/// Keep `found` unless it is a crossing already held: one standing at the
2028/// same point with the first curve staying there between the two, which is
2029/// one root polished from two seeds. A curve passing the same point twice
2030/// (a section shaped as a figure eight, crossing an edge at its double
2031/// point) crosses there twice, once per passage, and both are kept. A
2032/// closed first curve is measured the short way round its seam.
2033fn push_unique_3d(
2034    a: &Curve,
2035    crossings: &mut Vec<Crossing<Point>>,
2036    found: Crossing<Point>,
2037    tol: Tolerances,
2038) {
2039    let reach = tol.confusion() * 100.0;
2040    let (lo, hi) = a.domain();
2041    let period = hi - lo;
2042    let closed = a.is_periodic()
2043        || (period.is_finite()
2044            && a.point_at(lo, tol)
2045                .ok()
2046                .zip(a.point_at(hi, tol).ok())
2047                .is_some_and(|(p, q)| p.distance(q) <= reach));
2048    let same = |c: &Crossing<Point>| {
2049        if c.point.distance(found.point) > reach {
2050            return false;
2051        }
2052        let mut mid = f64::midpoint(c.on_a, found.on_a);
2053        if closed && (c.on_a - found.on_a).abs() > period * 0.5 {
2054            mid += period * 0.5;
2055            if mid > hi {
2056                mid -= period;
2057            }
2058        }
2059        a.point_at(mid, tol)
2060            .is_ok_and(|p| p.distance(found.point) <= reach)
2061    };
2062    if crossings.iter().any(same) {
2063        return;
2064    }
2065    crossings.push(found);
2066}
2067
2068#[cfg(test)]
2069#[allow(clippy::unwrap_used)]
2070mod tests {
2071    use super::*;
2072    use ogeom_geom::{BSpline2d, Circle2d, CircleCurve, Line2d, LineCurve};
2073    use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, KnotVector, Vector2};
2074
2075    const T: Tolerances = Tolerances::millimetres();
2076
2077    fn line2(from: Point2, to: Point2) -> PlanarCurve {
2078        Line2d::segment(from, to, T).unwrap().into()
2079    }
2080
2081    fn circle2(centre: Point2, radius: f64) -> PlanarCurve {
2082        Circle2d::new(
2083            Circle2::new(
2084                Frame2::new(centre, Direction2::new(Vector2::new(1.0, 0.0), T).unwrap()),
2085                radius,
2086                T,
2087            )
2088            .unwrap(),
2089        )
2090        .into()
2091    }
2092
2093    #[test]
2094    fn two_lines_cross_where_algebra_says() {
2095        let a = line2(Point2::new(0.0, 0.0), Point2::new(4.0, 4.0));
2096        let b = line2(Point2::new(0.0, 4.0), Point2::new(4.0, 0.0));
2097        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
2098        assert_eq!(found.crossings.len(), 1);
2099        let hit = &found.crossings[0];
2100        assert!(hit.point.is_equal(Point2::new(2.0, 2.0), T));
2101        // Parameters are arc length on a segment.
2102        approx::assert_relative_eq!(hit.on_a, 8.0_f64.sqrt(), epsilon = 1e-9);
2103
2104        // Segments that would cross beyond their ends do not.
2105        let short = line2(Point2::new(0.0, 4.0), Point2::new(1.0, 3.0));
2106        assert!(
2107            intersect_curves_2d(&a, &short, CurveCurveOptions::default(), T)
2108                .unwrap()
2109                .is_empty()
2110        );
2111    }
2112
2113    #[test]
2114    fn collinear_lines_overlap_rather_than_crossing_everywhere() {
2115        let a = line2(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0));
2116        let b = line2(Point2::new(4.0, 0.0), Point2::new(20.0, 0.0));
2117        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
2118        assert!(found.crossings.is_empty());
2119        assert_eq!(found.overlaps.len(), 1);
2120        let overlap = &found.overlaps[0];
2121        approx::assert_relative_eq!(overlap.on_a.0, 4.0, epsilon = 1e-9);
2122        approx::assert_relative_eq!(overlap.on_a.1, 10.0, epsilon = 1e-9);
2123        approx::assert_relative_eq!(overlap.on_b.0, 0.0, epsilon = 1e-9);
2124        approx::assert_relative_eq!(overlap.on_b.1, 6.0, epsilon = 1e-9);
2125
2126        // Parallel but apart: nothing.
2127        let above = line2(Point2::new(0.0, 1.0), Point2::new(10.0, 1.0));
2128        assert!(
2129            intersect_curves_2d(&a, &above, CurveCurveOptions::default(), T)
2130                .unwrap()
2131                .is_empty()
2132        );
2133    }
2134
2135    #[test]
2136    fn a_line_meets_a_circle_in_two_points_one_or_none() {
2137        let circle = circle2(Point2::new(0.0, 0.0), 2.0);
2138        let through = line2(Point2::new(-5.0, 0.0), Point2::new(5.0, 0.0));
2139        let found =
2140            intersect_curves_2d(&through, &circle, CurveCurveOptions::default(), T).unwrap();
2141        assert_eq!(found.crossings.len(), 2);
2142        for hit in &found.crossings {
2143            approx::assert_relative_eq!(
2144                hit.point.distance(Point2::new(0.0, 0.0)),
2145                2.0,
2146                epsilon = 1e-9
2147            );
2148            // The circle parameter really evaluates to the crossing point.
2149            let PlanarCurve::Circle(_) = &circle else {
2150                unreachable!()
2151            };
2152            let on_circle = circle.point_at(hit.on_b, T).unwrap();
2153            assert!(on_circle.is_equal(hit.point, T));
2154        }
2155
2156        let tangent = line2(Point2::new(-5.0, 2.0), Point2::new(5.0, 2.0));
2157        assert_eq!(
2158            intersect_curves_2d(&tangent, &circle, CurveCurveOptions::default(), T)
2159                .unwrap()
2160                .crossings
2161                .len(),
2162            1
2163        );
2164        let missing = line2(Point2::new(-5.0, 3.0), Point2::new(5.0, 3.0));
2165        assert!(
2166            intersect_curves_2d(&missing, &circle, CurveCurveOptions::default(), T)
2167                .unwrap()
2168                .is_empty()
2169        );
2170    }
2171
2172    #[test]
2173    fn two_circles_cross_touch_coincide_or_miss() {
2174        let a = circle2(Point2::new(0.0, 0.0), 2.0);
2175
2176        let crossing = circle2(Point2::new(3.0, 0.0), 2.0);
2177        let found = intersect_curves_2d(&a, &crossing, CurveCurveOptions::default(), T).unwrap();
2178        assert_eq!(found.crossings.len(), 2);
2179        for hit in &found.crossings {
2180            let on_a = a.point_at(hit.on_a, T).unwrap();
2181            let on_b = crossing.point_at(hit.on_b, T).unwrap();
2182            assert!(on_a.is_equal(hit.point, T));
2183            assert!(on_b.is_equal(hit.point, T));
2184        }
2185
2186        let touching = circle2(Point2::new(4.0, 0.0), 2.0);
2187        assert_eq!(
2188            intersect_curves_2d(&a, &touching, CurveCurveOptions::default(), T)
2189                .unwrap()
2190                .crossings
2191                .len(),
2192            1
2193        );
2194
2195        let same = circle2(Point2::new(0.0, 0.0), 2.0);
2196        let coincident = intersect_curves_2d(&a, &same, CurveCurveOptions::default(), T).unwrap();
2197        assert!(coincident.crossings.is_empty());
2198        assert_eq!(coincident.overlaps.len(), 1);
2199
2200        let apart = circle2(Point2::new(10.0, 0.0), 2.0);
2201        assert!(
2202            intersect_curves_2d(&a, &apart, CurveCurveOptions::default(), T)
2203                .unwrap()
2204                .is_empty()
2205        );
2206    }
2207
2208    #[test]
2209    fn the_general_path_handles_what_has_no_closed_form() {
2210        // A spline sine-ish wave against a line: three crossings, found by
2211        // sampling and polished by Newton to rounding.
2212        let wave: PlanarCurve = BSpline2d::new(
2213            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2214            vec![
2215                Point2::new(0.0, -1.0),
2216                Point2::new(1.0, 3.0),
2217                Point2::new(2.0, -3.0),
2218                Point2::new(3.0, 3.0),
2219                Point2::new(4.0, -1.0),
2220            ],
2221            T,
2222        )
2223        .unwrap()
2224        .into();
2225        let axis = line2(Point2::new(-1.0, 0.0), Point2::new(5.0, 0.0));
2226        let found = intersect_curves_2d(&wave, &axis, CurveCurveOptions::default(), T).unwrap();
2227        assert_eq!(found.crossings.len(), 3, "a wave crosses its axis thrice");
2228        for hit in &found.crossings {
2229            assert!(hit.gap < 1e-9);
2230            assert!(hit.point.y.abs() < 1e-9);
2231            let on_wave = wave.point_at(hit.on_a, T).unwrap();
2232            assert!(on_wave.is_equal(hit.point, T));
2233        }
2234    }
2235
2236    /// One circle written twice in the plane, a quarter turn apart and
2237    /// wound against each other: the overlap's ranges correspond.
2238    #[test]
2239    fn one_planar_circle_written_twice_states_the_correspondence() {
2240        let a = circle2(Point2::new(1.0, 2.0), 3.0);
2241        let quarter = Frame2::new(
2242            Point2::new(1.0, 2.0),
2243            Direction2::new(Vector2::new(0.0, 1.0), T).unwrap(),
2244        );
2245        let b: PlanarCurve = Circle2d::new(Circle2::new(quarter, 3.0, T).unwrap()).into();
2246        let flipped: PlanarCurve = ogeom_geom::Reversible::reversed(&b);
2247        for other in [b, flipped] {
2248            let found = intersect_curves_2d(&a, &other, CurveCurveOptions::default(), T).unwrap();
2249            assert_eq!(found.overlaps.len(), 1);
2250            let overlap = found.overlaps[0];
2251            for i in 0..=8 {
2252                let t = f64::from(i) / 8.0;
2253                let ta = (overlap.on_a.1 - overlap.on_a.0).mul_add(t, overlap.on_a.0);
2254                let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
2255                let pa = a.point_at(ta, T).unwrap();
2256                let pb = other
2257                    .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
2258                    .unwrap();
2259                assert!(pa.distance(pb) < 1e-9, "at {t}: {pa:?} against {pb:?}");
2260            }
2261        }
2262    }
2263
2264    /// Collinear segments meeting end to end share their end point, in the
2265    /// plane and in space, as a perpendicular pair meeting there does.
2266    #[test]
2267    fn collinear_segments_meeting_end_to_end_share_a_point() {
2268        let a = line2(Point2::new(0.0, 0.0), Point2::new(5.0, 0.0));
2269        let b = line2(Point2::new(5.0, 0.0), Point2::new(9.0, 0.0));
2270        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
2271        assert!(found.overlaps.is_empty());
2272        assert_eq!(found.crossings.len(), 1);
2273        assert!(found.crossings[0].point.distance(Point2::new(5.0, 0.0)) < 1e-12);
2274
2275        let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(0.0, 0.0, 5.0), T)
2276            .unwrap()
2277            .into();
2278        let b: Curve = LineCurve::segment(Point::new(0.0, 0.0, 5.0), Point::new(0.0, 0.0, 7.0), T)
2279            .unwrap()
2280            .into();
2281        let found = intersect_curves(&a, &b, CurveCurveOptions::default(), T).unwrap();
2282        assert!(found.overlaps.is_empty());
2283        assert_eq!(found.crossings.len(), 1);
2284        assert!(found.crossings[0].point.distance(Point::new(0.0, 0.0, 5.0)) < 1e-12);
2285    }
2286
2287    /// Two descriptions of one circle overlap over the whole turn, and the
2288    /// overlap's two ranges *correspond*: `on_b`'s ends are where `b` stands
2289    /// at `a`'s own ends. A caller carrying a split from one to the other
2290    /// (the boolean, pairing a hole's arcs against the disc that fills them)
2291    /// reads that correspondence and gets the same point back, whatever phase
2292    /// and winding the two were written with.
2293    #[test]
2294    fn one_circle_written_twice_states_the_correspondence_between_them() {
2295        use ogeom_math::{Direction, Vector};
2296        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
2297        // The same circle seen from underneath, started a third of a turn
2298        // round: opposite winding, arbitrary phase.
2299        let third = 2.0 * core::f64::consts::PI / 3.0;
2300        let flipped = Frame::new(
2301            Point::ORIGIN,
2302            -Direction::Z,
2303            Direction::new(Vector::new(third.cos(), third.sin(), 0.0), T).unwrap(),
2304            T,
2305        )
2306        .unwrap();
2307        let b: Curve = CircleCurve::new(Circle::new(flipped, 3.0, T).unwrap()).into();
2308        for pair in [(&a, &b), (&b, &a)] {
2309            let found = intersect_curves(pair.0, pair.1, CurveCurveOptions::default(), T).unwrap();
2310            assert!(
2311                found.crossings.is_empty(),
2312                "every point is a hit, so none is"
2313            );
2314            assert_eq!(found.overlaps.len(), 1);
2315            let overlap = &found.overlaps[0];
2316            let span = overlap.on_a.1 - overlap.on_a.0;
2317            for i in 0..=8 {
2318                let t = f64::from(i) / 8.0;
2319                let ta = span.mul_add(t, overlap.on_a.0);
2320                let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
2321                let pa = pair.0.point_at(ta, T).unwrap();
2322                let pb = pair
2323                    .1
2324                    .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
2325                    .unwrap();
2326                assert!(
2327                    pa.distance(pb) < 1e-9,
2328                    "at {t}: {pa:?} against {pb:?} (on_a {:?} on_b {:?})",
2329                    overlap.on_a,
2330                    overlap.on_b
2331                );
2332            }
2333        }
2334    }
2335
2336    #[test]
2337    fn space_curves_cross_within_a_gap_and_report_it() {
2338        // Two circles that would cross in a shared plane, with one lifted a
2339        // hair out of it: the crossings become passes with a real, small gap
2340        // that must be reported, not zeroed. (Not chain links: linked circles
2341        // never approach, since passing through each other's *disks* is what
2342        // linked means, and these radii hold the curves a constant two units
2343        // apart.)
2344        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2345        let lifted = Frame::new(
2346            Point::new(3.0, 0.0, 0.001),
2347            ogeom_math::Direction::Z,
2348            ogeom_math::Direction::X,
2349            T,
2350        )
2351        .unwrap();
2352        let b: Curve = CircleCurve::new(Circle::new(lifted, 2.0, T).unwrap()).into();
2353
2354        let options = CurveCurveOptions {
2355            gap: 1e-2,
2356            ..CurveCurveOptions::default()
2357        };
2358        let found = intersect_curves(&a, &b, options, T).unwrap();
2359        assert_eq!(found.crossings.len(), 2, "two near-crossings");
2360        for hit in &found.crossings {
2361            assert!(hit.gap > 1e-4, "the gap is real and must not be zeroed");
2362            assert!(hit.gap < 2e-3, "but small: {}", hit.gap);
2363        }
2364
2365        // Tighten the gap below the offset and the crossings vanish.
2366        let strict = CurveCurveOptions {
2367            gap: 1e-5,
2368            ..CurveCurveOptions::default()
2369        };
2370        assert!(intersect_curves(&a, &b, strict, T).unwrap().is_empty());
2371    }
2372
2373    /// A figure eight passes its double point twice, and a line through
2374    /// that point is crossed once per passage, at parameters half a turn
2375    /// apart. The ring of controls is symmetric under a mirror that shifts
2376    /// the parameter by half a turn and under a point reflection, so the
2377    /// double point is the origin.
2378    #[test]
2379    fn a_line_through_a_figure_eight_s_double_point_is_crossed_twice() {
2380        let ring = [
2381            Point::new(2.0, 0.0, 0.0),
2382            Point::new(1.0, 1.0, 0.0),
2383            Point::new(-1.0, -1.0, 0.0),
2384            Point::new(-2.0, 0.0, 0.0),
2385            Point::new(-1.0, 1.0, 0.0),
2386            Point::new(1.0, -1.0, 0.0),
2387        ];
2388        let a: Curve = ogeom_geom::BSplineCurve::periodic(&ring, 3, T)
2389            .unwrap()
2390            .into();
2391        let b: Curve = LineCurve::segment(Point::new(0.0, 0.0, -1.0), Point::new(0.0, 0.0, 1.0), T)
2392            .unwrap()
2393            .into();
2394        let found = intersect_curves(&a, &b, CurveCurveOptions::default(), T).unwrap();
2395        assert_eq!(found.crossings.len(), 2, "{:?}", found.crossings);
2396        let (lo, hi) = a.domain();
2397        let apart = (found.crossings[0].on_a - found.crossings[1].on_a).abs();
2398        assert!(
2399            (apart - (hi - lo) / 2.0).abs() < 1e-6,
2400            "half a turn apart: {apart}"
2401        );
2402        for hit in &found.crossings {
2403            assert!(hit.point.distance(Point::ORIGIN) < 1e-9, "{:?}", hit.point);
2404        }
2405    }
2406
2407    #[test]
2408    fn skew_lines_in_space_miss_and_close_ones_meet() {
2409        let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2410            .unwrap()
2411            .into();
2412        let skew: Curve =
2413            LineCurve::segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0), T)
2414                .unwrap()
2415                .into();
2416        assert!(
2417            intersect_curves(&a, &skew, CurveCurveOptions::default(), T)
2418                .unwrap()
2419                .is_empty(),
2420            "a unit apart is not a crossing"
2421        );
2422
2423        let meeting: Curve =
2424            LineCurve::segment(Point::new(5.0, -5.0, 0.0), Point::new(5.0, 5.0, 0.0), T)
2425                .unwrap()
2426                .into();
2427        let found = intersect_curves(&a, &meeting, CurveCurveOptions::default(), T).unwrap();
2428        assert_eq!(found.crossings.len(), 1);
2429        assert!(
2430            found.crossings[0]
2431                .point
2432                .is_equal(Point::new(5.0, 0.0, 0.0), T)
2433        );
2434        assert!(found.crossings[0].gap < 1e-12);
2435
2436        // Collinear 3D lines overlap.
2437        let collinear: Curve =
2438            LineCurve::segment(Point::new(4.0, 0.0, 0.0), Point::new(20.0, 0.0, 0.0), T)
2439                .unwrap()
2440                .into();
2441        let shared = intersect_curves(&a, &collinear, CurveCurveOptions::default(), T).unwrap();
2442        assert_eq!(shared.overlaps.len(), 1);
2443    }
2444
2445    #[test]
2446    fn a_fitted_curve_tracing_an_arc_is_one_overlap_not_a_row_of_crossings() {
2447        // A spline fitted along a circle's arc sits within its fit budget
2448        // of the circle everywhere, and "crosses" it at every wobble. The
2449        // sampling path reports the stretch as one overlap and keeps no
2450        // crossing inside it. Read as crossings, a section tracing the arc
2451        // it was cut along would shatter into hundreds of pieces.
2452        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
2453        let points: Vec<Point> = (0..=40)
2454            .map(|i| {
2455                let a = 0.2 + 1.0 * f64::from(i) / 40.0;
2456                Point::new(4.0 * a.cos(), 4.0 * a.sin(), 0.0)
2457            })
2458            .collect();
2459        let fitted: Curve = ogeom_geom::fit::fit_points(&points, 3, 1e-7, T)
2460            .unwrap()
2461            .curve
2462            .into();
2463        let options = CurveCurveOptions {
2464            gap: 1e-5,
2465            ..CurveCurveOptions::default()
2466        };
2467        let found = intersect_curves(&fitted, &circle, options, T).unwrap();
2468        assert_eq!(found.overlaps.len(), 1, "one shared stretch: {found:?}");
2469        let (lo, hi) = found.overlaps[0].on_a;
2470        let (fa, fb) = fitted.domain();
2471        assert!(
2472            lo - fa < 1e-3 && fb - hi < 1e-3,
2473            "the whole fit runs along the circle"
2474        );
2475        assert!(
2476            found.crossings.is_empty(),
2477            "no crossing survives inside the overlap: {:?}",
2478            found.crossings
2479        );
2480    }
2481
2482    #[test]
2483    fn an_arc_ending_tangent_to_a_line_is_one_crossing_with_its_reach() {
2484        // A circle and its tangent line touch at one point, but the
2485        // stationarity conditions go flat along the touch and every seed
2486        // converges somewhere in a valley the width of the gap. One contact
2487        // comes back, at the touch, owning the valley's length as its reach.
2488        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
2489        let line: Curve =
2490            LineCurve::segment(Point::new(4.0, -3.0, 0.0), Point::new(4.0, 3.0, 0.0), T)
2491                .unwrap()
2492                .into();
2493        let options = CurveCurveOptions {
2494            gap: 1e-5,
2495            ..CurveCurveOptions::default()
2496        };
2497        let found = intersect_curves(&circle, &line, options, T).unwrap();
2498        assert_eq!(found.crossings.len(), 1, "one touch: {:?}", found.crossings);
2499        let touch = found.crossings[0];
2500        assert!(
2501            touch.point.distance(Point::new(4.0, 0.0, 0.0)) < 2e-2,
2502            "{touch:?}"
2503        );
2504        assert!(touch.reach < 5e-2, "the valley is short: {touch:?}");
2505        assert!(found.overlaps.is_empty());
2506    }
2507
2508    #[test]
2509    fn unusable_options_are_refused() {
2510        let a = line2(Point2::new(0.0, 0.0), Point2::new(1.0, 0.0));
2511        for options in [
2512            CurveCurveOptions {
2513                samples: 1,
2514                ..CurveCurveOptions::default()
2515            },
2516            CurveCurveOptions {
2517                gap: 0.0,
2518                ..CurveCurveOptions::default()
2519            },
2520            CurveCurveOptions {
2521                gap: f64::NAN,
2522                ..CurveCurveOptions::default()
2523            },
2524        ] {
2525            assert!(intersect_curves_2d(&a, &a.clone(), options, T).is_err());
2526        }
2527    }
2528
2529    /// Plane sections of one cylinder: a circle and two ellipses tilted
2530    /// different ways. Any two meet where their planes' common line pierces
2531    /// the cylinder, twice, and a circle lifted parallel to another meets
2532    /// it nowhere.
2533    #[test]
2534    fn circles_and_ellipses_in_different_planes_meet_where_the_planes_do() {
2535        use ogeom_geom::EllipseCurve;
2536        use ogeom_math::{Direction, Ellipse, Vector};
2537        let radius = 2.0;
2538        let tilted = |normal: Vector, major: Vector, lean: f64| -> Curve {
2539            let frame = Frame::new(
2540                Point::ORIGIN,
2541                Direction::new(normal, T).unwrap(),
2542                Direction::new(major, T).unwrap(),
2543                T,
2544            )
2545            .unwrap();
2546            EllipseCurve::new(Ellipse::new(frame, radius / lean.cos(), radius, T).unwrap()).into()
2547        };
2548        let (p, q) = (0.4_f64, 0.7_f64);
2549        let about_x = tilted(
2550            Vector::new(0.0, -p.sin(), p.cos()),
2551            Vector::new(0.0, p.cos(), p.sin()),
2552            p,
2553        );
2554        let about_y = tilted(
2555            Vector::new(-q.sin(), 0.0, q.cos()),
2556            Vector::new(q.cos(), 0.0, q.sin()),
2557            q,
2558        );
2559        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
2560        for (a, b) in [
2561            (&about_x, &about_y),
2562            (&about_y, &about_x),
2563            (&circle, &about_x),
2564            (&about_y, &circle),
2565        ] {
2566            let found = intersect_curves(a, b, CurveCurveOptions::default(), T).unwrap();
2567            assert_eq!(found.crossings.len(), 2, "{found:?}");
2568            for hit in &found.crossings {
2569                let on_a = a.point_at(hit.on_a, T).unwrap();
2570                let on_b = b.point_at(hit.on_b, T).unwrap();
2571                assert!(on_a.distance(on_b) < 1e-9, "{on_a:?} against {on_b:?}");
2572                assert!((on_a.x.hypot(on_a.y) - radius).abs() < 1e-9);
2573            }
2574        }
2575        let lifted = Frame::new(Point::new(0.0, 0.0, 1.0), Direction::Z, Direction::X, T).unwrap();
2576        let above: Curve = CircleCurve::new(Circle::new(lifted, radius, T).unwrap()).into();
2577        assert!(
2578            intersect_curves(&circle, &above, CurveCurveOptions::default(), T)
2579                .unwrap()
2580                .is_empty()
2581        );
2582    }
2583
2584    /// Two circles in planes a millionth of a radian apart, passing within
2585    /// the gap of each other where their shadows cross: the first crosses
2586    /// the second's plane a twentieth of a unit away from there, where the
2587    /// two are far apart. The near pass is found all the same.
2588    #[test]
2589    fn circles_in_all_but_one_plane_meet_where_they_pass() {
2590        use ogeom_math::{Direction, Vector};
2591        let flat: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2592        let lean = 1e-6;
2593        let normal = Direction::new(Vector::new(lean, 0.0, 1.0), T).unwrap();
2594        let centre = Point::new(1.0, 0.0, lean.mul_add(-0.5, 5e-8));
2595        let tilted_frame = Frame::new(
2596            centre,
2597            normal,
2598            Direction::new(Vector::new(1.0, 0.0, -lean), T).unwrap(),
2599            T,
2600        )
2601        .unwrap();
2602        let tilted: Curve = CircleCurve::new(Circle::new(tilted_frame, 2.0, T).unwrap()).into();
2603        let found = intersect_curves(&flat, &tilted, CurveCurveOptions::default(), T).unwrap();
2604        assert_eq!(found.crossings.len(), 2, "{found:?}");
2605        for hit in &found.crossings {
2606            assert!(hit.gap < 1e-7, "{hit:?}");
2607            let p = flat.point_at(hit.on_a, T).unwrap();
2608            assert!((p.x - 0.5).abs() < 1e-4, "{p:?}");
2609        }
2610    }
2611
2612    /// A line across an ellipse in its plane meets it twice, a line through
2613    /// a circle's plane meets it once where it pierces the circle, and a
2614    /// line through the plane inside the circle misses it. Either order of
2615    /// the pair answers the same, parameters swapped.
2616    #[test]
2617    fn lines_meet_circles_and_ellipses_in_closed_form() {
2618        use ogeom_geom::EllipseCurve;
2619        use ogeom_math::Ellipse;
2620        let ellipse: Curve =
2621            EllipseCurve::new(Ellipse::new(Frame::WORLD, 3.0, 2.0, T).unwrap()).into();
2622        let across: Curve =
2623            LineCurve::segment(Point::new(-5.0, 1.0, 0.0), Point::new(5.0, 1.0, 0.0), T)
2624                .unwrap()
2625                .into();
2626        for (a, b, line_first) in [(&ellipse, &across, false), (&across, &ellipse, true)] {
2627            let found = intersect_curves(a, b, CurveCurveOptions::default(), T).unwrap();
2628            assert_eq!(found.crossings.len(), 2, "{found:?}");
2629            for hit in &found.crossings {
2630                let p = a.point_at(hit.on_a, T).unwrap();
2631                let q = b.point_at(hit.on_b, T).unwrap();
2632                assert!(p.distance(q) < 1e-9, "{p:?} against {q:?}");
2633                let on_line = if line_first { p } else { q };
2634                assert!((on_line.y - 1.0).abs() < 1e-12);
2635            }
2636        }
2637        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2638        let through: Curve =
2639            LineCurve::segment(Point::new(2.0, 0.0, -1.0), Point::new(2.0, 0.0, 1.0), T)
2640                .unwrap()
2641                .into();
2642        let found = intersect_curves(&circle, &through, CurveCurveOptions::default(), T).unwrap();
2643        assert_eq!(found.crossings.len(), 1);
2644        assert!(found.crossings[0].on_a.abs() < 1e-9);
2645        assert!((found.crossings[0].on_b - 1.0).abs() < 1e-9);
2646        let inside: Curve =
2647            LineCurve::segment(Point::new(1.0, 0.0, -1.0), Point::new(1.0, 0.0, 1.0), T)
2648                .unwrap()
2649                .into();
2650        assert!(
2651            intersect_curves(&circle, &inside, CurveCurveOptions::default(), T)
2652                .unwrap()
2653                .is_empty()
2654        );
2655    }
2656
2657    /// Two trims of one circle share whatever stretch they share, across the
2658    /// seam or not, walked the same way or the other: every piece comes
2659    /// back inside both windows, and each end of it is the same point on
2660    /// both curves.
2661    #[test]
2662    fn arcs_of_one_circle_overlap_across_the_seam() {
2663        use ogeom_geom::{Curve3d as _, TrimmedCurve};
2664        let circle = |x: ogeom_math::Direction, z: ogeom_math::Direction| -> Curve {
2665            let frame = Frame::new(Point::new(10.0, 20.0, 30.0), z, x, T).unwrap();
2666            CircleCurve::new(Circle::new(frame, 50.0, T).unwrap()).into()
2667        };
2668        let trim = |c: &Curve, a: f64, b: f64| -> Curve {
2669            TrimmedCurve::new(c.clone(), a, b, T).unwrap().into()
2670        };
2671        let (x, y) = (ogeom_math::Direction::X, ogeom_math::Direction::Y);
2672        let (up, down) = (ogeom_math::Direction::Z, -ogeom_math::Direction::Z);
2673        let plain = circle(x, up);
2674        let turned = circle(y, up);
2675        let backwards = circle(x, down);
2676        // `a`'s share of its own length that `b` covers.
2677        for (a, b, share) in [
2678            (trim(&plain, 5.5, 7.0), trim(&plain, 0.2, 1.0), 0.517 / 1.5),
2679            (trim(&plain, 0.2, 1.0), trim(&plain, 5.5, 7.0), 0.517 / 0.8),
2680            (
2681                trim(&plain, 5.8, 6.8),
2682                trim(&backwards, 5.0, 6.2),
2683                0.4336 / 1.0,
2684            ),
2685            (trim(&plain, 5.0, 7.5), backwards.clone(), 1.0),
2686            (trim(&plain, 0.5, 6.0), trim(&turned, 4.0, 6.0), 1.218 / 5.5),
2687        ] {
2688            let found = intersect_curves(&a, &b, CurveCurveOptions::default(), T).unwrap();
2689            let (wa, wb) = (a.domain(), b.domain());
2690            let mut covered = 0.0;
2691            for o in &found.overlaps {
2692                for (t, w) in [
2693                    (o.on_a.0, wa),
2694                    (o.on_a.1, wa),
2695                    (o.on_b.0, wb),
2696                    (o.on_b.1, wb),
2697                ] {
2698                    assert!(t >= w.0 - 1e-9 && t <= w.1 + 1e-9, "{t} outside {w:?}");
2699                }
2700                for (ta, tb) in [(o.on_a.0, o.on_b.0), (o.on_a.1, o.on_b.1)] {
2701                    let gap = a
2702                        .point_at(ta, T)
2703                        .unwrap()
2704                        .distance(b.point_at(tb, T).unwrap());
2705                    assert!(gap < 1e-9, "ends {gap} apart");
2706                }
2707                covered += (o.on_a.1 - o.on_a.0).abs();
2708            }
2709            let want = share * (wa.1 - wa.0);
2710            assert!((covered - want).abs() < 2e-3, "{covered} against {want}");
2711        }
2712    }
2713
2714    /// A trimmed arc answers as its circle does inside its window: a line
2715    /// tangent to it, one crossing it twice a hair below the tangent, and a
2716    /// circle touching it from outside are all found.
2717    #[test]
2718    fn a_trimmed_arc_meets_tangents_as_its_circle_does() {
2719        use ogeom_geom::Trimmed2d;
2720        let options = CurveCurveOptions::default();
2721        let circle = circle2(Point2::new(0.0, 0.0), 10.0);
2722        let arc: PlanarCurve = Trimmed2d::new(circle, 0.3, 2.9, T).unwrap().into();
2723        let tangent = line2(Point2::new(-20.0, 10.0), Point2::new(20.0, 10.0));
2724        let found = intersect_curves_2d(&arc, &tangent, options, T).unwrap();
2725        assert_eq!(found.crossings.len(), 1);
2726        assert!(found.crossings[0].point.is_equal(Point2::new(0.0, 10.0), T));
2727        let grazing = line2(
2728            Point2::new(-20.0, 10.0 - 1e-4),
2729            Point2::new(20.0, 10.0 - 1e-4),
2730        );
2731        let found = intersect_curves_2d(&arc, &grazing, options, T).unwrap();
2732        assert_eq!(found.crossings.len(), 2);
2733        let beside: PlanarCurve =
2734            Trimmed2d::new(circle2(Point2::new(20.0, 0.0), 10.0), 2.0, 4.5, T)
2735                .unwrap()
2736                .into();
2737        let whole = circle2(Point2::new(0.0, 0.0), 10.0);
2738        let found = intersect_curves_2d(&whole, &beside, options, T).unwrap();
2739        assert_eq!(found.crossings.len(), 1);
2740        assert!(found.crossings[0].point.is_equal(Point2::new(10.0, 0.0), T));
2741        // Outside the window, nothing.
2742        let below = line2(Point2::new(-20.0, -10.0), Point2::new(20.0, -10.0));
2743        assert!(
2744            intersect_curves_2d(&arc, &below, options, T)
2745                .unwrap()
2746                .is_empty()
2747        );
2748    }
2749}