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//! The result is a sound enclosure of every parameter at which the curve
6//! could pass through the point, `None` when it definitely doesn't. Instead
7//! of only asking "could this segment contain the point?" and bisecting,
8//! every segment is *clipped*: each Cartesian axis `k` yields a scalar spline
9//! `g_k(t) = X_k(t) - p_k W(t)` whose zeros are exactly the parameters where
10//! that coordinate matches, and a fat line around its graph's control
11//! polygon bounds those zeros to an interval. Intersecting the axes'
12//! intervals shrinks the segment directly towards the solution, so a
13//! transversal hit converges in a handful of clips instead of one
14//! bisection per bit of precision.
15
16use crate::{
17    aabb::{aabb_could_contain, curve_could_meet_aabb},
18    fat_line::{
19        Stalled, carried_width, extent, fat_line_zeros, greville_abscissae, restriction, stalled,
20    },
21    nurb_curve::{NurbCurve, ParameterRefinable},
22};
23use geop_core_math::{
24    geop_error::{GeopError, GeopResult},
25    scalars::Scalar,
26    solvers::subdivide::{Order, Searched, Split, subdivide},
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<const D: usize, const C: usize>(
39    seg: &NurbCurve<D>,
40    xi: &[Scalar],
41    point: &Vector<C>,
42) -> Option<Scalar> {
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/// Every parameter at which `curve` could pass through `point`: the
65/// [`Scalar::union`] of every converged segment's clipped parameter
66/// interval, exploring breadth-first up to `max_nodes` segments. `None` only
67/// when every part of the domain was rejected: running out of budget is
68/// never read as "not contained" (nor as "contained") — it is an error, since the search is
69/// incomplete (see `surface.md` §5).
70///
71/// Each segment is clipped (see [`clip`]). An empty clip rejects it. While
72/// the clip keeps shrinking the segment it is restricted and clipped again
73/// ([`restriction`]). Once it stalls — several solutions or a tangency in
74/// one segment, or the resolution of the data reached — the segment is
75/// reported with its *clipped* interval (tighter than its domain, and still
76/// an enclosure) if it has converged, and bisected otherwise ([`stalled`]).
77/// `min_subdivision_size` thus only decides when to stop *bisecting*: a
78/// segment that clipping keeps shrinking is never cut short, so a point
79/// merely within `min_subdivision_size` of the curve is still rejected.
80///
81/// Restriction cuts at the clip's *outer* bounds ([`Scalar::lower`] /
82/// [`Scalar::upper`]), which are free choices only on the outside: any cut
83/// outside the enclosure loses nothing, a cut through it would. Bisection
84/// uses the sharpened midpoint as always.
85pub fn curve_could_contain<const D: usize, const C: usize>(
86    curve: &NurbCurve<D>,
87    point: &Vector<C>,
88    max_nodes: usize,
89    min_subdivision_size: Scalar,
90) -> GeopResult<Option<Scalar>>
91where
92    NurbCurve<D>: ParameterRefinable<C>,
93{
94    debug_assert_eq!(
95        C + 1,
96        D,
97        "point dimension must match the curve's Cartesian dimension"
98    );
99    // A straight segment is tested as itself, not its box, before anything
100    // is cloned (see `curve_could_meet_aabb`).
101    let mut point_box = [Scalar::ENTIRE; 3];
102    point_box[..C].copy_from_slice(&point.to_array());
103    if !curve_could_meet_aabb(curve, &point_box, C) {
104        return Ok(None);
105    }
106    let mut solution: Option<Scalar> = None;
107    let mut report = |t: Scalar| {
108        solution = Some(match solution {
109            Some(existing) => existing.union(t),
110            None => t,
111        });
112    };
113
114    let searched = subdivide(
115        [curve.clone()],
116        Order::BreadthFirst,
117        max_nodes,
118        |seg, _| -> GeopResult<_> {
119            // Cheap prefilter against the cached bounding box.
120            if !aabb_could_contain(&seg.aabb, point) {
121                return Ok(Split::Done);
122            }
123
124            let Some(xi) =
125                greville_abscissae(&seg.knot_vector, seg.degree, seg.control_points.len())?
126            else {
127                // Degree 0 has no graph polygon to clip; the box test above is
128                // all we can say.
129                report(seg.domain_as_scalar());
130                return Ok(Split::Done);
131            };
132            let Some(t_hat) = clip(&seg, &xi, point) else {
133                return Ok(Split::Done);
134            };
135
136            let ranges = [seg.domain()];
137            let sizes = [extent([seg.control_points.iter().copied()])];
138            // The query point's own width counts too, like a second object's.
139            let point_width = (0..C)
140                .map(|k| point[k].width().to_f64())
141                .fold(0.0, f64::max);
142            let carried = carried_width(&seg.control_points).max(point_width);
143            if let Some(bounds) = restriction(&[t_hat], &ranges, &sizes, carried)?
144                && let Ok(restricted) = seg.sub_curve(bounds[0].0, bounds[0].1)
145            {
146                return Ok(Split::Restrict(restricted));
147            }
148
149            let halves = match stalled(&ranges, &sizes, carried, min_subdivision_size) {
150                Stalled::Converged => None,
151                Stalled::Bisect(order) if order.is_empty() => None,
152                // Cannot split (e.g. midpoint already at multiplicity p+1).
153                Stalled::Bisect(_) => seg.split_mid().ok(),
154            };
155            Ok(match halves {
156                Some((left, right)) => Split::Halves(left, right),
157                None => {
158                    report(t_hat);
159                    Split::Done
160                }
161            })
162        },
163    )?;
164    if let Searched::Exhausted { pending } = searched {
165        return Err(GeopError::new(format!(
166            "curve_could_contain (clipping): exhausted max_nodes={max_nodes} with {pending} \
167             segments pending; the result would be incomplete"
168        )));
169    }
170
171    Ok(solution)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::curve_could_contain;
177    use crate::nurb_curve::NurbCurve;
178    use crate::test_fixtures::{hom, line};
179    use geop_core_math::{scalars::Scalar, vector::Vector3};
180
181    const MAX: usize = 500;
182    const MIN_SUBDIVISION_SIZE: f64 = 1e-3;
183
184    /// Exact rational quarter circle of radius 1 in the xy-plane.
185    fn quarter_circle() -> NurbCurve<4> {
186        let f = Scalar::from_f64;
187        let w = std::f64::consts::FRAC_1_SQRT_2;
188        NurbCurve::try_new(
189            2,
190            vec![hom(1., 0., 0., 1.), hom(w, w, 0., w), hom(0., 1., 0., 1.)],
191            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
192        )
193        .unwrap()
194    }
195
196    /// Cubic B-spline with two interior knots, wiggling through 3-D.
197    fn wiggle() -> NurbCurve<4> {
198        let f = Scalar::from_f64;
199        NurbCurve::try_new(
200            3,
201            vec![
202                hom(0., 0., 0., 1.),
203                hom(1., 2., 0., 1.),
204                hom(2., -1., 1., 1.),
205                hom(3., 2., 0., 1.),
206                hom(4., 0., -1., 1.),
207                hom(5., 1., 0., 1.),
208            ],
209            vec![
210                f(0.),
211                f(0.),
212                f(0.),
213                f(0.),
214                f(0.3),
215                f(0.6),
216                f(1.),
217                f(1.),
218                f(1.),
219                f(1.),
220            ],
221        )
222        .unwrap()
223    }
224
225    /// Point at `t` must be found, and the returned enclosure must contain `t`.
226    fn assert_contains_at(c: &NurbCurve<4>, t: f64) {
227        let t = Scalar::from_f64(t);
228        let p = c.evaluate(t).unwrap();
229        let found = curve_could_contain(c, &p, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
230            .unwrap()
231            .unwrap_or_else(|| panic!("point at t={t:?} not found"));
232        assert!(
233            found.could_be_equal(t),
234            "enclosure {found:?} misses t={t:?}"
235        );
236    }
237
238    #[test]
239    fn line_contains() {
240        for t in [0., 0.25, 0.5, 1.] {
241            assert_contains_at(&line([0.; 3], [1., 0., 0.]), t);
242        }
243    }
244
245    #[test]
246    fn quarter_circle_contains() {
247        for t in [0., 0.1, 0.5, 0.77, 1.] {
248            assert_contains_at(&quarter_circle(), t);
249        }
250    }
251
252    #[test]
253    fn wiggle_contains() {
254        for t in [0., 0.05, 0.3, 0.42, 0.6, 0.9, 1.] {
255            assert_contains_at(&wiggle(), t);
256        }
257    }
258
259    /// Clipping narrows a transversal hit well below `min_subdivision_size`
260    /// instead of stopping at a bisection leaf.
261    #[test]
262    fn result_is_tight() {
263        let c = wiggle();
264        let t = Scalar::from_f64(0.42);
265        let p = c.evaluate(t).unwrap();
266        let found = curve_could_contain(&c, &p, MAX, Scalar::from_f64(MIN_SUBDIVISION_SIZE))
267            .unwrap()
268            .unwrap();
269        assert!(
270            found
271                .width()
272                .definitely_less(Scalar::from_f64(MIN_SUBDIVISION_SIZE)),
273            "{found:?}"
274        );
275    }
276
277    #[test]
278    fn misses_off_curve_points() {
279        let f = Scalar::from_f64;
280        let off = |x, y, z| Vector3::from_array([f(x), f(y), f(z)]);
281        // Inside the circle's bounding box and control polygon, off the arc.
282        assert!(
283            curve_could_contain(
284                &quarter_circle(),
285                &off(0.6, 0.6, 0.),
286                MAX,
287                f(MIN_SUBDIVISION_SIZE)
288            )
289            .unwrap()
290            .is_none()
291        );
292        // Beside the line, beyond its end.
293        assert!(
294            curve_could_contain(
295                &line([0.; 3], [1., 0., 0.]),
296                &off(1.5, 0., 0.),
297                MAX,
298                f(MIN_SUBDIVISION_SIZE)
299            )
300            .unwrap()
301            .is_none()
302        );
303        assert!(
304            curve_could_contain(
305                &line([0.; 3], [1., 0., 0.]),
306                &off(0.5, 0.1, 0.),
307                MAX,
308                f(MIN_SUBDIVISION_SIZE)
309            )
310            .unwrap()
311            .is_none()
312        );
313        assert!(
314            curve_could_contain(&wiggle(), &off(2.5, 0.5, 0.5), MAX, f(MIN_SUBDIVISION_SIZE))
315                .unwrap()
316                .is_none()
317        );
318    }
319
320    /// `min_subdivision_size` bounds bisection, not accuracy: a point off the
321    /// arc by far less than it is still rejected, because clipping keeps
322    /// shrinking the segment until it proves the miss.
323    #[test]
324    fn misses_points_closer_than_min_subdivision_size() {
325        let f = Scalar::from_f64;
326        let r = 1. + 1e-7;
327        let (x, y) = (r * 0.6, r * 0.8);
328        let p = Vector3::from_array([f(x), f(y), f(0.)]);
329        let found =
330            curve_could_contain(&quarter_circle(), &p, MAX, f(MIN_SUBDIVISION_SIZE)).unwrap();
331        assert!(found.is_none(), "{found:?}");
332    }
333}