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}