Skip to main content

geop_core_geometry/intersection/
curve_curve.rs

1//! Curve–curve intersection by per-axis fat line clipping — the design is
2//! `curve_curve.md` next to this file; it reuses the clip of
3//! `contains/curve.md` and the search schedule of `contains/surface.md`.
4//!
5//! For curves `A(s) = H_A / W_A` and `B(t) = H_B / W_B` with positive
6//! weights, `A_k(s) = B_k(t)` iff
7//! `g_k(s, t) = H_{A,k}(s) W_B(t) - W_A(s) H_{B,k}(t) = 0`, a polynomial
8//! tensor-product spline in the *independent* parameters `s`, `t` with
9//! coefficients `d_ij = P_{i,k} Q_{j,w} - P_{i,w} Q_{j,k}` — no division, no
10//! degree elevation. Its zeros are clipped in both directions
11//! ([`clip_tensor`]), exactly as a surface's are in `contains::surface`.
12//!
13//! Assumes no coincident arcs (`curve_curve.md`): the result is a list of
14//! paired `(s, t)` boxes, and reaching some count of them means nothing.
15
16use super::{
17    Intersections,
18    coincidence::{self, Hit, Overlap},
19};
20use crate::{
21    aabb::{aabb_could_overlap, curve_could_meet_aabb},
22    contains::curve::curve_could_contain,
23    fat_line::{
24        Stalled, carried_width, chord, clip_tensor, extent, greville_abscissae, restriction,
25        spanning, stalled,
26    },
27    nurb_curve::{NurbCurve, ParameterRefinable},
28};
29use geop_core_math::{
30    disjoint_set::DisjointSet,
31    geop_error::{GeopError, GeopResult, WithContext},
32    matrix::Matrix,
33    scalars::Scalar,
34    solvers::{
35        linear::solve_linear_system,
36        newton,
37        subdivide::{Order, Searched, Split, subdivide},
38    },
39    vector::Vector,
40};
41
42/// Clip the pair: a box `[ŝ, t̂]` inside both domains enclosing every
43/// `(s, t)` with `A(s) = B(t)`, or `None` if some equation proves there is
44/// none. `NurbCurve::try_new` guarantees positive weights, which the cross
45/// multiplication needs.
46///
47/// The equations are combinations `g_n = n · (H_A W_B - W_A H_B)` along
48/// free-choice directions ([`spanning`]): the `C - 1` directions
49/// perpendicular to `B`'s chord (nearly independent of `t`, so they pin `s`)
50/// and those perpendicular to `A`'s (pinning `t`). Together they span
51/// space, so a pair satisfying all of them is a solution. If they don't —
52/// a closed segment without a chord, or two collinear curves — the
53/// coordinate axes are added.
54fn clip<const D: usize, const C: usize>(
55    a: &NurbCurve<D>,
56    b: &NurbCurve<D>,
57) -> GeopResult<Option<[Scalar; 2]>> {
58    let mut hats = [a.domain_as_scalar(), b.domain_as_scalar()];
59    let (na, nb) = (a.control_points.len(), b.control_points.len());
60    let greville = [
61        greville_abscissae(&a.knot_vector, a.degree, na)?,
62        greville_abscissae(&b.knot_vector, b.degree, nb)?,
63    ];
64
65    let mut dirs = chord::<D, C>(&b.control_points)
66        .orthonormal_complement()
67        .unwrap_or_default();
68    dirs.extend(
69        chord::<D, C>(&a.control_points)
70            .orthonormal_complement()
71            .unwrap_or_default(),
72    );
73
74    let w = D - 1;
75    let mut d = Vec::with_capacity(na * nb);
76    for n in spanning(dirs) {
77        let nb_pts: Vec<Scalar> = b
78            .control_points
79            .iter()
80            .map(|q| n.prod_dot(&q.head()))
81            .collect();
82        d.clear();
83        for p in &a.control_points {
84            let np = n.prod_dot(&p.head());
85            d.extend(
86                b.control_points
87                    .iter()
88                    .zip(&nb_pts)
89                    .map(|(q, &nq)| np.mul(q[w]).sub(p[w].mul(nq))),
90            );
91        }
92        if !clip_tensor(&d, &[na, nb], &greville, &mut hats) {
93            return Ok(None);
94        }
95    }
96    Ok(Some(hats))
97}
98
99/// All `(s, t)` with `curve_a(s) = curve_b(t)`, as paired parameter boxes
100/// (`curve_curve.md`), for curves that do **not** overlap along an arc — see
101/// [`curve_curve_intersect`] for the wrapper that handles overlaps.
102/// Breadth-first over pairs of subcurves:
103///
104/// - the cached AABBs and the [`clip`] are necessary conditions — failing
105///   either rejects the pair;
106/// - if one parameter is pinned onto a clamped end, the other curve is
107///   searched for that endpoint with [`curve_could_contain`];
108/// - while the clip keeps shrinking the pair, both curves are restricted to
109///   it ([`crate::fat_line::restriction`]). Rebuilding the coefficients
110///   after a restriction is what couples the directions: clipping `t`
111///   shrinks the rows unioned for `s`;
112/// - once clipping stalls ([`crate::fat_line::stalled`]), a pair whose
113///   segments' extents are both within `min_subdivision_size` has converged
114///   and reports its segments' domains — a *candidate*, not an existence
115///   proof; any other has one curve bisected. `min_subdivision_size` thus
116///   bounds only bisection, never a pair clipping is still shrinking.
117///
118/// Boxes that overlap are merged by union into one unresolved cluster
119/// (`DisjointSet`), never averaged. Exhausting `max_nodes` is an error —
120/// the result would be incomplete, and neither "empty" nor "coincident" may
121/// be read into it.
122pub fn curve_curve_crossings<const D: usize, const C: usize>(
123    curve_a: &NurbCurve<D>,
124    curve_b: &NurbCurve<D>,
125    max_nodes: usize,
126    min_subdivision_size: Scalar,
127) -> GeopResult<Vec<(Scalar, Scalar)>>
128where
129    NurbCurve<D>: ParameterRefinable<C>,
130{
131    let mut solutions: DisjointSet<(Scalar, Scalar)> = DisjointSet::new();
132    let root = (curve_a.clone(), curve_b.clone());
133    let searched = subdivide(
134        [root],
135        Order::BreadthFirst,
136        max_nodes,
137        |(a, b), budget| -> GeopResult<_> {
138            if !aabb_could_overlap(&a.aabb, &b.aabb, C) {
139                return Ok(Split::Done);
140            }
141            let Some(hats) = clip::<D, C>(&a, &b)? else {
142                return Ok(Split::Done);
143            };
144
145            // If the clip pinned `s` (`t`) exactly onto a clamped end of its
146            // curve, every solution has that curve's endpoint as its spatial
147            // point, so the rest is point containment in the other curve (the
148            // "boundary evaluation" of `contains/surface.md` §3).
149            let pinned = (a.pinned_end::<C>(hats[0]).map(|p| (p, true)))
150                .or_else(|| b.pinned_end::<C>(hats[1]).map(|p| (p, false)));
151            if let Some((point, on_a)) = pinned {
152                let (other, free) = if on_a { (&b, hats[1]) } else { (&a, hats[0]) };
153                if let Some(t) = curve_could_contain(other, &point, budget, min_subdivision_size)?
154                    && t.could_be_equal(free)
155                {
156                    let free = free.intersect(t);
157                    solutions.insert(if on_a {
158                        (hats[0], free)
159                    } else {
160                        (free, hats[1])
161                    });
162                }
163                return Ok(Split::Done);
164            }
165
166            let ranges = [a.domain(), b.domain()];
167            let sizes = [
168                extent([a.control_points.clone()]),
169                extent([b.control_points.clone()]),
170            ];
171            let carried = carried_width(&a.control_points).max(carried_width(&b.control_points));
172            if let Some(bounds) = restriction(&hats, &ranges, &sizes, carried)?
173                && let (Ok(ra), Ok(rb)) = (
174                    a.sub_curve(bounds[0].0, bounds[0].1),
175                    b.sub_curve(bounds[1].0, bounds[1].1),
176                )
177            {
178                return Ok(Split::Restrict((ra, rb)));
179            }
180
181            let order = match stalled(&ranges, &sizes, carried, min_subdivision_size) {
182                Stalled::Converged => {
183                    // The pieces' whole domains, not the tighter clip: pieces the
184                    // search could not separate — a tangency converges on a chain
185                    // of adjacent ones — must merge into one unresolved cluster,
186                    // and adjacent domains share an endpoint where clips need not
187                    // touch. A transversal crossing loses nothing: restriction has
188                    // already cut its pieces down to the clip.
189                    solutions.insert((a.domain_as_scalar(), b.domain_as_scalar()));
190                    return Ok(Split::Done);
191                }
192                Stalled::Bisect(order) => order,
193            };
194            let children = order.iter().find_map(|&dir| {
195                if dir == 0 {
196                    let (l, r) = a.split_mid().ok()?;
197                    Some(((l, b.clone()), (r, b.clone())))
198                } else {
199                    let (l, r) = b.split_mid().ok()?;
200                    Some(((a.clone(), l), (a.clone(), r)))
201                }
202            });
203            Ok(match children {
204                Some((first, second)) => Split::Halves(first, second),
205                // Nothing left to cut or split: what's here is the candidate.
206                None => {
207                    solutions.insert((hats[0], hats[1]));
208                    Split::Done
209                }
210            })
211        },
212    )?;
213    if let Searched::Exhausted { pending } = searched {
214        return Err(GeopError::new(format!(
215            "curve_curve_crossings: exhausted max_nodes={max_nodes} with {pending} \
216             pairs pending; the result would be incomplete"
217        )));
218    }
219
220    Ok(solutions.into_vec())
221}
222
223/// Where `other(t)` lies on `curve`, if it does — or, for a `reach` above
224/// zero, where `curve` could pass within `reach` of it along every axis:
225/// its parameter there.
226fn point_on<const D: usize, const C: usize>(
227    curve: &NurbCurve<D>,
228    other: &NurbCurve<D>,
229    t: Scalar,
230    reach: Scalar,
231    max_nodes: usize,
232    min_subdivision_size: Scalar,
233) -> GeopResult<Option<Scalar>>
234where
235    NurbCurve<D>: ParameterRefinable<C>,
236{
237    let mut point = other.evaluate_cartesian(t)?;
238    if reach.definitely_greater(Scalar::ZERO) {
239        let around = reach.neg().union(reach);
240        point = point.map(|x| x.add(around));
241    }
242    curve_could_contain(curve, &point, max_nodes, min_subdivision_size)
243}
244
245/// Every stretch of `a` lying on `b` (see [`coincidence`]), or within
246/// `reach` of it, with `b`'s parameter as the partner: candidates are `a`'s
247/// ends found on `b` and `b`'s ends found on `a`.
248pub(crate) fn curve_curve_overlaps<const D: usize, const C: usize>(
249    a: &NurbCurve<D>,
250    b: &NurbCurve<D>,
251    reach: Scalar,
252    max_nodes: usize,
253    min_subdivision_size: Scalar,
254) -> GeopResult<Vec<Overlap<Scalar>>>
255where
256    NurbCurve<D>: ParameterRefinable<C>,
257{
258    let on = |curve: &NurbCurve<D>, other: &NurbCurve<D>, t: Scalar| {
259        point_on(curve, other, t, reach, max_nodes, min_subdivision_size)
260    };
261    let (a0, a1) = a.domain();
262    let (b0, b1) = b.domain();
263    let mut candidates = Vec::new();
264    for s in [a0, a1] {
265        if let Some(t) = on(b, a, s)? {
266            candidates.push(Hit { t: s, partner: t });
267        }
268    }
269    for t in [b0, b1] {
270        if let Some(s) = on(a, b, t)? {
271            candidates.push(Hit { t: s, partner: t });
272        }
273    }
274    // Two curves lying on each other run the same way. Probed where two ends
275    // are found apart by no more than a shared vertex's width, a curve "lies
276    // on" another crossing it at any angle, as far as enclosures tell; its
277    // tangent tells. A curve within `reach` of another is taken as one with
278    // it whichever way it runs.
279    let same_way = |s: Scalar, t: Scalar| -> GeopResult<bool> {
280        if reach.definitely_greater(Scalar::ZERO) {
281            return Ok(true);
282        }
283        let (along_a, along_b) = (a.tangent_cartesian(s)?, b.tangent_cartesian(t)?);
284        Ok((0..C).all(|i| {
285            (i + 1..C).all(|j| {
286                (along_a[i].mul(along_b[j]))
287                    .sub(along_a[j].mul(along_b[i]))
288                    .could_be_equal(Scalar::ZERO)
289            })
290        }))
291    };
292    coincidence::find_overlaps(candidates, |s| {
293        Ok(on(b, a, s)?.filter(|&t| same_way(s, t).unwrap_or(true)))
294    })
295}
296
297/// What [`curve_curve_overlaps_and_crossings`] finds: the overlaps, and the
298/// parameter pairs of the isolated crossings.
299pub type OverlapsAndCrossings = (Vec<Overlap<Scalar>>, Vec<(Scalar, Scalar)>);
300
301/// Overlaps of `a` with `b`, and the isolated crossings away from them (the
302/// clipping search run on each stretch of `a` between overlaps).
303///
304/// With `reach` zero an overlap is a stretch lying on `b`. Above zero it is
305/// one running within `reach` of `b`: for a caller that works to a
306/// resolution of its own — a drawing to what a sheet shows — two curves
307/// that close are one line, and are taken as one. Without it the clipping
308/// search has to cover such a stretch with pairs of pieces no larger than
309/// `min_subdivision_size`, which no node budget does for a long one.
310pub fn curve_curve_overlaps_and_crossings<const D: usize, const C: usize>(
311    a: &NurbCurve<D>,
312    b: &NurbCurve<D>,
313    reach: Scalar,
314    max_nodes: usize,
315    min_subdivision_size: Scalar,
316) -> GeopResult<OverlapsAndCrossings>
317where
318    NurbCurve<D>: ParameterRefinable<C>,
319{
320    let overlaps = curve_curve_overlaps(a, b, reach, max_nodes, min_subdivision_size)?;
321    let crossings = coincidence::crossings_between(a, &overlaps, |piece| {
322        curve_curve_crossings(piece, b, max_nodes, min_subdivision_size)
323    })?;
324    Ok((overlaps, crossings))
325}
326
327/// Points where `curve_a` crosses — or, overlapping along an arc, coincides
328/// with — `curve_b`, under the [`Intersections`] contract.
329///
330/// Overlaps are found directly ([`curve_curve_overlaps`]: ends of each curve
331/// located on the other, then one midpoint probe per candidate stretch)
332/// instead of being inferred from a search hitting `max_solutions`. Then:
333///
334/// - no overlap: [`Intersections::Found`] with the clipping search's
335///   crossings, at most `max_solutions`;
336/// - an overlap: [`Intersections::Coincident`] with, in this order and up to
337///   `max_solutions` in total, the overlaps' end points, the isolated
338///   crossings on the rest of `curve_a`, and points spread evenly over the
339///   overlaps.
340///
341/// Exhausting `max_nodes` in any sub-search is an error.
342pub fn curve_curve_intersect<const D: usize, const C: usize>(
343    curve_a: &NurbCurve<D>,
344    curve_b: &NurbCurve<D>,
345    max_solutions: usize,
346    max_nodes: usize,
347    min_subdivision_size: Scalar,
348) -> GeopResult<Intersections<(Scalar, Scalar)>>
349where
350    NurbCurve<D>: ParameterRefinable<C>,
351{
352    // Disjoint bounding boxes rule out crossings and overlaps alike, before
353    // any candidate probe runs — a straight segment's own extent, not its
354    // box (see `curve_could_meet_aabb`).
355    if max_solutions == 0
356        || !curve_could_meet_aabb(curve_a, &curve_b.aabb, C)
357        || !curve_could_meet_aabb(curve_b, &curve_a.aabb, C)
358    {
359        return Ok(Intersections::Found(vec![]));
360    }
361    let ctx = |e: GeopError| {
362        e.with_context(format!(
363            "curve_curve_intersect(curve_a={curve_a:?}, curve_b={curve_b:?}, max_nodes={max_nodes}, \
364             min_subdivision_size={min_subdivision_size:?})"
365        ))
366    };
367    let (overlaps, crossings) = curve_curve_overlaps_and_crossings(
368        curve_a,
369        curve_b,
370        Scalar::ZERO,
371        max_nodes,
372        min_subdivision_size,
373    )
374    .with_context(&ctx)?;
375    let samples = coincidence::samples(&overlaps, crossings.len(), max_solutions, |s| {
376        point_on(
377            curve_b,
378            curve_a,
379            s,
380            Scalar::ZERO,
381            max_nodes,
382            min_subdivision_size,
383        )
384    })?;
385    Ok(coincidence::assemble(
386        &overlaps,
387        crossings,
388        samples,
389        max_solutions,
390    ))
391}
392
393/// Polish one isolated `(t_a, t_b)` — as returned by [`curve_curve_intersect`]
394/// — by Gauss-Newton on `A(t_a) - B(t_b) = 0` ([`newton::refine`]).
395///
396/// Two unknowns against `C` equations, so each step solves the normal
397/// equations `(JᵀJ)δ = -JᵀF` with `J = [A'(t_a), -B'(t_b)]`; parallel
398/// tangents make `JᵀJ` singular, and leave the box as it is. So does a
399/// refined box that provably holds no crossing — see
400/// `curve_surface::refine_crossing`.
401pub fn refine_crossing<const D: usize, const C: usize>(
402    curve_a: &NurbCurve<D>,
403    curve_b: &NurbCurve<D>,
404    t_a: Scalar,
405    t_b: Scalar,
406) -> (Scalar, Scalar)
407where
408    NurbCurve<D>: ParameterRefinable<C>,
409{
410    let refined = newton::refine(
411        [t_a, t_b],
412        [curve_a.domain(), curve_b.domain()],
413        |[ta, tb]| {
414            let pa = curve_a.evaluate_cartesian(ta).ok()?;
415            let pb = curve_b.evaluate_cartesian(tb).ok()?;
416            let da = curve_a.tangent_cartesian(ta).ok()?;
417            let db = curve_b.tangent_cartesian(tb).ok()?;
418            let f = pa.sub(&pb);
419            let m = Matrix::from_rows([
420                [da.prod_dot(&da), da.prod_dot(&db).neg()],
421                [da.prod_dot(&db).neg(), db.prod_dot(&db)],
422            ]);
423            let rhs = Vector::from_array([da.prod_dot(&f).neg(), db.prod_dot(&f)]);
424            let delta = solve_linear_system(&m, &rhs).ok()?;
425            Some([ta.add(delta[0]), tb.add(delta[1])])
426        },
427    );
428    let [a_ref, b_ref] = refined;
429    let holds_a_crossing = match (
430        curve_a.evaluate_cartesian(a_ref),
431        curve_b.evaluate_cartesian(b_ref),
432    ) {
433        (Ok(pa), Ok(pb)) => pa.could_be_equal(&pb),
434        _ => false,
435    };
436    if !holds_a_crossing {
437        return (t_a, t_b);
438    }
439    (a_ref, b_ref)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::{curve_curve_crossings, curve_curve_intersect};
445    use crate::nurb_curve::NurbCurve;
446    use crate::test_fixtures::{knots, line, weighted};
447    use geop_core_math::{scalars::Scalar, vector::Vector4};
448
449    const MAX: usize = 5000;
450    const MIN_SUBDIVISION_SIZE: f64 = 1e-6;
451
452    /// Two edges meeting at a vertex at a shallow angle, each carrying the
453    /// width its construction left it: each end is found on the other curve
454    /// at a parameter of its own, and the stretch between them lies on the
455    /// other curve, as far as enclosures tell. It is no overlap: the two run
456    /// three degrees apart. A helix met a cylinder's rim so, and validation
457    /// called the edges coincident along an arc; the example `square_thread`
458    /// is what reproduces that, these lines only say what should hold.
459    #[test]
460    fn edges_meeting_at_a_shallow_angle_do_not_overlap() {
461        let width = 1e-10;
462        let edge = |from: [f64; 3], to: [f64; 3]| {
463            let wide = |p: [f64; 3]| {
464                let mut at = p.map(|x| Scalar::new(x - width, x + width)).to_vec();
465                at.push(Scalar::ONE);
466                Vector4::from_array([at[0], at[1], at[2], at[3]])
467            };
468            NurbCurve::try_new(1, vec![wide(from), wide(to)], knots(&[0.0, 0.0, 1.0, 1.0])).unwrap()
469        };
470        let (sin, cos) = 3f64.to_radians().sin_cos();
471        let a = edge([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
472        let b = edge([1.0 - 5e-10, 0.0, 0.0], [1.0 + cos, sin, 0.0]);
473        let found =
474            curve_curve_intersect::<4, 3>(&a, &b, 8, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
475                .unwrap();
476        assert!(!found.is_coincident(), "{found:?}");
477        assert!(!found.into_vec().is_empty(), "they do meet");
478
479        // Lying on each other, tangents parallel, they do.
480        let c = edge([0.5, 0.0, 0.0], [2.0, 0.0, 0.0]);
481        let found =
482            curve_curve_intersect::<4, 3>(&a, &c, 8, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
483                .unwrap();
484        assert!(found.is_coincident(), "{found:?}");
485    }
486
487    /// Exact rational quarter circle of radius 1 in the xy-plane.
488    fn quarter_circle() -> NurbCurve<4> {
489        let w = std::f64::consts::FRAC_1_SQRT_2;
490        NurbCurve::try_new(
491            2,
492            vec![
493                weighted(1., 0., 0., 1.),
494                weighted(1., 1., 0., w),
495                weighted(0., 1., 0., 1.),
496            ],
497            knots(&[0., 0., 0., 1., 1., 1.]),
498        )
499        .unwrap()
500    }
501
502    fn solve(a: &NurbCurve<4>, b: &NurbCurve<4>) -> Vec<(Scalar, Scalar)> {
503        curve_curve_crossings::<4, 3>(a, b, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE)).unwrap()
504    }
505
506    /// Every returned pair's two points agree, and there are `n` of them.
507    fn assert_solutions(a: &NurbCurve<4>, b: &NurbCurve<4>, n: usize) -> Vec<(Scalar, Scalar)> {
508        let sols = solve(a, b);
509        assert_eq!(sols.len(), n, "{sols:?}");
510        sols
511    }
512
513    /// `curve_curve.md` §3: projections alone can't contract this pair.
514    #[test]
515    fn crossing_diagonals() {
516        let a = line([0., 0., 0.], [1., 1., 0.]);
517        let b = line([0., 1., 0.], [1., 0., 0.]);
518        let sols = assert_solutions(&a, &b, 1);
519        let half = Scalar::from_f64(0.5);
520        assert!(
521            sols[0].0.could_be_equal(half) && sols[0].1.could_be_equal(half),
522            "{sols:?}"
523        );
524    }
525
526    #[test]
527    fn circle_meets_line_once() {
528        // x = y meets the arc at (√½, √½), parameter ½ by symmetry.
529        let sols = assert_solutions(&quarter_circle(), &line([0., 0., 0.], [1., 1., 0.]), 1);
530        assert!(sols[0].0.could_be_equal(Scalar::from_f64(0.5)), "{sols:?}");
531    }
532
533    #[test]
534    fn circle_meets_chord_twice() {
535        // x + y = 1.15 meets the arc at x = (1.15 ± √0.6775) / 2 ≈ 0.987 and
536        // 0.163; the chord spans x ∈ [0.15, 1], so both.
537        let chord = line([1., 0.15, 0.], [0.15, 1., 0.]);
538        assert_solutions(&quarter_circle(), &chord, 2);
539    }
540
541    /// Shared endpoint: the kernel's most common case (edges meeting at a
542    /// vertex).
543    #[test]
544    fn shared_endpoint() {
545        let a = line([0., 0., 0.], [1., 0., 0.]);
546        let b = line([1., 0., 0.], [1., 1., 1.]);
547        let sols = assert_solutions(&a, &b, 1);
548        assert!(
549            sols[0].0.could_be_equal(Scalar::ONE) && sols[0].1.could_be_equal(Scalar::ZERO),
550            "{sols:?}"
551        );
552    }
553
554    #[test]
555    fn skew_lines_miss() {
556        let a = line([0., 0., 0.], [1., 1., 0.]);
557        let b = line([0., 1., 0.01], [1., 0., 0.01]);
558        assert_solutions(&a, &b, 0);
559        assert_solutions(&quarter_circle(), &line([0., 0., 0.], [0.5, 0.5, 0.]), 0);
560    }
561
562    #[test]
563    fn budget_exhaustion_is_an_error() {
564        let a = line([0., 0., 0.], [1., 1., 0.]);
565        let b = line([0., 1., 0.], [1., 0., 0.]);
566        assert!(
567            curve_curve_crossings::<4, 3>(&a, &b, 1, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
568                .is_err()
569        );
570    }
571
572    // ── The coincidence-handling wrapper ─────────────────────────────────────
573
574    use crate::intersection::Intersections;
575
576    fn wrap(a: &NurbCurve<4>, b: &NurbCurve<4>) -> Intersections<(Scalar, Scalar)> {
577        curve_curve_intersect::<4, 3>(a, b, 5, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE)).unwrap()
578    }
579
580    /// Both ends of the arc lie on its chord, but the arc leaves it: the
581    /// midpoint probe rules the candidate stretch out.
582    #[test]
583    fn arc_with_ends_on_chord_is_not_coincident() {
584        let r = wrap(&quarter_circle(), &line([1., 0., 0.], [0., 1., 0.]));
585        assert!(!r.is_coincident(), "{r:?}");
586        assert_eq!(r.len(), 2, "{r:?}");
587    }
588
589    #[test]
590    fn shared_vertex_is_not_coincident() {
591        let r = wrap(
592            &line([0., 0., 0.], [1., 0., 0.]),
593            &line([1., 0., 0.], [1., 1., 1.]),
594        );
595        assert!(!r.is_coincident() && r.len() == 1, "{r:?}");
596    }
597
598    /// The overlap's ends come first: `a` at s = ½ (where `b` starts) and
599    /// s = 1 (`a`'s end, `b` at t = ½).
600    #[test]
601    fn partial_overlap_reports_its_ends() {
602        let a = line([0., 0., 0.], [1., 0., 0.]);
603        let b = line([0.5, 0., 0.], [1.5, 0., 0.]);
604        let r = wrap(&a, &b);
605        assert!(r.is_coincident(), "{r:?}");
606        let v = r.as_slice();
607        let half = Scalar::from_f64(0.5);
608        assert!(
609            v[0].0.could_be_equal(half) && v[0].1.could_be_equal(Scalar::ZERO),
610            "{v:?}"
611        );
612        assert!(
613            v[1].0.could_be_equal(Scalar::ONE) && v[1].1.could_be_equal(half),
614            "{v:?}"
615        );
616        for (s, _) in v {
617            assert!(!s.definitely_less(half), "{v:?}");
618        }
619    }
620
621    /// Same arc, split differently: coincident over the shared quarter.
622    #[test]
623    fn arc_pieces_overlap() {
624        let arc = quarter_circle();
625        let (left, _) = arc.split(Scalar::from_f64(0.75)).unwrap();
626        let (_, right) = arc.split(Scalar::from_f64(0.25)).unwrap();
627        let r = wrap(&left, &right);
628        assert!(r.is_coincident(), "{r:?}");
629        for (s, t) in r.as_slice() {
630            assert!(s.could_be_equal(*t), "same curve, same parameter: {r:?}");
631        }
632    }
633
634    /// The test suite of the old `curve_curve` search, run unchanged against the
635    /// coincidence-handling wrapper — the drop-in contract it must keep.
636    mod old_suite {
637        use super::super::curve_curve_intersect;
638        use crate::intersection::curve_curve::refine_crossing;
639        use crate::nurb_curve::NurbCurve;
640        use geop_core_math::{
641            scalars::Scalar,
642            vector::{Vector3, Vector4},
643        };
644
645        const MAX_NODES: usize = 2000;
646
647        fn ptc(x: f64, y: f64, z: f64, w: f64) -> Vector4 {
648            Vector4::from_array([
649                Scalar::from_f64(x),
650                Scalar::from_f64(y),
651                Scalar::from_f64(z),
652                Scalar::from_f64(w),
653            ])
654        }
655
656        /// Horizontal line along the x axis, y=0.3, x ∈ [0,1].
657        fn horizontal_line() -> NurbCurve<4> {
658            let f = Scalar::from_f64;
659            NurbCurve::try_new(
660                1,
661                vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
662                vec![f(0.), f(0.), f(1.), f(1.)],
663            )
664            .unwrap()
665        }
666
667        /// Vertical line along the y axis at x=0.5, y ∈ [-1,1] -- crosses
668        /// `horizontal_line` once at (0.5, 0.3, 0).
669        fn vertical_crossing_line() -> NurbCurve<4> {
670            let f = Scalar::from_f64;
671            NurbCurve::try_new(
672                1,
673                vec![ptc(0.5, -1., 0., 1.), ptc(0.5, 1., 0., 1.)],
674                vec![f(0.), f(0.), f(1.), f(1.)],
675            )
676            .unwrap()
677        }
678
679        /// Vertical line along the y axis at x=2.0, y ∈ [-1,1] -- never crosses
680        /// `horizontal_line` (x ∈ [0,1]).
681        fn vertical_missing_line() -> NurbCurve<4> {
682            let f = Scalar::from_f64;
683            NurbCurve::try_new(
684                1,
685                vec![ptc(2.0, -1., 0., 1.), ptc(2.0, 1., 0., 1.)],
686                vec![f(0.), f(0.), f(1.), f(1.)],
687            )
688            .unwrap()
689        }
690
691        /// Quadratic Bézier dipping below y=0.3 and back, crossing
692        /// `horizontal_line` twice. x=0.3 is deliberately not the midpoint of
693        /// `horizontal_line`'s x range [0,1], avoiding the "both halves always
694        /// survive" tie pathology that exact midpoints trigger.
695        fn double_dip_curve() -> NurbCurve<4> {
696            let f = Scalar::from_f64;
697            NurbCurve::try_new(
698                2,
699                vec![
700                    ptc(0.3, 1.0, 0., 1.),
701                    ptc(0.5, -2.0, 0., 1.),
702                    ptc(0.7, 1.0, 0., 1.),
703                ],
704                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
705            )
706            .unwrap()
707        }
708
709        /// Straight line lying *on* `horizontal_line` (same y=0.3, z=0), spanning
710        /// only part of its x range: from (0.5, 0.3, 0) to (1.5, 0.3, 0). The
711        /// overlap with `horizontal_line` (x ∈ [0,1]) is x ∈ [0.5, 1].
712        fn coincident_overlap_line() -> NurbCurve<4> {
713            let f = Scalar::from_f64;
714            NurbCurve::try_new(
715                1,
716                vec![ptc(0.5, 0.3, 0., 1.), ptc(1.5, 0.3, 0., 1.)],
717                vec![f(0.), f(0.), f(1.), f(1.)],
718            )
719            .unwrap()
720        }
721
722        /// Straight line lying *on* `horizontal_line` exactly (same domain,
723        /// x ∈ [0,1], y=0.3, z=0) — fully coincident, not just partially.
724        fn full_coincident_line() -> NurbCurve<4> {
725            let f = Scalar::from_f64;
726            NurbCurve::try_new(
727                1,
728                vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
729                vec![f(0.), f(0.), f(1.), f(1.)],
730            )
731            .unwrap()
732        }
733
734        const MIN_SUBDIVISION_SIZE: f64 = 1e-6;
735
736        // ── Single crossing ───────────────────────────────────────────────────────
737
738        #[test]
739        fn single_crossing_curves_have_one_solution() {
740            let a = horizontal_line();
741            let b = vertical_crossing_line();
742            let result =
743                curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
744                    .unwrap();
745            assert_eq!(result.len(), 1);
746        }
747
748        // ── No crossing ───────────────────────────────────────────────────────────
749
750        #[test]
751        fn curves_missing_each_other_have_no_solution() {
752            let a = horizontal_line();
753            let b = vertical_missing_line();
754            let result =
755                curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
756                    .unwrap();
757            assert!(result.is_empty());
758        }
759
760        // ── Budget ────────────────────────────────────────────────────────────────
761
762        #[test]
763        fn max_solutions_zero_returns_empty() {
764            let a = horizontal_line();
765            let b = vertical_crossing_line();
766            let result =
767                curve_curve_intersect(&a, &b, 0, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
768                    .unwrap();
769            assert!(result.is_empty());
770        }
771
772        #[test]
773        fn max_nodes_exhausted_errors() {
774            // Adapted: see the same test in `curve_surface`'s copy — the
775            // coincident pair no longer overruns a tiny budget, so a pair
776            // with two crossings that genuinely needs more nodes stands in.
777            let a = horizontal_line();
778            let b = double_dip_curve();
779            let result =
780                curve_curve_intersect(&a, &b, 1000, 1, Scalar::from_f64(MIN_SUBDIVISION_SIZE));
781            assert!(result.is_err());
782        }
783
784        // ── Two crossings ─────────────────────────────────────────────────────────
785
786        #[test]
787        fn two_crossings_found_when_budget_allows() {
788            let a = horizontal_line();
789            let b = double_dip_curve();
790            let result =
791                curve_curve_intersect(&a, &b, 2, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
792                    .unwrap()
793                    .into_vec();
794            assert_eq!(result.len(), 2);
795            assert!(
796                !result[0].0.could_be_equal(result[1].0),
797                "the two crossings should remain distinct"
798            );
799        }
800
801        #[test]
802        fn max_solutions_one_caps_at_one_even_with_two_crossings() {
803            let a = horizontal_line();
804            let b = double_dip_curve();
805            let result =
806                curve_curve_intersect(&a, &b, 1, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
807                    .unwrap();
808            assert_eq!(result.len(), 1);
809        }
810
811        // ── min_subdivision_size controls precision ────────────────────────────
812
813        #[test]
814        fn min_subdivision_size_controls_precision() {
815            let a = horizontal_line();
816            let b = vertical_crossing_line();
817            let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(1e-3))
818                .unwrap()
819                .into_vec();
820            assert_eq!(result.len(), 1);
821
822            let (t_a, _) = result[0];
823            assert!(
824                t_a.sub(Scalar::from_f64(0.5))
825                    .abs()
826                    .could_be_less(Scalar::from_f64(1e-2))
827            );
828        }
829
830        // ── Coincident overlap: must terminate ───────────────────────────────────
831
832        #[test]
833        fn coincident_overlap_terminates() {
834            let a = horizontal_line();
835            let b = coincident_overlap_line();
836
837            // A single dive must converge to exactly one result.
838            let result_one =
839                curve_curve_intersect(&a, &b, 1, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
840                    .unwrap();
841            assert_eq!(result_one.len(), 1);
842
843            // Asking for more solutions still terminates, with at most that many
844            // (possibly fewer after merging) segments along the overlap, and
845            // each solution lying within the overlapping x ∈ [0.5, 1] range.
846            let result_many =
847                curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
848                    .unwrap();
849            assert!(!result_many.is_empty());
850            assert!(result_many.len() <= 5);
851
852            let lower_bound = Scalar::from_f64(0.5 - MIN_SUBDIVISION_SIZE);
853            for &(t_a, _) in result_many.as_slice() {
854                assert!(t_a.could_be_greater(lower_bound));
855            }
856        }
857
858        // ── Coincident: an evenly-spread solution count, not just 1-or-cap ──────
859
860        #[test]
861        fn full_coincidence_reaches_max_solutions() {
862            let a = horizontal_line();
863            let b = full_coincident_line();
864            // Two curves coincident over their *entire* shared domain, with a
865            // generous node budget, should reliably reach the requested
866            // solution count via the evenly-spread search -- and be reported
867            // via the explicit `Coincident` variant, not just inferred from
868            // hitting the length cap.
869            let result = curve_curve_intersect(&a, &b, 5, 5000, Scalar::from_f64(1e-3)).unwrap();
870            assert!(result.is_coincident());
871            assert_eq!(result.len(), 5);
872        }
873
874        // ── 2-D (D=3) pcurve intersection — the motivating use case ──────────────
875
876        fn pt2(x: f64, y: f64) -> Vector3 {
877            Vector3::from_array([Scalar::from_f64(x), Scalar::from_f64(y), Scalar::ONE])
878        }
879
880        /// Horizontal 2-D segment y=0.3, x ∈ [0,1].
881        fn horizontal_line_2d() -> crate::nurb_curve::NurbCurve2D {
882            let f = Scalar::from_f64;
883            NurbCurve::try_new(
884                1,
885                vec![pt2(0., 0.3), pt2(1., 0.3)],
886                vec![f(0.), f(0.), f(1.), f(1.)],
887            )
888            .unwrap()
889        }
890
891        /// Vertical 2-D segment x=0.5, y ∈ [-1,1] — crosses the horizontal line
892        /// once at (0.5, 0.3).
893        fn vertical_crossing_line_2d() -> crate::nurb_curve::NurbCurve2D {
894            let f = Scalar::from_f64;
895            NurbCurve::try_new(
896                1,
897                vec![pt2(0.5, -1.), pt2(0.5, 1.)],
898                vec![f(0.), f(0.), f(1.), f(1.)],
899            )
900            .unwrap()
901        }
902
903        #[test]
904        fn single_crossing_2d() {
905            let a = horizontal_line_2d();
906            let b = vertical_crossing_line_2d();
907            let result =
908                curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
909                    .unwrap()
910                    .into_vec();
911            assert_eq!(result.len(), 1);
912            let (t_a, _) = result[0];
913            let hit = a.evaluate(t_a).unwrap();
914            assert!(
915                hit[0]
916                    .sub(Scalar::from_f64(0.5))
917                    .abs()
918                    .could_be_less(Scalar::from_f64(1e-3))
919            );
920            assert!(
921                hit[1]
922                    .sub(Scalar::from_f64(0.3))
923                    .abs()
924                    .could_be_less(Scalar::from_f64(1e-3))
925            );
926        }
927
928        // ── Hard-to-intersect curves: quartic tangencies ─────────────────────────
929        //
930        // Both curves below are built by converting a monomial `(2t-1)^n` (in
931        // `x = 2t-1`, over the standard `t ∈ [0,1]` NURBS domain) to its exact
932        // Bernstein/Bezier control points, so `y` is *exactly* `x^n` along the
933        // curve, not merely close to it — an honest, analytically-known worst
934        // case rather than an approximation of one.
935
936        /// Degree-2 Bezier tracing `y = x^2` exactly (`x = 2t-1`, `t ∈ [0,1]`),
937        /// touching `y = 0` at `t = 0.5` with **order-2** contact (an ordinary
938        /// parabola-tangent-to-a-line case) — the case one level of
939        /// cross-product deflation is built to resolve.
940        fn quadratic_tangent_to_x_axis() -> NurbCurve<4> {
941            let f = Scalar::from_f64;
942            NurbCurve::try_new(
943                2,
944                vec![
945                    ptc(-1., 1., 0., 1.),
946                    ptc(0., -1., 0., 1.),
947                    ptc(1., 1., 0., 1.),
948                ],
949                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
950            )
951            .unwrap()
952        }
953
954        /// Degree-4 Bezier tracing `y = x^4` exactly (`x = 2t-1`, `t ∈ [0,1]`),
955        /// touching `y = 0` at `t = 0.5` with **order-4** contact — flatter than
956        /// cross-product deflation (which resolves order-2) can fully
957        /// regularize: at the touch point both the tangent cross product *and*
958        /// its first derivative vanish (`y = 16s^4` near `s = t-0.5` has
959        /// `y'' = 192s^2 = 0` at `s = 0` too). The deliberately hard case: does
960        /// refinement stay *sound* (never claims a narrower, wrong answer) when
961        /// it cannot fully converge, rather than just being tight when it can.
962        fn quartic_tangent_to_x_axis() -> NurbCurve<4> {
963            let f = Scalar::from_f64;
964            NurbCurve::try_new(
965                4,
966                vec![
967                    ptc(-1., 1., 0., 1.),
968                    ptc(-0.5, -1., 0., 1.),
969                    ptc(0., 1., 0., 1.),
970                    ptc(0.5, -1., 0., 1.),
971                    ptc(1., 1., 0., 1.),
972                ],
973                vec![
974                    f(0.),
975                    f(0.),
976                    f(0.),
977                    f(0.),
978                    f(0.),
979                    f(1.),
980                    f(1.),
981                    f(1.),
982                    f(1.),
983                    f(1.),
984                ],
985            )
986            .unwrap()
987        }
988
989        /// The x axis, x ∈ [-1,1] — the common tangent line for both curves
990        /// above, touched at (0,0,0).
991        fn x_axis_line() -> NurbCurve<4> {
992            let f = Scalar::from_f64;
993            NurbCurve::try_new(
994                1,
995                vec![ptc(-1., 0., 0., 1.), ptc(1., 0., 0., 1.)],
996                vec![f(0.), f(0.), f(1.), f(1.)],
997            )
998            .unwrap()
999        }
1000
1001        /// An order-2 tangency: `refine_crossing` is plain Newton, whose
1002        /// Jacobian is singular right at a tangential contact — so this is a
1003        /// soundness check, same shape as the order-4 case below, not a
1004        /// tightness one.
1005        #[test]
1006        fn refine_crossing_stays_sound_for_order_2_tangency() {
1007            let a = quadratic_tangent_to_x_axis();
1008            let b = x_axis_line();
1009
1010            let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(1e-3))
1011                .unwrap()
1012                .into_vec();
1013            assert_eq!(
1014                found.len(),
1015                1,
1016                "a single tangential touch, not a crossing pair"
1017            );
1018            let (t_a, t_b) = found[0];
1019
1020            // Soundness: the search's own (loose) box must already bracket the
1021            // true touch point.
1022            assert!(t_a.could_be_equal(Scalar::from_f64(0.5)));
1023            assert!(t_b.could_be_equal(Scalar::from_f64(0.5)));
1024
1025            let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
1026
1027            // Soundness: refinement must still contain the true parameter.
1028            assert!(ra.could_be_equal(Scalar::from_f64(0.5)));
1029            assert!(rb.could_be_equal(Scalar::from_f64(0.5)));
1030            // Never worse than the input: refinement can only tighten (or leave
1031            // it unchanged, which is what happens here since plain Newton's own
1032            // Jacobian is singular at this tangency too).
1033            assert!(ra.is_subset_of(t_a));
1034            assert!(rb.is_subset_of(t_b));
1035        }
1036
1037        /// Order-4 tangency: deflation's own Jacobian is *also* singular right
1038        /// at the touch point, so refinement is expected to stall -- the
1039        /// requirement under test is that it stays sound (still encloses the
1040        /// true parameter, never claims a narrower box than it actually proved)
1041        /// rather than that it achieves full precision.
1042        #[test]
1043        fn refine_crossing_stays_sound_for_order_4_tangency() {
1044            let a = quartic_tangent_to_x_axis();
1045            let b = x_axis_line();
1046
1047            let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, Scalar::from_f64(1e-3))
1048                .unwrap()
1049                .into_vec();
1050            assert!(!found.is_empty(), "the touch point must still be found");
1051
1052            for (t_a, t_b) in found {
1053                // Soundness of the search itself.
1054                assert!(t_a.could_be_equal(Scalar::from_f64(0.5)));
1055                assert!(t_b.could_be_equal(Scalar::from_f64(0.5)));
1056
1057                let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
1058
1059                // The refinement guarantee under test: whatever comes back
1060                // still encloses the true touch point (order-4 flatness may
1061                // well mean it comes back completely unchanged -- that is a
1062                // pass, not a failure, per `refine_crossing`'s own "can only
1063                // tighten, never fail" contract).
1064                assert!(
1065                    ra.could_be_equal(Scalar::from_f64(0.5)),
1066                    "refine_crossing must never lose the true root: got {ra:?}"
1067                );
1068                assert!(rb.could_be_equal(Scalar::from_f64(0.5)));
1069                // And never claim a box the search didn't already prove.
1070                assert!(ra.is_subset_of(t_a));
1071                assert!(rb.is_subset_of(t_b));
1072            }
1073        }
1074
1075        /// A transversal crossing very close to a quartic's flat spot (two
1076        /// distinct roots of `x^4 = 0.0001`, i.e. `x = ±0.1`, extremely close
1077        /// together and ill-conditioned near `x=0`) — not tangential at all,
1078        /// but numerically adversarial: checks the search still separates and
1079        /// soundly encloses both nearby crossings instead of merging or losing
1080        /// one.
1081        fn quartic_minus_epsilon() -> NurbCurve<4> {
1082            let f = Scalar::from_f64;
1083            // y = x^4 - 0.0001, same control-point construction as
1084            // `quartic_tangent_to_x_axis` with every y-coordinate shifted down
1085            // by the constant 0.0001 (Bezier control points are affine in the
1086            // curve's own coordinates, so a constant shift is just a shift of
1087            // every control point's y).
1088            let dy = 0.0001;
1089            NurbCurve::try_new(
1090                4,
1091                vec![
1092                    ptc(-1., 1. - dy, 0., 1.),
1093                    ptc(-0.5, -1. - dy, 0., 1.),
1094                    ptc(0., 1. - dy, 0., 1.),
1095                    ptc(0.5, -1. - dy, 0., 1.),
1096                    ptc(1., 1. - dy, 0., 1.),
1097                ],
1098                vec![
1099                    f(0.),
1100                    f(0.),
1101                    f(0.),
1102                    f(0.),
1103                    f(0.),
1104                    f(1.),
1105                    f(1.),
1106                    f(1.),
1107                    f(1.),
1108                    f(1.),
1109                ],
1110            )
1111            .unwrap()
1112        }
1113
1114        #[test]
1115        fn close_transversal_crossings_near_quartic_flat_spot() {
1116            let a = quartic_minus_epsilon();
1117            let b = x_axis_line();
1118
1119            let found = curve_curve_intersect(&a, &b, 4, MAX_NODES, Scalar::from_f64(1e-4))
1120                .unwrap()
1121                .into_vec();
1122            assert_eq!(
1123                found.len(),
1124                2,
1125                "two distinct, separated crossings near x=±0.1"
1126            );
1127            assert!(
1128                !found[0].0.could_be_equal(found[1].0),
1129                "the two nearby crossings must remain distinct"
1130            );
1131
1132            // x = 2t-1 = ±0.1 -> t = 0.45 or t = 0.55.
1133            for &(t_a, t_b) in &found {
1134                let near_left = t_a
1135                    .sub(Scalar::from_f64(0.45))
1136                    .abs()
1137                    .could_be_less(Scalar::from_f64(1e-2));
1138                let near_right = t_a
1139                    .sub(Scalar::from_f64(0.55))
1140                    .abs()
1141                    .could_be_less(Scalar::from_f64(1e-2));
1142                assert!(near_left || near_right, "crossing at unexpected t={t_a:?}");
1143
1144                let (ra, _) = refine_crossing(&a, &b, t_a, t_b);
1145                assert!(ra.is_subset_of(t_a), "refinement must only ever tighten");
1146            }
1147        }
1148    }
1149}