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