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 ยง8 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    match (a, b) {
146        (PlanarCurve::Line(x), PlanarCurve::Line(y)) => Ok(line_line_2d(x, y, tol)),
147        (PlanarCurve::Line(x), PlanarCurve::Circle(y)) => Ok(line_circle_2d(x, y, false, tol)),
148        (PlanarCurve::Circle(x), PlanarCurve::Line(y)) => Ok(line_circle_2d(y, x, true, tol)),
149        (PlanarCurve::Circle(x), PlanarCurve::Circle(y)) => Ok(circle_circle_2d(x, y, tol)),
150        _ => general_2d(a, b, options, tol),
151    }
152}
153
154/// Where two space curves pass within `options.gap` of each other.
155///
156/// # Errors
157///
158/// As [`intersect_curves_2d`].
159pub fn intersect_curves(
160    a: &Curve,
161    b: &Curve,
162    options: CurveCurveOptions,
163    tol: Tolerances,
164) -> OgeomResult<CurveIntersection<Point>> {
165    check(options)?;
166    let (basis_a, window_a) = through_trim(a);
167    let (basis_b, window_b) = through_trim(b);
168    if let Some(found) = same_curve_3d(basis_a, basis_b) {
169        return Ok(clipped_to_windows(
170            found,
171            (basis_a, window_a),
172            (basis_b, window_b),
173            tol,
174        ));
175    }
176    if let Some(found) = analytic_3d(basis_a, basis_b, options, tol) {
177        return Ok(clipped_to_windows(
178            found,
179            (basis_a, window_a),
180            (basis_b, window_b),
181            tol,
182        ));
183    }
184    general_3d(a, b, options, tol)
185}
186
187/// A curve seen through a trim: the curve the analytic path can answer for,
188/// and the window it is restricted to.
189///
190/// The trim shares its basis's parameterization, so the window is stated in
191/// the same numbers the analytic answer comes back in and clipping is an
192/// interval intersection rather than a change of variable. A *reversed* trim
193/// does renumber, so it is left to the sampling path rather than mis-read.
194fn through_trim(curve: &Curve) -> (&Curve, Option<(f64, f64)>) {
195    match curve {
196        Curve::Trimmed(t) if !t.is_reversed() => (t.basis(), Some(Curve3d::domain(&**t))),
197        other => (other, None),
198    }
199}
200
201/// The closed-form answers for a pair of space curves, or `None` where there
202/// is none and the sampling path is the honest route.
203fn analytic_3d(
204    a: &Curve,
205    b: &Curve,
206    options: CurveCurveOptions,
207    tol: Tolerances,
208) -> Option<CurveIntersection<Point>> {
209    match (a, b) {
210        (Curve::Line(x), Curve::Line(y)) => Some(line_line_3d(x, y, options, tol)),
211        (Curve::Circle(x), Curve::Circle(y)) => same_circle_3d(x, y, tol),
212        (Curve::Ellipse(x), Curve::Ellipse(y)) => same_ellipse_3d(x, y, tol),
213        _ => None,
214    }
215}
216
217/// Restrict an answer about two whole curves to the windows their trims
218/// actually cover.
219///
220/// Both parts matter. A crossing is kept only where *both* parameters fall
221/// inside their window, on a periodic basis after whichever whole turn
222/// brings them there. An overlap is an interval on each side tied by an
223/// affine correspondence, so it is clipped on one side, carried across, and
224/// clipped again, and what comes back is the stretch both trims really share.
225fn clipped_to_windows(
226    found: CurveIntersection<Point>,
227    a: (&Curve, Option<(f64, f64)>),
228    b: (&Curve, Option<(f64, f64)>),
229    tol: Tolerances,
230) -> CurveIntersection<Point> {
231    let (basis_a, window_a) = a;
232    let (basis_b, window_b) = b;
233    if window_a.is_none() && window_b.is_none() {
234        return found;
235    }
236    let period = |curve: &Curve| -> Option<f64> {
237        if curve.is_periodic() {
238            let (lo, hi) = curve.domain();
239            (hi > lo).then_some(hi - lo)
240        } else {
241            None
242        }
243    };
244    let (pa, pb) = (period(basis_a), period(basis_b));
245    let slack = tol.parametric();
246    let placed = |t: f64, window: Option<(f64, f64)>, period: Option<f64>| -> Option<f64> {
247        let Some((lo, hi)) = window else {
248            return Some(t);
249        };
250        for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
251            let shifted = period.map_or(t, |p| p.mul_add(k, t));
252            if shifted >= lo - slack && shifted <= hi + slack {
253                return Some(shifted);
254            }
255            if period.is_none() {
256                break;
257            }
258        }
259        None
260    };
261
262    let mut crossings = Vec::with_capacity(found.crossings.len());
263    for crossing in found.crossings {
264        let (Some(on_a), Some(on_b)) = (
265            placed(crossing.on_a, window_a, pa),
266            placed(crossing.on_b, window_b, pb),
267        ) else {
268            continue;
269        };
270        crossings.push(Crossing {
271            on_a,
272            on_b,
273            ..crossing
274        });
275    }
276
277    let mut overlaps = Vec::with_capacity(found.overlaps.len());
278    for overlap in found.overlaps {
279        let span_a = overlap.on_a.1 - overlap.on_a.0;
280        let span_b = overlap.on_b.1 - overlap.on_b.0;
281        if span_a.abs() <= f64::MIN_POSITIVE || span_b.abs() <= f64::MIN_POSITIVE {
282            continue;
283        }
284        let to_b = |t: f64| overlap.on_b.0 + span_b * (t - overlap.on_a.0) / span_a;
285        let to_a = |t: f64| overlap.on_a.0 + span_a * (t - overlap.on_b.0) / span_b;
286        let ordered = |r: (f64, f64)| if r.0 <= r.1 { r } else { (r.1, r.0) };
287        let mut kept = ordered(overlap.on_a);
288        // The other side's window, spoken in this side's parameter, and
289        // shifted by whole turns until it meets what is left.
290        if let Some(w) = window_b {
291            let (wlo, whi) = ordered((to_a(w.0), to_a(w.1)));
292            let shift = pa.unwrap_or(0.0);
293            let mut best: Option<(f64, f64)> = None;
294            for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
295                let candidate = (
296                    kept.0.max(shift.mul_add(k, wlo)),
297                    kept.1.min(shift.mul_add(k, whi)),
298                );
299                if candidate.1 - candidate.0 > best.map_or(0.0, |(lo, hi)| hi - lo) {
300                    best = Some(candidate);
301                }
302                if shift == 0.0 {
303                    break;
304                }
305            }
306            let Some(candidate) = best else { continue };
307            kept = candidate;
308        }
309        if let Some(w) = window_a {
310            let (wlo, whi) = ordered(w);
311            kept = (kept.0.max(wlo), kept.1.min(whi));
312        }
313        if kept.1 - kept.0 <= slack {
314            continue;
315        }
316        let (on_a, on_b) = if span_a >= 0.0 {
317            (kept, (to_b(kept.0), to_b(kept.1)))
318        } else {
319            ((kept.1, kept.0), (to_b(kept.1), to_b(kept.0)))
320        };
321        overlaps.push(Overlap { on_a, on_b });
322    }
323    CurveIntersection {
324        crossings,
325        overlaps,
326    }
327}
328
329/// Two circles tracing the same point set in space: the circle counterpart of
330/// collinear lines, and the one 3D circle pair the sampling path cannot
331/// answer: every sample is a hit, and "the crossings" do not exist. Distinct
332/// circles return `None` and fall through to the general machinery, which
333/// handles genuinely crossing pairs.
334fn same_circle_3d(
335    a: &ogeom_geom::CircleCurve,
336    b: &ogeom_geom::CircleCurve,
337    tol: Tolerances,
338) -> Option<CurveIntersection<Point>> {
339    let (ca, cb) = (a.circle(), b.circle());
340    if ca.centre().distance(cb.centre()) > tol.confusion() {
341        return None;
342    }
343    if (ca.radius() - cb.radius()).abs() > tol.confusion() {
344        return None;
345    }
346    // Parallel or antiparallel axes both trace the same set, at whatever
347    // phase and winding each was written with.
348    let (za, zb) = (ca.frame().z().vector(), cb.frame().z().vector());
349    if za.cross(zb).magnitude() > tol.angular() {
350        return None;
351    }
352    // The ranges are a *correspondence*, which is what an overlap means and
353    // what a caller carrying a split across the pair relies on: `on_b`'s ends
354    // are the parameters at which `b` stands where `a`'s own ends do. Phase
355    // comes from where `a` starts on `b`, winding from whether the two run
356    // the same way there, and a pair written with opposite windings runs
357    // `on_b` backwards, which is exactly the truth about them.
358    let (lo, hi) = Curve3d::domain(a);
359    let start = a.point_at(lo, tol).ok()?;
360    let local = cb.frame().to_local(start);
361    let angle = local.y.atan2(local.x);
362    let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
363    let along_a = a.d1_at(lo, tol).ok()?;
364    let along_b = b.d1_at(phase, tol).ok()?;
365    let winding: f64 = if along_a.dot(along_b) >= 0.0 {
366        1.0
367    } else {
368        -1.0
369    };
370    Some(CurveIntersection {
371        crossings: Vec::new(),
372        overlaps: vec![Overlap {
373            on_a: (lo, hi),
374            on_b: (phase, winding.mul_add(hi - lo, phase)),
375        }],
376    })
377}
378
379/// The *same description* twice: one curve object meeting itself, forward
380/// or reversed. A fitted seam reused as a wedge's apex ring is exactly this
381/// pair, and the sampling path (every sample a hit) cannot answer it, for
382/// the same reason it cannot answer coincident circles. Equality here is
383/// structural, so two independent fits of one path still fall through to
384/// the general machinery, which is the honest place for them.
385fn same_curve_3d(a: &Curve, b: &Curve) -> Option<CurveIntersection<Point>> {
386    let (lo, hi) = Curve3d::domain(a);
387    if a == b {
388        return Some(CurveIntersection {
389            crossings: Vec::new(),
390            overlaps: vec![Overlap {
391                on_a: (lo, hi),
392                on_b: (lo, hi),
393            }],
394        });
395    }
396    use ogeom_geom::Reversible as _;
397    if *a == b.clone().reversed() {
398        let (blo, bhi) = Curve3d::domain(b);
399        return Some(CurveIntersection {
400            crossings: Vec::new(),
401            overlaps: vec![Overlap {
402                on_a: (lo, hi),
403                on_b: (bhi, blo),
404            }],
405        });
406    }
407    None
408}
409
410/// Two ellipses tracing the same point set in space: the ellipse counterpart
411/// of [`same_circle_3d`], and just as invisible to the sampling path. Unlike
412/// a circle, an ellipse's natural parameter is pinned to its major axis, so
413/// the correspondence is affine only when the two `x` axes line up (parallel
414/// or antiparallel) as well as the planes and radii; anything else falls
415/// through to the general machinery.
416fn same_ellipse_3d(
417    a: &ogeom_geom::EllipseCurve,
418    b: &ogeom_geom::EllipseCurve,
419    tol: Tolerances,
420) -> Option<CurveIntersection<Point>> {
421    let (ea, eb) = (a.ellipse(), b.ellipse());
422    if ea.frame().origin().distance(eb.frame().origin()) > tol.confusion() {
423        return None;
424    }
425    if (ea.major_radius() - eb.major_radius()).abs() > tol.confusion()
426        || (ea.minor_radius() - eb.minor_radius()).abs() > tol.confusion()
427    {
428        return None;
429    }
430    let (za, zb) = (ea.frame().z().vector(), eb.frame().z().vector());
431    if za.cross(zb).magnitude() > tol.angular() {
432        return None;
433    }
434    let (xa, xb) = (ea.frame().x().vector(), eb.frame().x().vector());
435    if xa.cross(xb).magnitude() > tol.angular() {
436        return None;
437    }
438    // As for circles: phase from where `a` starts on `b`, winding from
439    // whether the two run the same way there, and the ranges come back as
440    // the correspondence an overlap means.
441    let (lo, hi) = Curve3d::domain(a);
442    let start = a.point_at(lo, tol).ok()?;
443    let angle = ogeom_math::elementary::ellipse_parameter(&eb, start, tol).ok()?;
444    let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
445    let along_a = a.d1_at(lo, tol).ok()?;
446    let along_b = b.d1_at(phase, tol).ok()?;
447    let winding: f64 = if along_a.dot(along_b) >= 0.0 {
448        1.0
449    } else {
450        -1.0
451    };
452    Some(CurveIntersection {
453        crossings: Vec::new(),
454        overlaps: vec![Overlap {
455            on_a: (lo, hi),
456            on_b: (phase, winding.mul_add(hi - lo, phase)),
457        }],
458    })
459}
460
461fn check(options: CurveCurveOptions) -> OgeomResult<()> {
462    if options.samples < 2 {
463        ogeom_bail!(Construction, "seeding needs at least two segments");
464    }
465    if !options.gap.is_finite() || options.gap <= 0.0 {
466        ogeom_bail!(Construction, "a gap of {} is not a distance", options.gap);
467    }
468    Ok(())
469}
470
471// --- analytic, planar --------------------------------------------------------
472
473fn line_line_2d(
474    a: &ogeom_geom::Line2d,
475    b: &ogeom_geom::Line2d,
476    tol: Tolerances,
477) -> CurveIntersection<Point2> {
478    let (oa, da) = (a.axis().location, a.axis().direction.vector());
479    let (ob, db) = (b.axis().location, b.axis().direction.vector());
480    let cross = da.cross(db);
481
482    if cross.abs() <= tol.angular() {
483        // Parallel. Collinear if one origin is on the other line.
484        let between = ob - oa;
485        if between.cross(da).abs() > tol.confusion() {
486            return CurveIntersection::empty();
487        }
488        // The shared stretch, as each line's own parameter range.
489        let (a_lo, a_hi) = a.domain();
490        let (b_lo, b_hi) = b.domain();
491        // Where b's range lands on a's parameter: t_a = (p - oa)ยทda.
492        let project = |p: Point2| (p - oa).dot(da);
493        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
494        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
495        if lo >= hi {
496            return CurveIntersection::empty();
497        }
498        // And back onto b.
499        let back = |t: f64| (oa + da * t - ob).dot(db);
500        return CurveIntersection {
501            crossings: Vec::new(),
502            overlaps: vec![Overlap {
503                on_a: (lo, hi),
504                // Paired end to end with `on_a`, not sorted: two lines written in
505                // opposite directions run `on_b` backwards, and a consumer
506                // carrying a stretch across by the correspondence (the
507                // boolean clipping a contact to the edge it runs along)
508                // reads a sorted pair as the reflected stretch.
509                on_b: (back(lo), back(hi)),
510            }],
511        };
512    }
513
514    let between = ob - oa;
515    let t = between.cross(db) / cross;
516    let s = between.cross(da) / cross;
517    let (a_lo, a_hi) = a.domain();
518    let (b_lo, b_hi) = b.domain();
519    if t < a_lo - tol.parametric()
520        || t > a_hi + tol.parametric()
521        || s < b_lo - tol.parametric()
522        || s > b_hi + tol.parametric()
523    {
524        return CurveIntersection::empty();
525    }
526    CurveIntersection {
527        crossings: vec![Crossing {
528            on_a: t,
529            on_b: s,
530            point: oa + da * t,
531            gap: 0.0,
532            reach: 0.0,
533        }],
534        overlaps: Vec::new(),
535    }
536}
537
538fn line_circle_2d(
539    line: &ogeom_geom::Line2d,
540    circle: &ogeom_geom::Circle2d,
541    swapped: bool,
542    tol: Tolerances,
543) -> CurveIntersection<Point2> {
544    let (o, d) = (line.axis().location, line.axis().direction.vector());
545    let c = circle.circle();
546    let centre = c.centre();
547    let radius = c.radius();
548
549    // Foot of the perpendicular from the centre onto the line.
550    let along = (centre - o).dot(d);
551    let foot = o + d * along;
552    let gap = foot.distance(centre);
553    if gap > radius + tol.confusion() {
554        return CurveIntersection::empty();
555    }
556    let half = radius.mul_add(radius, -(gap * gap)).max(0.0).sqrt();
557    let candidates = if half <= tol.confusion() {
558        vec![along]
559    } else {
560        vec![along - half, along + half]
561    };
562
563    let (l_lo, l_hi) = line.domain();
564    let mut crossings = Vec::new();
565    for t in candidates {
566        if t < l_lo - tol.parametric() || t > l_hi + tol.parametric() {
567            continue;
568        }
569        let p = o + d * t;
570        let Some(s) = circle_parameter(circle, p, tol) else {
571            continue;
572        };
573        let (on_a, on_b) = if swapped { (s, t) } else { (t, s) };
574        crossings.push(Crossing {
575            on_a,
576            on_b,
577            point: p,
578            gap: 0.0,
579            reach: 0.0,
580        });
581    }
582    sort_crossings(&mut crossings);
583    CurveIntersection {
584        crossings,
585        overlaps: Vec::new(),
586    }
587}
588
589fn circle_circle_2d(
590    a: &ogeom_geom::Circle2d,
591    b: &ogeom_geom::Circle2d,
592    tol: Tolerances,
593) -> CurveIntersection<Point2> {
594    let (ca, cb) = (a.circle(), b.circle());
595    let between = cb.centre() - ca.centre();
596    let distance = between.magnitude();
597    let (ra, rb) = (ca.radius(), cb.radius());
598
599    if distance <= tol.confusion() {
600        if (ra - rb).abs() <= tol.confusion() {
601            // The same circle: the overlap is both whole domains.
602            return CurveIntersection {
603                crossings: Vec::new(),
604                overlaps: vec![Overlap {
605                    on_a: a.domain(),
606                    on_b: b.domain(),
607                }],
608            };
609        }
610        return CurveIntersection::empty();
611    }
612    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
613        return CurveIntersection::empty();
614    }
615
616    // The radical line: where the two circles' equations agree.
617    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
618    let squared = ra.mul_add(ra, -(along * along));
619    let direction = between * (1.0 / distance);
620    let foot = ca.centre() + direction * along;
621    let mut crossings = Vec::new();
622    let mut push = |p: Point2| {
623        if let (Some(s), Some(t)) = (circle_parameter(a, p, tol), circle_parameter(b, p, tol)) {
624            crossings.push(Crossing {
625                on_a: s,
626                on_b: t,
627                point: p,
628                gap: 0.0,
629                reach: 0.0,
630            });
631        }
632    };
633    if squared <= tol.confusion() * tol.confusion() {
634        push(foot);
635    } else {
636        let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
637        push(foot + offset);
638        push(foot - offset);
639    }
640    sort_crossings(&mut crossings);
641    CurveIntersection {
642        crossings,
643        overlaps: Vec::new(),
644    }
645}
646
647/// The parameter at which a circle passes through a point on it.
648fn circle_parameter(curve: &ogeom_geom::Circle2d, p: Point2, tol: Tolerances) -> Option<f64> {
649    let c = curve.circle();
650    let local = p - c.centre();
651    let x = local.dot(c.frame().x().vector());
652    let y = local.dot(c.frame().y().vector());
653    let mut angle = y.atan2(x);
654    if curve.is_reversed() {
655        angle = -angle;
656    }
657    let angle = angle.rem_euclid(core::f64::consts::TAU);
658    let (lo, hi) = curve.domain();
659    // Fold into the arc's own range where the arc covers it.
660    if angle >= lo - tol.parametric() && angle <= hi + tol.parametric() {
661        return Some(angle.clamp(lo, hi));
662    }
663    let shifted = angle - core::f64::consts::TAU;
664    if shifted >= lo - tol.parametric() && shifted <= hi + tol.parametric() {
665        return Some(shifted.clamp(lo, hi));
666    }
667    None
668}
669
670// --- analytic, spatial -------------------------------------------------------
671
672fn line_line_3d(
673    a: &ogeom_geom::LineCurve,
674    b: &ogeom_geom::LineCurve,
675    options: CurveCurveOptions,
676    tol: Tolerances,
677) -> CurveIntersection<Point> {
678    let (oa, da) = (a.axis().location, a.axis().direction.vector());
679    let (ob, db) = (b.axis().location, b.axis().direction.vector());
680    let cross = da.cross(db);
681    let denominator = cross.square_magnitude();
682
683    if denominator <= tol.angular() * tol.angular() {
684        // Parallel: collinear overlap or nothing.
685        let between = ob - oa;
686        if between.cross(da).magnitude() > tol.confusion() {
687            return CurveIntersection::empty();
688        }
689        let (a_lo, a_hi) = a.domain();
690        let (b_lo, b_hi) = b.domain();
691        let project = |p: Point| (p - oa).dot(da);
692        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
693        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
694        if lo >= hi {
695            return CurveIntersection::empty();
696        }
697        let back = |t: f64| (oa + da * t - ob).dot(db);
698        return CurveIntersection {
699            crossings: Vec::new(),
700            overlaps: vec![Overlap {
701                on_a: (lo, hi),
702                // Paired end to end with `on_a`, not sorted: two lines written in
703                // opposite directions run `on_b` backwards, and a consumer
704                // carrying a stretch across by the correspondence (the
705                // boolean clipping a contact to the edge it runs along)
706                // reads a sorted pair as the reflected stretch.
707                on_b: (back(lo), back(hi)),
708            }],
709        };
710    }
711
712    // Closest approach of two skew lines, in closed form.
713    let between = ob - oa;
714    let t = between.cross(db).dot(cross) / denominator;
715    let s = between.cross(da).dot(cross) / denominator;
716    let pa = oa + da * t;
717    let pb = ob + db * s;
718    let gap = pa.distance(pb);
719    let (a_lo, a_hi) = a.domain();
720    let (b_lo, b_hi) = b.domain();
721    if gap > options.gap
722        || t < a_lo - tol.parametric()
723        || t > a_hi + tol.parametric()
724        || s < b_lo - tol.parametric()
725        || s > b_hi + tol.parametric()
726    {
727        return CurveIntersection::empty();
728    }
729    CurveIntersection {
730        crossings: vec![Crossing {
731            on_a: t,
732            on_b: s,
733            point: pa,
734            gap,
735            reach: 0.0,
736        }],
737        overlaps: Vec::new(),
738    }
739}
740
741// --- the general path --------------------------------------------------------
742
743/// Sampled segments of one curve, with the parameters they span.
744struct Sampled<P> {
745    points: Vec<P>,
746    parameters: Vec<f64>,
747}
748
749fn sample_2d(curve: &PlanarCurve, n: usize, tol: Tolerances) -> Sampled<Point2> {
750    let (lo, hi) = curve.domain();
751    let mut points = Vec::with_capacity(n + 1);
752    let mut parameters = Vec::with_capacity(n + 1);
753    for i in 0..=n {
754        #[allow(clippy::cast_precision_loss)]
755        let t = lo + (hi - lo) * i as f64 / n as f64;
756        if let Ok(p) = curve.point_at(t, tol) {
757            points.push(p);
758            parameters.push(t);
759        }
760    }
761    Sampled { points, parameters }
762}
763
764fn sample_3d(curve: &Curve, n: usize, tol: Tolerances) -> Sampled<Point> {
765    let (lo, hi) = curve.domain();
766    let mut points = Vec::with_capacity(n + 1);
767    let mut parameters = Vec::with_capacity(n + 1);
768    for i in 0..=n {
769        #[allow(clippy::cast_precision_loss)]
770        let t = lo + (hi - lo) * i as f64 / n as f64;
771        if let Ok(p) = curve.point_at(t, tol) {
772            points.push(p);
773            parameters.push(t);
774        }
775    }
776    Sampled { points, parameters }
777}
778
779fn general_2d(
780    a: &PlanarCurve,
781    b: &PlanarCurve,
782    options: CurveCurveOptions,
783    tol: Tolerances,
784) -> OgeomResult<CurveIntersection<Point2>> {
785    let sa = sample_2d(a, options.samples, tol);
786    let sb = sample_2d(b, options.samples, tol);
787
788    let mut crossings: Vec<Crossing<Point2>> = Vec::new();
789    for i in 1..sa.points.len() {
790        for j in 1..sb.points.len() {
791            let Some((ta, tb)) = segments_cross_2d(
792                (sa.points[i - 1], sa.points[i]),
793                (sb.points[j - 1], sb.points[j]),
794            ) else {
795                continue;
796            };
797            let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
798            let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
799            if let Some(found) = polish_2d(a, b, seed_a, seed_b, options, tol) {
800                push_unique_2d(&mut crossings, found, tol);
801            }
802        }
803    }
804    sort_crossings(&mut crossings);
805    Ok(CurveIntersection {
806        crossings,
807        overlaps: Vec::new(),
808    })
809}
810
811fn general_3d(
812    a: &Curve,
813    b: &Curve,
814    options: CurveCurveOptions,
815    tol: Tolerances,
816) -> OgeomResult<CurveIntersection<Point>> {
817    let sa = sample_3d(a, options.samples, tol);
818    let sb = sample_3d(b, options.samples, tol);
819
820    // Segment pairs whose closest approach is within reach seed the polish.
821    // The threshold is the sampling sag plus the acceptable gap: what could
822    // converge is seeded, what could not is skipped.
823    let mut reach = options.gap;
824    for s in [&sa, &sb] {
825        let longest = s
826            .points
827            .windows(2)
828            .map(|w| w[0].distance(w[1]))
829            .fold(0.0_f64, f64::max);
830        reach += longest;
831    }
832
833    let mut crossings: Vec<Crossing<Point>> = Vec::new();
834    for i in 1..sa.points.len() {
835        for j in 1..sb.points.len() {
836            let (ta, tb, gap) = segments_approach_3d(
837                (sa.points[i - 1], sa.points[i]),
838                (sb.points[j - 1], sb.points[j]),
839            );
840            if gap > reach {
841                continue;
842            }
843            let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
844            let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
845            if let Some(found) = polish_3d(a, b, seed_a, seed_b, options, tol) {
846                push_unique_3d(&mut crossings, found, tol);
847            }
848        }
849    }
850    sort_crossings(&mut crossings);
851
852    // A tangential contact is one crossing, however many the polish
853    // returns. Where two curves touch, the stationarity conditions go flat
854    // along the contact: every seed converges somewhere in a valley the
855    // width of the gap, and an arc ending on the line it is tangent to
856    // comes back as thirty crossings inside a micron or two. Consecutive
857    // crossings with the first curve staying within the gap of the second
858    // all the way between them are the same contact, and the nearest
859    // approach among them speaks for it.
860    if crossings.len() > 1 {
861        let mut merged: Vec<Crossing<Point>> = Vec::with_capacity(crossings.len());
862        let mut run_start: Option<Point> = None;
863        for c in crossings {
864            if let Some(last) = merged.last_mut()
865                && contact_between_3d(a, b, last, &c, options, tol)
866            {
867                let start = run_start.get_or_insert(last.point);
868                let reach = start.distance(c.point).max(last.reach);
869                if c.gap < last.gap {
870                    *last = c;
871                }
872                last.reach = reach;
873                continue;
874            }
875            run_start = None;
876            merged.push(c);
877        }
878        // A touch astride the first curve's period seam comes back as a
879        // crossing at each end of the parameter range; the two are one
880        // contact as well.
881        if merged.len() > 1 && a.is_periodic() {
882            let (lo, hi) = a.domain();
883            let (first, last) = (merged[0], merged[merged.len() - 1]);
884            let wrapped = Crossing {
885                on_a: first.on_a + (hi - lo),
886                ..first
887            };
888            if contact_between_3d(a, b, &last, &wrapped, options, tol) {
889                let reach = last
890                    .reach
891                    .max(first.reach)
892                    .max(last.point.distance(first.point));
893                let keep = if first.gap <= last.gap {
894                    0
895                } else {
896                    merged.len() - 1
897                };
898                merged[keep].reach = reach;
899                if keep == 0 {
900                    merged.pop();
901                } else {
902                    merged.remove(0);
903                }
904            }
905        }
906        crossings = merged;
907    }
908
909    // Stretches where the first curve stays within the gap of the second
910    // are shared support, not a row of crossings. A fitted section tracing
911    // the arc it was cut along wobbles about it by less than the gap and
912    // "crosses" it at every wobble; read as crossings, those shatter the
913    // curve into hundreds of pieces and pave the edge at each. So every
914    // sample of the first curve asks its foot on the second, a run of
915    // consecutive samples within the gap is an overlap with its ends
916    // bisected to parametric resolution, and the crossings inside it are
917    // the overlap's, not the caller's.
918    let overlaps = shared_support_3d(a, b, &sa, &sb, options, tol);
919    if !overlaps.is_empty() {
920        crossings.retain(|c| {
921            !overlaps.iter().any(|o| {
922                let (lo, hi) = order(o.on_a.0, o.on_a.1);
923                c.on_a >= lo - tol.parametric() && c.on_a <= hi + tol.parametric()
924            })
925        });
926    }
927    // Every surviving crossing owns the valley it sits in: how far along the
928    // first curve the second stays within the caller's gap. A transversal
929    // crossing leaves the gap within a gap's length and says nothing; a
930    // tangential one (a line touching a fitted rim that wobbles about its
931    // circle by the fit's budget) stays inside for the root of gap times
932    // radius on either side, and the polish lands on whichever wobble's
933    // floor it found. The consumer placing a vertex there owns that much
934    // doubt, which the spread of several polished crossings only stated
935    // when there were several. A valley longer than a tangency's (the
936    // radius being at most the shorter curve's length) is a shared stretch
937    // the overlap pass speaks for, not a crossing's to own.
938    let gap = options.gap.max(tol.confusion());
939    let extent = {
940        let along = |s: &Sampled<Point>| {
941            s.points
942                .windows(2)
943                .map(|w| w[0].distance(w[1]))
944                .sum::<f64>()
945        };
946        along(&sa).min(along(&sb))
947    };
948    let cap = 4.0 * (gap * extent).sqrt();
949    for c in &mut crossings {
950        let valley = valley_extent_3d(a, b, &sb, c, gap, tol);
951        if valley > gap * 8.0 && valley <= cap {
952            c.reach = c.reach.max(valley);
953        }
954    }
955    Ok(CurveIntersection {
956        crossings,
957        overlaps,
958    })
959}
960
961/// Whether the first curve stays within the gap of the second all the way
962/// from one crossing to the next: three stations between them, each foot
963/// seeded from the crossings' own parameters.
964fn contact_between_3d(
965    a: &Curve,
966    b: &Curve,
967    from: &Crossing<Point>,
968    to: &Crossing<Point>,
969    options: CurveCurveOptions,
970    tol: Tolerances,
971) -> bool {
972    if (to.on_a - from.on_a).abs() <= tol.parametric() {
973        return true;
974    }
975    (1..=3).all(|k| {
976        let f = f64::from(k) / 4.0;
977        let t = from.on_a + (to.on_a - from.on_a) * f;
978        let seed = from.on_b + (to.on_b - from.on_b) * f;
979        a.point_at(t, tol)
980            .ok()
981            .and_then(|p| foot_on_3d(b, p, seed, tol))
982            .is_some_and(|(_, gap)| gap <= options.gap)
983    })
984}
985
986/// Runs of the first curve's samples whose feet on the second lie within
987/// the gap, each bisected to its parametric ends.
988fn shared_support_3d(
989    a: &Curve,
990    b: &Curve,
991    sa: &Sampled<Point>,
992    sb: &Sampled<Point>,
993    options: CurveCurveOptions,
994    tol: Tolerances,
995) -> Vec<Overlap> {
996    // The foot of a point on the second curve, seeded from the sampled
997    // polyline's nearest segment.
998    let foot = |p: Point| -> Option<(f64, f64)> {
999        let mut seed = (f64::INFINITY, 0.0);
1000        for j in 1..sb.points.len() {
1001            let (_, tb, gap) = segments_approach_3d((p, p), (sb.points[j - 1], sb.points[j]));
1002            if gap < seed.0 {
1003                seed = (
1004                    gap,
1005                    sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb,
1006                );
1007            }
1008        }
1009        if !seed.0.is_finite() {
1010            return None;
1011        }
1012        foot_on_3d(b, p, seed.1, tol)
1013    };
1014    let hugs = |t: f64| -> Option<(f64, f64)> {
1015        let p = a.point_at(t, tol).ok()?;
1016        let (s, gap) = foot(p)?;
1017        (gap <= options.gap).then_some((s, gap))
1018    };
1019    let feet: Vec<Option<(f64, f64)>> = sa.points.iter().map(|p| foot(*p)).collect();
1020    let within = |i: usize| feet[i].is_some_and(|(_, gap)| gap <= options.gap);
1021
1022    let mut overlaps = Vec::new();
1023    let mut i = 0;
1024    while i < sa.points.len() {
1025        if !within(i) {
1026            i += 1;
1027            continue;
1028        }
1029        let start = i;
1030        while i + 1 < sa.points.len() && within(i + 1) {
1031            i += 1;
1032        }
1033        let end = i;
1034        i += 1;
1035        if end == start {
1036            continue;
1037        }
1038        // The run's ends: where the samples stop hugging, bisected between
1039        // the last inside sample and the first outside one.
1040        let refine = |inside: usize, outside: Option<usize>| -> (f64, f64) {
1041            let (mut t_in, s_in) = (sa.parameters[inside], feet[inside].map_or(0.0, |f| f.0));
1042            let Some(out) = outside else {
1043                return (t_in, s_in);
1044            };
1045            let mut s_at = s_in;
1046            let mut t_out = sa.parameters[out];
1047            for _ in 0..48 {
1048                if (t_out - t_in).abs() <= tol.parametric() {
1049                    break;
1050                }
1051                let mid = f64::midpoint(t_in, t_out);
1052                match hugs(mid) {
1053                    Some((s, _)) => {
1054                        t_in = mid;
1055                        s_at = s;
1056                    }
1057                    None => t_out = mid,
1058                }
1059            }
1060            (t_in, s_at)
1061        };
1062        let (lo_a, lo_b) = refine(start, start.checked_sub(1));
1063        let (hi_a, hi_b) = refine(end, (end + 1 < sa.points.len()).then_some(end + 1));
1064        if hi_a - lo_a <= tol.parametric() || (hi_b - lo_b).abs() <= tol.parametric() {
1065            continue;
1066        }
1067        overlaps.push(Overlap {
1068            on_a: (lo_a, hi_a),
1069            on_b: (lo_b, hi_b),
1070        });
1071    }
1072    overlaps
1073}
1074
1075/// Newton on the foot-point condition `(c(s) - p) . c'(s) = 0` from a seed,
1076/// clamped to the curve's domain; the parameter and the distance there.
1077fn foot_on_3d(curve: &Curve, p: Point, seed: f64, tol: Tolerances) -> Option<(f64, f64)> {
1078    let mut s = clamp_3d(curve, seed);
1079    let mut best = (s, curve.point_at(s, tol).ok()?.distance(p));
1080    for _ in 0..30 {
1081        let d = curve.derivatives_at(s, 2, tol).ok()?;
1082        let zero = ogeom_math::Vector::ZERO;
1083        let (c, d1, d2) = (
1084            d.first().copied().unwrap_or(zero),
1085            d.get(1).copied().unwrap_or(zero),
1086            d.get(2).copied().unwrap_or(zero),
1087        );
1088        let gap = c - (p - Point::ORIGIN);
1089        let g = gap.dot(d1);
1090        let dg = d1.dot(d1) + gap.dot(d2);
1091        if dg.abs() <= f64::MIN_POSITIVE {
1092            break;
1093        }
1094        let next = clamp_3d(curve, s - g / dg);
1095        let dist = curve.point_at(next, tol).ok()?.distance(p);
1096        let moved = (next - s).abs();
1097        s = next;
1098        if dist < best.1 {
1099            best = (s, dist);
1100        }
1101        if moved <= tol.parametric() {
1102            break;
1103        }
1104    }
1105    Some(best)
1106}
1107
1108/// Newton on `c1(t) - c2(s) = 0` in the plane.
1109fn polish_2d(
1110    a: &PlanarCurve,
1111    b: &PlanarCurve,
1112    seed_a: f64,
1113    seed_b: f64,
1114    options: CurveCurveOptions,
1115    tol: Tolerances,
1116) -> Option<Crossing<Point2>> {
1117    let system = |x: &[f64]| {
1118        let (t, s) = (clamp_2d(a, x[0]), clamp_2d(b, x[1]));
1119        let pa = a.point_at(t, tol).unwrap_or(Point2::ORIGIN);
1120        let pb = b.point_at(s, tol).unwrap_or(Point2::ORIGIN);
1121        let da = a
1122            .d1_at(t, tol)
1123            .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1124        let db = b
1125            .d1_at(s, tol)
1126            .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1127        (
1128            vec![pa.x - pb.x, pa.y - pb.y],
1129            vec![vec![da.x, -db.x], vec![da.y, -db.y]],
1130        )
1131    };
1132    let criteria = solve::Criteria {
1133        residual: tol.confusion() * 0.01,
1134        step: tol.parametric(),
1135        max_iterations: 40,
1136    };
1137    let found = solve::newton_system(system, &[seed_a, seed_b], criteria).ok()?;
1138    let (t, s) = (clamp_2d(a, found.value[0]), clamp_2d(b, found.value[1]));
1139    let pa = a.point_at(t, tol).ok()?;
1140    let pb = b.point_at(s, tol).ok()?;
1141    let gap = pa.distance(pb);
1142    if gap > options.gap {
1143        return None;
1144    }
1145    Some(Crossing {
1146        on_a: t,
1147        on_b: s,
1148        point: pa,
1149        gap,
1150        reach: 0.0,
1151    })
1152}
1153
1154/// Gaussโ€“Newton on the closest approach of two space curves.
1155///
1156/// Three equations would be overdetermined for two unknowns, so the system is
1157/// the two *stationarity* conditions (the gap vector perpendicular to both
1158/// tangents), whose solutions are the local closest approaches. The gap test
1159/// afterwards decides whether the approach found is a crossing.
1160fn polish_3d(
1161    a: &Curve,
1162    b: &Curve,
1163    seed_a: f64,
1164    seed_b: f64,
1165    options: CurveCurveOptions,
1166    tol: Tolerances,
1167) -> Option<Crossing<Point>> {
1168    let system = |x: &[f64]| {
1169        let (t, s) = (clamp_3d(a, x[0]), clamp_3d(b, x[1]));
1170        let pa = a.point_at(t, tol).unwrap_or(Point::ORIGIN);
1171        let pb = b.point_at(s, tol).unwrap_or(Point::ORIGIN);
1172        let da = a.derivatives_at(t, 2, tol).unwrap_or_default();
1173        let db = b.derivatives_at(s, 2, tol).unwrap_or_default();
1174        let zero = ogeom_math::Vector::ZERO;
1175        let (d1a, d2a) = (
1176            da.get(1).copied().unwrap_or(zero),
1177            da.get(2).copied().unwrap_or(zero),
1178        );
1179        let (d1b, d2b) = (
1180            db.get(1).copied().unwrap_or(zero),
1181            db.get(2).copied().unwrap_or(zero),
1182        );
1183        let gap = pa - pb;
1184        (
1185            vec![gap.dot(d1a), -gap.dot(d1b)],
1186            vec![
1187                vec![d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
1188                vec![-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
1189            ],
1190        )
1191    };
1192    let criteria = solve::Criteria {
1193        residual: tol.confusion() * 0.01,
1194        step: tol.parametric(),
1195        max_iterations: 40,
1196    };
1197    let found = solve::newton_system(system, &[seed_a, seed_b], criteria).ok()?;
1198    let (t, s) = (clamp_3d(a, found.value[0]), clamp_3d(b, found.value[1]));
1199    let pa = a.point_at(t, tol).ok()?;
1200    let pb = b.point_at(s, tol).ok()?;
1201    let gap = pa.distance(pb);
1202    if gap > options.gap {
1203        return None;
1204    }
1205    Some(Crossing {
1206        on_a: t,
1207        on_b: s,
1208        point: pa,
1209        gap,
1210        reach: 0.0,
1211    })
1212}
1213
1214// --- small helpers -----------------------------------------------------------
1215
1216fn clamp_2d(curve: &PlanarCurve, t: f64) -> f64 {
1217    let (lo, hi) = curve.domain();
1218    if curve.is_periodic() {
1219        let span = hi - lo;
1220        if span > 0.0 {
1221            return lo + (t - lo).rem_euclid(span);
1222        }
1223    }
1224    t.clamp(lo, hi)
1225}
1226
1227fn clamp_3d(curve: &Curve, t: f64) -> f64 {
1228    let (lo, hi) = curve.domain();
1229    if curve.is_periodic() {
1230        let span = hi - lo;
1231        if span > 0.0 {
1232            return lo + (t - lo).rem_euclid(span);
1233        }
1234    }
1235    t.clamp(lo, hi)
1236}
1237
1238/// Where two planar segments cross, as fractions along each.
1239fn segments_cross_2d(a: (Point2, Point2), b: (Point2, Point2)) -> Option<(f64, f64)> {
1240    let da = a.1 - a.0;
1241    let db = b.1 - b.0;
1242    let cross = da.cross(db);
1243    if cross.abs() <= f64::MIN_POSITIVE {
1244        return None;
1245    }
1246    let between = b.0 - a.0;
1247    let t = between.cross(db) / cross;
1248    let s = between.cross(da) / cross;
1249    if !(0.0..=1.0).contains(&t) || !(0.0..=1.0).contains(&s) {
1250        return None;
1251    }
1252    Some((t, s))
1253}
1254
1255/// The distance from `p` to the curve `b`, through its samples and a local
1256/// polish on the nearest segment's parameter span.
1257fn distance_to_curve_3d(b: &Curve, sb: &Sampled<Point>, p: Point, tol: Tolerances) -> f64 {
1258    let mut best = (0_usize, f64::INFINITY);
1259    for i in 1..sb.points.len() {
1260        let (q0, q1) = (sb.points[i - 1], sb.points[i]);
1261        let d = q1 - q0;
1262        let len2 = d.dot(d);
1263        let f = if len2 <= f64::MIN_POSITIVE {
1264            0.0
1265        } else {
1266            ((p - q0).dot(d) / len2).clamp(0.0, 1.0)
1267        };
1268        let dist = p.distance(q0 + d * f);
1269        if dist < best.1 {
1270            best = (i, dist);
1271        }
1272    }
1273    if best.0 == 0 {
1274        return best.1;
1275    }
1276    let (mut lo, mut hi) = (sb.parameters[best.0 - 1], sb.parameters[best.0]);
1277    let at = |t: f64| -> f64 { b.point_at(t, tol).map_or(f64::INFINITY, |q| q.distance(p)) };
1278    // Golden-section on the segment's span: the distance is unimodal there
1279    // at any sampling that resolved the curve at all.
1280    let phi = 0.5 * (3.0 - 5.0_f64.sqrt());
1281    let (mut x1, mut x2) = (lo + phi * (hi - lo), hi - phi * (hi - lo));
1282    let (mut f1, mut f2) = (at(x1), at(x2));
1283    for _ in 0..48 {
1284        if f1 < f2 {
1285            hi = x2;
1286            x2 = x1;
1287            f2 = f1;
1288            x1 = lo + phi * (hi - lo);
1289            f1 = at(x1);
1290        } else {
1291            lo = x1;
1292            x1 = x2;
1293            f1 = f2;
1294            x2 = hi - phi * (hi - lo);
1295            f2 = at(x2);
1296        }
1297    }
1298    f1.min(f2).min(best.1)
1299}
1300
1301/// How far from a crossing, along the first curve, the second curve stays
1302/// within `gap`: the larger of the two directions, in space.
1303fn valley_extent_3d(
1304    a: &Curve,
1305    b: &Curve,
1306    sb: &Sampled<Point>,
1307    crossing: &Crossing<Point>,
1308    gap: f64,
1309    tol: Tolerances,
1310) -> f64 {
1311    let (lo, hi) = a.domain();
1312    let span = hi - lo;
1313    if span <= 0.0 {
1314        return 0.0;
1315    }
1316    let mut extent = 0.0_f64;
1317    for direction in [-1.0, 1.0] {
1318        let inside = |t: f64| -> bool {
1319            if t < lo || t > hi {
1320                return false;
1321            }
1322            a.point_at(t, tol)
1323                .is_ok_and(|q| distance_to_curve_3d(b, sb, q, tol) <= gap)
1324        };
1325        let mut step = span * 1e-6;
1326        let mut last_in = crossing.on_a;
1327        let mut first_out: Option<f64> = None;
1328        while step <= span {
1329            let t = crossing.on_a + direction * step;
1330            if inside(t) {
1331                last_in = t;
1332                step *= 2.0;
1333            } else {
1334                first_out = Some(t);
1335                break;
1336            }
1337        }
1338        let edge = match first_out {
1339            Some(mut out) => {
1340                let mut r#in = last_in;
1341                for _ in 0..30 {
1342                    let mid = f64::midpoint(r#in, out);
1343                    if inside(mid) {
1344                        r#in = mid;
1345                    } else {
1346                        out = mid;
1347                    }
1348                }
1349                r#in
1350            }
1351            None => last_in,
1352        };
1353        if let Ok(q) = a.point_at(edge, tol) {
1354            extent = extent.max(q.distance(crossing.point));
1355        }
1356    }
1357    extent
1358}
1359
1360/// The closest approach of two spatial segments, as fractions and a distance.
1361fn segments_approach_3d(a: (Point, Point), b: (Point, Point)) -> (f64, f64, f64) {
1362    let da = a.1 - a.0;
1363    let db = b.1 - b.0;
1364    let between = a.0 - b.0;
1365    let (aa, bb, ab) = (da.dot(da), db.dot(db), da.dot(db));
1366    let (ad, bd) = (da.dot(between), db.dot(between));
1367    let denominator = ab.mul_add(-ab, aa * bb);
1368
1369    let (mut t, mut s) = if denominator.abs() <= f64::MIN_POSITIVE {
1370        (
1371            0.0,
1372            if bb > 0.0 {
1373                (bd / bb).clamp(0.0, 1.0)
1374            } else {
1375                0.0
1376            },
1377        )
1378    } else {
1379        (
1380            (ab.mul_add(bd, -(bb * ad)) / denominator).clamp(0.0, 1.0),
1381            (aa.mul_add(bd, -(ab * ad)) / denominator).clamp(0.0, 1.0),
1382        )
1383    };
1384    // One clamped end may pull the other; a single re-projection settles it.
1385    if bb > 0.0 {
1386        s = ((da.dot(between) + t * aa - 0.0).mul_add(0.0, db.dot(between + da * t)) / bb)
1387            .clamp(0.0, 1.0);
1388    }
1389    if aa > 0.0 {
1390        t = (da.dot(db * s - between) / aa).clamp(0.0, 1.0);
1391    }
1392    let pa = a.0 + da * t;
1393    let pb = b.0 + db * s;
1394    (t, s, pa.distance(pb))
1395}
1396
1397fn order(a: f64, b: f64) -> (f64, f64) {
1398    if a <= b { (a, b) } else { (b, a) }
1399}
1400
1401fn sort_crossings<P>(crossings: &mut [Crossing<P>]) {
1402    crossings.sort_by(|x, y| {
1403        x.on_a
1404            .partial_cmp(&y.on_a)
1405            .unwrap_or(core::cmp::Ordering::Equal)
1406    });
1407}
1408
1409fn push_unique_2d(crossings: &mut Vec<Crossing<Point2>>, found: Crossing<Point2>, tol: Tolerances) {
1410    let reach = tol.confusion() * 100.0;
1411    if crossings
1412        .iter()
1413        .any(|c| c.point.distance(found.point) <= reach)
1414    {
1415        return;
1416    }
1417    crossings.push(found);
1418}
1419
1420fn push_unique_3d(crossings: &mut Vec<Crossing<Point>>, found: Crossing<Point>, tol: Tolerances) {
1421    let reach = tol.confusion() * 100.0;
1422    if crossings
1423        .iter()
1424        .any(|c| c.point.distance(found.point) <= reach)
1425    {
1426        return;
1427    }
1428    crossings.push(found);
1429}
1430
1431#[cfg(test)]
1432#[allow(clippy::unwrap_used)]
1433mod tests {
1434    use super::*;
1435    use ogeom_geom::{BSpline2d, Circle2d, CircleCurve, Line2d, LineCurve};
1436    use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, KnotVector, Vector2};
1437
1438    const T: Tolerances = Tolerances::millimetres();
1439
1440    fn line2(from: Point2, to: Point2) -> PlanarCurve {
1441        Line2d::segment(from, to, T).unwrap().into()
1442    }
1443
1444    fn circle2(centre: Point2, radius: f64) -> PlanarCurve {
1445        Circle2d::new(
1446            Circle2::new(
1447                Frame2::new(centre, Direction2::new(Vector2::new(1.0, 0.0), T).unwrap()),
1448                radius,
1449                T,
1450            )
1451            .unwrap(),
1452        )
1453        .into()
1454    }
1455
1456    #[test]
1457    fn two_lines_cross_where_algebra_says() {
1458        let a = line2(Point2::new(0.0, 0.0), Point2::new(4.0, 4.0));
1459        let b = line2(Point2::new(0.0, 4.0), Point2::new(4.0, 0.0));
1460        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
1461        assert_eq!(found.crossings.len(), 1);
1462        let hit = &found.crossings[0];
1463        assert!(hit.point.is_equal(Point2::new(2.0, 2.0), T));
1464        // Parameters are arc length on a segment.
1465        approx::assert_relative_eq!(hit.on_a, 8.0_f64.sqrt(), epsilon = 1e-9);
1466
1467        // Segments that would cross beyond their ends do not.
1468        let short = line2(Point2::new(0.0, 4.0), Point2::new(1.0, 3.0));
1469        assert!(
1470            intersect_curves_2d(&a, &short, CurveCurveOptions::default(), T)
1471                .unwrap()
1472                .is_empty()
1473        );
1474    }
1475
1476    #[test]
1477    fn collinear_lines_overlap_rather_than_crossing_everywhere() {
1478        let a = line2(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0));
1479        let b = line2(Point2::new(4.0, 0.0), Point2::new(20.0, 0.0));
1480        let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
1481        assert!(found.crossings.is_empty());
1482        assert_eq!(found.overlaps.len(), 1);
1483        let overlap = &found.overlaps[0];
1484        approx::assert_relative_eq!(overlap.on_a.0, 4.0, epsilon = 1e-9);
1485        approx::assert_relative_eq!(overlap.on_a.1, 10.0, epsilon = 1e-9);
1486        approx::assert_relative_eq!(overlap.on_b.0, 0.0, epsilon = 1e-9);
1487        approx::assert_relative_eq!(overlap.on_b.1, 6.0, epsilon = 1e-9);
1488
1489        // Parallel but apart: nothing.
1490        let above = line2(Point2::new(0.0, 1.0), Point2::new(10.0, 1.0));
1491        assert!(
1492            intersect_curves_2d(&a, &above, CurveCurveOptions::default(), T)
1493                .unwrap()
1494                .is_empty()
1495        );
1496    }
1497
1498    #[test]
1499    fn a_line_meets_a_circle_in_two_points_one_or_none() {
1500        let circle = circle2(Point2::new(0.0, 0.0), 2.0);
1501        let through = line2(Point2::new(-5.0, 0.0), Point2::new(5.0, 0.0));
1502        let found =
1503            intersect_curves_2d(&through, &circle, CurveCurveOptions::default(), T).unwrap();
1504        assert_eq!(found.crossings.len(), 2);
1505        for hit in &found.crossings {
1506            approx::assert_relative_eq!(
1507                hit.point.distance(Point2::new(0.0, 0.0)),
1508                2.0,
1509                epsilon = 1e-9
1510            );
1511            // The circle parameter really evaluates to the crossing point.
1512            let PlanarCurve::Circle(_) = &circle else {
1513                unreachable!()
1514            };
1515            let on_circle = circle.point_at(hit.on_b, T).unwrap();
1516            assert!(on_circle.is_equal(hit.point, T));
1517        }
1518
1519        let tangent = line2(Point2::new(-5.0, 2.0), Point2::new(5.0, 2.0));
1520        assert_eq!(
1521            intersect_curves_2d(&tangent, &circle, CurveCurveOptions::default(), T)
1522                .unwrap()
1523                .crossings
1524                .len(),
1525            1
1526        );
1527        let missing = line2(Point2::new(-5.0, 3.0), Point2::new(5.0, 3.0));
1528        assert!(
1529            intersect_curves_2d(&missing, &circle, CurveCurveOptions::default(), T)
1530                .unwrap()
1531                .is_empty()
1532        );
1533    }
1534
1535    #[test]
1536    fn two_circles_cross_touch_coincide_or_miss() {
1537        let a = circle2(Point2::new(0.0, 0.0), 2.0);
1538
1539        let crossing = circle2(Point2::new(3.0, 0.0), 2.0);
1540        let found = intersect_curves_2d(&a, &crossing, CurveCurveOptions::default(), T).unwrap();
1541        assert_eq!(found.crossings.len(), 2);
1542        for hit in &found.crossings {
1543            let on_a = a.point_at(hit.on_a, T).unwrap();
1544            let on_b = crossing.point_at(hit.on_b, T).unwrap();
1545            assert!(on_a.is_equal(hit.point, T));
1546            assert!(on_b.is_equal(hit.point, T));
1547        }
1548
1549        let touching = circle2(Point2::new(4.0, 0.0), 2.0);
1550        assert_eq!(
1551            intersect_curves_2d(&a, &touching, CurveCurveOptions::default(), T)
1552                .unwrap()
1553                .crossings
1554                .len(),
1555            1
1556        );
1557
1558        let same = circle2(Point2::new(0.0, 0.0), 2.0);
1559        let coincident = intersect_curves_2d(&a, &same, CurveCurveOptions::default(), T).unwrap();
1560        assert!(coincident.crossings.is_empty());
1561        assert_eq!(coincident.overlaps.len(), 1);
1562
1563        let apart = circle2(Point2::new(10.0, 0.0), 2.0);
1564        assert!(
1565            intersect_curves_2d(&a, &apart, CurveCurveOptions::default(), T)
1566                .unwrap()
1567                .is_empty()
1568        );
1569    }
1570
1571    #[test]
1572    fn the_general_path_handles_what_has_no_closed_form() {
1573        // A spline sine-ish wave against a line: three crossings, found by
1574        // sampling and polished by Newton to rounding.
1575        let wave: PlanarCurve = BSpline2d::new(
1576            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
1577            vec![
1578                Point2::new(0.0, -1.0),
1579                Point2::new(1.0, 3.0),
1580                Point2::new(2.0, -3.0),
1581                Point2::new(3.0, 3.0),
1582                Point2::new(4.0, -1.0),
1583            ],
1584            T,
1585        )
1586        .unwrap()
1587        .into();
1588        let axis = line2(Point2::new(-1.0, 0.0), Point2::new(5.0, 0.0));
1589        let found = intersect_curves_2d(&wave, &axis, CurveCurveOptions::default(), T).unwrap();
1590        assert_eq!(found.crossings.len(), 3, "a wave crosses its axis thrice");
1591        for hit in &found.crossings {
1592            assert!(hit.gap < 1e-9);
1593            assert!(hit.point.y.abs() < 1e-9);
1594            let on_wave = wave.point_at(hit.on_a, T).unwrap();
1595            assert!(on_wave.is_equal(hit.point, T));
1596        }
1597    }
1598
1599    /// Two descriptions of one circle overlap over the whole turn, and the
1600    /// overlap's two ranges *correspond*: `on_b`'s ends are where `b` stands
1601    /// at `a`'s own ends. A caller carrying a split from one to the other
1602    /// (the boolean, pairing a hole's arcs against the disc that fills them)
1603    /// reads that correspondence and gets the same point back, whatever phase
1604    /// and winding the two were written with.
1605    #[test]
1606    fn one_circle_written_twice_states_the_correspondence_between_them() {
1607        use ogeom_math::{Direction, Vector};
1608        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
1609        // The same circle seen from underneath, started a third of a turn
1610        // round: opposite winding, arbitrary phase.
1611        let third = 2.0 * core::f64::consts::PI / 3.0;
1612        let flipped = Frame::new(
1613            Point::ORIGIN,
1614            -Direction::Z,
1615            Direction::new(Vector::new(third.cos(), third.sin(), 0.0), T).unwrap(),
1616            T,
1617        )
1618        .unwrap();
1619        let b: Curve = CircleCurve::new(Circle::new(flipped, 3.0, T).unwrap()).into();
1620        for pair in [(&a, &b), (&b, &a)] {
1621            let found = intersect_curves(pair.0, pair.1, CurveCurveOptions::default(), T).unwrap();
1622            assert!(
1623                found.crossings.is_empty(),
1624                "every point is a hit, so none is"
1625            );
1626            assert_eq!(found.overlaps.len(), 1);
1627            let overlap = &found.overlaps[0];
1628            let span = overlap.on_a.1 - overlap.on_a.0;
1629            for i in 0..=8 {
1630                let t = f64::from(i) / 8.0;
1631                let ta = span.mul_add(t, overlap.on_a.0);
1632                let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
1633                let pa = pair.0.point_at(ta, T).unwrap();
1634                let pb = pair
1635                    .1
1636                    .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
1637                    .unwrap();
1638                assert!(
1639                    pa.distance(pb) < 1e-9,
1640                    "at {t}: {pa:?} against {pb:?} (on_a {:?} on_b {:?})",
1641                    overlap.on_a,
1642                    overlap.on_b
1643                );
1644            }
1645        }
1646    }
1647
1648    #[test]
1649    fn space_curves_cross_within_a_gap_and_report_it() {
1650        // Two circles that would cross in a shared plane, with one lifted a
1651        // hair out of it: the crossings become passes with a real, small gap
1652        // that must be reported, not zeroed. (Not chain links: a first draft
1653        // of this test used linked circles, and linked circles never approach:
1654        // passing through each other's *disks* is what linked means, and these
1655        // radii hold the curves a constant two units apart.)
1656        let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1657        let lifted = Frame::new(
1658            Point::new(3.0, 0.0, 0.001),
1659            ogeom_math::Direction::Z,
1660            ogeom_math::Direction::X,
1661            T,
1662        )
1663        .unwrap();
1664        let b: Curve = CircleCurve::new(Circle::new(lifted, 2.0, T).unwrap()).into();
1665
1666        let options = CurveCurveOptions {
1667            gap: 1e-2,
1668            ..CurveCurveOptions::default()
1669        };
1670        let found = intersect_curves(&a, &b, options, T).unwrap();
1671        assert_eq!(found.crossings.len(), 2, "two near-crossings");
1672        for hit in &found.crossings {
1673            assert!(hit.gap > 1e-4, "the gap is real and must not be zeroed");
1674            assert!(hit.gap < 2e-3, "but small: {}", hit.gap);
1675        }
1676
1677        // Tighten the gap below the offset and the crossings vanish.
1678        let strict = CurveCurveOptions {
1679            gap: 1e-5,
1680            ..CurveCurveOptions::default()
1681        };
1682        assert!(intersect_curves(&a, &b, strict, T).unwrap().is_empty());
1683    }
1684
1685    #[test]
1686    fn skew_lines_in_space_miss_and_close_ones_meet() {
1687        let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
1688            .unwrap()
1689            .into();
1690        let skew: Curve =
1691            LineCurve::segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0), T)
1692                .unwrap()
1693                .into();
1694        assert!(
1695            intersect_curves(&a, &skew, CurveCurveOptions::default(), T)
1696                .unwrap()
1697                .is_empty(),
1698            "a unit apart is not a crossing"
1699        );
1700
1701        let meeting: Curve =
1702            LineCurve::segment(Point::new(5.0, -5.0, 0.0), Point::new(5.0, 5.0, 0.0), T)
1703                .unwrap()
1704                .into();
1705        let found = intersect_curves(&a, &meeting, CurveCurveOptions::default(), T).unwrap();
1706        assert_eq!(found.crossings.len(), 1);
1707        assert!(
1708            found.crossings[0]
1709                .point
1710                .is_equal(Point::new(5.0, 0.0, 0.0), T)
1711        );
1712        assert!(found.crossings[0].gap < 1e-12);
1713
1714        // Collinear 3D lines overlap.
1715        let collinear: Curve =
1716            LineCurve::segment(Point::new(4.0, 0.0, 0.0), Point::new(20.0, 0.0, 0.0), T)
1717                .unwrap()
1718                .into();
1719        let shared = intersect_curves(&a, &collinear, CurveCurveOptions::default(), T).unwrap();
1720        assert_eq!(shared.overlaps.len(), 1);
1721    }
1722
1723    #[test]
1724    fn a_fitted_curve_tracing_an_arc_is_one_overlap_not_a_row_of_crossings() {
1725        // A spline fitted along a circle's arc sits within its fit budget
1726        // of the circle everywhere, and "crosses" it at every wobble. The
1727        // sampling path reports the stretch as one overlap and keeps no
1728        // crossing inside it; read as crossings, a section tracing the arc
1729        // it was cut along shattered into hundreds of pieces.
1730        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1731        let points: Vec<Point> = (0..=40)
1732            .map(|i| {
1733                let a = 0.2 + 1.0 * f64::from(i) / 40.0;
1734                Point::new(4.0 * a.cos(), 4.0 * a.sin(), 0.0)
1735            })
1736            .collect();
1737        let fitted: Curve = ogeom_geom::fit::fit_points(&points, 3, 1e-7, T)
1738            .unwrap()
1739            .curve
1740            .into();
1741        let options = CurveCurveOptions {
1742            gap: 1e-5,
1743            ..CurveCurveOptions::default()
1744        };
1745        let found = intersect_curves(&fitted, &circle, options, T).unwrap();
1746        assert_eq!(found.overlaps.len(), 1, "one shared stretch: {found:?}");
1747        let (lo, hi) = found.overlaps[0].on_a;
1748        let (fa, fb) = fitted.domain();
1749        assert!(
1750            lo - fa < 1e-3 && fb - hi < 1e-3,
1751            "the whole fit runs along the circle"
1752        );
1753        assert!(
1754            found.crossings.is_empty(),
1755            "no crossing survives inside the overlap: {:?}",
1756            found.crossings
1757        );
1758    }
1759
1760    #[test]
1761    fn an_arc_ending_tangent_to_a_line_is_one_crossing_with_its_reach() {
1762        // A circle and its tangent line touch at one point, but the
1763        // stationarity conditions go flat along the touch and every seed
1764        // converges somewhere in a valley the width of the gap. One contact
1765        // comes back, at the touch, owning the valley's length as its reach.
1766        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1767        let line: Curve =
1768            LineCurve::segment(Point::new(4.0, -3.0, 0.0), Point::new(4.0, 3.0, 0.0), T)
1769                .unwrap()
1770                .into();
1771        let options = CurveCurveOptions {
1772            gap: 1e-5,
1773            ..CurveCurveOptions::default()
1774        };
1775        let found = intersect_curves(&circle, &line, options, T).unwrap();
1776        assert_eq!(found.crossings.len(), 1, "one touch: {:?}", found.crossings);
1777        let touch = found.crossings[0];
1778        assert!(
1779            touch.point.distance(Point::new(4.0, 0.0, 0.0)) < 2e-2,
1780            "{touch:?}"
1781        );
1782        assert!(touch.reach < 5e-2, "the valley is short: {touch:?}");
1783        assert!(found.overlaps.is_empty());
1784    }
1785
1786    #[test]
1787    fn unusable_options_are_refused() {
1788        let a = line2(Point2::new(0.0, 0.0), Point2::new(1.0, 0.0));
1789        for options in [
1790            CurveCurveOptions {
1791                samples: 1,
1792                ..CurveCurveOptions::default()
1793            },
1794            CurveCurveOptions {
1795                gap: 0.0,
1796                ..CurveCurveOptions::default()
1797            },
1798            CurveCurveOptions {
1799                gap: f64::NAN,
1800                ..CurveCurveOptions::default()
1801            },
1802        ] {
1803            assert!(intersect_curves_2d(&a, &a.clone(), options, T).is_err());
1804        }
1805    }
1806}