Skip to main content

geop_core_geometry/contains/
curve.rs

1//! Curve/point containment by per-axis fat line clipping — the derivation
2//! is in `curve.md` next to this file; this module implements its Clip B
3//! ("fat line") together with the iteration of its section 5.
4//!
5//! Same contract as [`super::curve_bisect::curve_could_contain`] (a sound
6//! enclosure of every parameter at which the curve could pass through the
7//! point, `None` when it definitely doesn't), but instead of only asking
8//! "could this segment's hull contain the point?" and bisecting, every
9//! segment is *clipped*: each Cartesian axis `k` yields a scalar spline
10//! `g_k(t) = X_k(t) - p_k W(t)` whose zeros are exactly the parameters where
11//! that coordinate matches, and a fat line around its graph's control
12//! polygon bounds those zeros to an interval. Intersecting the axes'
13//! intervals shrinks the segment directly towards the solution, so a
14//! transversal hit converges in a handful of clips instead of one
15//! bisection per bit of precision.
16
17use std::collections::VecDeque;
18
19use crate::{
20    aabb::aabb_could_contain,
21    fat_line::{fat_line_zeros, greville_abscissae},
22    nurb_curve::{NurbCurve, ParameterRefinable},
23};
24use geop_core_math::{
25    geop_error::{GeopError, GeopResult},
26    scalars::Scalar,
27    vector::Vector,
28};
29
30/// Clip `seg` against `point`, axis by axis: the intersection of every
31/// axis's [`fat_line_zeros`] with `seg`'s domain, or `None` if some axis
32/// proves the segment misses the point.
33///
34/// Axis `k`'s coefficients `d_i = P_{i,k} - p_k P_{i,w}` come straight from
35/// the homogeneous control points, with no division: since
36/// `NurbCurve::try_new` guarantees positive weights, `W(t) > 0` and
37/// `C_k(t) = p_k ⇔ X_k(t) - p_k W(t) = 0`.
38fn clip<S: Scalar, const D: usize, const C: usize>(
39    seg: &NurbCurve<S, D>,
40    xi: &[S],
41    point: &Vector<S, C>,
42) -> Option<S> {
43    let mut t_hat = seg.domain_as_scalar();
44    let mut d = Vec::with_capacity(seg.control_points.len());
45    for k in 0..C {
46        d.clear();
47        d.extend(
48            seg.control_points
49                .iter()
50                .map(|q| q[k].sub(point[k].mul(q[D - 1]))),
51        );
52        let zeros = fat_line_zeros(xi, &d)?;
53        // Both constraints hold at once, so their intersection encloses
54        // the solution — but `intersect` of disjoint enclosures is not
55        // empty, it returns an input, so disjointness is checked first.
56        if !zeros.could_be_equal(t_hat) {
57            return None;
58        }
59        t_hat = t_hat.intersect(zeros);
60    }
61    Some(t_hat)
62}
63
64/// Euclidean distance between the segment's first and last (dehomogenized)
65/// control points — the same convergence measure `curve_bisect::curve_could_contain`
66/// uses.
67fn chord_length<S: Scalar, const D: usize, const C: usize>(seg: &NurbCurve<S, D>) -> GeopResult<S> {
68    let dehom = |q: &Vector<S, D>| -> GeopResult<Vector<S, C>> {
69        let inv_w = S::ONE.div(q[D - 1])?;
70        let mut v = Vector::<S, C>::zero();
71        for c in 0..C {
72            v[c] = q[c].mul(inv_w);
73        }
74        Ok(v)
75    };
76    let first = dehom(&seg.control_points[0])?;
77    let last = dehom(&seg.control_points[seg.control_points.len() - 1])?;
78    Ok(last.sub(&first).norm())
79}
80
81/// Enclosure of `{C(t) : t ∈ t_hat}`, or `None` if it can't be had cheaply.
82///
83/// De Boor evaluation with an interval parameter encloses the polynomial
84/// of the *one* knot span `find_knot_span` picks, over the whole interval —
85/// so it is only an enclosure of the curve if `t_hat` lies definitely inside
86/// a single span. Interior knots must therefore be definitely outside
87/// `t_hat`: even one touching `t_hat`'s upper end makes `find_knot_span`
88/// pick the span to its right and extrapolate that polynomial over the
89/// rest. The domain's own end knots may touch; `find_knot_span` handles
90/// both ends explicitly.
91fn evaluate_over<S: Scalar, const D: usize, const C: usize>(
92    seg: &NurbCurve<S, D>,
93    t_hat: S,
94) -> Option<Vector<S, C>>
95where
96    NurbCurve<S, D>: ParameterRefinable<S, C>,
97{
98    let (lo, hi) = (t_hat.lower(), t_hat.upper());
99    let interior_knots = &seg.knot_vector[seg.degree + 1..seg.control_points.len()];
100    let single_span = interior_knots
101        .iter()
102        .all(|&u| u.definitely_less(lo) || u.definitely_greater(hi));
103    if !single_span {
104        return None;
105    }
106    seg.evaluate_cartesian(t_hat).ok()
107}
108
109/// Fat-line-clipping counterpart of [`super::curve_bisect::curve_could_contain`],
110/// with the same tunables: the [`Scalar::union`] of every converged
111/// segment's clipped parameter interval, exploring breadth-first up to
112/// `max_nodes` segments. `None` only when every part of the domain was
113/// rejected: unlike `curve_bisect`, running out of budget is never read as "not
114/// contained" (nor as "contained") — it is an error, since the search is
115/// incomplete (see `surface.md` §5).
116///
117/// Each segment is clipped (see [`clip`]). An empty clip rejects it. A
118/// segment converges — and reports its *clipped* interval, tighter than its
119/// domain and still an enclosure — once either the curve evaluated over that
120/// interval contains the point within `min_subdivision_size` in every axis,
121/// or its chord is no longer definitely greater than `min_subdivision_size`.
122/// Otherwise, if the clip removed at least 20% of the domain, the segment is
123/// restricted to the clip and clipped again; if it didn't — several
124/// solutions in one segment, or a tangency, where clipping stalls — it is
125/// bisected instead, exactly as the hull search always does. (20% is the
126/// usual Bézier/fat line clipping rule, Sederberg & Nishita.)
127///
128/// Restriction cuts at the clip's *outer* bounds ([`Scalar::lower`] /
129/// [`Scalar::upper`]), which are free choices only on the outside: any cut
130/// outside the enclosure loses nothing, a cut through it would. Bisection
131/// uses the sharpened midpoint as always.
132pub fn curve_could_contain<S: Scalar, const D: usize, const C: usize>(
133    curve: &NurbCurve<S, D>,
134    point: &Vector<S, C>,
135    max_nodes: usize,
136    min_subdivision_size: S,
137) -> GeopResult<Option<S>>
138where
139    NurbCurve<S, D>: ParameterRefinable<S, C>,
140{
141    debug_assert_eq!(
142        C + 1,
143        D,
144        "point dimension must match the curve's Cartesian dimension"
145    );
146    let min_progress = S::from_ratio(4, 5)?;
147
148    let mut queue: VecDeque<NurbCurve<S, D>> = VecDeque::new();
149    queue.push_back(curve.clone());
150
151    let mut explored = 0usize;
152    let mut solution: Option<S> = None;
153    let mut report = |t: S| {
154        solution = Some(match solution {
155            Some(existing) => existing.union(t),
156            None => t,
157        });
158    };
159
160    while let Some(seg) = queue.pop_front() {
161        if explored >= max_nodes {
162            return Err(GeopError::new(format!(
163                "curve_could_contain (clipping): exhausted max_nodes={max_nodes} with {} \
164                 segments pending; the result would be incomplete",
165                queue.len() + 1
166            )));
167        }
168        explored += 1;
169
170        // Cheap prefilter against the cached bounding box, as in `curve_bisect`.
171        if !aabb_could_contain(&seg.aabb, point) {
172            continue;
173        }
174
175        let Some(xi) = greville_abscissae(&seg.knot_vector, seg.degree, seg.control_points.len())?
176        else {
177            // Degree 0 has no graph polygon to clip; the box test above is
178            // all we can say.
179            report(seg.domain_as_scalar());
180            continue;
181        };
182        let Some(t_hat) = clip(&seg, &xi, point) else {
183            continue;
184        };
185
186        // `t_hat` encloses every solution in this segment, but a narrow
187        // `t_hat` alone doesn't mean the point is near the curve: a single
188        // axis can pin it down while the others were never checked at that
189        // precision. So a narrow `t_hat` is settled by evaluating the curve
190        // over it — an enclosure of `C(t_hat)`. Missing the point proves
191        // there is no solution; containing it with a physically small
192        // enclosure is the same statement the chord test below makes. This
193        // is also what ends a search whose solution sits exactly on a domain
194        // end, where the clip collapses but no cut can be made.
195        if !t_hat.width().definitely_greater(min_subdivision_size) {
196            if let Some(on_curve) = evaluate_over(&seg, t_hat) {
197                if !on_curve.could_be_equal(point) {
198                    continue;
199                }
200                if (0..C).all(|c| !on_curve[c].width().definitely_greater(min_subdivision_size)) {
201                    report(t_hat);
202                    continue;
203                }
204            }
205        }
206
207        let (t0, t1) = seg.domain();
208        let domain_width = seg.domain_as_scalar().width();
209        if !chord_length::<S, D, C>(&seg)?.definitely_greater(min_subdivision_size) {
210            report(t_hat);
211            continue;
212        }
213
214        if t_hat
215            .width()
216            .definitely_less(domain_width.mul(min_progress))
217        {
218            let (lo, hi) = (t_hat.lower(), t_hat.upper());
219            // `sub_curve` only cuts at bounds strictly inside the domain; if
220            // neither is (e.g. a clip collapsed onto a domain end), no cut is
221            // possible and we fall through to bisection — re-queuing an
222            // uncut segment would just repeat this node until the budget.
223            let inside = |t: S| t.definitely_greater(t0) && t.definitely_less(t1);
224            if inside(lo) || inside(hi) {
225                if let Ok(restricted) = seg.sub_curve(lo, hi) {
226                    queue.push_back(restricted);
227                    continue;
228                }
229            }
230        }
231
232        match seg.split_mid() {
233            Ok((left, right)) => {
234                queue.push_back(left);
235                queue.push_back(right);
236            }
237            // Cannot split (e.g. midpoint already at multiplicity p+1).
238            Err(_) => report(t_hat),
239        }
240    }
241
242    Ok(solution)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::curve_could_contain;
248    use crate::nurb_curve::NurbCurve;
249    use geop_core_math::for_all_scalars;
250    use geop_core_math::{
251        scalars::Scalar,
252        vector::{Vector3, Vector4},
253    };
254
255    fn pt<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
256        Vector4::from_array([
257            S::from_f64(x),
258            S::from_f64(y),
259            S::from_f64(z),
260            S::from_f64(w),
261        ])
262    }
263
264    const MAX: usize = 500;
265    const EPS: f64 = 1e-3;
266
267    /// Degree-1 line from (0,0,0) to (1,0,0).
268    fn line<S: Scalar>() -> NurbCurve<S, 4> {
269        let f = S::from_f64;
270        NurbCurve::try_new(
271            1,
272            vec![pt(0., 0., 0., 1.), pt(1., 0., 0., 1.)],
273            vec![f(0.), f(0.), f(1.), f(1.)],
274        )
275        .unwrap()
276    }
277
278    /// Exact rational quarter circle of radius 1 in the xy-plane.
279    fn quarter_circle<S: Scalar>() -> NurbCurve<S, 4> {
280        let f = S::from_f64;
281        let w = std::f64::consts::FRAC_1_SQRT_2;
282        NurbCurve::try_new(
283            2,
284            vec![pt(1., 0., 0., 1.), pt(w, w, 0., w), pt(0., 1., 0., 1.)],
285            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
286        )
287        .unwrap()
288    }
289
290    /// Cubic B-spline with two interior knots, wiggling through 3-D.
291    fn wiggle<S: Scalar>() -> NurbCurve<S, 4> {
292        let f = S::from_f64;
293        NurbCurve::try_new(
294            3,
295            vec![
296                pt(0., 0., 0., 1.),
297                pt(1., 2., 0., 1.),
298                pt(2., -1., 1., 1.),
299                pt(3., 2., 0., 1.),
300                pt(4., 0., -1., 1.),
301                pt(5., 1., 0., 1.),
302            ],
303            vec![
304                f(0.),
305                f(0.),
306                f(0.),
307                f(0.),
308                f(0.3),
309                f(0.6),
310                f(1.),
311                f(1.),
312                f(1.),
313                f(1.),
314            ],
315        )
316        .unwrap()
317    }
318
319    /// Point at `t` must be found, and the returned enclosure must contain `t`.
320    fn assert_contains_at<S: Scalar>(c: &NurbCurve<S, 4>, t: f64) {
321        let t = S::from_f64(t);
322        let p = c.evaluate(t).unwrap();
323        let found = curve_could_contain(c, &p, MAX, S::from_f64(EPS))
324            .unwrap()
325            .unwrap_or_else(|| panic!("point at t={t:?} not found"));
326        assert!(
327            found.could_be_equal(t),
328            "enclosure {found:?} misses t={t:?}"
329        );
330    }
331
332    fn check_line_contains<S: Scalar>() {
333        for t in [0., 0.25, 0.5, 1.] {
334            assert_contains_at(&line::<S>(), t);
335        }
336    }
337    #[test]
338    fn line_contains() {
339        for_all_scalars!(check_line_contains);
340    }
341
342    fn check_quarter_circle_contains<S: Scalar>() {
343        for t in [0., 0.1, 0.5, 0.77, 1.] {
344            assert_contains_at(&quarter_circle::<S>(), t);
345        }
346    }
347    #[test]
348    fn quarter_circle_contains() {
349        for_all_scalars!(check_quarter_circle_contains);
350    }
351
352    fn check_wiggle_contains<S: Scalar>() {
353        for t in [0., 0.05, 0.3, 0.42, 0.6, 0.9, 1.] {
354            assert_contains_at(&wiggle::<S>(), t);
355        }
356    }
357    #[test]
358    fn wiggle_contains() {
359        for_all_scalars!(check_wiggle_contains);
360    }
361
362    /// Clipping narrows a transversal hit well below `min_subdivision_size`
363    /// instead of stopping at a bisection leaf.
364    fn check_result_is_tight<S: Scalar>() {
365        let c = wiggle::<S>();
366        let t = S::from_f64(0.42);
367        let p = c.evaluate(t).unwrap();
368        let found = curve_could_contain(&c, &p, MAX, S::from_f64(EPS))
369            .unwrap()
370            .unwrap();
371        assert!(found.width().definitely_less(S::from_f64(EPS)), "{found:?}");
372    }
373    #[test]
374    fn result_is_tight() {
375        for_all_scalars!(check_result_is_tight);
376    }
377
378    fn check_misses_off_curve_points<S: Scalar>() {
379        let f = S::from_f64;
380        let off = |x, y, z| Vector3::from_array([f(x), f(y), f(z)]);
381        // Inside the circle's bounding box and control polygon, off the arc.
382        assert!(
383            curve_could_contain(&quarter_circle::<S>(), &off(0.6, 0.6, 0.), MAX, f(EPS))
384                .unwrap()
385                .is_none()
386        );
387        // Beside the line, beyond its end.
388        assert!(
389            curve_could_contain(&line::<S>(), &off(1.5, 0., 0.), MAX, f(EPS))
390                .unwrap()
391                .is_none()
392        );
393        assert!(
394            curve_could_contain(&line::<S>(), &off(0.5, 0.1, 0.), MAX, f(EPS))
395                .unwrap()
396                .is_none()
397        );
398        assert!(
399            curve_could_contain(&wiggle::<S>(), &off(2.5, 0.5, 0.5), MAX, f(EPS))
400                .unwrap()
401                .is_none()
402        );
403    }
404    #[test]
405    fn misses_off_curve_points() {
406        for_all_scalars!(check_misses_off_curve_points);
407    }
408}