Skip to main content

geop_core_geometry/intersection/
curve_curve_bisect.rs

1//! The previous curve–curve search — convex hull tests and bisection, with
2//! coincidence read from reaching `max_solutions` — kept only as the
3//! baseline for `examples/intersection_bench.rs`. The kernel uses
4//! [`super::curve_curve`].
5
6use std::cmp::Ordering;
7use std::collections::BinaryHeap;
8
9use crate::{
10    aabb::aabb_could_overlap,
11    fat_axis::HasFatAxes,
12    nurb_curve::{HasConvexHull, NurbCurve, dehomogenize},
13};
14use geop_core_math::{
15    disjoint_set::DisjointSet,
16    geop_error::{GeopError, GeopResult},
17    scalars::Scalar,
18};
19
20use super::Intersections;
21
22/// An entry in the outer-loop priority queue: a `(seg_a, seg_b)` pair
23/// awaiting a DFS dive, ordered by `level` (shallower first) — see
24/// `curve_curve_intersect`'s own doc comment for why popping the shallowest
25/// pending pair first, rather than a plain LIFO stack, matters.
26struct QueueEntry<S: Scalar, const D: usize> {
27    level: usize,
28    seg_a: NurbCurve<S, D>,
29    seg_b: NurbCurve<S, D>,
30}
31
32impl<S: Scalar, const D: usize> PartialEq for QueueEntry<S, D> {
33    fn eq(&self, other: &Self) -> bool {
34        self.level == other.level
35    }
36}
37impl<S: Scalar, const D: usize> Eq for QueueEntry<S, D> {}
38impl<S: Scalar, const D: usize> PartialOrd for QueueEntry<S, D> {
39    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
40        Some(self.cmp(other))
41    }
42}
43impl<S: Scalar, const D: usize> Ord for QueueEntry<S, D> {
44    fn cmp(&self, other: &Self) -> Ordering {
45        // `BinaryHeap` is a max-heap; reverse so the smallest `level` is popped first.
46        other.level.cmp(&self.level)
47    }
48}
49
50/// Result of a single recursive DFS dive.
51enum DfsOutcome<S: Scalar, const D: usize> {
52    /// This subtree's hulls definitely cannot overlap; nothing found.
53    NoSolution,
54    /// A converged `(t_a, t_b)` candidate — each the [`Scalar::union`] of
55    /// whichever segment it converged on — along with the sibling subtrees
56    /// skipped on the way to it (each tagged with its own depth, for the
57    /// outer-loop priority queue).
58    Found {
59        solution: (S, S),
60        unexplored: Vec<(NurbCurve<S, D>, NurbCurve<S, D>, usize)>,
61    },
62}
63
64/// Recursively narrow `(seg_a, seg_b)` until either the hulls definitely
65/// cannot overlap (`NoSolution`), or neither segment's chord is definitely
66/// greater than `min_subdivision_size` (`Found`). Always dives into the
67/// *left* half of whichever side it split, stashing the right half in
68/// `unexplored` rather than recursing into it directly — the outer loop
69/// (`curve_curve_intersect`) is what actually explores those, via its own
70/// priority queue, so that work spreads evenly across the whole domain
71/// instead of this dive exhaustively finishing one side first.
72///
73/// `explored` is a node-visit counter shared across the *entire* search
74/// (threaded through every dive, not reset per call) — exceeding
75/// `max_nodes` aborts the whole search with an error rather than silently
76/// returning a possibly-incomplete result; see `curve_curve_intersect`'s
77/// own doc comment for why that distinction matters to callers.
78fn dfs<S: Scalar, const D: usize, const C: usize>(
79    seg_a: NurbCurve<S, D>,
80    seg_b: NurbCurve<S, D>,
81    level: usize,
82    min_subdivision_size: S,
83    explored: &mut usize,
84    max_nodes: usize,
85) -> GeopResult<DfsOutcome<S, D>>
86where
87    NurbCurve<S, D>: HasConvexHull<S, C> + HasFatAxes<S, C>,
88{
89    *explored += 1;
90    if *explored > max_nodes {
91        return Err(GeopError::new(
92            "curve_curve_intersect: exhausted max_nodes before the search converged",
93        ));
94    }
95
96    // No artificial padding: `seg_a`/`seg_b`'s own domains are already
97    // honest interval bounds (that's the whole point of interval
98    // arithmetic), so a converged solution's true position is already
99    // guaranteed to lie within them — inflating it further isn't adding
100    // safety, just needless imprecision that callers then have to account
101    // for themselves (e.g. `disjointness_check::coincides_with_a_vertex`
102    // used to need its own separate distance-epsilon fudge to compensate
103    // for this padding before comparing against a vertex's own tight
104    // bound). `DisjointSet::insert` merging only on an exact
105    // `Scalar::could_be_equal` overlap is the right, epsilon-free behavior;
106    // if a near-tangential touch converges to several adjacent-but-not-
107    // quite-overlapping leaves instead of one, that's real information (the
108    // touch genuinely isn't pinned down tighter than that yet), not
109    // something to paper over here.
110    let found_here = |seg_a: &NurbCurve<S, D>, seg_b: &NurbCurve<S, D>| {
111        let (a0, a1) = seg_a.domain();
112        let (b0, b1) = seg_b.domain();
113        DfsOutcome::Found {
114            solution: (a0.union(a1), b0.union(b1)),
115            unexplored: vec![],
116        }
117    };
118
119    // Cheap prefilter: each segment's cached axis-aligned bounding box (see
120    // `aabb::compute_aabb`) is far quicker to compare than building a convex
121    // hull and running GJK, and just as sound — if the boxes can't overlap,
122    // neither can the (tighter-fitting) hulls they contain. This alone
123    // resolves most dfs nodes; the hull/GJK check below only runs when it
124    // doesn't.
125    if !aabb_could_overlap(&seg_a.aabb, &seg_b.aabb, C) {
126        return Ok(DfsOutcome::NoSolution);
127    }
128
129    // Second cheap prefilter, tried before the expensive iterative GJK
130    // check below: each segment's own fat line/plane (through its
131    // endpoints, for a curve) is often a far more effective separating
132    // axis than the world-axis-aligned AABB for a diagonal segment — and
133    // testing along one fixed axis is a handful of dot products, not
134    // GJK's up-to-64-iteration simplex search. Sound for the same reason
135    // the AABB prefilter is: a fixed axis proving separation is a
136    // sufficient (if not exhaustive) condition, so `false` here is as
137    // trustworthy as `hull.definitely_no_overlap` — see `fat_axis`'s own
138    // module doc. Tried in both directions since either segment's own
139    // axis might be the one that resolves it.
140    if let (Ok(pts_a), Ok(pts_b)) = (
141        dehomogenize::<S, D, C>(&seg_a.control_points),
142        dehomogenize::<S, D, C>(&seg_b.control_points),
143    ) {
144        if seg_a.fat_axes_separate(&pts_b) || seg_b.fat_axes_separate(&pts_a) {
145            return Ok(DfsOutcome::NoSolution);
146        }
147    }
148
149    let (hull_a, hull_b) = match (seg_a.convex_hull(), seg_b.convex_hull()) {
150        (Ok(a), Ok(b)) => (a, b),
151        // Degenerate segment (zero weight): can't bound or split it any
152        // further — report whatever's here rather than silently dropping it.
153        _ => return Ok(found_here(&seg_a, &seg_b)),
154    };
155    if hull_a.definitely_no_overlap(&hull_b) {
156        return Ok(DfsOutcome::NoSolution);
157    }
158
159    let (size_a, size_b) = match (seg_a.size(), seg_b.size()) {
160        (Ok(a), Ok(b)) => (a, b),
161        _ => return Ok(found_here(&seg_a, &seg_b)),
162    };
163
164    // A segment counts as converged once *either* its physical chord
165    // (`size`) or its own parameter-domain width is no longer definitely
166    // greater than `min_subdivision_size` — not just `size` alone. `size`
167    // is a *physical* chord length, derived from the (possibly
168    // Boehm-insertion-noise-inflated) control points; the domain width is a
169    // direct, purely-parametric measure of how much room is even left to
170    // subdivide, immune to that noise. Relying on `size` alone lets a
171    // segment whose domain has already narrowed to (or past) what's
172    // representable keep getting picked as "still needs splitting" forever,
173    // since its noisy `size` never registers as small — this domain-width
174    // check is a second, independent way to recognize "nothing more to
175    // gain here" even when `size` is lying.
176    let (a0, a1) = seg_a.domain();
177    let width_a = a1.sub(a0);
178    let (b0, b1) = seg_b.domain();
179    let width_b = b1.sub(b0);
180    let a_converged = !size_a.definitely_greater(min_subdivision_size)
181        || !width_a.definitely_greater(min_subdivision_size);
182    let b_converged = !size_b.definitely_greater(min_subdivision_size)
183        || !width_b.definitely_greater(min_subdivision_size);
184
185    if a_converged && b_converged {
186        return Ok(found_here(&seg_a, &seg_b));
187    }
188
189    // Split whichever of the two segments still isn't converged; if both
190    // still aren't, split whichever is larger (ties go to `b`), same as
191    // before. Cannot split and hasn't converged — report whatever's here.
192    let split_a = if a_converged {
193        false
194    } else if b_converged {
195        true
196    } else {
197        size_a.definitely_greater(size_b)
198    };
199    let (left, right) = if split_a {
200        match seg_a.split_mid() {
201            Ok((l, r)) => ((l, seg_b.clone()), (r, seg_b)),
202            Err(_) => return Ok(found_here(&seg_a, &seg_b)),
203        }
204    } else {
205        match seg_b.split_mid() {
206            Ok((l, r)) => ((seg_a.clone(), l), (seg_a, r)),
207            Err(_) => return Ok(found_here(&seg_a, &seg_b)),
208        }
209    };
210
211    match dfs::<S, D, C>(
212        left.0,
213        left.1,
214        level + 1,
215        min_subdivision_size,
216        explored,
217        max_nodes,
218    )? {
219        DfsOutcome::Found {
220            solution,
221            mut unexplored,
222        } => {
223            unexplored.push((right.0, right.1, level + 1));
224            Ok(DfsOutcome::Found {
225                solution,
226                unexplored,
227            })
228        }
229        DfsOutcome::NoSolution => dfs::<S, D, C>(
230            right.0,
231            right.1,
232            level + 1,
233            min_subdivision_size,
234            explored,
235            max_nodes,
236        ),
237    }
238}
239
240/// Points where `curve_a` crosses (or, in the coincident case, overlaps)
241/// `curve_b`.
242///
243/// A DFS-with-priority-queue search (restored, against the current
244/// [`DisjointSet`]-based solution representation, from an earlier
245/// implementation removed by commit `d75bff5`): the outer loop always dives
246/// from the *shallowest* still-unexplored `(seg_a, seg_b)` pair (a
247/// level-ordered [`BinaryHeap`], so the search spreads laterally across the
248/// whole domain before going deep anywhere), each dive following [`dfs`]'s
249/// own leftmost-branch-first policy and stashing every sibling subtree it
250/// skips along the way back onto the queue at its own depth. For an
251/// isolated, genuine crossing this behaves essentially like a plain
252/// stack-based DFS (hull-overlap pruning quickly discards everything but
253/// the local neighborhood of the crossing, regardless of traversal order).
254/// But for a coincident pair — where hull-overlap pruning can't narrow
255/// anything down, since the two curves overlap almost everywhere along the
256/// shared region — a plain LIFO stack tends to exhaustively refine one
257/// small neighborhood (feeding [`DisjointSet`] a long run of adjacent,
258/// `could_be_equal` candidates that all merge into one ever-widening
259/// solution) before ever reaching a genuinely different part of the domain.
260/// The breadth-first-by-level ordering here instead guarantees an
261/// evenly-spread set of solutions across the *whole* shared region — which
262/// is what actually lets a caller reliably treat "found `max_solutions`
263/// distinct solutions" as a coincidence signal in the first place.
264///
265/// The search stops once `max_solutions` distinct solutions have been
266/// found, or the queue empties (every subtree explored, genuinely fewer
267/// solutions than `max_solutions`) — either way, `Ok`. If it instead
268/// exhausts `max_nodes` mid-dive without reaching either of those, that's a
269/// genuinely unknown result: this returns an error rather than silently
270/// reporting a possibly-incomplete solution set as if it were final. A
271/// caller checking `len() >= max_solutions` to detect coincidence has no
272/// way to tell "genuinely converged short of the budget" apart from "ran
273/// out of nodes early" otherwise — and the latter is, if anything, itself
274/// evidence of extended overlap, so callers relying on that heuristic
275/// should treat this error the same way they'd treat hitting
276/// `max_solutions`.
277///
278/// Generic over the curves' shared homogeneous dimension `D` (e.g. `D=4`
279/// for 3-D curves, `D=3` for 2-D pcurves) — both curves must share the same
280/// `D`.
281pub fn curve_curve_intersect<S: Scalar, const D: usize, const C: usize>(
282    curve_a: &NurbCurve<S, D>,
283    curve_b: &NurbCurve<S, D>,
284    max_solutions: usize,
285    max_nodes: usize,
286    min_subdivision_size: S,
287) -> GeopResult<Intersections<(S, S)>>
288where
289    NurbCurve<S, D>: HasConvexHull<S, C> + HasFatAxes<S, C>,
290{
291    let mut queue: BinaryHeap<QueueEntry<S, D>> = BinaryHeap::new();
292    queue.push(QueueEntry {
293        level: 0,
294        seg_a: curve_a.clone(),
295        seg_b: curve_b.clone(),
296    });
297
298    let mut explored = 0usize;
299    let mut solutions: DisjointSet<(S, S)> = DisjointSet::new();
300
301    while solutions.len() < max_solutions {
302        let Some(entry) = queue.pop() else { break };
303
304        match dfs::<S, D, C>(
305            entry.seg_a,
306            entry.seg_b,
307            entry.level,
308            min_subdivision_size,
309            &mut explored,
310            max_nodes,
311        )? {
312            DfsOutcome::NoSolution => continue,
313            DfsOutcome::Found {
314                solution,
315                unexplored,
316            } => {
317                solutions.insert(solution);
318                for (a, b, lvl) in unexplored {
319                    queue.push(QueueEntry {
320                        level: lvl,
321                        seg_a: a,
322                        seg_b: b,
323                    });
324                }
325            }
326        }
327    }
328
329    let result = solutions.into_vec();
330    Ok(if max_solutions > 0 && result.len() >= max_solutions {
331        Intersections::Coincident(result)
332    } else {
333        Intersections::Found(result)
334    })
335}
336
337#[cfg(test)]
338mod tests {
339    use super::curve_curve_intersect;
340    use crate::intersection::curve_curve::refine_crossing;
341    use crate::nurb_curve::NurbCurve;
342    use geop_core_math::for_all_scalars;
343    use geop_core_math::{
344        scalars::Scalar,
345        vector::{Vector3, Vector4},
346    };
347
348    const MAX_NODES: usize = 2000;
349
350    fn ptc<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
351        Vector4::from_array([
352            S::from_f64(x),
353            S::from_f64(y),
354            S::from_f64(z),
355            S::from_f64(w),
356        ])
357    }
358
359    /// Horizontal line along the x axis, y=0.3, x ∈ [0,1].
360    fn horizontal_line<S: Scalar>() -> NurbCurve<S, 4> {
361        let f = S::from_f64;
362        NurbCurve::try_new(
363            1,
364            vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
365            vec![f(0.), f(0.), f(1.), f(1.)],
366        )
367        .unwrap()
368    }
369
370    /// Vertical line along the y axis at x=0.5, y ∈ [-1,1] -- crosses
371    /// `horizontal_line` once at (0.5, 0.3, 0).
372    fn vertical_crossing_line<S: Scalar>() -> NurbCurve<S, 4> {
373        let f = S::from_f64;
374        NurbCurve::try_new(
375            1,
376            vec![ptc(0.5, -1., 0., 1.), ptc(0.5, 1., 0., 1.)],
377            vec![f(0.), f(0.), f(1.), f(1.)],
378        )
379        .unwrap()
380    }
381
382    /// Vertical line along the y axis at x=2.0, y ∈ [-1,1] -- never crosses
383    /// `horizontal_line` (x ∈ [0,1]).
384    fn vertical_missing_line<S: Scalar>() -> NurbCurve<S, 4> {
385        let f = S::from_f64;
386        NurbCurve::try_new(
387            1,
388            vec![ptc(2.0, -1., 0., 1.), ptc(2.0, 1., 0., 1.)],
389            vec![f(0.), f(0.), f(1.), f(1.)],
390        )
391        .unwrap()
392    }
393
394    /// Quadratic Bézier dipping below y=0.3 and back, crossing
395    /// `horizontal_line` twice. x=0.3 is deliberately not the midpoint of
396    /// `horizontal_line`'s x range [0,1], avoiding the "both halves always
397    /// survive" tie pathology that exact midpoints trigger.
398    fn double_dip_curve<S: Scalar>() -> NurbCurve<S, 4> {
399        let f = S::from_f64;
400        NurbCurve::try_new(
401            2,
402            vec![
403                ptc(0.3, 1.0, 0., 1.),
404                ptc(0.5, -2.0, 0., 1.),
405                ptc(0.7, 1.0, 0., 1.),
406            ],
407            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
408        )
409        .unwrap()
410    }
411
412    /// Straight line lying *on* `horizontal_line` (same y=0.3, z=0), spanning
413    /// only part of its x range: from (0.5, 0.3, 0) to (1.5, 0.3, 0). The
414    /// overlap with `horizontal_line` (x ∈ [0,1]) is x ∈ [0.5, 1].
415    fn coincident_overlap_line<S: Scalar>() -> NurbCurve<S, 4> {
416        let f = S::from_f64;
417        NurbCurve::try_new(
418            1,
419            vec![ptc(0.5, 0.3, 0., 1.), ptc(1.5, 0.3, 0., 1.)],
420            vec![f(0.), f(0.), f(1.), f(1.)],
421        )
422        .unwrap()
423    }
424
425    /// Straight line lying *on* `horizontal_line` exactly (same domain,
426    /// x ∈ [0,1], y=0.3, z=0) — fully coincident, not just partially.
427    fn full_coincident_line<S: Scalar>() -> NurbCurve<S, 4> {
428        let f = S::from_f64;
429        NurbCurve::try_new(
430            1,
431            vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
432            vec![f(0.), f(0.), f(1.), f(1.)],
433        )
434        .unwrap()
435    }
436
437    const EPS: f64 = 1e-6;
438
439    // ── Single crossing ───────────────────────────────────────────────────────
440
441    fn check_single_crossing_curves_have_one_solution<S: Scalar>() {
442        let a = horizontal_line::<S>();
443        let b = vertical_crossing_line::<S>();
444        let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
445        assert_eq!(result.len(), 1);
446    }
447    #[test]
448    fn single_crossing_curves_have_one_solution() {
449        for_all_scalars!(check_single_crossing_curves_have_one_solution);
450    }
451
452    // ── No crossing ───────────────────────────────────────────────────────────
453
454    fn check_curves_missing_each_other_have_no_solution<S: Scalar>() {
455        let a = horizontal_line::<S>();
456        let b = vertical_missing_line::<S>();
457        let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
458        assert!(result.is_empty());
459    }
460    #[test]
461    fn curves_missing_each_other_have_no_solution() {
462        for_all_scalars!(check_curves_missing_each_other_have_no_solution);
463    }
464
465    // ── Budget ────────────────────────────────────────────────────────────────
466
467    fn check_max_solutions_zero_returns_empty<S: Scalar>() {
468        let a = horizontal_line::<S>();
469        let b = vertical_crossing_line::<S>();
470        let result = curve_curve_intersect(&a, &b, 0, MAX_NODES, S::from_f64(EPS)).unwrap();
471        assert!(result.is_empty());
472    }
473    #[test]
474    fn max_solutions_zero_returns_empty() {
475        for_all_scalars!(check_max_solutions_zero_returns_empty);
476    }
477
478    fn check_max_nodes_exhausted_errors<S: Scalar>() {
479        let a = horizontal_line::<S>();
480        let b = full_coincident_line::<S>();
481        // A coincident pair searching for far more solutions than a tiny
482        // node budget can possibly separate must error, not silently
483        // return a truncated/misleading result.
484        let result = curve_curve_intersect(&a, &b, 1000, 3, S::from_f64(EPS));
485        assert!(result.is_err());
486    }
487    #[test]
488    fn max_nodes_exhausted_errors() {
489        for_all_scalars!(check_max_nodes_exhausted_errors);
490    }
491
492    // ── Two crossings ─────────────────────────────────────────────────────────
493
494    fn check_two_crossings_found_when_budget_allows<S: Scalar>() {
495        let a = horizontal_line::<S>();
496        let b = double_dip_curve::<S>();
497        let result = curve_curve_intersect(&a, &b, 2, MAX_NODES, S::from_f64(EPS))
498            .unwrap()
499            .into_vec();
500        assert_eq!(result.len(), 2);
501        assert!(
502            !result[0].0.could_be_equal(result[1].0),
503            "the two crossings should remain distinct"
504        );
505    }
506    #[test]
507    fn two_crossings_found_when_budget_allows() {
508        for_all_scalars!(check_two_crossings_found_when_budget_allows);
509    }
510
511    fn check_max_solutions_one_caps_at_one_even_with_two_crossings<S: Scalar>() {
512        let a = horizontal_line::<S>();
513        let b = double_dip_curve::<S>();
514        let result = curve_curve_intersect(&a, &b, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
515        assert_eq!(result.len(), 1);
516    }
517    #[test]
518    fn max_solutions_one_caps_at_one_even_with_two_crossings() {
519        for_all_scalars!(check_max_solutions_one_caps_at_one_even_with_two_crossings);
520    }
521
522    // ── min_subdivision_size controls precision ────────────────────────────
523
524    fn check_min_subdivision_size_controls_precision<S: Scalar>() {
525        let a = horizontal_line::<S>();
526        let b = vertical_crossing_line::<S>();
527        let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
528            .unwrap()
529            .into_vec();
530        assert_eq!(result.len(), 1);
531
532        let (t_a, _) = result[0];
533        assert!(
534            t_a.sub(S::from_f64(0.5))
535                .abs()
536                .could_be_less(S::from_f64(1e-2))
537        );
538    }
539    #[test]
540    fn min_subdivision_size_controls_precision() {
541        for_all_scalars!(check_min_subdivision_size_controls_precision);
542    }
543
544    // ── Coincident overlap: must terminate ───────────────────────────────────
545
546    fn check_coincident_overlap_terminates<S: Scalar + 'static>() {
547        let a = horizontal_line::<S>();
548        let b = coincident_overlap_line::<S>();
549
550        // A single dive must converge to exactly one result.
551        let result_one = curve_curve_intersect(&a, &b, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
552        assert_eq!(result_one.len(), 1);
553
554        // Asking for more solutions still terminates, with at most that many
555        // (possibly fewer after merging) segments along the overlap, and
556        // each solution lying within the overlapping x ∈ [0.5, 1] range.
557        let result_many = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
558        assert!(!result_many.is_empty());
559        assert!(result_many.len() <= 5);
560
561        let lower_bound = S::from_f64(0.5 - EPS);
562        for &(t_a, _) in result_many.as_slice() {
563            assert!(t_a.could_be_greater(lower_bound));
564        }
565    }
566    #[test]
567    fn coincident_overlap_terminates() {
568        for_all_scalars!(check_coincident_overlap_terminates);
569    }
570
571    // ── Coincident: an evenly-spread solution count, not just 1-or-cap ──────
572
573    fn check_full_coincidence_reaches_max_solutions<S: Scalar>() {
574        let a = horizontal_line::<S>();
575        let b = full_coincident_line::<S>();
576        // Two curves coincident over their *entire* shared domain, with a
577        // generous node budget, should reliably reach the requested
578        // solution count via the evenly-spread search -- and be reported
579        // via the explicit `Coincident` variant, not just inferred from
580        // hitting the length cap.
581        let result = curve_curve_intersect(&a, &b, 5, 5000, S::from_f64(1e-3)).unwrap();
582        assert!(result.is_coincident());
583        assert_eq!(result.len(), 5);
584    }
585    #[test]
586    fn full_coincidence_reaches_max_solutions() {
587        for_all_scalars!(check_full_coincidence_reaches_max_solutions);
588    }
589
590    // ── 2-D (D=3) pcurve intersection — the motivating use case ──────────────
591
592    fn pt2<S: Scalar>(x: f64, y: f64) -> Vector3<S> {
593        Vector3::from_array([S::from_f64(x), S::from_f64(y), S::ONE])
594    }
595
596    /// Horizontal 2-D segment y=0.3, x ∈ [0,1].
597    fn horizontal_line_2d<S: Scalar>() -> crate::nurb_curve::NurbCurve2D<S> {
598        let f = S::from_f64;
599        NurbCurve::try_new(
600            1,
601            vec![pt2(0., 0.3), pt2(1., 0.3)],
602            vec![f(0.), f(0.), f(1.), f(1.)],
603        )
604        .unwrap()
605    }
606
607    /// Vertical 2-D segment x=0.5, y ∈ [-1,1] — crosses the horizontal line
608    /// once at (0.5, 0.3).
609    fn vertical_crossing_line_2d<S: Scalar>() -> crate::nurb_curve::NurbCurve2D<S> {
610        let f = S::from_f64;
611        NurbCurve::try_new(
612            1,
613            vec![pt2(0.5, -1.), pt2(0.5, 1.)],
614            vec![f(0.), f(0.), f(1.), f(1.)],
615        )
616        .unwrap()
617    }
618
619    fn check_single_crossing_2d<S: Scalar>() {
620        let a = horizontal_line_2d::<S>();
621        let b = vertical_crossing_line_2d::<S>();
622        let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS))
623            .unwrap()
624            .into_vec();
625        assert_eq!(result.len(), 1);
626        let (t_a, _) = result[0];
627        let hit = a.evaluate(t_a).unwrap();
628        assert!(
629            hit[0]
630                .sub(S::from_f64(0.5))
631                .abs()
632                .could_be_less(S::from_f64(1e-3))
633        );
634        assert!(
635            hit[1]
636                .sub(S::from_f64(0.3))
637                .abs()
638                .could_be_less(S::from_f64(1e-3))
639        );
640    }
641    #[test]
642    fn single_crossing_2d() {
643        for_all_scalars!(check_single_crossing_2d);
644    }
645
646    // ── Hard-to-intersect curves: quartic tangencies ─────────────────────────
647    //
648    // Both curves below are built by converting a monomial `(2t-1)^n` (in
649    // `x = 2t-1`, over the standard `t ∈ [0,1]` NURBS domain) to its exact
650    // Bernstein/Bezier control points, so `y` is *exactly* `x^n` along the
651    // curve, not merely close to it — an honest, analytically-known worst
652    // case rather than an approximation of one.
653
654    /// Degree-2 Bezier tracing `y = x^2` exactly (`x = 2t-1`, `t ∈ [0,1]`),
655    /// touching `y = 0` at `t = 0.5` with **order-2** contact (an ordinary
656    /// parabola-tangent-to-a-line case) — the case one level of
657    /// cross-product deflation is built to resolve.
658    fn quadratic_tangent_to_x_axis<S: Scalar>() -> NurbCurve<S, 4> {
659        let f = S::from_f64;
660        NurbCurve::try_new(
661            2,
662            vec![
663                ptc(-1., 1., 0., 1.),
664                ptc(0., -1., 0., 1.),
665                ptc(1., 1., 0., 1.),
666            ],
667            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
668        )
669        .unwrap()
670    }
671
672    /// Degree-4 Bezier tracing `y = x^4` exactly (`x = 2t-1`, `t ∈ [0,1]`),
673    /// touching `y = 0` at `t = 0.5` with **order-4** contact — flatter than
674    /// cross-product deflation (which resolves order-2) can fully
675    /// regularize: at the touch point both the tangent cross product *and*
676    /// its first derivative vanish (`y = 16s^4` near `s = t-0.5` has
677    /// `y'' = 192s^2 = 0` at `s = 0` too). The deliberately hard case: does
678    /// refinement stay *sound* (never claims a narrower, wrong answer) when
679    /// it cannot fully converge, rather than just being tight when it can.
680    fn quartic_tangent_to_x_axis<S: Scalar>() -> NurbCurve<S, 4> {
681        let f = S::from_f64;
682        NurbCurve::try_new(
683            4,
684            vec![
685                ptc(-1., 1., 0., 1.),
686                ptc(-0.5, -1., 0., 1.),
687                ptc(0., 1., 0., 1.),
688                ptc(0.5, -1., 0., 1.),
689                ptc(1., 1., 0., 1.),
690            ],
691            vec![
692                f(0.),
693                f(0.),
694                f(0.),
695                f(0.),
696                f(0.),
697                f(1.),
698                f(1.),
699                f(1.),
700                f(1.),
701                f(1.),
702            ],
703        )
704        .unwrap()
705    }
706
707    /// The x axis, x ∈ [-1,1] — the common tangent line for both curves
708    /// above, touched at (0,0,0).
709    fn x_axis_line<S: Scalar>() -> NurbCurve<S, 4> {
710        let f = S::from_f64;
711        NurbCurve::try_new(
712            1,
713            vec![ptc(-1., 0., 0., 1.), ptc(1., 0., 0., 1.)],
714            vec![f(0.), f(0.), f(1.), f(1.)],
715        )
716        .unwrap()
717    }
718
719    /// An order-2 tangency: with Krawczyk-verified deflation currently
720    /// disabled (see the module comment near the top of the file),
721    /// `refine_crossing` is plain Newton, whose Jacobian is *also* singular
722    /// right at a tangential contact — so this is a soundness check, same
723    /// shape as the order-4 case below, not a tightness one. (Once deflation
724    /// is re-enabled, this is exactly the case it's meant to resolve to
725    /// machine/fixed-point precision instead.)
726    fn check_refine_crossing_stays_sound_for_order_2_tangency<S: Scalar>() {
727        let a = quadratic_tangent_to_x_axis::<S>();
728        let b = x_axis_line::<S>();
729
730        let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
731            .unwrap()
732            .into_vec();
733        assert_eq!(
734            found.len(),
735            1,
736            "a single tangential touch, not a crossing pair"
737        );
738        let (t_a, t_b) = found[0];
739
740        // Soundness: the search's own (loose) box must already bracket the
741        // true touch point.
742        assert!(t_a.could_be_equal(S::from_f64(0.5)));
743        assert!(t_b.could_be_equal(S::from_f64(0.5)));
744
745        let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
746
747        // Soundness: refinement must still contain the true parameter.
748        assert!(ra.could_be_equal(S::from_f64(0.5)));
749        assert!(rb.could_be_equal(S::from_f64(0.5)));
750        // Never worse than the input: refinement can only tighten (or leave
751        // it unchanged, which is what happens here since plain Newton's own
752        // Jacobian is singular at this tangency too).
753        assert!(ra.is_subset_of(t_a));
754        assert!(rb.is_subset_of(t_b));
755    }
756    #[test]
757    fn refine_crossing_stays_sound_for_order_2_tangency() {
758        for_all_scalars!(check_refine_crossing_stays_sound_for_order_2_tangency);
759    }
760
761    /// Order-4 tangency: deflation's own Jacobian is *also* singular right
762    /// at the touch point, so refinement is expected to stall -- the
763    /// requirement under test is that it stays sound (still encloses the
764    /// true parameter, never claims a narrower box than it actually proved)
765    /// rather than that it achieves full precision.
766    fn check_refine_crossing_stays_sound_for_order_4_tangency<S: Scalar>() {
767        let a = quartic_tangent_to_x_axis::<S>();
768        let b = x_axis_line::<S>();
769
770        let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
771            .unwrap()
772            .into_vec();
773        assert!(!found.is_empty(), "the touch point must still be found");
774
775        for (t_a, t_b) in found {
776            // Soundness of the search itself.
777            assert!(t_a.could_be_equal(S::from_f64(0.5)));
778            assert!(t_b.could_be_equal(S::from_f64(0.5)));
779
780            let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
781
782            // The refinement guarantee under test: whatever comes back
783            // still encloses the true touch point (order-4 flatness may
784            // well mean it comes back completely unchanged -- that is a
785            // pass, not a failure, per `refine_crossing`'s own "can only
786            // tighten, never fail" contract).
787            assert!(
788                ra.could_be_equal(S::from_f64(0.5)),
789                "refine_crossing must never lose the true root: got {ra:?}"
790            );
791            assert!(rb.could_be_equal(S::from_f64(0.5)));
792            // And never claim a box the search didn't already prove.
793            assert!(ra.is_subset_of(t_a));
794            assert!(rb.is_subset_of(t_b));
795        }
796    }
797    #[test]
798    fn refine_crossing_stays_sound_for_order_4_tangency() {
799        for_all_scalars!(check_refine_crossing_stays_sound_for_order_4_tangency);
800    }
801
802    /// A transversal crossing very close to a quartic's flat spot (two
803    /// distinct roots of `x^4 = 0.0001`, i.e. `x = ±0.1`, extremely close
804    /// together and ill-conditioned near `x=0`) — not tangential at all,
805    /// but numerically adversarial: checks the search still separates and
806    /// soundly encloses both nearby crossings instead of merging or losing
807    /// one.
808    fn quartic_minus_epsilon<S: Scalar>() -> NurbCurve<S, 4> {
809        let f = S::from_f64;
810        // y = x^4 - 0.0001, same control-point construction as
811        // `quartic_tangent_to_x_axis` with every y-coordinate shifted down
812        // by the constant 0.0001 (Bezier control points are affine in the
813        // curve's own coordinates, so a constant shift is just a shift of
814        // every control point's y).
815        let dy = 0.0001;
816        NurbCurve::try_new(
817            4,
818            vec![
819                ptc(-1., 1. - dy, 0., 1.),
820                ptc(-0.5, -1. - dy, 0., 1.),
821                ptc(0., 1. - dy, 0., 1.),
822                ptc(0.5, -1. - dy, 0., 1.),
823                ptc(1., 1. - dy, 0., 1.),
824            ],
825            vec![
826                f(0.),
827                f(0.),
828                f(0.),
829                f(0.),
830                f(0.),
831                f(1.),
832                f(1.),
833                f(1.),
834                f(1.),
835                f(1.),
836            ],
837        )
838        .unwrap()
839    }
840
841    fn check_close_transversal_crossings_near_quartic_flat_spot<S: Scalar>() {
842        let a = quartic_minus_epsilon::<S>();
843        let b = x_axis_line::<S>();
844
845        let found = curve_curve_intersect(&a, &b, 4, MAX_NODES, S::from_f64(1e-4))
846            .unwrap()
847            .into_vec();
848        assert_eq!(
849            found.len(),
850            2,
851            "two distinct, separated crossings near x=±0.1"
852        );
853        assert!(
854            !found[0].0.could_be_equal(found[1].0),
855            "the two nearby crossings must remain distinct"
856        );
857
858        // x = 2t-1 = ±0.1 -> t = 0.45 or t = 0.55.
859        for &(t_a, t_b) in &found {
860            let near_left = t_a
861                .sub(S::from_f64(0.45))
862                .abs()
863                .could_be_less(S::from_f64(1e-2));
864            let near_right = t_a
865                .sub(S::from_f64(0.55))
866                .abs()
867                .could_be_less(S::from_f64(1e-2));
868            assert!(near_left || near_right, "crossing at unexpected t={t_a:?}");
869
870            let (ra, _) = refine_crossing(&a, &b, t_a, t_b);
871            assert!(ra.is_subset_of(t_a), "refinement must only ever tighten");
872        }
873    }
874    #[test]
875    fn close_transversal_crossings_near_quartic_flat_spot() {
876        for_all_scalars!(check_close_transversal_crossings_near_quartic_flat_spot);
877    }
878}