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 std::collections::VecDeque;
17
18use super::{
19    Intersections,
20    coincidence::{self, Hit, Overlap},
21};
22use crate::nurb_surface::clamp;
23use crate::{
24    aabb::aabb_could_overlap,
25    contains::curve::curve_could_contain,
26    fat_line::{
27        Plan, carried_width, clip_tensor, converged, directions, extent, greville_abscissae, plan,
28    },
29    knot_insertion::pinned_clamped_end,
30    nurb_curve::{NurbCurve, ParameterRefinable, dehomogenize},
31};
32use geop_core_math::{
33    disjoint_set::DisjointSet,
34    geop_error::{GeopError, GeopResult, WithContext},
35    matrix::{Matrix, solve_linear_system},
36    scalars::Scalar,
37    vector::Vector,
38};
39
40/// Clip the pair: a box `[ŝ, t̂]` inside both domains enclosing every
41/// `(s, t)` with `A(s) = B(t)`, or `None` if some equation proves there is
42/// none. `NurbCurve::try_new` guarantees positive weights, which the cross
43/// multiplication needs.
44///
45/// The equations are combinations `g_n = n · (H_A W_B - W_A H_B)` along
46/// free-choice directions ([`directions`]): the `C - 1` directions
47/// perpendicular to `B`'s chord (nearly independent of `t`, so they pin `s`)
48/// and those perpendicular to `A`'s (pinning `t`). Together they span
49/// space, so a pair satisfying all of them is a solution. If they don't —
50/// a closed segment without a chord, or two collinear curves — the
51/// coordinate axes are added.
52fn clip<S: Scalar, const D: usize, const C: usize>(
53    a: &NurbCurve<S, D>,
54    b: &NurbCurve<S, D>,
55) -> GeopResult<Option<[S; 2]>> {
56    let mut hats = [a.domain_as_scalar(), b.domain_as_scalar()];
57    let (na, nb) = (a.control_points.len(), b.control_points.len());
58    let greville = [
59        greville_abscissae(&a.knot_vector, a.degree, na)?,
60        greville_abscissae(&b.knot_vector, b.degree, nb)?,
61    ];
62
63    let chord = |c: &NurbCurve<S, D>| {
64        directions::sub(
65            directions::cartesian::<S, D, C>(&c.control_points[c.control_points.len() - 1]),
66            directions::cartesian::<S, D, C>(&c.control_points[0]),
67        )
68    };
69    let mut dirs = directions::complement(chord(b));
70    dirs.extend(directions::complement(chord(a)));
71    let dirs = directions::spanning(dirs);
72
73    let w = D - 1;
74    let mut d = Vec::with_capacity(na * nb);
75    for n in dirs {
76        let n = directions::sharp::<S, C>(n);
77        let nb_pts: Vec<S> = b
78            .control_points
79            .iter()
80            .map(|q| directions::dot(&n, q))
81            .collect();
82        d.clear();
83        for p in &a.control_points {
84            let np = directions::dot(&n, p);
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/// If the clip pinned `s` (`t`) exactly onto a clamped end of its curve,
100/// every solution has that curve's *endpoint* as its spatial point, so the
101/// rest is point containment in the other curve (the "boundary evaluation"
102/// of `contains/surface.md` §3). Returns the endpoint and whether it is `A`'s.
103fn pinned_endpoint<S: Scalar, const D: usize, const C: usize>(
104    a: &NurbCurve<S, D>,
105    b: &NurbCurve<S, D>,
106    hats: [S; 2],
107) -> Option<(Vector<S, C>, bool)> {
108    let endpoint = |curve: &NurbCurve<S, D>, first: bool| {
109        let cp = if first {
110            curve.control_points[0]
111        } else {
112            curve.control_points[curve.control_points.len() - 1]
113        };
114        dehomogenize::<S, D, C>(&[cp]).ok().map(|v| v[0])
115    };
116    let pinned = |curve: &NurbCurve<S, D>, hat: S| {
117        pinned_clamped_end(
118            hat,
119            &curve.knot_vector,
120            curve.control_points.len(),
121            curve.degree,
122        )
123    };
124    if let Some(first) = pinned(a, hats[0]) {
125        return endpoint(a, first).map(|p| (p, true));
126    }
127    if let Some(first) = pinned(b, hats[1]) {
128        return endpoint(b, first).map(|p| (p, false));
129    }
130    None
131}
132
133/// All `(s, t)` with `curve_a(s) = curve_b(t)`, as paired parameter boxes
134/// (`curve_curve.md`), for curves that do **not** overlap along an arc — see
135/// [`curve_curve_intersect`] for the wrapper that handles overlaps.
136/// Breadth-first over pairs of subcurves:
137///
138/// - the cached AABBs and the [`clip`] are necessary conditions — failing
139///   either rejects the pair;
140/// - a pair converges once both segments' extents are within
141///   `min_subdivision_size` ([`crate::fat_line::converged`]), and reports its
142///   segments' domains — a *candidate*, not an existence proof;
143/// - if one parameter is pinned onto a clamped end, the other curve is
144///   searched for that endpoint with [`curve_could_contain`];
145/// - otherwise both curves are restricted to the clip, or one is bisected,
146///   per [`crate::fat_line::plan`]. Rebuilding the coefficients after a
147///   restriction is what couples the directions: clipping `t` shrinks the
148///   rows unioned for `s`.
149///
150/// Boxes that overlap are merged by union into one unresolved cluster
151/// (`DisjointSet`), never averaged. Exhausting `max_nodes` is an error —
152/// the result would be incomplete, and neither "empty" nor "coincident" may
153/// be read into it.
154pub fn curve_curve_crossings<S: Scalar, const D: usize, const C: usize>(
155    curve_a: &NurbCurve<S, D>,
156    curve_b: &NurbCurve<S, D>,
157    max_nodes: usize,
158    min_subdivision_size: S,
159) -> GeopResult<Vec<(S, S)>>
160where
161    NurbCurve<S, D>: ParameterRefinable<S, C>,
162{
163    let mut queue: VecDeque<(NurbCurve<S, D>, NurbCurve<S, D>)> = VecDeque::new();
164    queue.push_back((curve_a.clone(), curve_b.clone()));
165    let mut explored = 0usize;
166    let mut solutions: DisjointSet<(S, S)> = DisjointSet::new();
167
168    while let Some((a, b)) = queue.pop_front() {
169        if explored >= max_nodes {
170            return Err(GeopError::new(format!(
171                "curve_curve_crossings: exhausted max_nodes={max_nodes} with {} \
172                 pairs pending; the result would be incomplete",
173                queue.len() + 1
174            )));
175        }
176        explored += 1;
177
178        if !aabb_could_overlap(&a.aabb, &b.aabb, C) {
179            continue;
180        }
181        let Some(hats) = clip::<S, D, C>(&a, &b)? else {
182            continue;
183        };
184
185        let sizes = [
186            extent([a.control_points.clone()]),
187            extent([b.control_points.clone()]),
188        ];
189        let carried = carried_width(&a.control_points).max(carried_width(&b.control_points));
190        if converged(&sizes, carried, min_subdivision_size) {
191            // The pieces' whole domains, not the tighter clip: pieces the
192            // search could not separate — a tangency converges on a chain of
193            // adjacent ones — must merge into one unresolved cluster, and
194            // adjacent domains share an endpoint where clips need not touch.
195            // A transversal crossing loses nothing: restriction has already
196            // cut its pieces down to the clip.
197            solutions.insert((a.domain_as_scalar(), b.domain_as_scalar()));
198            continue;
199        }
200
201        if let Some((point, on_a)) = pinned_endpoint::<S, D, C>(&a, &b, hats) {
202            let (other, free) = if on_a { (&b, hats[1]) } else { (&a, hats[0]) };
203            let budget = max_nodes - explored;
204            if let Some(t) = curve_could_contain(other, &point, budget, min_subdivision_size)? {
205                if t.could_be_equal(free) {
206                    let free = free.intersect(t);
207                    solutions.insert(if on_a {
208                        (hats[0], free)
209                    } else {
210                        (free, hats[1])
211                    });
212                }
213            }
214            continue;
215        }
216
217        let ranges = [a.domain(), b.domain()];
218        let order = match plan(&hats, &ranges, &sizes, min_subdivision_size)? {
219            Plan::Restrict(bounds) => {
220                match (
221                    a.sub_curve(bounds[0].0, bounds[0].1),
222                    b.sub_curve(bounds[1].0, bounds[1].1),
223                ) {
224                    (Ok(ra), Ok(rb)) => {
225                        queue.push_back((ra, rb));
226                        continue;
227                    }
228                    _ => vec![0, 1],
229                }
230            }
231            Plan::Bisect(order) => order,
232        };
233        let children = order.iter().find_map(|&dir| {
234            if dir == 0 {
235                let (l, r) = a.split_mid().ok()?;
236                Some([(l, b.clone()), (r, b.clone())])
237            } else {
238                let (l, r) = b.split_mid().ok()?;
239                Some([(a.clone(), l), (a.clone(), r)])
240            }
241        });
242        match children {
243            Some(children) => queue.extend(children),
244            // Nothing left to cut or split: what's here is the candidate.
245            None => solutions.insert((hats[0], hats[1])),
246        }
247    }
248
249    Ok(solutions.into_vec())
250}
251
252/// Every stretch of `a` lying on `b` (see [`coincidence`]), with
253/// `b`'s parameter as the partner: candidates are `a`'s ends found on `b`
254/// and `b`'s ends found on `a`.
255pub(crate) fn curve_curve_overlaps<S: Scalar, const D: usize, const C: usize>(
256    a: &NurbCurve<S, D>,
257    b: &NurbCurve<S, D>,
258    max_nodes: usize,
259    min_subdivision_size: S,
260) -> GeopResult<Vec<Overlap<S, S>>>
261where
262    NurbCurve<S, D>: ParameterRefinable<S, C>,
263{
264    let on = |curve: &NurbCurve<S, D>, other: &NurbCurve<S, D>, t: S| -> GeopResult<Option<S>> {
265        let point = other.evaluate_cartesian(t)?;
266        curve_could_contain(curve, &point, max_nodes, min_subdivision_size)
267    };
268    let (a0, a1) = a.domain();
269    let (b0, b1) = b.domain();
270    let mut candidates = Vec::new();
271    for s in [a0, a1] {
272        if let Some(t) = on(b, a, s)? {
273            candidates.push(Hit { t: s, partner: t });
274        }
275    }
276    for t in [b0, b1] {
277        if let Some(s) = on(a, b, t)? {
278            candidates.push(Hit { t: s, partner: t });
279        }
280    }
281    coincidence::find_overlaps(candidates, |s| on(b, a, s))
282}
283
284/// Overlaps of `a` with `b`, and the isolated crossings away from them (the
285/// clipping search run on each stretch of `a` between overlaps).
286pub(crate) fn curve_curve_overlaps_and_crossings<S: Scalar, const D: usize, const C: usize>(
287    a: &NurbCurve<S, D>,
288    b: &NurbCurve<S, D>,
289    max_nodes: usize,
290    min_subdivision_size: S,
291) -> GeopResult<(Vec<Overlap<S, S>>, Vec<(S, S)>)>
292where
293    NurbCurve<S, D>: ParameterRefinable<S, C>,
294{
295    let overlaps = curve_curve_overlaps(a, b, max_nodes, min_subdivision_size)?;
296    if overlaps.is_empty() {
297        let crossings = curve_curve_crossings(a, b, max_nodes, min_subdivision_size)?;
298        return Ok((overlaps, crossings));
299    }
300    let mut crossings = Vec::new();
301    for (lo, hi) in coincidence::gaps(a.domain(), &overlaps) {
302        let piece = a.sub_curve(lo, hi)?;
303        crossings.extend(curve_curve_crossings(
304            &piece,
305            b,
306            max_nodes,
307            min_subdivision_size,
308        )?);
309    }
310    Ok((overlaps, crossings))
311}
312
313/// Points where `curve_a` crosses — or, overlapping along an arc, coincides
314/// with — `curve_b`: the drop-in counterpart of
315/// [`super::curve_curve_bisect::curve_curve_intersect`], with the same signature
316/// and [`Intersections`] contract.
317///
318/// Overlaps are found directly ([`curve_curve_overlaps`]: ends of each curve
319/// located on the other, then one midpoint probe per candidate stretch)
320/// instead of being inferred from a search hitting `max_solutions`. Then:
321///
322/// - no overlap: [`Intersections::Found`] with the clipping search's
323///   crossings, at most `max_solutions`;
324/// - an overlap: [`Intersections::Coincident`] with, in this order and up to
325///   `max_solutions` in total, the overlaps' end points, the isolated
326///   crossings on the rest of `curve_a`, and points spread evenly over the
327///   overlaps.
328///
329/// Exhausting `max_nodes` in any sub-search is an error.
330pub fn curve_curve_intersect<S: Scalar, const D: usize, const C: usize>(
331    curve_a: &NurbCurve<S, D>,
332    curve_b: &NurbCurve<S, D>,
333    max_solutions: usize,
334    max_nodes: usize,
335    min_subdivision_size: S,
336) -> GeopResult<Intersections<(S, S)>>
337where
338    NurbCurve<S, D>: ParameterRefinable<S, C>,
339{
340    // Disjoint bounding boxes rule out crossings and overlaps alike, before
341    // any candidate probe runs.
342    if max_solutions == 0 || !aabb_could_overlap(&curve_a.aabb, &curve_b.aabb, C) {
343        return Ok(Intersections::Found(vec![]));
344    }
345    let ctx = |e: GeopError| {
346        e.with_context(format!(
347            "curve_curve_intersect(curve_a={curve_a:?}, curve_b={curve_b:?}, max_nodes={max_nodes}, \
348             min_subdivision_size={min_subdivision_size:?})"
349        ))
350    };
351    let (overlaps, crossings) =
352        curve_curve_overlaps_and_crossings(curve_a, curve_b, max_nodes, min_subdivision_size)
353            .with_context(&ctx)?;
354    let samples = coincidence::samples(&overlaps, max_solutions, |s| {
355        let point = curve_a.evaluate_cartesian(s)?;
356        curve_could_contain(curve_b, &point, max_nodes, min_subdivision_size)
357    })?;
358    Ok(coincidence::assemble(
359        &overlaps,
360        crossings,
361        samples,
362        max_solutions,
363    ))
364}
365
366// The Krawczyk-verified refinement below (`TangentialDeflation`,
367// `plain_krawczyk_step`, and a Krawczyk-based `refine_crossing`) is
368// disabled for now — wiring it into `refine_crossing` regressed the
369// `geop-ops-booleans` remesh test suite's runtime (more iterations and
370// extra `evaluate`/`tangent`/`second_derivative` calls per refinement, in a
371// hot path called for every candidate edge/edge and edge/face crossing).
372// The math itself (`geop_core_math::interval_newton`, `geop_core_math::matrix`)
373// and the `Intersections` enum are unaffected and stay in active use; only
374// this file's *use* of Krawczyk for polishing a crossing is reverted to the
375// original plain (unverified) Gauss-Newton below. Kept here, commented out,
376// rather than deleted, in case it's worth revisiting with a cheaper
377// convergence check.
378//
379// use geop_core_math::interval_newton::{KrawczykStep, gauss_newton_krawczyk_step};
380//
381// /// Cross-product tangential deflation for [`refine_crossing`] — see
382// /// `geop_core_math::interval_newton`'s own doc comment for the Krawczyk math
383// /// this feeds into, and the module-level rationale for why a tangential
384// /// contact (parallel tangents, Cauchy-Schwarz equality) leaves the plain
385// /// system's Jacobian `J = [A'(s), -B'(t)]` rank-deficient: deflating to
386// /// `G = [A(s)-B(t); A'(s)×B'(t)]` recovers full rank generically, without
387// /// adding a search dimension.
388// ///
389// /// Only 3-D curves (`C = 3`) have a cross product to build `G` from; 2-D
390// /// pcurves (`C = 2`) get a no-op impl below — [`refine_crossing`]'s
391// /// unconditional "never returns a worse enclosure than it was given"
392// /// guarantee still holds there, it just can't rescue a tangential contact
393// /// the same way a 3-D one can (same as any other refinement stall).
394// pub trait TangentialDeflation<S: Scalar>: Sized {
395//     /// One Gauss-Newton-Krawczyk step on the deflated system, evaluated at
396//     /// `x_hat` (sharp point) and enclosed over `x_box`, or `None` if
397//     /// deflation isn't available here (2-D) or is itself singular (contact
398//     /// of higher order than this deflates for).
399//     fn deflated_krawczyk_step(
400//         &self,
401//         other: &Self,
402//         x_hat: Vector<S, 2>,
403//         x_box: Vector<S, 2>,
404//     ) -> Option<KrawczykStep<S>>;
405// }
406//
407// impl<S: Scalar> TangentialDeflation<S> for NurbCurve<S, 4> {
408//     fn deflated_krawczyk_step(
409//         &self,
410//         other: &Self,
411//         x_hat: Vector<S, 2>,
412//         x_box: Vector<S, 2>,
413//     ) -> Option<KrawczykStep<S>> {
414//         let pa = self.evaluate(x_hat[0]).ok()?;
415//         let pb = other.evaluate(x_hat[1]).ok()?;
416//         let ta = self.tangent(x_hat[0]).ok()?;
417//         let tb = other.tangent(x_hat[1]).ok()?;
418//         let saa = self.second_derivative(x_hat[0]).ok()?;
419//         let sbb = other.second_derivative(x_hat[1]).ok()?;
420//
421//         let ta_box = self.tangent(x_box[0]).ok()?;
422//         let tb_box = other.tangent(x_box[1]).ok()?;
423//         let saa_box = self.second_derivative(x_box[0]).ok()?;
424//         let sbb_box = other.second_derivative(x_box[1]).ok()?;
425//
426//         let f = pa.sub(&pb);
427//         let cross_hat = ta.prod_cross(&tb);
428//         let f_hat = Vector::from_array([f[0], f[1], f[2], cross_hat[0], cross_hat[1], cross_hat[2]]);
429//
430//         // ∂G/∂s = [A'(s); A''(s)×B'(t)], ∂G/∂t = [-B'(t); A'(s)×B''(t)].
431//         let tb_neg = tb.neg();
432//         let d_cross_ds_hat = saa.prod_cross(&tb);
433//         let d_cross_dt_hat = ta.prod_cross(&sbb);
434//         let jac_hat = Matrix::from_rows([
435//             [ta[0], tb_neg[0]],
436//             [ta[1], tb_neg[1]],
437//             [ta[2], tb_neg[2]],
438//             [d_cross_ds_hat[0], d_cross_dt_hat[0]],
439//             [d_cross_ds_hat[1], d_cross_dt_hat[1]],
440//             [d_cross_ds_hat[2], d_cross_dt_hat[2]],
441//         ]);
442//
443//         let tb_box_neg = tb_box.neg();
444//         let d_cross_ds_box = saa_box.prod_cross(&tb_box);
445//         let d_cross_dt_box = ta_box.prod_cross(&sbb_box);
446//         let jac_box = Matrix::from_rows([
447//             [ta_box[0], tb_box_neg[0]],
448//             [ta_box[1], tb_box_neg[1]],
449//             [ta_box[2], tb_box_neg[2]],
450//             [d_cross_ds_box[0], d_cross_dt_box[0]],
451//             [d_cross_ds_box[1], d_cross_dt_box[1]],
452//             [d_cross_ds_box[2], d_cross_dt_box[2]],
453//         ]);
454//
455//         gauss_newton_krawczyk_step(x_hat, f_hat, jac_hat, x_box, jac_box).ok()
456//     }
457// }
458//
459// impl<S: Scalar> TangentialDeflation<S> for NurbCurve<S, 3> {
460//     fn deflated_krawczyk_step(
461//         &self,
462//         _other: &Self,
463//         _x_hat: Vector<S, 2>,
464//         _x_box: Vector<S, 2>,
465//     ) -> Option<KrawczykStep<S>> {
466//         None
467//     }
468// }
469//
470// /// One [`gauss_newton_krawczyk_step`] on the plain system
471// /// `F(s, t) = A(s) - B(t) ∈ Rᶜ`, generic over `C` via [`ParameterRefinable`].
472// fn plain_krawczyk_step<S: Scalar, const D: usize, const C: usize>(
473//     curve_a: &NurbCurve<S, D>,
474//     curve_b: &NurbCurve<S, D>,
475//     x_hat: Vector<S, 2>,
476//     x_box: Vector<S, 2>,
477// ) -> Option<KrawczykStep<S>>
478// where
479//     NurbCurve<S, D>: ParameterRefinable<S, C>,
480// {
481//     let pa = curve_a.evaluate_cartesian(x_hat[0]).ok()?;
482//     let pb = curve_b.evaluate_cartesian(x_hat[1]).ok()?;
483//     let da = curve_a.tangent_cartesian(x_hat[0]).ok()?;
484//     let db = curve_b.tangent_cartesian(x_hat[1]).ok()?;
485//     let da_box = curve_a.tangent_cartesian(x_box[0]).ok()?;
486//     let db_box = curve_b.tangent_cartesian(x_box[1]).ok()?;
487//
488//     let f_hat = pa.sub(&pb);
489//     let db_neg = db.neg();
490//     let mut jac_hat = Matrix::<S, C, 2>::zero();
491//     let db_box_neg = db_box.neg();
492//     let mut jac_box = Matrix::<S, C, 2>::zero();
493//     for c in 0..C {
494//         jac_hat[(c, 0)] = da[c];
495//         jac_hat[(c, 1)] = db_neg[c];
496//         jac_box[(c, 0)] = da_box[c];
497//         jac_box[(c, 1)] = db_box_neg[c];
498//     }
499//
500//     gauss_newton_krawczyk_step(x_hat, f_hat, jac_hat, x_box, jac_box).ok()
501// }
502//
503// /// Krawczyk-verified version of `refine_crossing` -- see the module comment
504// /// above for why this is currently disabled.
505// pub fn refine_crossing_krawczyk<S: Scalar, const D: usize, const C: usize>(
506//     curve_a: &NurbCurve<S, D>,
507//     curve_b: &NurbCurve<S, D>,
508//     t_a: S,
509//     t_b: S,
510// ) -> (S, S)
511// where
512//     NurbCurve<S, D>: ParameterRefinable<S, C> + TangentialDeflation<S>,
513// {
514//     let (a_lo, a_hi) = curve_a.domain();
515//     let (b_lo, b_hi) = curve_b.domain();
516//
517//     let mut x_box = Vector::from_array([t_a, t_b]);
518//     let mut deflated = false;
519//
520//     for _ in 0..20 {
521//         let x_hat_raw = Vector::from_array([x_box[0].midpoint(), x_box[1].midpoint()]);
522//         let x_hat = Vector::from_array([
523//             clamp(x_hat_raw[0], a_lo, a_hi),
524//             clamp(x_hat_raw[1], b_lo, b_hi),
525//         ]);
526//
527//         let step = match plain_krawczyk_step::<S, D, C>(curve_a, curve_b, x_hat, x_box) {
528//             Some(step) if !deflated => step,
529//             _ => {
530//                 deflated = true;
531//                 match curve_a.deflated_krawczyk_step(curve_b, x_hat, x_box) {
532//                     Some(step) => step,
533//                     None => break,
534//                 }
535//             }
536//         };
537//
538//         if step.empty {
539//             break;
540//         }
541//
542//         let stalled = !step.contracted[0].width().definitely_less(x_box[0].width())
543//             && !step.contracted[1].width().definitely_less(x_box[1].width());
544//         x_box = step.contracted;
545//         if stalled {
546//             break;
547//         }
548//     }
549//
550//     if !x_box[0].could_be_equal(t_a) || !x_box[1].could_be_equal(t_b) {
551//         return (t_a, t_b);
552//     }
553//     (t_a.intersect(x_box[0]), t_b.intersect(x_box[1]))
554// }
555
556/// Newton iterations for [`refine_crossing`]. See `curve_surface`'s own
557/// constant — this only affects how tightly an isolated answer is pinned down.
558const REFINE_ITERATIONS: usize = 12;
559
560/// Polish one isolated `(t_a, t_b)` — as returned by [`curve_curve_intersect`]
561/// — by Gauss-Newton on `A(t_a) - B(t_b) = 0`.
562///
563/// Two unknowns against `C` equations, so this solves the normal equations
564/// `(JᵀJ)δ = -JᵀF` with `J = [A'(t_a), -B'(t_b)]`. See
565/// `curve_surface::refine_crossing` for why subdivision and Newton are split
566/// this way, and why this is opt-in rather than applied to everything the
567/// search returns.
568///
569/// Infallible by construction: anything that stops Newton — parallel tangents
570/// making `JᵀJ` singular, an iterate leaving a domain, a refined box disjoint
571/// from the one subdivision proved the solution lies in — returns the incoming
572/// box unchanged. Refinement can only tighten, never fail.
573///
574/// (The Krawczyk-verified version above this is currently disabled — see the
575/// module comment near the top of the file — so this is plain, unverified
576/// Newton, same as before that work: it tightens an already-isolated crossing
577/// but doesn't itself certify existence/uniqueness or handle a tangential
578/// contact any better than stalling on it.)
579pub fn refine_crossing<S: Scalar, const D: usize, const C: usize>(
580    curve_a: &NurbCurve<S, D>,
581    curve_b: &NurbCurve<S, D>,
582    t_a: S,
583    t_b: S,
584) -> (S, S)
585where
586    NurbCurve<S, D>: ParameterRefinable<S, C>,
587{
588    let (a_lo, a_hi) = curve_a.domain();
589    let (b_lo, b_hi) = curve_b.domain();
590    let (mut ta, mut tb) = (t_a.sharpen(), t_b.sharpen());
591
592    for iteration in 0..REFINE_ITERATIONS {
593        let (Ok(pa), Ok(pb)) = (
594            curve_a.evaluate_cartesian(ta),
595            curve_b.evaluate_cartesian(tb),
596        ) else {
597            return (t_a, t_b);
598        };
599        let (Ok(da), Ok(db)) = (curve_a.tangent_cartesian(ta), curve_b.tangent_cartesian(tb))
600        else {
601            return (t_a, t_b);
602        };
603
604        let f = pa.sub(&pb);
605        let m = Matrix::from_rows([
606            [da.prod_dot(&da), da.prod_dot(&db).neg()],
607            [da.prod_dot(&db).neg(), db.prod_dot(&db)],
608        ]);
609        let rhs = Vector::from_array([da.prod_dot(&f).neg(), db.prod_dot(&f)]);
610        let Ok(delta) = solve_linear_system(&m, &rhs) else {
611            return (t_a, t_b);
612        };
613
614        let next = [ta.add(delta[0]), tb.add(delta[1])];
615        let next = if iteration + 1 == REFINE_ITERATIONS {
616            next
617        } else {
618            [next[0].sharpen(), next[1].sharpen()]
619        };
620        ta = clamp(next[0], a_lo, a_hi);
621        tb = clamp(next[1], b_lo, b_hi);
622    }
623
624    if !ta.could_be_equal(t_a) || !tb.could_be_equal(t_b) {
625        return (t_a, t_b);
626    }
627    let (a_ref, b_ref) = (t_a.intersect(ta), t_b.intersect(tb));
628    // The refined box must still be able to hold a crossing — see
629    // `curve_surface::refine_crossing`: plain Newton can return a narrow box
630    // that provably misses where its root is not isolated and regular.
631    let holds_a_crossing = match (
632        curve_a.evaluate_cartesian(a_ref),
633        curve_b.evaluate_cartesian(b_ref),
634    ) {
635        (Ok(pa), Ok(pb)) => pa.could_be_equal(&pb),
636        _ => false,
637    };
638    if !holds_a_crossing {
639        return (t_a, t_b);
640    }
641    (a_ref, b_ref)
642}
643
644#[cfg(test)]
645mod tests {
646    use super::curve_curve_crossings;
647    use crate::nurb_curve::NurbCurve;
648    use geop_core_math::for_all_scalars;
649    use geop_core_math::{scalars::Scalar, vector::Vector4};
650
651    const MAX: usize = 5000;
652    const EPS: f64 = 1e-6;
653
654    fn pt<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
655        Vector4::from_array([
656            S::from_f64(x * w),
657            S::from_f64(y * w),
658            S::from_f64(z * w),
659            S::from_f64(w),
660        ])
661    }
662
663    fn knots<S: Scalar>(k: &[f64]) -> Vec<S> {
664        k.iter().map(|&x| S::from_f64(x)).collect()
665    }
666
667    fn line<S: Scalar>(a: [f64; 3], b: [f64; 3]) -> NurbCurve<S, 4> {
668        NurbCurve::try_new(
669            1,
670            vec![pt(a[0], a[1], a[2], 1.), pt(b[0], b[1], b[2], 1.)],
671            knots(&[0., 0., 1., 1.]),
672        )
673        .unwrap()
674    }
675
676    /// Exact rational quarter circle of radius 1 in the xy-plane.
677    fn quarter_circle<S: Scalar>() -> NurbCurve<S, 4> {
678        let w = std::f64::consts::FRAC_1_SQRT_2;
679        NurbCurve::try_new(
680            2,
681            vec![pt(1., 0., 0., 1.), pt(1., 1., 0., w), pt(0., 1., 0., 1.)],
682            knots(&[0., 0., 0., 1., 1., 1.]),
683        )
684        .unwrap()
685    }
686
687    fn solve<S: Scalar>(a: &NurbCurve<S, 4>, b: &NurbCurve<S, 4>) -> Vec<(S, S)> {
688        curve_curve_crossings::<S, 4, 3>(a, b, MAX, S::from_f64(EPS)).unwrap()
689    }
690
691    /// Every returned pair's two points agree, and there are `n` of them.
692    fn assert_solutions<S: Scalar>(
693        a: &NurbCurve<S, 4>,
694        b: &NurbCurve<S, 4>,
695        n: usize,
696    ) -> Vec<(S, S)> {
697        let sols = solve(a, b);
698        assert_eq!(sols.len(), n, "{sols:?}");
699        sols
700    }
701
702    /// `curve_curve.md` §3: projections alone can't contract this pair.
703    fn check_crossing_diagonals<S: Scalar>() {
704        let a = line::<S>([0., 0., 0.], [1., 1., 0.]);
705        let b = line::<S>([0., 1., 0.], [1., 0., 0.]);
706        let sols = assert_solutions(&a, &b, 1);
707        let half = S::from_f64(0.5);
708        assert!(
709            sols[0].0.could_be_equal(half) && sols[0].1.could_be_equal(half),
710            "{sols:?}"
711        );
712    }
713    #[test]
714    fn crossing_diagonals() {
715        for_all_scalars!(check_crossing_diagonals);
716    }
717
718    fn check_circle_meets_line_once<S: Scalar>() {
719        // x = y meets the arc at (√½, √½), parameter ½ by symmetry.
720        let sols = assert_solutions(&quarter_circle::<S>(), &line([0., 0., 0.], [1., 1., 0.]), 1);
721        assert!(sols[0].0.could_be_equal(S::from_f64(0.5)), "{sols:?}");
722    }
723    #[test]
724    fn circle_meets_line_once() {
725        for_all_scalars!(check_circle_meets_line_once);
726    }
727
728    fn check_circle_meets_chord_twice<S: Scalar>() {
729        // x + y = 1.15 meets the arc at x = (1.15 ± √0.6775) / 2 ≈ 0.987 and
730        // 0.163; the chord spans x ∈ [0.15, 1], so both.
731        let chord = line::<S>([1., 0.15, 0.], [0.15, 1., 0.]);
732        assert_solutions(&quarter_circle::<S>(), &chord, 2);
733    }
734    #[test]
735    fn circle_meets_chord_twice() {
736        for_all_scalars!(check_circle_meets_chord_twice);
737    }
738
739    /// Shared endpoint: the kernel's most common case (edges meeting at a
740    /// vertex).
741    fn check_shared_endpoint<S: Scalar>() {
742        let a = line::<S>([0., 0., 0.], [1., 0., 0.]);
743        let b = line::<S>([1., 0., 0.], [1., 1., 1.]);
744        let sols = assert_solutions(&a, &b, 1);
745        assert!(
746            sols[0].0.could_be_equal(S::ONE) && sols[0].1.could_be_equal(S::ZERO),
747            "{sols:?}"
748        );
749    }
750    #[test]
751    fn shared_endpoint() {
752        for_all_scalars!(check_shared_endpoint);
753    }
754
755    fn check_skew_lines_miss<S: Scalar>() {
756        let a = line::<S>([0., 0., 0.], [1., 1., 0.]);
757        let b = line::<S>([0., 1., 0.01], [1., 0., 0.01]);
758        assert_solutions(&a, &b, 0);
759        assert_solutions(
760            &quarter_circle::<S>(),
761            &line([0., 0., 0.], [0.5, 0.5, 0.]),
762            0,
763        );
764    }
765    #[test]
766    fn skew_lines_miss() {
767        for_all_scalars!(check_skew_lines_miss);
768    }
769
770    fn check_budget_exhaustion_is_an_error<S: Scalar>() {
771        let a = line::<S>([0., 0., 0.], [1., 1., 0.]);
772        let b = line::<S>([0., 1., 0.], [1., 0., 0.]);
773        assert!(curve_curve_crossings::<S, 4, 3>(&a, &b, 1, S::from_f64(EPS)).is_err());
774    }
775    #[test]
776    fn budget_exhaustion_is_an_error() {
777        for_all_scalars!(check_budget_exhaustion_is_an_error);
778    }
779
780    // ── The coincidence-handling wrapper ─────────────────────────────────────
781
782    use super::curve_curve_intersect;
783    use crate::intersection::Intersections;
784
785    fn wrap<S: Scalar>(a: &NurbCurve<S, 4>, b: &NurbCurve<S, 4>) -> Intersections<(S, S)> {
786        curve_curve_intersect::<S, 4, 3>(a, b, 5, MAX, S::from_f64(EPS)).unwrap()
787    }
788
789    /// Both ends of the arc lie on its chord, but the arc leaves it: the
790    /// midpoint probe rules the candidate stretch out.
791    fn check_arc_with_ends_on_chord_is_not_coincident<S: Scalar>() {
792        let r = wrap(&quarter_circle::<S>(), &line([1., 0., 0.], [0., 1., 0.]));
793        assert!(!r.is_coincident(), "{r:?}");
794        assert_eq!(r.len(), 2, "{r:?}");
795    }
796    #[test]
797    fn arc_with_ends_on_chord_is_not_coincident() {
798        for_all_scalars!(check_arc_with_ends_on_chord_is_not_coincident);
799    }
800
801    fn check_shared_vertex_is_not_coincident<S: Scalar>() {
802        let r = wrap(
803            &line::<S>([0., 0., 0.], [1., 0., 0.]),
804            &line([1., 0., 0.], [1., 1., 1.]),
805        );
806        assert!(!r.is_coincident() && r.len() == 1, "{r:?}");
807    }
808    #[test]
809    fn shared_vertex_is_not_coincident() {
810        for_all_scalars!(check_shared_vertex_is_not_coincident);
811    }
812
813    /// The overlap's ends come first: `a` at s = ½ (where `b` starts) and
814    /// s = 1 (`a`'s end, `b` at t = ½).
815    fn check_partial_overlap_reports_its_ends<S: Scalar>() {
816        let a = line::<S>([0., 0., 0.], [1., 0., 0.]);
817        let b = line::<S>([0.5, 0., 0.], [1.5, 0., 0.]);
818        let r = wrap(&a, &b);
819        assert!(r.is_coincident(), "{r:?}");
820        let v = r.as_slice();
821        let half = S::from_f64(0.5);
822        assert!(
823            v[0].0.could_be_equal(half) && v[0].1.could_be_equal(S::ZERO),
824            "{v:?}"
825        );
826        assert!(
827            v[1].0.could_be_equal(S::ONE) && v[1].1.could_be_equal(half),
828            "{v:?}"
829        );
830        for (s, _) in v {
831            assert!(!s.definitely_less(half), "{v:?}");
832        }
833    }
834    #[test]
835    fn partial_overlap_reports_its_ends() {
836        for_all_scalars!(check_partial_overlap_reports_its_ends);
837    }
838
839    /// Same arc, split differently: coincident over the shared quarter.
840    fn check_arc_pieces_overlap<S: Scalar>() {
841        let arc = quarter_circle::<S>();
842        let (left, _) = arc.split(S::from_f64(0.75)).unwrap();
843        let (_, right) = arc.split(S::from_f64(0.25)).unwrap();
844        let r = wrap(&left, &right);
845        assert!(r.is_coincident(), "{r:?}");
846        for (s, t) in r.as_slice() {
847            assert!(s.could_be_equal(*t), "same curve, same parameter: {r:?}");
848        }
849    }
850    #[test]
851    fn arc_pieces_overlap() {
852        for_all_scalars!(check_arc_pieces_overlap);
853    }
854
855    /// The test suite of the old `curve_curve` search, run unchanged against the
856    /// coincidence-handling wrapper — the drop-in contract it must keep.
857    mod old_suite {
858        use super::super::curve_curve_intersect;
859        use crate::intersection::curve_curve::refine_crossing;
860        use crate::nurb_curve::NurbCurve;
861        use geop_core_math::for_all_scalars;
862        use geop_core_math::{
863            scalars::Scalar,
864            vector::{Vector3, Vector4},
865        };
866
867        const MAX_NODES: usize = 2000;
868
869        fn ptc<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
870            Vector4::from_array([
871                S::from_f64(x),
872                S::from_f64(y),
873                S::from_f64(z),
874                S::from_f64(w),
875            ])
876        }
877
878        /// Horizontal line along the x axis, y=0.3, x ∈ [0,1].
879        fn horizontal_line<S: Scalar>() -> NurbCurve<S, 4> {
880            let f = S::from_f64;
881            NurbCurve::try_new(
882                1,
883                vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
884                vec![f(0.), f(0.), f(1.), f(1.)],
885            )
886            .unwrap()
887        }
888
889        /// Vertical line along the y axis at x=0.5, y ∈ [-1,1] -- crosses
890        /// `horizontal_line` once at (0.5, 0.3, 0).
891        fn vertical_crossing_line<S: Scalar>() -> NurbCurve<S, 4> {
892            let f = S::from_f64;
893            NurbCurve::try_new(
894                1,
895                vec![ptc(0.5, -1., 0., 1.), ptc(0.5, 1., 0., 1.)],
896                vec![f(0.), f(0.), f(1.), f(1.)],
897            )
898            .unwrap()
899        }
900
901        /// Vertical line along the y axis at x=2.0, y ∈ [-1,1] -- never crosses
902        /// `horizontal_line` (x ∈ [0,1]).
903        fn vertical_missing_line<S: Scalar>() -> NurbCurve<S, 4> {
904            let f = S::from_f64;
905            NurbCurve::try_new(
906                1,
907                vec![ptc(2.0, -1., 0., 1.), ptc(2.0, 1., 0., 1.)],
908                vec![f(0.), f(0.), f(1.), f(1.)],
909            )
910            .unwrap()
911        }
912
913        /// Quadratic Bézier dipping below y=0.3 and back, crossing
914        /// `horizontal_line` twice. x=0.3 is deliberately not the midpoint of
915        /// `horizontal_line`'s x range [0,1], avoiding the "both halves always
916        /// survive" tie pathology that exact midpoints trigger.
917        fn double_dip_curve<S: Scalar>() -> NurbCurve<S, 4> {
918            let f = S::from_f64;
919            NurbCurve::try_new(
920                2,
921                vec![
922                    ptc(0.3, 1.0, 0., 1.),
923                    ptc(0.5, -2.0, 0., 1.),
924                    ptc(0.7, 1.0, 0., 1.),
925                ],
926                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
927            )
928            .unwrap()
929        }
930
931        /// Straight line lying *on* `horizontal_line` (same y=0.3, z=0), spanning
932        /// only part of its x range: from (0.5, 0.3, 0) to (1.5, 0.3, 0). The
933        /// overlap with `horizontal_line` (x ∈ [0,1]) is x ∈ [0.5, 1].
934        fn coincident_overlap_line<S: Scalar>() -> NurbCurve<S, 4> {
935            let f = S::from_f64;
936            NurbCurve::try_new(
937                1,
938                vec![ptc(0.5, 0.3, 0., 1.), ptc(1.5, 0.3, 0., 1.)],
939                vec![f(0.), f(0.), f(1.), f(1.)],
940            )
941            .unwrap()
942        }
943
944        /// Straight line lying *on* `horizontal_line` exactly (same domain,
945        /// x ∈ [0,1], y=0.3, z=0) — fully coincident, not just partially.
946        fn full_coincident_line<S: Scalar>() -> NurbCurve<S, 4> {
947            let f = S::from_f64;
948            NurbCurve::try_new(
949                1,
950                vec![ptc(0., 0.3, 0., 1.), ptc(1., 0.3, 0., 1.)],
951                vec![f(0.), f(0.), f(1.), f(1.)],
952            )
953            .unwrap()
954        }
955
956        const EPS: f64 = 1e-6;
957
958        // ── Single crossing ───────────────────────────────────────────────────────
959
960        fn check_single_crossing_curves_have_one_solution<S: Scalar>() {
961            let a = horizontal_line::<S>();
962            let b = vertical_crossing_line::<S>();
963            let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
964            assert_eq!(result.len(), 1);
965        }
966        #[test]
967        fn single_crossing_curves_have_one_solution() {
968            for_all_scalars!(check_single_crossing_curves_have_one_solution);
969        }
970
971        // ── No crossing ───────────────────────────────────────────────────────────
972
973        fn check_curves_missing_each_other_have_no_solution<S: Scalar>() {
974            let a = horizontal_line::<S>();
975            let b = vertical_missing_line::<S>();
976            let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
977            assert!(result.is_empty());
978        }
979        #[test]
980        fn curves_missing_each_other_have_no_solution() {
981            for_all_scalars!(check_curves_missing_each_other_have_no_solution);
982        }
983
984        // ── Budget ────────────────────────────────────────────────────────────────
985
986        fn check_max_solutions_zero_returns_empty<S: Scalar>() {
987            let a = horizontal_line::<S>();
988            let b = vertical_crossing_line::<S>();
989            let result = curve_curve_intersect(&a, &b, 0, MAX_NODES, S::from_f64(EPS)).unwrap();
990            assert!(result.is_empty());
991        }
992        #[test]
993        fn max_solutions_zero_returns_empty() {
994            for_all_scalars!(check_max_solutions_zero_returns_empty);
995        }
996
997        fn check_max_nodes_exhausted_errors<S: Scalar>() {
998            // Adapted: see the same test in `curve_surface`'s copy — the
999            // coincident pair no longer overruns a tiny budget, so a pair
1000            // with two crossings that genuinely needs more nodes stands in.
1001            let a = horizontal_line::<S>();
1002            let b = double_dip_curve::<S>();
1003            let result = curve_curve_intersect(&a, &b, 1000, 1, S::from_f64(EPS));
1004            assert!(result.is_err());
1005        }
1006        #[test]
1007        fn max_nodes_exhausted_errors() {
1008            for_all_scalars!(check_max_nodes_exhausted_errors);
1009        }
1010
1011        // ── Two crossings ─────────────────────────────────────────────────────────
1012
1013        fn check_two_crossings_found_when_budget_allows<S: Scalar>() {
1014            let a = horizontal_line::<S>();
1015            let b = double_dip_curve::<S>();
1016            let result = curve_curve_intersect(&a, &b, 2, MAX_NODES, S::from_f64(EPS))
1017                .unwrap()
1018                .into_vec();
1019            assert_eq!(result.len(), 2);
1020            assert!(
1021                !result[0].0.could_be_equal(result[1].0),
1022                "the two crossings should remain distinct"
1023            );
1024        }
1025        #[test]
1026        fn two_crossings_found_when_budget_allows() {
1027            for_all_scalars!(check_two_crossings_found_when_budget_allows);
1028        }
1029
1030        fn check_max_solutions_one_caps_at_one_even_with_two_crossings<S: Scalar>() {
1031            let a = horizontal_line::<S>();
1032            let b = double_dip_curve::<S>();
1033            let result = curve_curve_intersect(&a, &b, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1034            assert_eq!(result.len(), 1);
1035        }
1036        #[test]
1037        fn max_solutions_one_caps_at_one_even_with_two_crossings() {
1038            for_all_scalars!(check_max_solutions_one_caps_at_one_even_with_two_crossings);
1039        }
1040
1041        // ── min_subdivision_size controls precision ────────────────────────────
1042
1043        fn check_min_subdivision_size_controls_precision<S: Scalar>() {
1044            let a = horizontal_line::<S>();
1045            let b = vertical_crossing_line::<S>();
1046            let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
1047                .unwrap()
1048                .into_vec();
1049            assert_eq!(result.len(), 1);
1050
1051            let (t_a, _) = result[0];
1052            assert!(
1053                t_a.sub(S::from_f64(0.5))
1054                    .abs()
1055                    .could_be_less(S::from_f64(1e-2))
1056            );
1057        }
1058        #[test]
1059        fn min_subdivision_size_controls_precision() {
1060            for_all_scalars!(check_min_subdivision_size_controls_precision);
1061        }
1062
1063        // ── Coincident overlap: must terminate ───────────────────────────────────
1064
1065        fn check_coincident_overlap_terminates<S: Scalar + 'static>() {
1066            let a = horizontal_line::<S>();
1067            let b = coincident_overlap_line::<S>();
1068
1069            // A single dive must converge to exactly one result.
1070            let result_one = curve_curve_intersect(&a, &b, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1071            assert_eq!(result_one.len(), 1);
1072
1073            // Asking for more solutions still terminates, with at most that many
1074            // (possibly fewer after merging) segments along the overlap, and
1075            // each solution lying within the overlapping x ∈ [0.5, 1] range.
1076            let result_many =
1077                curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1078            assert!(!result_many.is_empty());
1079            assert!(result_many.len() <= 5);
1080
1081            let lower_bound = S::from_f64(0.5 - EPS);
1082            for &(t_a, _) in result_many.as_slice() {
1083                assert!(t_a.could_be_greater(lower_bound));
1084            }
1085        }
1086        #[test]
1087        fn coincident_overlap_terminates() {
1088            for_all_scalars!(check_coincident_overlap_terminates);
1089        }
1090
1091        // ── Coincident: an evenly-spread solution count, not just 1-or-cap ──────
1092
1093        fn check_full_coincidence_reaches_max_solutions<S: Scalar>() {
1094            let a = horizontal_line::<S>();
1095            let b = full_coincident_line::<S>();
1096            // Two curves coincident over their *entire* shared domain, with a
1097            // generous node budget, should reliably reach the requested
1098            // solution count via the evenly-spread search -- and be reported
1099            // via the explicit `Coincident` variant, not just inferred from
1100            // hitting the length cap.
1101            let result = curve_curve_intersect(&a, &b, 5, 5000, S::from_f64(1e-3)).unwrap();
1102            assert!(result.is_coincident());
1103            assert_eq!(result.len(), 5);
1104        }
1105        #[test]
1106        fn full_coincidence_reaches_max_solutions() {
1107            for_all_scalars!(check_full_coincidence_reaches_max_solutions);
1108        }
1109
1110        // ── 2-D (D=3) pcurve intersection — the motivating use case ──────────────
1111
1112        fn pt2<S: Scalar>(x: f64, y: f64) -> Vector3<S> {
1113            Vector3::from_array([S::from_f64(x), S::from_f64(y), S::ONE])
1114        }
1115
1116        /// Horizontal 2-D segment y=0.3, x ∈ [0,1].
1117        fn horizontal_line_2d<S: Scalar>() -> crate::nurb_curve::NurbCurve2D<S> {
1118            let f = S::from_f64;
1119            NurbCurve::try_new(
1120                1,
1121                vec![pt2(0., 0.3), pt2(1., 0.3)],
1122                vec![f(0.), f(0.), f(1.), f(1.)],
1123            )
1124            .unwrap()
1125        }
1126
1127        /// Vertical 2-D segment x=0.5, y ∈ [-1,1] — crosses the horizontal line
1128        /// once at (0.5, 0.3).
1129        fn vertical_crossing_line_2d<S: Scalar>() -> crate::nurb_curve::NurbCurve2D<S> {
1130            let f = S::from_f64;
1131            NurbCurve::try_new(
1132                1,
1133                vec![pt2(0.5, -1.), pt2(0.5, 1.)],
1134                vec![f(0.), f(0.), f(1.), f(1.)],
1135            )
1136            .unwrap()
1137        }
1138
1139        fn check_single_crossing_2d<S: Scalar>() {
1140            let a = horizontal_line_2d::<S>();
1141            let b = vertical_crossing_line_2d::<S>();
1142            let result = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(EPS))
1143                .unwrap()
1144                .into_vec();
1145            assert_eq!(result.len(), 1);
1146            let (t_a, _) = result[0];
1147            let hit = a.evaluate(t_a).unwrap();
1148            assert!(
1149                hit[0]
1150                    .sub(S::from_f64(0.5))
1151                    .abs()
1152                    .could_be_less(S::from_f64(1e-3))
1153            );
1154            assert!(
1155                hit[1]
1156                    .sub(S::from_f64(0.3))
1157                    .abs()
1158                    .could_be_less(S::from_f64(1e-3))
1159            );
1160        }
1161        #[test]
1162        fn single_crossing_2d() {
1163            for_all_scalars!(check_single_crossing_2d);
1164        }
1165
1166        // ── Hard-to-intersect curves: quartic tangencies ─────────────────────────
1167        //
1168        // Both curves below are built by converting a monomial `(2t-1)^n` (in
1169        // `x = 2t-1`, over the standard `t ∈ [0,1]` NURBS domain) to its exact
1170        // Bernstein/Bezier control points, so `y` is *exactly* `x^n` along the
1171        // curve, not merely close to it — an honest, analytically-known worst
1172        // case rather than an approximation of one.
1173
1174        /// Degree-2 Bezier tracing `y = x^2` exactly (`x = 2t-1`, `t ∈ [0,1]`),
1175        /// touching `y = 0` at `t = 0.5` with **order-2** contact (an ordinary
1176        /// parabola-tangent-to-a-line case) — the case one level of
1177        /// cross-product deflation is built to resolve.
1178        fn quadratic_tangent_to_x_axis<S: Scalar>() -> NurbCurve<S, 4> {
1179            let f = S::from_f64;
1180            NurbCurve::try_new(
1181                2,
1182                vec![
1183                    ptc(-1., 1., 0., 1.),
1184                    ptc(0., -1., 0., 1.),
1185                    ptc(1., 1., 0., 1.),
1186                ],
1187                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
1188            )
1189            .unwrap()
1190        }
1191
1192        /// Degree-4 Bezier tracing `y = x^4` exactly (`x = 2t-1`, `t ∈ [0,1]`),
1193        /// touching `y = 0` at `t = 0.5` with **order-4** contact — flatter than
1194        /// cross-product deflation (which resolves order-2) can fully
1195        /// regularize: at the touch point both the tangent cross product *and*
1196        /// its first derivative vanish (`y = 16s^4` near `s = t-0.5` has
1197        /// `y'' = 192s^2 = 0` at `s = 0` too). The deliberately hard case: does
1198        /// refinement stay *sound* (never claims a narrower, wrong answer) when
1199        /// it cannot fully converge, rather than just being tight when it can.
1200        fn quartic_tangent_to_x_axis<S: Scalar>() -> NurbCurve<S, 4> {
1201            let f = S::from_f64;
1202            NurbCurve::try_new(
1203                4,
1204                vec![
1205                    ptc(-1., 1., 0., 1.),
1206                    ptc(-0.5, -1., 0., 1.),
1207                    ptc(0., 1., 0., 1.),
1208                    ptc(0.5, -1., 0., 1.),
1209                    ptc(1., 1., 0., 1.),
1210                ],
1211                vec![
1212                    f(0.),
1213                    f(0.),
1214                    f(0.),
1215                    f(0.),
1216                    f(0.),
1217                    f(1.),
1218                    f(1.),
1219                    f(1.),
1220                    f(1.),
1221                    f(1.),
1222                ],
1223            )
1224            .unwrap()
1225        }
1226
1227        /// The x axis, x ∈ [-1,1] — the common tangent line for both curves
1228        /// above, touched at (0,0,0).
1229        fn x_axis_line<S: Scalar>() -> NurbCurve<S, 4> {
1230            let f = S::from_f64;
1231            NurbCurve::try_new(
1232                1,
1233                vec![ptc(-1., 0., 0., 1.), ptc(1., 0., 0., 1.)],
1234                vec![f(0.), f(0.), f(1.), f(1.)],
1235            )
1236            .unwrap()
1237        }
1238
1239        /// An order-2 tangency: with Krawczyk-verified deflation currently
1240        /// disabled (see the module comment near the top of the file),
1241        /// `refine_crossing` is plain Newton, whose Jacobian is *also* singular
1242        /// right at a tangential contact — so this is a soundness check, same
1243        /// shape as the order-4 case below, not a tightness one. (Once deflation
1244        /// is re-enabled, this is exactly the case it's meant to resolve to
1245        /// machine/fixed-point precision instead.)
1246        fn check_refine_crossing_stays_sound_for_order_2_tangency<S: Scalar>() {
1247            let a = quadratic_tangent_to_x_axis::<S>();
1248            let b = x_axis_line::<S>();
1249
1250            let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
1251                .unwrap()
1252                .into_vec();
1253            assert_eq!(
1254                found.len(),
1255                1,
1256                "a single tangential touch, not a crossing pair"
1257            );
1258            let (t_a, t_b) = found[0];
1259
1260            // Soundness: the search's own (loose) box must already bracket the
1261            // true touch point.
1262            assert!(t_a.could_be_equal(S::from_f64(0.5)));
1263            assert!(t_b.could_be_equal(S::from_f64(0.5)));
1264
1265            let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
1266
1267            // Soundness: refinement must still contain the true parameter.
1268            assert!(ra.could_be_equal(S::from_f64(0.5)));
1269            assert!(rb.could_be_equal(S::from_f64(0.5)));
1270            // Never worse than the input: refinement can only tighten (or leave
1271            // it unchanged, which is what happens here since plain Newton's own
1272            // Jacobian is singular at this tangency too).
1273            assert!(ra.is_subset_of(t_a));
1274            assert!(rb.is_subset_of(t_b));
1275        }
1276        #[test]
1277        fn refine_crossing_stays_sound_for_order_2_tangency() {
1278            for_all_scalars!(check_refine_crossing_stays_sound_for_order_2_tangency);
1279        }
1280
1281        /// Order-4 tangency: deflation's own Jacobian is *also* singular right
1282        /// at the touch point, so refinement is expected to stall -- the
1283        /// requirement under test is that it stays sound (still encloses the
1284        /// true parameter, never claims a narrower box than it actually proved)
1285        /// rather than that it achieves full precision.
1286        fn check_refine_crossing_stays_sound_for_order_4_tangency<S: Scalar>() {
1287            let a = quartic_tangent_to_x_axis::<S>();
1288            let b = x_axis_line::<S>();
1289
1290            let found = curve_curve_intersect(&a, &b, 5, MAX_NODES, S::from_f64(1e-3))
1291                .unwrap()
1292                .into_vec();
1293            assert!(!found.is_empty(), "the touch point must still be found");
1294
1295            for (t_a, t_b) in found {
1296                // Soundness of the search itself.
1297                assert!(t_a.could_be_equal(S::from_f64(0.5)));
1298                assert!(t_b.could_be_equal(S::from_f64(0.5)));
1299
1300                let (ra, rb) = refine_crossing(&a, &b, t_a, t_b);
1301
1302                // The refinement guarantee under test: whatever comes back
1303                // still encloses the true touch point (order-4 flatness may
1304                // well mean it comes back completely unchanged -- that is a
1305                // pass, not a failure, per `refine_crossing`'s own "can only
1306                // tighten, never fail" contract).
1307                assert!(
1308                    ra.could_be_equal(S::from_f64(0.5)),
1309                    "refine_crossing must never lose the true root: got {ra:?}"
1310                );
1311                assert!(rb.could_be_equal(S::from_f64(0.5)));
1312                // And never claim a box the search didn't already prove.
1313                assert!(ra.is_subset_of(t_a));
1314                assert!(rb.is_subset_of(t_b));
1315            }
1316        }
1317        #[test]
1318        fn refine_crossing_stays_sound_for_order_4_tangency() {
1319            for_all_scalars!(check_refine_crossing_stays_sound_for_order_4_tangency);
1320        }
1321
1322        /// A transversal crossing very close to a quartic's flat spot (two
1323        /// distinct roots of `x^4 = 0.0001`, i.e. `x = ±0.1`, extremely close
1324        /// together and ill-conditioned near `x=0`) — not tangential at all,
1325        /// but numerically adversarial: checks the search still separates and
1326        /// soundly encloses both nearby crossings instead of merging or losing
1327        /// one.
1328        fn quartic_minus_epsilon<S: Scalar>() -> NurbCurve<S, 4> {
1329            let f = S::from_f64;
1330            // y = x^4 - 0.0001, same control-point construction as
1331            // `quartic_tangent_to_x_axis` with every y-coordinate shifted down
1332            // by the constant 0.0001 (Bezier control points are affine in the
1333            // curve's own coordinates, so a constant shift is just a shift of
1334            // every control point's y).
1335            let dy = 0.0001;
1336            NurbCurve::try_new(
1337                4,
1338                vec![
1339                    ptc(-1., 1. - dy, 0., 1.),
1340                    ptc(-0.5, -1. - dy, 0., 1.),
1341                    ptc(0., 1. - dy, 0., 1.),
1342                    ptc(0.5, -1. - dy, 0., 1.),
1343                    ptc(1., 1. - dy, 0., 1.),
1344                ],
1345                vec![
1346                    f(0.),
1347                    f(0.),
1348                    f(0.),
1349                    f(0.),
1350                    f(0.),
1351                    f(1.),
1352                    f(1.),
1353                    f(1.),
1354                    f(1.),
1355                    f(1.),
1356                ],
1357            )
1358            .unwrap()
1359        }
1360
1361        fn check_close_transversal_crossings_near_quartic_flat_spot<S: Scalar>() {
1362            let a = quartic_minus_epsilon::<S>();
1363            let b = x_axis_line::<S>();
1364
1365            let found = curve_curve_intersect(&a, &b, 4, MAX_NODES, S::from_f64(1e-4))
1366                .unwrap()
1367                .into_vec();
1368            assert_eq!(
1369                found.len(),
1370                2,
1371                "two distinct, separated crossings near x=±0.1"
1372            );
1373            assert!(
1374                !found[0].0.could_be_equal(found[1].0),
1375                "the two nearby crossings must remain distinct"
1376            );
1377
1378            // x = 2t-1 = ±0.1 -> t = 0.45 or t = 0.55.
1379            for &(t_a, t_b) in &found {
1380                let near_left = t_a
1381                    .sub(S::from_f64(0.45))
1382                    .abs()
1383                    .could_be_less(S::from_f64(1e-2));
1384                let near_right = t_a
1385                    .sub(S::from_f64(0.55))
1386                    .abs()
1387                    .could_be_less(S::from_f64(1e-2));
1388                assert!(near_left || near_right, "crossing at unexpected t={t_a:?}");
1389
1390                let (ra, _) = refine_crossing(&a, &b, t_a, t_b);
1391                assert!(ra.is_subset_of(t_a), "refinement must only ever tighten");
1392            }
1393        }
1394        #[test]
1395        fn close_transversal_crossings_near_quartic_flat_spot() {
1396            for_all_scalars!(check_close_transversal_crossings_near_quartic_flat_spot);
1397        }
1398    }
1399}