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