Skip to main content

geop_core_geometry/intersection/
curve_surface_bisect.rs

1//! The previous curve–surface search — convex hull tests and bisection,
2//! with coincidence read from reaching `max_solutions` — kept only as the
3//! baseline for `examples/intersection_bench.rs`. The kernel uses
4//! [`super::curve_surface`].
5
6use std::cmp::Ordering;
7use std::collections::BinaryHeap;
8
9use crate::{
10    aabb::aabb_could_overlap,
11    fat_axis::{curve_fat_axes_separate, surface_fat_plane_separates},
12    nurb_curve::{NurbCurve, dehomogenize},
13    nurb_surface::NurbSurface,
14};
15use geop_core_math::{
16    disjoint_set::DisjointSet,
17    geop_error::{GeopError, GeopResult},
18    scalars::Scalar,
19    vector::Vector2,
20};
21
22use super::Intersections;
23
24/// An entry in the outer-loop priority queue: a `(curve segment, surface
25/// patch)` pair awaiting a DFS dive, ordered by `level` (shallower first) —
26/// see `curve_surface_intersect`'s own doc comment for why popping the
27/// shallowest pending pair first, rather than a plain LIFO stack, matters.
28struct QueueEntry<S: Scalar> {
29    level: usize,
30    curve_seg: NurbCurve<S, 4>,
31    surf_patch: NurbSurface<S, 4>,
32}
33
34impl<S: Scalar> PartialEq for QueueEntry<S> {
35    fn eq(&self, other: &Self) -> bool {
36        self.level == other.level
37    }
38}
39impl<S: Scalar> Eq for QueueEntry<S> {}
40impl<S: Scalar> PartialOrd for QueueEntry<S> {
41    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
42        Some(self.cmp(other))
43    }
44}
45impl<S: Scalar> Ord for QueueEntry<S> {
46    fn cmp(&self, other: &Self) -> Ordering {
47        // `BinaryHeap` is a max-heap; reverse so the smallest `level` is popped first.
48        other.level.cmp(&self.level)
49    }
50}
51
52/// Result of a single recursive DFS dive.
53enum DfsOutcome<S: Scalar> {
54    /// This subtree's hulls definitely cannot overlap; nothing found.
55    NoSolution,
56    /// A converged `(t, uv)` candidate — `t0.union(t1)` / `u0.union(u1)` /
57    /// `v0.union(v1)` of whichever segment/patch it converged on — along
58    /// with the sibling subtrees skipped on the way to it (each tagged with
59    /// its own depth, for the outer-loop priority queue).
60    Found {
61        solution: (S, Vector2<S>),
62        unexplored: Vec<(NurbCurve<S, 4>, NurbSurface<S, 4>, usize)>,
63    },
64}
65
66/// Recursively narrow `(curve_seg, surf_patch)` until either the hulls
67/// definitely cannot overlap (`NoSolution`), or both the curve chord and the
68/// patch span are no longer definitely greater than `min_subdivision_size`
69/// (`Found`). Always dives into the *left* half of whichever side it split,
70/// stashing the right half in `unexplored` rather than recursing into it
71/// directly — the outer loop (`curve_surface_intersect`) is what actually
72/// explores those, via its own priority queue, so that work spreads evenly
73/// across the whole domain instead of this dive exhaustively finishing one
74/// side first.
75///
76/// `explored` is a node-visit counter shared across the *entire* search
77/// (threaded through every dive, not reset per call) — exceeding
78/// `max_nodes` aborts the whole search with an error rather than silently
79/// returning a possibly-incomplete result; see
80/// `curve_surface_intersect`'s own doc comment for why that distinction
81/// matters to callers.
82fn dfs<S: Scalar>(
83    curve_seg: NurbCurve<S, 4>,
84    surf_patch: NurbSurface<S, 4>,
85    level: usize,
86    min_subdivision_size: S,
87    explored: &mut usize,
88    max_nodes: usize,
89) -> GeopResult<DfsOutcome<S>> {
90    *explored += 1;
91    if *explored > max_nodes {
92        return Err(GeopError::new(
93            "curve_surface_intersect: exhausted max_nodes before the search converged",
94        ));
95    }
96
97    // No artificial padding — see `curve_curve::dfs`'s own `found_here`
98    // for the full reasoning: the segment's/patch's domains are already
99    // honest enclosures of whatever converged here, so inflating them adds
100    // no safety, only imprecision that callers then have to compensate for
101    // with a tolerance of their own.
102    let found_here = |curve_seg: &NurbCurve<S, 4>, surf_patch: &NurbSurface<S, 4>| {
103        let (t0, t1) = curve_seg.domain();
104        let (u0, u1) = surf_patch.domain_u();
105        let (v0, v1) = surf_patch.domain_v();
106        DfsOutcome::Found {
107            solution: (
108                t0.union(t1),
109                Vector2::from_array([u0.union(u1), v0.union(v1)]),
110            ),
111            unexplored: vec![],
112        }
113    };
114
115    // Cheap prefilter: see `curve_curve::dfs`'s identical check. Both
116    // `NurbCurve<S, 4>` and `NurbSurface<S, 4>` are 3-D (`c = 3`), so all 3
117    // cached axes are meaningful here.
118    if !aabb_could_overlap(&curve_seg.aabb, &surf_patch.aabb, 3) {
119        return Ok(DfsOutcome::NoSolution);
120    }
121
122    // Second cheap prefilter, tried before the expensive iterative GJK
123    // check below — see `curve_curve::dfs`'s identical addition and
124    // `fat_axis`'s own module doc. Tried in both directions: the surface's
125    // own fat plane (its 3 corners) against the curve's points, and the
126    // curve's own fat axis/axes against the surface's points.
127    if let (Ok(pts_c), Ok(pts_s)) = (
128        dehomogenize::<S, 4, 3>(&curve_seg.control_points),
129        dehomogenize::<S, 4, 3>(&surf_patch.control_points),
130    ) {
131        if surface_fat_plane_separates(&surf_patch, &pts_c)
132            || curve_fat_axes_separate(&curve_seg, &pts_s)
133        {
134            return Ok(DfsOutcome::NoSolution);
135        }
136    }
137
138    let (hull_c, hull_s) = match (curve_seg.convex_hull(), surf_patch.convex_hull()) {
139        (Ok(c), Ok(s)) => (c, s),
140        // Degenerate segment/patch (zero weight): can't bound or split it
141        // any further — report whatever's here rather than silently
142        // dropping it.
143        _ => return Ok(found_here(&curve_seg, &surf_patch)),
144    };
145    if hull_c.definitely_no_overlap(&hull_s) {
146        return Ok(DfsOutcome::NoSolution);
147    }
148
149    let (curve_size, surf_size) = match (curve_seg.size(), surf_patch.size()) {
150        (Ok(c), Ok(s)) => (c, s),
151        _ => return Ok(found_here(&curve_seg, &surf_patch)),
152    };
153
154    // A side counts as converged once *either* its physical size or its own
155    // parameter-domain width is no longer definitely greater than
156    // `min_subdivision_size` — see `curve_curve::dfs` for the same rule and
157    // why `size` alone isn't enough: `size` is derived from control points
158    // that can carry real interval width, so it can stay stubbornly large
159    // for a side whose domain has already narrowed to nothing left to
160    // subdivide. Without the domain-width half, such a side is picked as
161    // "still too big" forever, the dive keeps manufacturing distinct tiny
162    // leaves, and the outer loop reads the resulting flood of solutions as
163    // coincidence — which is exactly how a merely-hard-to-converge pair
164    // ends up misreported as an overlapping one.
165    let (t0, t1) = curve_seg.domain();
166    let curve_width = t1.sub(t0);
167    let (u0, u1) = surf_patch.domain_u();
168    let (v0, v1) = surf_patch.domain_v();
169    let surf_width = u1.sub(u0).union(v1.sub(v0));
170    let curve_converged = !curve_size.definitely_greater(min_subdivision_size)
171        || !curve_width.definitely_greater(min_subdivision_size);
172    let surf_converged = !surf_size.definitely_greater(min_subdivision_size)
173        || !surf_width.definitely_greater(min_subdivision_size);
174
175    if curve_converged && surf_converged {
176        return Ok(found_here(&curve_seg, &surf_patch));
177    }
178
179    // Split whichever side still isn't converged; if both still aren't,
180    // split whichever is physically larger (ties go to the surface). Cannot
181    // split and hasn't converged — report whatever's here; there's nothing
182    // more to do with this pair.
183    let split_curve = if curve_converged {
184        false
185    } else if surf_converged {
186        true
187    } else {
188        curve_size.definitely_greater(surf_size)
189    };
190    let (left, right) = if split_curve {
191        match curve_seg.split_mid() {
192            Ok((l, r)) => ((l, surf_patch.clone()), (r, surf_patch)),
193            Err(_) => return Ok(found_here(&curve_seg, &surf_patch)),
194        }
195    } else {
196        match surf_patch.split_mid() {
197            Ok((s0, s1)) => ((curve_seg.clone(), s0), (curve_seg, s1)),
198            Err(_) => return Ok(found_here(&curve_seg, &surf_patch)),
199        }
200    };
201
202    match dfs(
203        left.0,
204        left.1,
205        level + 1,
206        min_subdivision_size,
207        explored,
208        max_nodes,
209    )? {
210        DfsOutcome::Found {
211            solution,
212            mut unexplored,
213        } => {
214            unexplored.push((right.0, right.1, level + 1));
215            Ok(DfsOutcome::Found {
216                solution,
217                unexplored,
218            })
219        }
220        DfsOutcome::NoSolution => dfs(
221            right.0,
222            right.1,
223            level + 1,
224            min_subdivision_size,
225            explored,
226            max_nodes,
227        ),
228    }
229}
230
231/// Points where `curve` crosses (or, in the coplanar case, lies within)
232/// `surface`.
233///
234/// A DFS-with-priority-queue search (restored, against the current
235/// [`DisjointSet`]-based solution representation, from an earlier
236/// implementation removed by commit `d75bff5`): the outer loop always dives
237/// from the *shallowest* still-unexplored `(curve segment, surface patch)`
238/// pair (a level-ordered [`BinaryHeap`], so the search spreads laterally
239/// across the whole domain before going deep anywhere), each dive following
240/// [`dfs`]'s own leftmost-branch-first policy and stashing every sibling
241/// subtree it skips along the way back onto the queue at its own depth. For
242/// an isolated, genuine crossing this behaves essentially like a plain
243/// stack-based DFS (hull-overlap pruning quickly discards everything but
244/// the local neighborhood of the crossing, regardless of traversal order).
245/// But for a coincident or coplanar pair — where hull-overlap pruning can't
246/// narrow anything down, since the curve overlaps the surface almost
247/// everywhere along the shared region — a plain LIFO stack tends to
248/// exhaustively refine one small neighborhood (feeding [`DisjointSet`] a
249/// long run of adjacent, `could_be_equal` candidates that all merge into
250/// one ever-widening solution) before ever reaching a genuinely different
251/// part of the domain. The breadth-first-by-level ordering here instead
252/// guarantees an evenly-spread set of solutions across the *whole* shared
253/// region — which is what actually lets a caller reliably treat "found
254/// `max_solutions` distinct solutions" as a coincidence signal in the first
255/// place; see e.g. `booleans::remesh::remesh_edges_x_faces`.
256///
257/// The search stops once `max_solutions` distinct solutions have been
258/// found, or the queue empties (every subtree explored, genuinely fewer
259/// solutions than `max_solutions`) — either way, `Ok`. If it instead
260/// exhausts `max_nodes` mid-dive without reaching either of those, that's a
261/// genuinely unknown result: this returns an error rather than silently
262/// reporting a possibly-incomplete solution set as if it were final. A
263/// caller checking `len() >= max_solutions` to detect coincidence has no
264/// way to tell "genuinely converged short of the budget" apart from "ran
265/// out of nodes early" otherwise — and the latter is, if anything, itself
266/// evidence of extended overlap (an isolated crossing converges in a
267/// handful of nodes; only a broad shared region burns through a real
268/// budget), so callers relying on that heuristic should treat this error
269/// the same way they'd treat hitting `max_solutions`.
270pub fn curve_surface_intersect<S: Scalar>(
271    curve: &NurbCurve<S, 4>,
272    surface: &NurbSurface<S, 4>,
273    max_solutions: usize,
274    max_nodes: usize,
275    min_subdivision_size: S,
276) -> GeopResult<Intersections<(S, Vector2<S>)>> {
277    let mut queue: BinaryHeap<QueueEntry<S>> = BinaryHeap::new();
278    queue.push(QueueEntry {
279        level: 0,
280        curve_seg: curve.clone(),
281        surf_patch: surface.clone(),
282    });
283
284    let mut explored = 0usize;
285    let mut solutions: DisjointSet<(S, Vector2<S>)> = DisjointSet::new();
286
287    while solutions.len() < max_solutions {
288        let Some(entry) = queue.pop() else { break };
289
290        match dfs(
291            entry.curve_seg,
292            entry.surf_patch,
293            entry.level,
294            min_subdivision_size,
295            &mut explored,
296            max_nodes,
297        )? {
298            DfsOutcome::NoSolution => continue,
299            DfsOutcome::Found {
300                solution,
301                unexplored,
302            } => {
303                solutions.insert(solution);
304                for (c, s, lvl) in unexplored {
305                    queue.push(QueueEntry {
306                        level: lvl,
307                        curve_seg: c,
308                        surf_patch: s,
309                    });
310                }
311            }
312        }
313    }
314
315    let result = solutions.into_vec();
316    Ok(if max_solutions > 0 && result.len() >= max_solutions {
317        Intersections::Coincident(result)
318    } else {
319        Intersections::Found(result)
320    })
321}
322
323#[cfg(test)]
324mod tests {
325    use super::curve_surface_intersect;
326    use crate::{nurb_curve::NurbCurve, nurb_surface::NurbSurface};
327    use geop_core_math::for_all_scalars;
328    use geop_core_math::{scalars::Scalar, vector::Vector4};
329
330    const MAX_NODES: usize = 2000;
331
332    fn ptc<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
333        Vector4::from_array([
334            S::from_f64(x),
335            S::from_f64(y),
336            S::from_f64(z),
337            S::from_f64(w),
338        ])
339    }
340
341    fn pts<S: Scalar>(x: f64, y: f64, z: f64) -> Vector4<S> {
342        Vector4::from_array([S::from_f64(x), S::from_f64(y), S::from_f64(z), S::ONE])
343    }
344
345    /// Flat unit patch in the xy-plane (z = 0), x,y ∈ [0,1].
346    fn flat_xy<S: Scalar>() -> NurbSurface<S, 4> {
347        let f = S::from_f64;
348        NurbSurface::try_new(
349            1,
350            1,
351            vec![
352                pts(0., 0., 0.),
353                pts(0., 1., 0.),
354                pts(1., 0., 0.),
355                pts(1., 1., 0.),
356            ],
357            vec![f(0.), f(0.), f(1.), f(1.)],
358            vec![f(0.), f(0.), f(1.), f(1.)],
359        )
360        .unwrap()
361    }
362
363    /// Straight line crossing `flat_xy` once at (0.5, 0.5, 0).
364    fn vertical_crossing_line<S: Scalar>() -> NurbCurve<S, 4> {
365        let f = S::from_f64;
366        NurbCurve::try_new(
367            1,
368            vec![ptc(0.5, 0.5, -1., 1.), ptc(0.5, 0.5, 1., 1.)],
369            vec![f(0.), f(0.), f(1.), f(1.)],
370        )
371        .unwrap()
372    }
373
374    /// Straight line entirely above `flat_xy` (z ∈ [1, 2]) — never crosses.
375    fn line_above_surface<S: Scalar>() -> NurbCurve<S, 4> {
376        let f = S::from_f64;
377        NurbCurve::try_new(
378            1,
379            vec![ptc(0.5, 0.5, 1., 1.), ptc(0.5, 0.5, 2., 1.)],
380            vec![f(0.), f(0.), f(1.), f(1.)],
381        )
382        .unwrap()
383    }
384
385    /// Quadratic Bézier dipping below z=0 and back, crossing `flat_xy` twice.
386    /// y = 0.3 is deliberately not the midpoint of flat_xy's y range [0,1],
387    /// avoiding the "both halves always survive" tie pathology that exact
388    /// midpoints trigger (see `crossing_vyz` in `surface_surface.rs`).
389    fn double_dip_curve<S: Scalar>() -> NurbCurve<S, 4> {
390        let f = S::from_f64;
391        NurbCurve::try_new(
392            2,
393            vec![
394                ptc(0.2, 0.3, 1.0, 1.),
395                ptc(0.5, 0.3, -2.0, 1.),
396                ptc(0.8, 0.3, 1.0, 1.),
397            ],
398            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
399        )
400        .unwrap()
401    }
402
403    /// Straight line lying *in* the `flat_xy` plane (z = 0), spanning part of
404    /// its footprint: from (0.2, 0.5, 0) to (0.8, 0.5, 0).
405    fn coplanar_line<S: Scalar>() -> NurbCurve<S, 4> {
406        let f = S::from_f64;
407        NurbCurve::try_new(
408            1,
409            vec![ptc(0.2, 0.5, 0., 1.), ptc(0.8, 0.5, 0., 1.)],
410            vec![f(0.), f(0.), f(1.), f(1.)],
411        )
412        .unwrap()
413    }
414
415    /// Coplanar line spanning x ∈ [-0.5, 0.5] at y=0.5 — only the x ∈ [0, 0.5]
416    /// half (t ∈ [0.5, 1]) overlaps `flat_xy`'s footprint (x,y ∈ [0,1]).
417    fn partial_overlap_line<S: Scalar>() -> NurbCurve<S, 4> {
418        let f = S::from_f64;
419        NurbCurve::try_new(
420            1,
421            vec![ptc(-0.5, 0.5, 0., 1.), ptc(0.5, 0.5, 0., 1.)],
422            vec![f(0.), f(0.), f(1.), f(1.)],
423        )
424        .unwrap()
425    }
426
427    /// Coplanar line spanning x ∈ [-1, 2] at y=0.5 — much larger than
428    /// `flat_xy`'s x-extent [0,1], extending beyond it on both sides. Only
429    /// x ∈ [0,1] (t ∈ [1/3, 2/3]) overlaps the surface.
430    fn oversized_line<S: Scalar>() -> NurbCurve<S, 4> {
431        let f = S::from_f64;
432        NurbCurve::try_new(
433            1,
434            vec![ptc(-1.0, 0.5, 0., 1.), ptc(2.0, 0.5, 0., 1.)],
435            vec![f(0.), f(0.), f(1.), f(1.)],
436        )
437        .unwrap()
438    }
439
440    /// Coplanar line spanning x ∈ [0,1] at y=0.5 — exactly matches
441    /// `flat_xy`'s x-extent.
442    fn full_width_line<S: Scalar>() -> NurbCurve<S, 4> {
443        let f = S::from_f64;
444        NurbCurve::try_new(
445            1,
446            vec![ptc(0., 0.5, 0., 1.), ptc(1., 0.5, 0., 1.)],
447            vec![f(0.), f(0.), f(1.), f(1.)],
448        )
449        .unwrap()
450    }
451
452    /// Homogeneous control point with weight `w`, given its Cartesian
453    /// position `(x, y, z)`.
454    fn ptw<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
455        Vector4::from_array([
456            S::from_f64(x * w),
457            S::from_f64(y * w),
458            S::from_f64(z * w),
459            S::from_f64(w),
460        ])
461    }
462
463    /// Non-planar bilinear "saddle" patch: corner heights 0,1,1,0 over
464    /// x,y ∈ [0,2]. Its v=0.5 ridge line sits at z = 0.5.
465    fn bent_surface<S: Scalar>() -> NurbSurface<S, 4> {
466        let f = S::from_f64;
467        NurbSurface::try_new(
468            1,
469            1,
470            vec![
471                pts(0., 0., 0.),
472                pts(0., 2., 1.),
473                pts(2., 0., 1.),
474                pts(2., 2., 0.),
475            ],
476            vec![f(0.), f(0.), f(1.), f(1.)],
477            vec![f(0.), f(0.), f(1.), f(1.)],
478        )
479        .unwrap()
480    }
481
482    /// Quadratic Bézier running along `bent_surface`'s ridge line (y = 1),
483    /// dipping from z=-1 up to z=3 and back to z=-1 -- crossing the ridge's
484    /// z=0.5 height at two distinct points (t ≈ 0.25 and t ≈ 0.75).
485    fn bent_curve<S: Scalar>() -> NurbCurve<S, 4> {
486        let f = S::from_f64;
487        NurbCurve::try_new(
488            2,
489            vec![
490                ptc(0.2, 1.0, -1.0, 1.),
491                ptc(1.0, 1.0, 3.0, 1.),
492                ptc(1.8, 1.0, -1.0, 1.),
493            ],
494            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
495        )
496        .unwrap()
497    }
498
499    /// Degree-(2,2) rational patch covering one octant of the unit sphere
500    /// (x,y,z >= 0), built by revolving a quarter-circle meridian (in the
501    /// xz-plane) by a quarter turn around the z axis. Its v=0 edge is exactly
502    /// `equator_quarter_circle`.
503    fn sphere_octant_patch<S: Scalar>() -> NurbSurface<S, 4> {
504        let f = S::from_f64;
505        let w = 1.0 / 2.0_f64.sqrt();
506        NurbSurface::try_new(
507            2,
508            2,
509            vec![
510                // u = 0 (azimuth 0deg)
511                ptw(1., 0., 0., 1.),
512                ptw(1., 0., 1., w),
513                ptw(0., 0., 1., 1.),
514                // u = 1 (azimuth 45deg)
515                ptw(1., 1., 0., w),
516                ptw(1., 1., 1., 0.5),
517                ptw(0., 0., 1., w),
518                // u = 2 (azimuth 90deg)
519                ptw(0., 1., 0., 1.),
520                ptw(0., 1., 1., w),
521                ptw(0., 0., 1., 1.),
522            ],
523            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
524            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
525        )
526        .unwrap()
527    }
528
529    /// Quarter circle from (1,0,0) to (0,1,0) in the xy-plane -- exactly the
530    /// v=0 edge of `sphere_octant_patch`, i.e. coincident with that surface.
531    fn equator_quarter_circle<S: Scalar>() -> NurbCurve<S, 4> {
532        let f = S::from_f64;
533        let w = 1.0 / 2.0_f64.sqrt();
534        NurbCurve::try_new(
535            2,
536            vec![ptw(1., 0., 0., 1.), ptw(1., 1., 0., w), ptw(0., 1., 0., 1.)],
537            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
538        )
539        .unwrap()
540    }
541
542    const EPS: f64 = 1e-2;
543
544    // ── Single crossing ───────────────────────────────────────────────────────
545
546    fn check_single_crossing_curve_has_one_solution<S: Scalar>() {
547        let curve = vertical_crossing_line::<S>();
548        let surf = flat_xy::<S>();
549        let result =
550            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
551        assert_eq!(result.len(), 1);
552    }
553    #[test]
554    fn single_crossing_curve_has_one_solution() {
555        for_all_scalars!(check_single_crossing_curve_has_one_solution);
556    }
557
558    // ── No crossing ───────────────────────────────────────────────────────────
559
560    fn check_curve_missing_surface_has_no_solution<S: Scalar>() {
561        let curve = line_above_surface::<S>();
562        let surf = flat_xy::<S>();
563        let result =
564            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
565        assert!(result.is_empty());
566    }
567    #[test]
568    fn curve_missing_surface_has_no_solution() {
569        for_all_scalars!(check_curve_missing_surface_has_no_solution);
570    }
571
572    // ── Budget ────────────────────────────────────────────────────────────────
573
574    fn check_max_solutions_zero_returns_empty<S: Scalar>() {
575        let curve = vertical_crossing_line::<S>();
576        let surf = flat_xy::<S>();
577        let result =
578            curve_surface_intersect(&curve, &surf, 0, MAX_NODES, S::from_f64(EPS)).unwrap();
579        assert!(result.is_empty());
580    }
581    #[test]
582    fn max_solutions_zero_returns_empty() {
583        for_all_scalars!(check_max_solutions_zero_returns_empty);
584    }
585
586    fn check_max_nodes_exhausted_errors<S: Scalar>() {
587        let curve = coplanar_line::<S>();
588        let surf = flat_xy::<S>();
589        // A coincident pair searching for far more solutions than a tiny
590        // node budget can possibly separate must error, not silently
591        // return a truncated/misleading result.
592        let result = curve_surface_intersect(&curve, &surf, 1000, 3, S::from_f64(EPS));
593        assert!(result.is_err());
594    }
595    #[test]
596    fn max_nodes_exhausted_errors() {
597        for_all_scalars!(check_max_nodes_exhausted_errors);
598    }
599
600    // ── Two crossings ─────────────────────────────────────────────────────────
601
602    fn check_two_crossings_found_when_budget_allows<S: Scalar>() {
603        let curve = double_dip_curve::<S>();
604        let surf = flat_xy::<S>();
605        let result = curve_surface_intersect(&curve, &surf, 2, MAX_NODES, S::from_f64(EPS))
606            .unwrap()
607            .into_vec();
608        assert_eq!(result.len(), 2);
609        assert!(
610            !result[0].0.could_be_equal(result[1].0),
611            "the two crossings should remain distinct after unification"
612        );
613    }
614    #[test]
615    fn two_crossings_found_when_budget_allows() {
616        for_all_scalars!(check_two_crossings_found_when_budget_allows);
617    }
618
619    fn check_max_solutions_one_caps_at_one_even_with_two_crossings<S: Scalar>() {
620        let curve = double_dip_curve::<S>();
621        let surf = flat_xy::<S>();
622        let result =
623            curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
624        assert_eq!(result.len(), 1);
625    }
626    #[test]
627    fn max_solutions_one_caps_at_one_even_with_two_crossings() {
628        for_all_scalars!(check_max_solutions_one_caps_at_one_even_with_two_crossings);
629    }
630
631    // ── min_subdivision_size controls precision ────────────────────────────
632
633    fn check_min_subdivision_size_controls_precision<S: Scalar>() {
634        let curve = vertical_crossing_line::<S>();
635        let surf = flat_xy::<S>();
636        let result = curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(1e-3))
637            .unwrap()
638            .into_vec();
639        assert_eq!(result.len(), 1);
640
641        let (_, uv) = result[0];
642        assert!(
643            uv[0]
644                .sub(S::from_f64(0.5))
645                .abs()
646                .could_be_less(S::from_f64(1e-2))
647        );
648        assert!(
649            uv[1]
650                .sub(S::from_f64(0.5))
651                .abs()
652                .could_be_less(S::from_f64(1e-2))
653        );
654    }
655    #[test]
656    fn min_subdivision_size_controls_precision() {
657        for_all_scalars!(check_min_subdivision_size_controls_precision);
658    }
659
660    // ── Coplanar curve: must terminate ───────────────────────────────────────
661
662    fn check_coplanar_curve_terminates<S: Scalar>() {
663        let curve = coplanar_line::<S>();
664        let surf = flat_xy::<S>();
665
666        // A single dive must converge to exactly one result.
667        let result_one =
668            curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
669        assert_eq!(result_one.len(), 1);
670
671        // Asking for more solutions still terminates, with at most that many
672        // (possibly fewer after unification) segments along the coplanar overlap.
673        let result_many =
674            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
675        assert!(!result_many.is_empty());
676        assert!(result_many.len() <= 5);
677    }
678    #[test]
679    fn coplanar_curve_terminates() {
680        for_all_scalars!(check_coplanar_curve_terminates);
681    }
682
683    // ── Coincident: an evenly-spread solution count, not just 1-or-cap ──────
684
685    fn check_coincident_curve_reaches_max_solutions<S: Scalar>() {
686        let curve = full_width_line::<S>();
687        let surf = flat_xy::<S>();
688        // A curve running the *entire* width of the surface it's coincident
689        // with, with a generous node budget, should reliably reach the
690        // requested solution count via the evenly-spread search — this is
691        // exactly the property `max_solutions` saturating is meant to
692        // signal "coincident" to a caller in the first place.
693        let result = curve_surface_intersect(&curve, &surf, 5, 5000, S::from_f64(1e-3)).unwrap();
694        assert!(result.is_coincident());
695        assert_eq!(result.len(), 5);
696    }
697    #[test]
698    fn coincident_curve_reaches_max_solutions() {
699        for_all_scalars!(check_coincident_curve_reaches_max_solutions);
700    }
701
702    // ── Partial overlap: only part of the curve lies over the surface ───────
703
704    fn check_partial_overlap_coplanar_line<S: Scalar>() {
705        let curve = partial_overlap_line::<S>();
706        let surf = flat_xy::<S>();
707        let result =
708            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
709        assert!(!result.is_empty());
710        assert!(result.len() <= 5);
711
712        // Every solution must lie within the overlapping half of the curve
713        // (x >= 0, i.e. t >= 0.5), up to a small tolerance.
714        let lower_bound = S::from_f64(0.5 - EPS);
715        for &(t, _) in result.as_slice() {
716            assert!(!t.definitely_less(lower_bound));
717        }
718    }
719    #[test]
720    fn partial_overlap_coplanar_line() {
721        for_all_scalars!(check_partial_overlap_coplanar_line);
722    }
723
724    // ── Curve much larger than the surface ───────────────────────────────────
725
726    fn check_curve_larger_than_surface_terminates<S: Scalar>() {
727        let curve = oversized_line::<S>();
728        let surf = flat_xy::<S>();
729        let result =
730            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
731        assert!(!result.is_empty());
732        assert!(result.len() <= 5);
733
734        // Every solution must lie within the overlapping middle third of the
735        // curve (x ∈ [0,1], i.e. t ∈ [1/3, 2/3]), up to a small tolerance.
736        let lower_bound = S::from_f64(1.0 / 3.0 - EPS);
737        let upper_bound = S::from_f64(2.0 / 3.0 + EPS);
738        for &(t, _) in result.as_slice() {
739            assert!(!t.definitely_less(lower_bound));
740            assert!(!t.definitely_greater(upper_bound));
741        }
742    }
743    #[test]
744    fn curve_larger_than_surface_terminates() {
745        for_all_scalars!(check_curve_larger_than_surface_terminates);
746    }
747
748    // ── Curve exactly the same size as the surface ──────────────────────────
749
750    fn check_curve_same_size_as_surface_terminates<S: Scalar>() {
751        let curve = full_width_line::<S>();
752        let surf = flat_xy::<S>();
753
754        let result_one =
755            curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
756        assert_eq!(result_one.len(), 1);
757
758        let result_many =
759            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
760        assert!(!result_many.is_empty());
761        assert!(result_many.len() <= 5);
762    }
763    #[test]
764    fn curve_same_size_as_surface_terminates() {
765        for_all_scalars!(check_curve_same_size_as_surface_terminates);
766    }
767
768    // ── Bent surface / bent curve: distinct crossings ────────────────────────
769
770    fn check_bent_surface_bent_curve_two_distinct_crossings<S: Scalar>() {
771        let curve = bent_curve::<S>();
772        let surf = bent_surface::<S>();
773        let result = curve_surface_intersect(&curve, &surf, 4, MAX_NODES, S::from_f64(EPS))
774            .unwrap()
775            .into_vec();
776        assert_eq!(result.len(), 2);
777        assert!(
778            !result[0].0.could_be_equal(result[1].0),
779            "the two crossings of a bent curve through a bent surface should be distinct"
780        );
781    }
782    #[test]
783    fn bent_surface_bent_curve_two_distinct_crossings() {
784        for_all_scalars!(check_bent_surface_bent_curve_two_distinct_crossings);
785    }
786
787    // ── Coincident circle on a spherical patch: must terminate ───────────────
788
789    fn check_coincident_circle_on_sphere_patch_terminates<S: Scalar>() {
790        let curve = equator_quarter_circle::<S>();
791        let surf = sphere_octant_patch::<S>();
792
793        let result_one =
794            curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
795        assert_eq!(result_one.len(), 1);
796
797        let result_many =
798            curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
799        assert!(!result_many.is_empty());
800        assert!(result_many.len() <= 5);
801    }
802    #[test]
803    fn coincident_circle_on_sphere_patch_terminates() {
804        for_all_scalars!(check_coincident_circle_on_sphere_patch_terminates);
805    }
806}