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}