Skip to main content

geop_core_geometry/intersection/
curve_surface.rs

1//! Curve–surface intersection by per-axis fat line clipping — the design is
2//! `curve_surface.md` next to this file; it is `curve_curve` with one more
3//! parameter.
4//!
5//! For a curve `C = H_C / W_C` and a surface `S = H_S / W_S` with positive
6//! weights, `C_k(t) = S_k(u, v)` iff
7//! `g_k(t, u, v) = H_{C,k}(t) W_S(u, v) - W_C(t) H_{S,k}(u, v) = 0`, a
8//! polynomial tensor-product spline in three independent parameters with
9//! coefficients `d_ijl = P_{i,k} Q_{jl,w} - P_{i,w} Q_{jl,k}`. Its zeros are
10//! clipped in all three directions ([`clip_tensor`]) — nine clips per box.
11//!
12//! Assumes no arc of the curve lies on the surface (`curve_surface.md`): the
13//! result is a list of paired `(t, (u, v))` boxes, and reaching some count
14//! of them means nothing. Works on the untrimmed patch.
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::surface::{
26        boundary_curve, collapsed_boundary, surface_could_contain, surface_extents,
27        weights_positive,
28    },
29    fat_line::{
30        Plan, carried_width, clip_tensor, converged, directions, extent, greville_abscissae, plan,
31    },
32    intersection::curve_curve,
33    knot_insertion::pinned_clamped_end,
34    nurb_curve::{NurbCurve, dehomogenize},
35    nurb_surface::NurbSurface,
36};
37use geop_core_math::{
38    disjoint_set::DisjointSet,
39    geop_error::{GeopError, GeopResult, WithContext},
40    matrix::{Matrix, solve_linear_system},
41    scalars::Scalar,
42    vector::{Vector, Vector2},
43};
44
45/// Clip the pair: a box `[t̂, û, v̂]` inside the domains enclosing every
46/// `(t, u, v)` with `C(t) = S(u, v)`, or `None` if some equation proves
47/// there is none. The curve's weights are positive by construction; the
48/// surface's are checked (`curve_surface.md` §1) — without them the whole
49/// box is returned (no information) and the search subdivides.
50///
51/// The equations are combinations `g_n = n · (H_C W_S - W_C H_S)` along
52/// free-choice directions ([`directions`]), from the curve's chord `c` and
53/// the patch's mean edge directions `e_u`, `e_v`: the patch normal
54/// `e_u × e_v` (nearly independent of `u, v`, so it pins `t`), `c × e_v`
55/// (pins `u`) and `c × e_u` (pins `v`). If they don't span space — a curve
56/// without a chord, or lying in the patch's plane, which makes all three
57/// parallel — the coordinate axes are added.
58fn clip<S: Scalar>(c: &NurbCurve<S, 4>, s: &NurbSurface<S, 4>) -> GeopResult<Option<[S; 3]>> {
59    let (u0, u1) = s.domain_u();
60    let (v0, v1) = s.domain_v();
61    let mut hats = [c.domain_as_scalar(), u0.union(u1), v0.union(v1)];
62    if !weights_positive(s) {
63        return Ok(Some(hats));
64    }
65    let (nc, nu, nv) = (c.control_points.len(), s.num_u, s.num_v);
66    let greville = [
67        greville_abscissae(&c.knot_vector, c.degree, nc)?,
68        greville_abscissae(&s.knot_vector_u, s.degree_u, nu)?,
69        greville_abscissae(&s.knot_vector_v, s.degree_v, nv)?,
70    ];
71
72    let at = |i: usize, j: usize| directions::cartesian::<S, 4, 3>(&s.control_points[i * nv + j]);
73    let add = |a: [f64; 3], b: [f64; 3]| std::array::from_fn(|k| a[k] + b[k]);
74    let chord = directions::sub(
75        directions::cartesian::<S, 4, 3>(&c.control_points[nc - 1]),
76        directions::cartesian::<S, 4, 3>(&c.control_points[0]),
77    );
78    let e_u = directions::sub(
79        add(at(nu - 1, 0), at(nu - 1, nv - 1)),
80        add(at(0, 0), at(0, nv - 1)),
81    );
82    let e_v = directions::sub(
83        add(at(0, nv - 1), at(nu - 1, nv - 1)),
84        add(at(0, 0), at(nu - 1, 0)),
85    );
86    let dirs: Vec<[f64; 3]> = [
87        directions::cross(e_u, e_v),
88        directions::cross(chord, e_v),
89        directions::cross(chord, e_u),
90    ]
91    .into_iter()
92    .filter_map(directions::unit)
93    .collect();
94    let dirs = directions::spanning(dirs);
95
96    let mut d = Vec::with_capacity(nc * nu * nv);
97    for n in dirs {
98        let n = directions::sharp::<S, 3>(n);
99        let ns: Vec<S> = s
100            .control_points
101            .iter()
102            .map(|q| directions::dot(&n, q))
103            .collect();
104        d.clear();
105        for p in &c.control_points {
106            let np = directions::dot(&n, p);
107            d.extend(
108                s.control_points
109                    .iter()
110                    .zip(&ns)
111                    .map(|(q, &nq)| np.mul(q[3]).sub(p[3].mul(nq))),
112            );
113        }
114        if !clip_tensor(&d, &[nc, nu, nv], &greville, &mut hats) {
115            return Ok(None);
116        }
117    }
118    Ok(Some(hats))
119}
120
121/// The "boundary evaluation" of `contains/surface.md` §3, one dimension up:
122/// if the clip pinned a parameter exactly onto a clamped end, the problem
123/// loses that dimension. `t` pinned → the curve's endpoint must lie on the
124/// surface ([`surface_could_contain`]); `u` or `v` pinned → the
125/// curve must meet that boundary row of the surface
126/// ([`curve_curve::curve_curve_crossings`]). Returns the boxes found, or
127/// `None` if nothing is pinned.
128fn solve_pinned<S: Scalar>(
129    c: &NurbCurve<S, 4>,
130    s: &NurbSurface<S, 4>,
131    hats: [S; 3],
132    budget: usize,
133    min_subdivision_size: S,
134) -> GeopResult<Option<Vec<[S; 3]>>> {
135    let overlapping = |found: [S; 3]| -> Option<[S; 3]> {
136        (0..3)
137            .all(|i| found[i].could_be_equal(hats[i]))
138            .then(|| std::array::from_fn(|i| hats[i].intersect(found[i])))
139    };
140
141    if let Some(first) =
142        pinned_clamped_end(hats[0], &c.knot_vector, c.control_points.len(), c.degree)
143    {
144        let cp = if first {
145            c.control_points[0]
146        } else {
147            c.control_points[c.control_points.len() - 1]
148        };
149        let Ok(end) = dehomogenize::<S, 4, 3>(&[cp]) else {
150            return Ok(None);
151        };
152        let found = surface_could_contain(s, &end[0], budget, min_subdivision_size)?;
153        return Ok(Some(
154            found
155                .and_then(|(u, v)| overlapping([hats[0], u, v]))
156                .into_iter()
157                .collect(),
158        ));
159    }
160
161    if let Some((boundary, along_v)) = collapsed_boundary(s, hats[1], hats[2]) {
162        let pairs = curve_curve::curve_curve_crossings::<S, 4, 3>(
163            c,
164            &boundary,
165            budget,
166            min_subdivision_size,
167        )?;
168        return Ok(Some(
169            pairs
170                .into_iter()
171                .filter_map(|(t, w)| {
172                    overlapping(if along_v {
173                        [t, hats[1], w]
174                    } else {
175                        [t, w, hats[2]]
176                    })
177                })
178                .collect(),
179        ));
180    }
181    Ok(None)
182}
183
184/// All `(t, (u, v))` with `curve(t) = surface(u, v)`, as paired parameter
185/// boxes (`curve_surface.md`), for a curve with no arc lying on the surface —
186/// see [`curve_surface_intersect`] for the wrapper that handles that. The same search as
187/// [`curve_curve::curve_curve_crossings`], over (curve segment, patch)
188/// pairs with three parameter directions: AABB and [`clip`] rejection,
189/// convergence once both objects' extents are within `min_subdivision_size`
190/// ([`crate::fat_line::converged`]), a pinned parameter handed down one dimension
191/// ([`solve_pinned`]), and otherwise restriction or fair bisection per
192/// [`crate::fat_line::plan`].
193///
194/// Overlapping boxes merge by union into one unresolved cluster, never an
195/// average. Exhausting `max_nodes` is an error — the result would be
196/// incomplete.
197pub fn curve_surface_crossings<S: Scalar>(
198    curve: &NurbCurve<S, 4>,
199    surface: &NurbSurface<S, 4>,
200    max_nodes: usize,
201    min_subdivision_size: S,
202) -> GeopResult<Vec<(S, Vector2<S>)>> {
203    let mut queue: VecDeque<(NurbCurve<S, 4>, NurbSurface<S, 4>)> = VecDeque::new();
204    queue.push_back((curve.clone(), surface.clone()));
205    let mut explored = 0usize;
206    let mut solutions: DisjointSet<(S, Vector2<S>)> = DisjointSet::new();
207    let mut insert = |[t, u, v]: [S; 3]| solutions.insert((t, Vector2::from_array([u, v])));
208
209    while let Some((c, s)) = queue.pop_front() {
210        if explored >= max_nodes {
211            return Err(GeopError::new(format!(
212                "curve_surface_crossings: exhausted max_nodes={max_nodes} with {} \
213                 pairs pending; the result would be incomplete",
214                queue.len() + 1
215            )));
216        }
217        explored += 1;
218
219        if !aabb_could_overlap(&c.aabb, &s.aabb, 3) {
220            continue;
221        }
222        let Some(hats) = clip(&c, &s)? else {
223            continue;
224        };
225
226        let [size_u, size_v] = surface_extents(&s);
227        let sizes = [extent([c.control_points.clone()]), size_u, size_v];
228        let carried = carried_width(&c.control_points).max(carried_width(&s.control_points));
229        if converged(&sizes, carried, min_subdivision_size) {
230            // The pieces' whole domains, not the tighter clip, so pieces the
231            // search could not separate merge — see `curve_curve_crossings`.
232            let (u0, u1) = s.domain_u();
233            let (v0, v1) = s.domain_v();
234            insert([c.domain_as_scalar(), u0.union(u1), v0.union(v1)]);
235            continue;
236        }
237
238        if let Some(found) = solve_pinned(&c, &s, hats, max_nodes - explored, min_subdivision_size)?
239        {
240            found.into_iter().for_each(&mut insert);
241            continue;
242        }
243
244        let ranges = [c.domain(), s.domain_u(), s.domain_v()];
245        let order = match plan(&hats, &ranges, &sizes, min_subdivision_size)? {
246            Plan::Restrict(b) => match (c.sub_curve(b[0].0, b[0].1), s.sub_surface(b[1], b[2])) {
247                (Ok(rc), Ok(rs)) => {
248                    queue.push_back((rc, rs));
249                    continue;
250                }
251                _ => vec![0, 1, 2],
252            },
253            Plan::Bisect(order) => order,
254        };
255        let children = order.iter().find_map(|&dir| match dir {
256            0 => {
257                let (l, r) = c.split_mid().ok()?;
258                Some([(l, s.clone()), (r, s.clone())])
259            }
260            1 => {
261                let (l, r) = s.split_u_mid().ok()?;
262                Some([(c.clone(), l), (c.clone(), r)])
263            }
264            _ => {
265                let (l, r) = s.split_v_mid().ok()?;
266                Some([(c.clone(), l), (c.clone(), r)])
267            }
268        });
269        match children {
270            Some(children) => queue.extend(children),
271            // Nothing left to cut or split: what's here is the candidate.
272            None => insert(hats),
273        }
274    }
275
276    Ok(solutions.into_vec())
277}
278
279/// Every stretch of `curve` lying on `surface` (see [`coincidence`]), with
280/// the surface parameters as the partner. Candidates are the curve's ends
281/// found on the surface, and where the curve meets the patch's four
282/// boundary curves — through [`curve_curve::curve_curve_overlaps_and_crossings`],
283/// so a curve running *along* a boundary contributes that stretch's ends.
284/// A boundary that isn't clamped (so its control row isn't the surface
285/// there) contributes no candidates.
286pub(crate) fn curve_surface_overlaps<S: Scalar>(
287    curve: &NurbCurve<S, 4>,
288    surface: &NurbSurface<S, 4>,
289    max_nodes: usize,
290    min_subdivision_size: S,
291) -> GeopResult<Vec<Overlap<S, Vector2<S>>>> {
292    let on_surface = |t: S| -> GeopResult<Option<Vector2<S>>> {
293        let point = curve.evaluate(t)?;
294        Ok(
295            surface_could_contain(surface, &point, max_nodes, min_subdivision_size)?
296                .map(|(u, v)| Vector2::from_array([u, v])),
297        )
298    };
299
300    let mut candidates = Vec::new();
301    let (t0, t1) = curve.domain();
302    for t in [t0, t1] {
303        if let Some(uv) = on_surface(t)? {
304            candidates.push(Hit { t, partner: uv });
305        }
306    }
307
308    let (u0, u1) = surface.domain_u();
309    let (v0, v1) = surface.domain_v();
310    for (u_fixed, first, fixed) in [
311        (true, true, u0),
312        (true, false, u1),
313        (false, true, v0),
314        (false, false, v1),
315    ] {
316        let Some(boundary) = boundary_curve(surface, u_fixed, first) else {
317            continue;
318        };
319        let uv = |w: S| Vector2::from_array(if u_fixed { [fixed, w] } else { [w, fixed] });
320        let (overlaps, crossings) = curve_curve::curve_curve_overlaps_and_crossings::<S, 4, 3>(
321            curve,
322            &boundary,
323            max_nodes,
324            min_subdivision_size,
325        )?;
326        let ends = overlaps
327            .iter()
328            .flat_map(|o| [o.start, o.end])
329            .map(|h| (h.t, h.partner));
330        for (t, w) in ends.chain(crossings) {
331            candidates.push(Hit { t, partner: uv(w) });
332        }
333    }
334
335    coincidence::find_overlaps(candidates, on_surface)
336}
337
338/// Points where `curve` crosses — or, lying on it along an arc, coincides
339/// with — `surface`: the drop-in counterpart of
340/// [`super::curve_surface_bisect::curve_surface_intersect`], with the same
341/// signature and [`Intersections`] contract.
342///
343/// Overlaps are found directly ([`curve_surface_overlaps`]) instead of being
344/// inferred from a search hitting `max_solutions`. Without one, the result
345/// is [`Intersections::Found`] with the clipping search's crossings (at most
346/// `max_solutions`). With one, it is [`Intersections::Coincident`] with, in
347/// this order and up to `max_solutions` in total: the overlaps' end points,
348/// the isolated crossings on the rest of the curve, and points spread evenly
349/// over the overlaps. Works on the untrimmed patch. Exhausting `max_nodes`
350/// in any sub-search is an error.
351pub fn curve_surface_intersect<S: Scalar>(
352    curve: &NurbCurve<S, 4>,
353    surface: &NurbSurface<S, 4>,
354    max_solutions: usize,
355    max_nodes: usize,
356    min_subdivision_size: S,
357) -> GeopResult<Intersections<(S, Vector2<S>)>> {
358    // Disjoint bounding boxes rule out crossings and overlaps alike, before
359    // any candidate probe runs.
360    if max_solutions == 0 || !aabb_could_overlap(&curve.aabb, &surface.aabb, 3) {
361        return Ok(Intersections::Found(vec![]));
362    }
363    let ctx = |e: GeopError| {
364        e.with_context(format!(
365            "curve_surface_intersect(curve={curve:?}, surface={surface:?}, max_nodes={max_nodes}, \
366             min_subdivision_size={min_subdivision_size:?})"
367        ))
368    };
369    let overlaps = curve_surface_overlaps(curve, surface, max_nodes, min_subdivision_size)
370        .with_context(&ctx)?;
371    let crossings = if overlaps.is_empty() {
372        curve_surface_crossings(curve, surface, max_nodes, min_subdivision_size).with_context(
373            &|e: GeopError| ctx(e.with_context("no overlap found; crossing search")),
374        )?
375    } else {
376        let mut crossings = Vec::new();
377        for (lo, hi) in coincidence::gaps(curve.domain(), &overlaps) {
378            let piece = curve.sub_curve(lo, hi)?;
379            crossings.extend(curve_surface_crossings(
380                &piece,
381                surface,
382                max_nodes,
383                min_subdivision_size,
384            )?);
385        }
386        crossings
387    };
388    let samples = coincidence::samples(&overlaps, max_solutions, |t| {
389        let point = curve.evaluate(t)?;
390        Ok(
391            surface_could_contain(surface, &point, max_nodes, min_subdivision_size)?
392                .map(|(u, v)| Vector2::from_array([u, v])),
393        )
394    })?;
395    Ok(coincidence::assemble(
396        &overlaps,
397        crossings,
398        samples,
399        max_solutions,
400    ))
401}
402
403/// Newton iterations for [`refine`]. Quadratic convergence makes a handful
404/// plenty; this cannot change which solutions are found, only how tightly an
405/// already-isolated one is pinned down.
406const REFINE_ITERATIONS: usize = 12;
407
408/// Polish one isolated `(t, uv)` — as returned by [`curve_surface_intersect`]
409/// — by Newton on `C(t) - S(u, v) = 0`, three equations in the three unknowns
410/// `t`, `u`, `v`.
411///
412/// Subdivision and Newton divide the work: subdivision is the global method,
413/// reliably finding and separating every solution and recognizing coincidence
414/// even for a partial overlap, but converging only one bit per split; Newton
415/// cannot find anything but polishes an isolated solution quadratically.
416/// `curve_surface_intersect` deliberately does *not* apply this to everything
417/// it returns — most callers only need to know where and how many crossings
418/// there are, and refining changes results they already agree with. Call it
419/// when the parameter is about to be *used* as a split point, where the width
420/// genuinely matters: `NurbCurve::split` cannot absorb a
421/// `min_subdivision_size`-wide parameter (Boehm insertion amplifies it without
422/// bound), and a point evaluated at one is just as wide — which is how a wide
423/// crossing becomes a fat vertex and a fat sub-curve.
424///
425/// Every iterate but the last is sharpened, which is legitimate: it is only a
426/// seed for the next step. The final step is left unsharpened, so the returned
427/// widths honestly state how well the crossing is determined (see "Sharpen
428/// only where the value is a free choice" in `AGENTS.md`).
429///
430/// Infallible by construction: the incoming box is already a valid enclosure,
431/// so anything that stops Newton — a singular Jacobian at a tangential
432/// crossing or a pole, an iterate leaving the domain, a refined box disjoint
433/// from the one subdivision proved the solution lies in — just returns that
434/// box unchanged. Refinement can only tighten, never fail.
435pub fn refine_crossing<S: Scalar>(
436    curve: &NurbCurve<S, 4>,
437    surface: &NurbSurface<S, 4>,
438    t: S,
439    uv: Vector2<S>,
440) -> (S, Vector2<S>) {
441    let (t_lo, t_hi) = curve.domain();
442    let (u_lo, u_hi) = surface.domain_u();
443    let (v_lo, v_hi) = surface.domain_v();
444
445    let (mut tt, mut uu, mut vv) = (t.sharpen(), uv[0].sharpen(), uv[1].sharpen());
446    for iteration in 0..REFINE_ITERATIONS {
447        let (Ok(c), Ok(s)) = (curve.evaluate(tt), surface.evaluate(uu, vv)) else {
448            return (t, uv);
449        };
450        let (Ok(ct), Ok((su, sv))) = (curve.tangent(tt), surface.derivatives(uu, vv)) else {
451            return (t, uv);
452        };
453
454        let mut a = [[S::ZERO; 3]; 3];
455        let mut b = [S::ZERO; 3];
456        for row in 0..3 {
457            a[row] = [ct[row], su[row].neg(), sv[row].neg()];
458            b[row] = s[row].sub(c[row]);
459        }
460        let Ok(delta) = solve_linear_system(&Matrix::from_rows(a), &Vector::from_array(b)) else {
461            return (t, uv);
462        };
463
464        let next = [tt.add(delta[0]), uu.add(delta[1]), vv.add(delta[2])];
465        let next = if iteration + 1 == REFINE_ITERATIONS {
466            next
467        } else {
468            [next[0].sharpen(), next[1].sharpen(), next[2].sharpen()]
469        };
470        tt = clamp(next[0], t_lo, t_hi);
471        uu = clamp(next[1], u_lo, u_hi);
472        vv = clamp(next[2], v_lo, v_hi);
473    }
474
475    if !tt.could_be_equal(t) || !uu.could_be_equal(uv[0]) || !vv.could_be_equal(uv[1]) {
476        return (t, uv);
477    }
478    let (t_ref, u_ref, v_ref) = (t.intersect(tt), uv[0].intersect(uu), uv[1].intersect(vv));
479    // The refined box must still be able to hold a crossing: the curve and
480    // the surface evaluated over it have to overlap. Newton only assumes an
481    // isolated, regular root; where that fails — a curve lying on the
482    // surface's extension beyond the patch, so the iterate slides along it
483    // and gets clamped at the patch's edge — it can return a narrow box that
484    // provably misses, and the honest incoming box is what must be kept.
485    let holds_a_crossing = match (curve.evaluate(t_ref), surface.evaluate(u_ref, v_ref)) {
486        (Ok(c), Ok(s)) => c.could_be_equal(&s),
487        _ => false,
488    };
489    if !holds_a_crossing {
490        return (t, uv);
491    }
492    (t_ref, Vector2::from_array([u_ref, v_ref]))
493}
494
495#[cfg(test)]
496mod tests {
497    use super::curve_surface_crossings;
498    use crate::{nurb_curve::NurbCurve, nurb_surface::NurbSurface};
499    use geop_core_math::for_all_scalars;
500    use geop_core_math::{scalars::Scalar, vector::Vector4};
501
502    const MAX: usize = 5000;
503    const EPS: f64 = 1e-6;
504
505    fn pt<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
506        Vector4::from_array([
507            S::from_f64(x * w),
508            S::from_f64(y * w),
509            S::from_f64(z * w),
510            S::from_f64(w),
511        ])
512    }
513
514    fn knots<S: Scalar>(k: &[f64]) -> Vec<S> {
515        k.iter().map(|&x| S::from_f64(x)).collect()
516    }
517
518    fn line<S: Scalar>(a: [f64; 3], b: [f64; 3]) -> NurbCurve<S, 4> {
519        NurbCurve::try_new(
520            1,
521            vec![pt(a[0], a[1], a[2], 1.), pt(b[0], b[1], b[2], 1.)],
522            knots(&[0., 0., 1., 1.]),
523        )
524        .unwrap()
525    }
526
527    /// The unit square `S(u, v) = (u, v, 0)`.
528    fn plane<S: Scalar>() -> NurbSurface<S, 4> {
529        NurbSurface::try_new(
530            1,
531            1,
532            vec![
533                pt(0., 0., 0., 1.),
534                pt(0., 1., 0., 1.),
535                pt(1., 0., 0., 1.),
536                pt(1., 1., 0., 1.),
537            ],
538            knots(&[0., 0., 1., 1.]),
539            knots(&[0., 0., 1., 1.]),
540        )
541        .unwrap()
542    }
543
544    /// Exact rational sphere octant; `v = 1` is the pole (0, 0, 1).
545    fn sphere_octant<S: Scalar>() -> NurbSurface<S, 4> {
546        let w = std::f64::consts::FRAC_1_SQRT_2;
547        let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.)];
548        let cps = circle
549            .iter()
550            .flat_map(|&(x, y, wu)| {
551                circle
552                    .iter()
553                    .map(move |&(r, z, wv)| pt(x * r, y * r, z, wu * wv))
554            })
555            .collect();
556        let k = knots(&[0., 0., 0., 1., 1., 1.]);
557        NurbSurface::try_new(2, 2, cps, k.clone(), k).unwrap()
558    }
559
560    fn solve<S: Scalar>(c: &NurbCurve<S, 4>, s: &NurbSurface<S, 4>) -> Vec<(S, [S; 2])> {
561        curve_surface_crossings(c, s, MAX, S::from_f64(EPS))
562            .unwrap()
563            .into_iter()
564            .map(|(t, uv)| (t, [uv[0], uv[1]]))
565            .collect()
566    }
567
568    /// `curve_surface.md` §3's example: the clips isolate the root directly.
569    fn check_vertical_line_through_plane<S: Scalar>() {
570        let sols = solve(&line::<S>([0.25, 0.75, -1.], [0.25, 0.75, 1.]), &plane());
571        assert_eq!(sols.len(), 1, "{sols:?}");
572        let (t, [u, v]) = sols[0];
573        let f = S::from_f64;
574        assert!(t.could_be_equal(f(0.5)) && u.could_be_equal(f(0.25)) && v.could_be_equal(f(0.75)));
575    }
576    #[test]
577    fn vertical_line_through_plane() {
578        for_all_scalars!(check_vertical_line_through_plane);
579    }
580
581    fn check_oblique_line_through_plane<S: Scalar>() {
582        let sols = solve(&line::<S>([0., 0., -1.], [1., 0.5, 1.]), &plane());
583        assert_eq!(sols.len(), 1, "{sols:?}");
584        let (t, [u, v]) = sols[0];
585        let f = S::from_f64;
586        assert!(t.could_be_equal(f(0.5)) && u.could_be_equal(f(0.5)) && v.could_be_equal(f(0.25)));
587    }
588    #[test]
589    fn oblique_line_through_plane() {
590        for_all_scalars!(check_oblique_line_through_plane);
591    }
592
593    /// A line through the sphere's center pierces the octant once, at
594    /// `(1, 1, 1) / √3`.
595    fn check_line_pierces_sphere<S: Scalar>() {
596        let sols = solve(&line::<S>([0., 0., 0.], [1., 1., 1.]), &sphere_octant());
597        assert_eq!(sols.len(), 1, "{sols:?}");
598        let r = 1.0 / 3f64.sqrt();
599        assert!(sols[0].0.could_be_equal(S::from_f64(r)), "{sols:?}");
600    }
601    #[test]
602    fn line_pierces_sphere() {
603        for_all_scalars!(check_line_pierces_sphere);
604    }
605
606    /// Ending exactly on the surface — a curve meeting a face at its vertex.
607    fn check_endpoint_on_surface<S: Scalar>() {
608        let sols = solve(&line::<S>([0.3, 0.6, 1.], [0.3, 0.6, 0.]), &plane());
609        assert_eq!(sols.len(), 1, "{sols:?}");
610        assert!(sols[0].0.could_be_equal(S::ONE), "{sols:?}");
611    }
612    #[test]
613    fn endpoint_on_surface() {
614        for_all_scalars!(check_endpoint_on_surface);
615    }
616
617    /// Through the pole: every `u` is a preimage.
618    fn check_line_through_pole<S: Scalar>() {
619        let sols = solve(&line::<S>([0., 0., 0.5], [0., 0., 1.5]), &sphere_octant());
620        assert!(!sols.is_empty(), "{sols:?}");
621        for (t, [_, v]) in &sols {
622            assert!(
623                t.could_be_equal(S::from_f64(0.5)) && v.could_be_equal(S::ONE),
624                "{sols:?}"
625            );
626        }
627    }
628    #[test]
629    fn line_through_pole() {
630        for_all_scalars!(check_line_through_pole);
631    }
632
633    fn check_misses<S: Scalar>() {
634        assert!(solve(&line::<S>([0., 0., 0.1], [1., 1., 0.5]), &plane()).is_empty());
635        assert!(solve(&line::<S>([0., 0., 0.], [0.5, 0.5, 0.5]), &sphere_octant()).is_empty());
636        assert!(solve(&line::<S>([2., 2., -1.], [2., 2., 1.]), &plane()).is_empty());
637    }
638    #[test]
639    fn misses() {
640        for_all_scalars!(check_misses);
641    }
642
643    fn check_budget_exhaustion_is_an_error<S: Scalar>() {
644        let c = line::<S>([0., 0., -1.], [1., 0.5, 1.]);
645        assert!(curve_surface_crossings(&c, &plane(), 1, S::from_f64(EPS)).is_err());
646    }
647    #[test]
648    fn budget_exhaustion_is_an_error() {
649        for_all_scalars!(check_budget_exhaustion_is_an_error);
650    }
651
652    // ── The coincidence-handling wrapper ─────────────────────────────────────
653
654    use super::curve_surface_intersect;
655    use crate::intersection::Intersections;
656    use geop_core_math::vector::Vector2;
657
658    fn wrap<S: Scalar>(
659        c: &NurbCurve<S, 4>,
660        s: &NurbSurface<S, 4>,
661    ) -> Intersections<(S, Vector2<S>)> {
662        curve_surface_intersect(c, s, 5, MAX, S::from_f64(EPS)).unwrap()
663    }
664
665    /// The equator lies on the sphere octant's `v = 0` boundary.
666    fn check_equator_on_sphere_is_coincident<S: Scalar>() {
667        let w = std::f64::consts::FRAC_1_SQRT_2;
668        let equator = NurbCurve::try_new(
669            2,
670            vec![pt(1., 0., 0., 1.), pt(1., 1., 0., w), pt(0., 1., 0., 1.)],
671            knots(&[0., 0., 0., 1., 1., 1.]),
672        )
673        .unwrap();
674        let r = wrap(&equator, &sphere_octant::<S>());
675        assert!(r.is_coincident() && r.len() == 5, "{r:?}");
676        for (_, uv) in r.as_slice() {
677            assert!(uv[1].could_be_equal(S::ZERO), "{r:?}");
678        }
679    }
680    #[test]
681    fn equator_on_sphere_is_coincident() {
682        for_all_scalars!(check_equator_on_sphere_is_coincident);
683    }
684
685    /// An edge running along the face's `v = 0` edge.
686    fn check_curve_along_patch_boundary<S: Scalar>() {
687        let r = wrap(&line::<S>([0.2, 0., 0.], [0.8, 0., 0.]), &plane());
688        assert!(r.is_coincident(), "{r:?}");
689        for (_, uv) in r.as_slice() {
690            assert!(uv[1].could_be_equal(S::ZERO), "{r:?}");
691        }
692    }
693    #[test]
694    fn curve_along_patch_boundary() {
695        for_all_scalars!(check_curve_along_patch_boundary);
696    }
697
698    /// Enters the patch through `u = 0` at t = ½ and lies on it to its end:
699    /// those are the overlap's ends, reported first.
700    fn check_line_partly_on_plane<S: Scalar>() {
701        let r = wrap(&line::<S>([-0.5, 0.5, 0.], [0.5, 0.5, 0.]), &plane());
702        assert!(r.is_coincident(), "{r:?}");
703        let v = r.as_slice();
704        let half = S::from_f64(0.5);
705        assert!(
706            v[0].0.could_be_equal(half) && v[0].1[0].could_be_equal(S::ZERO),
707            "{v:?}"
708        );
709        assert!(
710            v[1].0.could_be_equal(S::ONE) && v[1].1[0].could_be_equal(half),
711            "{v:?}"
712        );
713        for (t, _) in v {
714            assert!(!t.definitely_less(half), "{v:?}");
715        }
716    }
717    #[test]
718    fn line_partly_on_plane() {
719        for_all_scalars!(check_line_partly_on_plane);
720    }
721
722    /// Both ends on the plane, the middle above it: two crossings, not an
723    /// overlap.
724    fn check_bump_with_ends_on_plane_is_not_coincident<S: Scalar>() {
725        let bump = NurbCurve::try_new(
726            2,
727            vec![
728                pt(0.2, 0.5, 0., 1.),
729                pt(0.5, 0.5, 0.6, 1.),
730                pt(0.8, 0.5, 0., 1.),
731            ],
732            knots(&[0., 0., 0., 1., 1., 1.]),
733        )
734        .unwrap();
735        let r = wrap(&bump, &plane::<S>());
736        assert!(!r.is_coincident() && r.len() == 2, "{r:?}");
737    }
738    #[test]
739    fn bump_with_ends_on_plane_is_not_coincident() {
740        for_all_scalars!(check_bump_with_ends_on_plane_is_not_coincident);
741    }
742
743    /// A revolved cylinder's top cap: the spoke of one quadrant against the
744    /// neighbouring quadrant's patch, which collapses to the centre along
745    /// its whole `v = 0` row. Coplanar, touching only at that pole: every
746    /// clip direction built from the patch's plane is its normal, and every
747    /// `u`-piece contains the pole. Exhausted the node budget before the
748    /// equations were required to span space and bisection went by spatial
749    /// extent.
750    fn check_spoke_touching_cap_quadrant_at_its_pole<S: Scalar>() {
751        let w = std::f64::consts::FRAC_1_SQRT_2;
752        let pole = pt(0., 0., 2., 1.);
753        let quadrant = NurbSurface::try_new(
754            2,
755            1,
756            vec![
757                pole,
758                pt(1., 0., 2., 1.),
759                pole,
760                pt(1., -1., 2., w),
761                pole,
762                pt(0., -1., 2., 1.),
763            ],
764            knots(&[0., 0., 0., 1., 1., 1.]),
765            knots(&[0., 0., 1., 1.]),
766        )
767        .unwrap();
768        let spoke = line::<S>([0., 1., 2.], [0., 0., 2.]);
769        let r = curve_surface_intersect(&spoke, &quadrant, 7, 5000, S::from_f64(1e-4)).unwrap();
770        assert!(!r.is_coincident() && !r.is_empty(), "{r:?}");
771        for (t, uv) in r.as_slice() {
772            assert!(
773                t.could_be_equal(S::ONE) && uv[1].could_be_equal(S::ZERO),
774                "{r:?}"
775            );
776        }
777    }
778    #[test]
779    fn spoke_touching_cap_quadrant_at_its_pole() {
780        for_all_scalars!(check_spoke_touching_cap_quadrant_at_its_pole);
781    }
782
783    /// The test suite of the old `curve_surface` search, run unchanged against the
784    /// coincidence-handling wrapper — the drop-in contract it must keep.
785    mod old_suite {
786        use super::super::curve_surface_intersect;
787        use crate::{nurb_curve::NurbCurve, nurb_surface::NurbSurface};
788        use geop_core_math::for_all_scalars;
789        use geop_core_math::{scalars::Scalar, vector::Vector4};
790
791        const MAX_NODES: usize = 2000;
792
793        fn ptc<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
794            Vector4::from_array([
795                S::from_f64(x),
796                S::from_f64(y),
797                S::from_f64(z),
798                S::from_f64(w),
799            ])
800        }
801
802        fn pts<S: Scalar>(x: f64, y: f64, z: f64) -> Vector4<S> {
803            Vector4::from_array([S::from_f64(x), S::from_f64(y), S::from_f64(z), S::ONE])
804        }
805
806        /// Flat unit patch in the xy-plane (z = 0), x,y ∈ [0,1].
807        fn flat_xy<S: Scalar>() -> NurbSurface<S, 4> {
808            let f = S::from_f64;
809            NurbSurface::try_new(
810                1,
811                1,
812                vec![
813                    pts(0., 0., 0.),
814                    pts(0., 1., 0.),
815                    pts(1., 0., 0.),
816                    pts(1., 1., 0.),
817                ],
818                vec![f(0.), f(0.), f(1.), f(1.)],
819                vec![f(0.), f(0.), f(1.), f(1.)],
820            )
821            .unwrap()
822        }
823
824        /// Straight line crossing `flat_xy` once at (0.5, 0.5, 0).
825        fn vertical_crossing_line<S: Scalar>() -> NurbCurve<S, 4> {
826            let f = S::from_f64;
827            NurbCurve::try_new(
828                1,
829                vec![ptc(0.5, 0.5, -1., 1.), ptc(0.5, 0.5, 1., 1.)],
830                vec![f(0.), f(0.), f(1.), f(1.)],
831            )
832            .unwrap()
833        }
834
835        /// Straight line entirely above `flat_xy` (z ∈ [1, 2]) — never crosses.
836        fn line_above_surface<S: Scalar>() -> NurbCurve<S, 4> {
837            let f = S::from_f64;
838            NurbCurve::try_new(
839                1,
840                vec![ptc(0.5, 0.5, 1., 1.), ptc(0.5, 0.5, 2., 1.)],
841                vec![f(0.), f(0.), f(1.), f(1.)],
842            )
843            .unwrap()
844        }
845
846        /// Quadratic Bézier dipping below z=0 and back, crossing `flat_xy` twice.
847        /// y = 0.3 is deliberately not the midpoint of flat_xy's y range [0,1],
848        /// avoiding the "both halves always survive" tie pathology that exact
849        /// midpoints trigger (see `crossing_vyz` in `surface_surface.rs`).
850        fn double_dip_curve<S: Scalar>() -> NurbCurve<S, 4> {
851            let f = S::from_f64;
852            NurbCurve::try_new(
853                2,
854                vec![
855                    ptc(0.2, 0.3, 1.0, 1.),
856                    ptc(0.5, 0.3, -2.0, 1.),
857                    ptc(0.8, 0.3, 1.0, 1.),
858                ],
859                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
860            )
861            .unwrap()
862        }
863
864        /// Straight line lying *in* the `flat_xy` plane (z = 0), spanning part of
865        /// its footprint: from (0.2, 0.5, 0) to (0.8, 0.5, 0).
866        fn coplanar_line<S: Scalar>() -> NurbCurve<S, 4> {
867            let f = S::from_f64;
868            NurbCurve::try_new(
869                1,
870                vec![ptc(0.2, 0.5, 0., 1.), ptc(0.8, 0.5, 0., 1.)],
871                vec![f(0.), f(0.), f(1.), f(1.)],
872            )
873            .unwrap()
874        }
875
876        /// Coplanar line spanning x ∈ [-0.5, 0.5] at y=0.5 — only the x ∈ [0, 0.5]
877        /// half (t ∈ [0.5, 1]) overlaps `flat_xy`'s footprint (x,y ∈ [0,1]).
878        fn partial_overlap_line<S: Scalar>() -> NurbCurve<S, 4> {
879            let f = S::from_f64;
880            NurbCurve::try_new(
881                1,
882                vec![ptc(-0.5, 0.5, 0., 1.), ptc(0.5, 0.5, 0., 1.)],
883                vec![f(0.), f(0.), f(1.), f(1.)],
884            )
885            .unwrap()
886        }
887
888        /// Coplanar line spanning x ∈ [-1, 2] at y=0.5 — much larger than
889        /// `flat_xy`'s x-extent [0,1], extending beyond it on both sides. Only
890        /// x ∈ [0,1] (t ∈ [1/3, 2/3]) overlaps the surface.
891        fn oversized_line<S: Scalar>() -> NurbCurve<S, 4> {
892            let f = S::from_f64;
893            NurbCurve::try_new(
894                1,
895                vec![ptc(-1.0, 0.5, 0., 1.), ptc(2.0, 0.5, 0., 1.)],
896                vec![f(0.), f(0.), f(1.), f(1.)],
897            )
898            .unwrap()
899        }
900
901        /// Coplanar line spanning x ∈ [0,1] at y=0.5 — exactly matches
902        /// `flat_xy`'s x-extent.
903        fn full_width_line<S: Scalar>() -> NurbCurve<S, 4> {
904            let f = S::from_f64;
905            NurbCurve::try_new(
906                1,
907                vec![ptc(0., 0.5, 0., 1.), ptc(1., 0.5, 0., 1.)],
908                vec![f(0.), f(0.), f(1.), f(1.)],
909            )
910            .unwrap()
911        }
912
913        /// Homogeneous control point with weight `w`, given its Cartesian
914        /// position `(x, y, z)`.
915        fn ptw<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
916            Vector4::from_array([
917                S::from_f64(x * w),
918                S::from_f64(y * w),
919                S::from_f64(z * w),
920                S::from_f64(w),
921            ])
922        }
923
924        /// Non-planar bilinear "saddle" patch: corner heights 0,1,1,0 over
925        /// x,y ∈ [0,2]. Its v=0.5 ridge line sits at z = 0.5.
926        fn bent_surface<S: Scalar>() -> NurbSurface<S, 4> {
927            let f = S::from_f64;
928            NurbSurface::try_new(
929                1,
930                1,
931                vec![
932                    pts(0., 0., 0.),
933                    pts(0., 2., 1.),
934                    pts(2., 0., 1.),
935                    pts(2., 2., 0.),
936                ],
937                vec![f(0.), f(0.), f(1.), f(1.)],
938                vec![f(0.), f(0.), f(1.), f(1.)],
939            )
940            .unwrap()
941        }
942
943        /// Quadratic Bézier running along `bent_surface`'s ridge line (y = 1),
944        /// dipping from z=-1 up to z=3 and back to z=-1 -- crossing the ridge's
945        /// z=0.5 height at two distinct points (t ≈ 0.25 and t ≈ 0.75).
946        fn bent_curve<S: Scalar>() -> NurbCurve<S, 4> {
947            let f = S::from_f64;
948            NurbCurve::try_new(
949                2,
950                vec![
951                    ptc(0.2, 1.0, -1.0, 1.),
952                    ptc(1.0, 1.0, 3.0, 1.),
953                    ptc(1.8, 1.0, -1.0, 1.),
954                ],
955                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
956            )
957            .unwrap()
958        }
959
960        /// Degree-(2,2) rational patch covering one octant of the unit sphere
961        /// (x,y,z >= 0), built by revolving a quarter-circle meridian (in the
962        /// xz-plane) by a quarter turn around the z axis. Its v=0 edge is exactly
963        /// `equator_quarter_circle`.
964        fn sphere_octant_patch<S: Scalar>() -> NurbSurface<S, 4> {
965            let f = S::from_f64;
966            let w = 1.0 / 2.0_f64.sqrt();
967            NurbSurface::try_new(
968                2,
969                2,
970                vec![
971                    // u = 0 (azimuth 0deg)
972                    ptw(1., 0., 0., 1.),
973                    ptw(1., 0., 1., w),
974                    ptw(0., 0., 1., 1.),
975                    // u = 1 (azimuth 45deg)
976                    ptw(1., 1., 0., w),
977                    ptw(1., 1., 1., 0.5),
978                    ptw(0., 0., 1., w),
979                    // u = 2 (azimuth 90deg)
980                    ptw(0., 1., 0., 1.),
981                    ptw(0., 1., 1., w),
982                    ptw(0., 0., 1., 1.),
983                ],
984                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
985                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
986            )
987            .unwrap()
988        }
989
990        /// Quarter circle from (1,0,0) to (0,1,0) in the xy-plane -- exactly the
991        /// v=0 edge of `sphere_octant_patch`, i.e. coincident with that surface.
992        fn equator_quarter_circle<S: Scalar>() -> NurbCurve<S, 4> {
993            let f = S::from_f64;
994            let w = 1.0 / 2.0_f64.sqrt();
995            NurbCurve::try_new(
996                2,
997                vec![ptw(1., 0., 0., 1.), ptw(1., 1., 0., w), ptw(0., 1., 0., 1.)],
998                vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
999            )
1000            .unwrap()
1001        }
1002
1003        const EPS: f64 = 1e-2;
1004
1005        // ── Single crossing ───────────────────────────────────────────────────────
1006
1007        fn check_single_crossing_curve_has_one_solution<S: Scalar>() {
1008            let curve = vertical_crossing_line::<S>();
1009            let surf = flat_xy::<S>();
1010            let result =
1011                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1012            assert_eq!(result.len(), 1);
1013        }
1014        #[test]
1015        fn single_crossing_curve_has_one_solution() {
1016            for_all_scalars!(check_single_crossing_curve_has_one_solution);
1017        }
1018
1019        // ── No crossing ───────────────────────────────────────────────────────────
1020
1021        fn check_curve_missing_surface_has_no_solution<S: Scalar>() {
1022            let curve = line_above_surface::<S>();
1023            let surf = flat_xy::<S>();
1024            let result =
1025                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1026            assert!(result.is_empty());
1027        }
1028        #[test]
1029        fn curve_missing_surface_has_no_solution() {
1030            for_all_scalars!(check_curve_missing_surface_has_no_solution);
1031        }
1032
1033        // ── Budget ────────────────────────────────────────────────────────────────
1034
1035        fn check_max_solutions_zero_returns_empty<S: Scalar>() {
1036            let curve = vertical_crossing_line::<S>();
1037            let surf = flat_xy::<S>();
1038            let result =
1039                curve_surface_intersect(&curve, &surf, 0, MAX_NODES, S::from_f64(EPS)).unwrap();
1040            assert!(result.is_empty());
1041        }
1042        #[test]
1043        fn max_solutions_zero_returns_empty() {
1044            for_all_scalars!(check_max_solutions_zero_returns_empty);
1045        }
1046
1047        fn check_max_nodes_exhausted_errors<S: Scalar>() {
1048            // Adapted: the old search could only see coincidence as endless
1049            // subdivision, so a coincident pair was the way to overrun a tiny
1050            // budget. The wrapper resolves that pair directly (and correctly,
1051            // as `Coincident`) within it, so the invariant — running out of
1052            // budget is an error, never a truncated result — is exercised on
1053            // a two-crossing pair that genuinely needs more nodes than that.
1054            let curve = double_dip_curve::<S>();
1055            let surf = flat_xy::<S>();
1056            let result = curve_surface_intersect(&curve, &surf, 1000, 1, S::from_f64(EPS));
1057            assert!(result.is_err());
1058        }
1059        #[test]
1060        fn max_nodes_exhausted_errors() {
1061            for_all_scalars!(check_max_nodes_exhausted_errors);
1062        }
1063
1064        // ── Two crossings ─────────────────────────────────────────────────────────
1065
1066        fn check_two_crossings_found_when_budget_allows<S: Scalar>() {
1067            let curve = double_dip_curve::<S>();
1068            let surf = flat_xy::<S>();
1069            let result = curve_surface_intersect(&curve, &surf, 2, MAX_NODES, S::from_f64(EPS))
1070                .unwrap()
1071                .into_vec();
1072            assert_eq!(result.len(), 2);
1073            assert!(
1074                !result[0].0.could_be_equal(result[1].0),
1075                "the two crossings should remain distinct after unification"
1076            );
1077        }
1078        #[test]
1079        fn two_crossings_found_when_budget_allows() {
1080            for_all_scalars!(check_two_crossings_found_when_budget_allows);
1081        }
1082
1083        fn check_max_solutions_one_caps_at_one_even_with_two_crossings<S: Scalar>() {
1084            let curve = double_dip_curve::<S>();
1085            let surf = flat_xy::<S>();
1086            let result =
1087                curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1088            assert_eq!(result.len(), 1);
1089        }
1090        #[test]
1091        fn max_solutions_one_caps_at_one_even_with_two_crossings() {
1092            for_all_scalars!(check_max_solutions_one_caps_at_one_even_with_two_crossings);
1093        }
1094
1095        // ── min_subdivision_size controls precision ────────────────────────────
1096
1097        fn check_min_subdivision_size_controls_precision<S: Scalar>() {
1098            let curve = vertical_crossing_line::<S>();
1099            let surf = flat_xy::<S>();
1100            let result = curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(1e-3))
1101                .unwrap()
1102                .into_vec();
1103            assert_eq!(result.len(), 1);
1104
1105            let (_, uv) = result[0];
1106            assert!(
1107                uv[0]
1108                    .sub(S::from_f64(0.5))
1109                    .abs()
1110                    .could_be_less(S::from_f64(1e-2))
1111            );
1112            assert!(
1113                uv[1]
1114                    .sub(S::from_f64(0.5))
1115                    .abs()
1116                    .could_be_less(S::from_f64(1e-2))
1117            );
1118        }
1119        #[test]
1120        fn min_subdivision_size_controls_precision() {
1121            for_all_scalars!(check_min_subdivision_size_controls_precision);
1122        }
1123
1124        // ── Coplanar curve: must terminate ───────────────────────────────────────
1125
1126        fn check_coplanar_curve_terminates<S: Scalar>() {
1127            let curve = coplanar_line::<S>();
1128            let surf = flat_xy::<S>();
1129
1130            // A single dive must converge to exactly one result.
1131            let result_one =
1132                curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1133            assert_eq!(result_one.len(), 1);
1134
1135            // Asking for more solutions still terminates, with at most that many
1136            // (possibly fewer after unification) segments along the coplanar overlap.
1137            let result_many =
1138                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1139            assert!(!result_many.is_empty());
1140            assert!(result_many.len() <= 5);
1141        }
1142        #[test]
1143        fn coplanar_curve_terminates() {
1144            for_all_scalars!(check_coplanar_curve_terminates);
1145        }
1146
1147        // ── Coincident: an evenly-spread solution count, not just 1-or-cap ──────
1148
1149        fn check_coincident_curve_reaches_max_solutions<S: Scalar>() {
1150            let curve = full_width_line::<S>();
1151            let surf = flat_xy::<S>();
1152            // A curve running the *entire* width of the surface it's coincident
1153            // with, with a generous node budget, should reliably reach the
1154            // requested solution count via the evenly-spread search — this is
1155            // exactly the property `max_solutions` saturating is meant to
1156            // signal "coincident" to a caller in the first place.
1157            let result =
1158                curve_surface_intersect(&curve, &surf, 5, 5000, S::from_f64(1e-3)).unwrap();
1159            assert!(result.is_coincident());
1160            assert_eq!(result.len(), 5);
1161        }
1162        #[test]
1163        fn coincident_curve_reaches_max_solutions() {
1164            for_all_scalars!(check_coincident_curve_reaches_max_solutions);
1165        }
1166
1167        // ── Partial overlap: only part of the curve lies over the surface ───────
1168
1169        fn check_partial_overlap_coplanar_line<S: Scalar>() {
1170            let curve = partial_overlap_line::<S>();
1171            let surf = flat_xy::<S>();
1172            let result =
1173                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1174            assert!(!result.is_empty());
1175            assert!(result.len() <= 5);
1176
1177            // Every solution must lie within the overlapping half of the curve
1178            // (x >= 0, i.e. t >= 0.5), up to a small tolerance.
1179            let lower_bound = S::from_f64(0.5 - EPS);
1180            for &(t, _) in result.as_slice() {
1181                assert!(!t.definitely_less(lower_bound));
1182            }
1183        }
1184        #[test]
1185        fn partial_overlap_coplanar_line() {
1186            for_all_scalars!(check_partial_overlap_coplanar_line);
1187        }
1188
1189        // ── Curve much larger than the surface ───────────────────────────────────
1190
1191        fn check_curve_larger_than_surface_terminates<S: Scalar>() {
1192            let curve = oversized_line::<S>();
1193            let surf = flat_xy::<S>();
1194            let result =
1195                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1196            assert!(!result.is_empty());
1197            assert!(result.len() <= 5);
1198
1199            // Every solution must lie within the overlapping middle third of the
1200            // curve (x ∈ [0,1], i.e. t ∈ [1/3, 2/3]), up to a small tolerance.
1201            let lower_bound = S::from_f64(1.0 / 3.0 - EPS);
1202            let upper_bound = S::from_f64(2.0 / 3.0 + EPS);
1203            for &(t, _) in result.as_slice() {
1204                assert!(!t.definitely_less(lower_bound));
1205                assert!(!t.definitely_greater(upper_bound));
1206            }
1207        }
1208        #[test]
1209        fn curve_larger_than_surface_terminates() {
1210            for_all_scalars!(check_curve_larger_than_surface_terminates);
1211        }
1212
1213        // ── Curve exactly the same size as the surface ──────────────────────────
1214
1215        fn check_curve_same_size_as_surface_terminates<S: Scalar>() {
1216            let curve = full_width_line::<S>();
1217            let surf = flat_xy::<S>();
1218
1219            let result_one =
1220                curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1221            assert_eq!(result_one.len(), 1);
1222
1223            let result_many =
1224                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1225            assert!(!result_many.is_empty());
1226            assert!(result_many.len() <= 5);
1227        }
1228        #[test]
1229        fn curve_same_size_as_surface_terminates() {
1230            for_all_scalars!(check_curve_same_size_as_surface_terminates);
1231        }
1232
1233        // ── Bent surface / bent curve: distinct crossings ────────────────────────
1234
1235        fn check_bent_surface_bent_curve_two_distinct_crossings<S: Scalar>() {
1236            let curve = bent_curve::<S>();
1237            let surf = bent_surface::<S>();
1238            let result = curve_surface_intersect(&curve, &surf, 4, MAX_NODES, S::from_f64(EPS))
1239                .unwrap()
1240                .into_vec();
1241            assert_eq!(result.len(), 2);
1242            assert!(
1243                !result[0].0.could_be_equal(result[1].0),
1244                "the two crossings of a bent curve through a bent surface should be distinct"
1245            );
1246        }
1247        #[test]
1248        fn bent_surface_bent_curve_two_distinct_crossings() {
1249            for_all_scalars!(check_bent_surface_bent_curve_two_distinct_crossings);
1250        }
1251
1252        // ── Coincident circle on a spherical patch: must terminate ───────────────
1253
1254        fn check_coincident_circle_on_sphere_patch_terminates<S: Scalar>() {
1255            let curve = equator_quarter_circle::<S>();
1256            let surf = sphere_octant_patch::<S>();
1257
1258            let result_one =
1259                curve_surface_intersect(&curve, &surf, 1, MAX_NODES, S::from_f64(EPS)).unwrap();
1260            assert_eq!(result_one.len(), 1);
1261
1262            let result_many =
1263                curve_surface_intersect(&curve, &surf, 5, MAX_NODES, S::from_f64(EPS)).unwrap();
1264            assert!(!result_many.is_empty());
1265            assert!(result_many.len() <= 5);
1266        }
1267        #[test]
1268        fn coincident_circle_on_sphere_patch_terminates() {
1269            for_all_scalars!(check_coincident_circle_on_sphere_patch_terminates);
1270        }
1271    }
1272}