Skip to main content

geop_core_geometry/contains/
surface.rs

1//! Surface/point containment by per-axis fat line clipping — the design is
2//! `surface.md` next to this file, the curve version it extends is
3//! `curve.md` / [`super::curve`].
4//!
5//! Each Cartesian axis `k` gives a polynomial tensor-product spline
6//! `g_k(u, v) = H_k(u, v) - p_k W(u, v)` whose zeros are exactly where that
7//! coordinate matches. Its control net is collapsed onto each parameter
8//! axis by interval union over the other index, and the resulting 1-D
9//! envelopes are clipped with the same fat line as curves
10//! ([`clip_tensor`]). Intersecting over the axes shrinks the patch towards
11//! the solution in both directions at once.
12
13use std::collections::VecDeque;
14
15use crate::{
16    aabb::aabb_could_contain,
17    fat_line::{Plan, carried_width, clip_tensor, converged, extent, greville_abscissae, plan},
18    knot_insertion::{is_clamped_at, pinned_clamped_end},
19    nurb_curve::NurbCurve,
20    nurb_surface::NurbSurface,
21};
22use geop_core_math::{
23    geop_error::{GeopError, GeopResult},
24    scalars::Scalar,
25    vector::Vector3,
26};
27
28use super::curve::curve_could_contain;
29
30/// Spatial extent of `patch` along `u` and along `v` (see
31/// [`crate::fat_line::extent`]): the longest control-polygon row running in
32/// each direction.
33pub(crate) fn surface_extents<S: Scalar>(patch: &NurbSurface<S, 4>) -> [f64; 2] {
34    let (nu, nv) = (patch.num_u, patch.num_v);
35    let cp = &patch.control_points;
36    [
37        extent((0..nv).map(|j| (0..nu).map(move |i| cp[i * nv + j]))),
38        extent(cp.chunks(nv).map(|row| row.iter().copied())),
39    ]
40}
41
42/// True if every weight of `patch` is definitely positive, so `W > 0` on it
43/// — the precondition of the clip (`surface.md` §1). Checked per patch,
44/// not assumed for every `NurbSurface`.
45pub(crate) fn weights_positive<S: Scalar>(patch: &NurbSurface<S, 4>) -> bool {
46    patch
47        .control_points
48        .iter()
49        .all(|q| q[3].definitely_greater(S::ZERO))
50}
51
52/// Clip `patch` against `point` (`surface.md` §§1–3): a box `(û, v̂)` inside
53/// the patch's domain enclosing every `(u, v)` with `S(u, v) = point`, or
54/// `None` if some axis proves there is none. Without positive weights the
55/// whole domain is returned (no information), and the search falls back to
56/// plain subdivision for this patch.
57fn clip<S: Scalar>(patch: &NurbSurface<S, 4>, point: &Vector3<S>) -> GeopResult<Option<(S, S)>> {
58    let (u0, u1) = patch.domain_u();
59    let (v0, v1) = patch.domain_v();
60    let mut hats = [u0.union(u1), v0.union(v1)];
61    if !weights_positive(patch) {
62        return Ok(Some((hats[0], hats[1])));
63    }
64    let (nu, nv) = (patch.num_u, patch.num_v);
65    let greville = [
66        greville_abscissae(&patch.knot_vector_u, patch.degree_u, nu)?,
67        greville_abscissae(&patch.knot_vector_v, patch.degree_v, nv)?,
68    ];
69    let mut d = Vec::with_capacity(nu * nv);
70    for k in 0..3 {
71        // `d[i * nv + j] = P_ij,k - p_k w_ij`, straight from the homogeneous
72        // net: no division.
73        d.clear();
74        d.extend(
75            patch
76                .control_points
77                .iter()
78                .map(|q| q[k].sub(point[k].mul(q[3]))),
79        );
80        if !clip_tensor(&d, &[nu, nv], &greville, &mut hats) {
81            return Ok(None);
82        }
83    }
84    Ok(Some((hats[0], hats[1])))
85}
86
87/// The boundary of `patch` at the start (`first`) or end of its `u`
88/// domain (`u_fixed`) or `v` domain, as a curve along the other parameter —
89/// the first/last control row, which *is* the surface there when the knot
90/// vector is clamped at that end. `None` if it isn't.
91pub(crate) fn boundary_curve<S: Scalar>(
92    patch: &NurbSurface<S, 4>,
93    u_fixed: bool,
94    first: bool,
95) -> Option<NurbCurve<S, 4>> {
96    let (nu, nv) = (patch.num_u, patch.num_v);
97    if u_fixed {
98        if !is_clamped_at(&patch.knot_vector_u, patch.degree_u, first) {
99            return None;
100        }
101        let i = if first { 0 } else { nu - 1 };
102        let row = patch.control_points[i * nv..(i + 1) * nv].to_vec();
103        NurbCurve::try_new(patch.degree_v, row, patch.knot_vector_v.clone()).ok()
104    } else {
105        if !is_clamped_at(&patch.knot_vector_v, patch.degree_v, first) {
106            return None;
107        }
108        let j = if first { 0 } else { nv - 1 };
109        let column = (0..nu).map(|i| patch.control_points[i * nv + j]).collect();
110        NurbCurve::try_new(patch.degree_u, column, patch.knot_vector_u.clone()).ok()
111    }
112}
113
114/// "A collapsed domain is handled as a boundary evaluation" (`surface.md`
115/// §3): if the clip pinned `u` (or `v`) exactly onto a clamped domain end,
116/// every solution lies on that boundary row, which *is* a NURBS curve.
117/// Returns it, and whether it runs along `v` (the `u` direction collapsed).
118pub(crate) fn collapsed_boundary<S: Scalar>(
119    patch: &NurbSurface<S, 4>,
120    u_hat: S,
121    v_hat: S,
122) -> Option<(NurbCurve<S, 4>, bool)> {
123    let (nu, nv) = (patch.num_u, patch.num_v);
124    if let Some(first) = pinned_clamped_end(u_hat, &patch.knot_vector_u, nu, patch.degree_u) {
125        return boundary_curve(patch, true, first).map(|c| (c, true));
126    }
127    if let Some(first) = pinned_clamped_end(v_hat, &patch.knot_vector_v, nv, patch.degree_v) {
128        return boundary_curve(patch, false, first).map(|c| (c, false));
129    }
130    None
131}
132
133/// Fat-line-clipping counterpart of [`super::surface_bisect::surface_could_contain`]
134/// (`surface.md` §§4–5), with the same tunables: the componentwise union of
135/// every converged patch's clipped `(u, v)` box, exploring breadth-first.
136///
137/// Per patch:
138/// - the cached AABB and the [`clip`] are necessary conditions — failing
139///   either rejects the patch;
140/// - it converges once its extent along both directions (control-polygon
141///   lengths, which bound a folded patch where corner chords don't; see
142///   [`crate::fat_line::converged`]) is within `min_subdivision_size`, and then — the other existing
143///   necessary condition — its convex hull must still could-contain the
144///   point, or it is rejected;
145/// - if a direction collapsed onto a clamped domain end, the boundary row is
146///   searched with [`curve_could_contain`];
147/// - otherwise it is restricted to the clip or bisected per
148///   [`crate::fat_line::plan`] (the spatially longest direction) — after a restriction, narrowing `v` tightens
149///   the next `u` projection and vice versa.
150///
151/// `None` means every part of the domain was rejected. Running out of
152/// `max_nodes` is never read as "not contained" (nor as "contained"): it is
153/// an error, since the search is incomplete. The boundary curve search gets
154/// the remaining budget as its own.
155pub fn surface_could_contain<S: Scalar>(
156    surface: &NurbSurface<S, 4>,
157    point: &Vector3<S>,
158    max_nodes: usize,
159    min_subdivision_size: S,
160) -> GeopResult<Option<(S, S)>> {
161    let mut queue: VecDeque<NurbSurface<S, 4>> = VecDeque::new();
162    queue.push_back(surface.clone());
163
164    let mut explored = 0usize;
165    let mut solution: Option<(S, S)> = None;
166    let mut report = |(u, v): (S, S)| {
167        solution = Some(match solution {
168            Some((su, sv)) => (su.union(u), sv.union(v)),
169            None => (u, v),
170        });
171    };
172
173    while let Some(patch) = queue.pop_front() {
174        if explored >= max_nodes {
175            return Err(GeopError::new(format!(
176                "surface_could_contain (clipping): exhausted max_nodes={max_nodes} with {} \
177                 patches pending; the result would be incomplete",
178                queue.len() + 1
179            )));
180        }
181        explored += 1;
182
183        if !aabb_could_contain(&patch.aabb, point) {
184            continue;
185        }
186        let Some((u_hat, v_hat)) = clip(&patch, point)? else {
187            continue;
188        };
189
190        let sizes = surface_extents(&patch);
191        // The query point's own width counts too, like a second object's.
192        let point_width = (0..3)
193            .map(|k| point[k].width().to_f64())
194            .fold(0.0, f64::max);
195        let carried = carried_width(&patch.control_points).max(point_width);
196        if converged(&sizes, carried, min_subdivision_size) {
197            match patch.convex_hull() {
198                Ok(hull) if !hull.could_contain(point) => {}
199                _ => report((u_hat, v_hat)),
200            }
201            continue;
202        }
203
204        if let Some((boundary, along_v)) = collapsed_boundary(&patch, u_hat, v_hat) {
205            let budget = max_nodes - explored;
206            if let Some(t) = curve_could_contain(&boundary, point, budget, min_subdivision_size)? {
207                let (fixed, free) = if along_v {
208                    (u_hat, v_hat)
209                } else {
210                    (v_hat, u_hat)
211                };
212                if t.could_be_equal(free) {
213                    let free = free.intersect(t);
214                    report(if along_v {
215                        (fixed, free)
216                    } else {
217                        (free, fixed)
218                    });
219                }
220            }
221            continue;
222        }
223
224        let ranges = [patch.domain_u(), patch.domain_v()];
225        let order = match plan(&[u_hat, v_hat], &ranges, &sizes, min_subdivision_size)? {
226            Plan::Restrict(b) => match patch.sub_surface(b[0], b[1]) {
227                Ok(restricted) => {
228                    queue.push_back(restricted);
229                    continue;
230                }
231                Err(_) => vec![0, 1],
232            },
233            Plan::Bisect(order) => order,
234        };
235        let halves = order.iter().find_map(|&dir| {
236            if dir == 0 {
237                patch.split_u_mid()
238            } else {
239                patch.split_v_mid()
240            }
241            .ok()
242        });
243        if let Some((left, right)) = halves {
244            queue.push_back(left);
245            queue.push_back(right);
246            continue;
247        }
248        // Nothing left to cut or split: what's here is the answer.
249        report((u_hat, v_hat));
250    }
251
252    Ok(solution)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::surface_could_contain;
258    use crate::nurb_surface::NurbSurface;
259    use geop_core_math::for_all_scalars;
260    use geop_core_math::{
261        scalars::Scalar,
262        vector::{Vector3, Vector4},
263    };
264
265    const MAX: usize = 2000;
266    const EPS: f64 = 1e-4;
267
268    /// Homogeneous control point for Cartesian `(x, y, z)` with weight `w`.
269    fn pt<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
270        Vector4::from_array([
271            S::from_f64(x * w),
272            S::from_f64(y * w),
273            S::from_f64(z * w),
274            S::from_f64(w),
275        ])
276    }
277
278    fn v3<S: Scalar>(x: f64, y: f64, z: f64) -> Vector3<S> {
279        Vector3::from_array([S::from_f64(x), S::from_f64(y), S::from_f64(z)])
280    }
281
282    fn knots<S: Scalar>(k: &[f64]) -> Vec<S> {
283        k.iter().map(|&x| S::from_f64(x)).collect()
284    }
285
286    /// Bilinear patch with one corner lifted to z=1.
287    fn lifted<S: Scalar>() -> NurbSurface<S, 4> {
288        NurbSurface::try_new(
289            1,
290            1,
291            vec![
292                pt(0., 0., 0., 1.),
293                pt(0., 1., 0., 1.),
294                pt(1., 0., 0., 1.),
295                pt(1., 1., 1., 1.),
296            ],
297            knots(&[0., 0., 1., 1.]),
298            knots(&[0., 0., 1., 1.]),
299        )
300        .unwrap()
301    }
302
303    /// Exact rational quarter cylinder: radius 1 around z, u around, v up.
304    fn quarter_cylinder<S: Scalar>() -> NurbSurface<S, 4> {
305        let w = std::f64::consts::FRAC_1_SQRT_2;
306        let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.)];
307        let cps = circle
308            .iter()
309            .flat_map(|&(x, y, wi)| [pt(x, y, 0., wi), pt(x, y, 1., wi)])
310            .collect();
311        NurbSurface::try_new(
312            2,
313            1,
314            cps,
315            knots(&[0., 0., 0., 1., 1., 1.]),
316            knots(&[0., 0., 1., 1.]),
317        )
318        .unwrap()
319    }
320
321    /// Exact rational sphere octant; `v = 1` is the pole (0, 0, 1).
322    fn sphere_octant<S: Scalar>() -> NurbSurface<S, 4> {
323        let w = std::f64::consts::FRAC_1_SQRT_2;
324        let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.)];
325        let meridian = [(1., 0., 1.), (1., 1., w), (0., 1., 1.)];
326        let cps = circle
327            .iter()
328            .flat_map(|&(x, y, wu)| {
329                meridian
330                    .iter()
331                    .map(move |&(r, z, wv)| pt(x * r, y * r, z, wu * wv))
332            })
333            .collect();
334        let k = knots(&[0., 0., 0., 1., 1., 1.]);
335        NurbSurface::try_new(2, 2, cps, k.clone(), k).unwrap()
336    }
337
338    /// Bicubic 5×5 net with interior knots in both directions, wavy in z.
339    fn wavy<S: Scalar>() -> NurbSurface<S, 4> {
340        let cps = (0..5)
341            .flat_map(|i| {
342                (0..5).map(move |j| {
343                    let z = ((i * 3 + j * 5) % 7) as f64 / 7.0 - 0.5;
344                    pt(i as f64, j as f64, z, 1.0)
345                })
346            })
347            .collect();
348        let k = knots(&[0., 0., 0., 0., 0.4, 1., 1., 1., 1.]);
349        NurbSurface::try_new(3, 3, cps, k.clone(), k).unwrap()
350    }
351
352    /// The point at `(u, v)` is found, and the result contains `(u, v)`.
353    fn assert_contains_at<S: Scalar>(s: &NurbSurface<S, 4>, u: f64, v: f64) {
354        let (u, v) = (S::from_f64(u), S::from_f64(v));
355        let p = s.evaluate(u, v).unwrap();
356        let (ru, rv) = surface_could_contain(s, &p, MAX, S::from_f64(EPS))
357            .unwrap()
358            .unwrap_or_else(|| panic!("point at ({u:?}, {v:?}) not found"));
359        assert!(
360            ru.could_be_equal(u) && rv.could_be_equal(v),
361            "({ru:?}, {rv:?}) misses ({u:?}, {v:?})"
362        );
363    }
364
365    fn check_contains_grid<S: Scalar>() {
366        for s in [lifted::<S>(), quarter_cylinder(), sphere_octant(), wavy()] {
367            for u in [0., 0.3, 0.4, 0.75, 1.] {
368                for v in [0., 0.2, 0.4, 0.5, 1.] {
369                    assert_contains_at(&s, u, v);
370                }
371            }
372        }
373    }
374    #[test]
375    fn contains_grid() {
376        for_all_scalars!(check_contains_grid);
377    }
378
379    /// The pole has a whole interval of preimages: every `u` at `v = 1`.
380    fn check_pole_keeps_every_u<S: Scalar>() {
381        let (u, v) = surface_could_contain(
382            &sphere_octant::<S>(),
383            &v3(0., 0., 1.),
384            MAX,
385            S::from_f64(EPS),
386        )
387        .unwrap()
388        .unwrap();
389        assert!(
390            u.could_be_equal(S::ZERO) && u.could_be_equal(S::ONE),
391            "{u:?}"
392        );
393        assert!(v.could_be_equal(S::ONE), "{v:?}");
394    }
395    #[test]
396    fn pole_keeps_every_u() {
397        for_all_scalars!(check_pole_keeps_every_u);
398    }
399
400    fn check_misses_off_surface_points<S: Scalar>() {
401        let miss = |s: &NurbSurface<S, 4>, p: Vector3<S>| {
402            surface_could_contain(s, &p, MAX, S::from_f64(EPS))
403                .unwrap()
404                .is_none()
405        };
406        assert!(miss(&lifted(), v3(0.5, 0.5, 5.)));
407        assert!(miss(&lifted(), v3(0.5, 0.5, 0.3)));
408        // Inside the cylinder's control hull, off the surface.
409        assert!(miss(&quarter_cylinder(), v3(0.8, 0.5, 0.5)));
410        assert!(miss(&sphere_octant(), v3(0.5, 0.5, 0.5)));
411        assert!(miss(&wavy(), v3(2., 2., 3.)));
412    }
413    #[test]
414    fn misses_off_surface_points() {
415        for_all_scalars!(check_misses_off_surface_points);
416    }
417
418    /// An exhausted budget is an incomplete search: an error, never "not
419    /// contained".
420    fn check_zero_budget_is_an_error<S: Scalar>() {
421        let s = lifted::<S>();
422        assert!(surface_could_contain(&s, &v3(0.5, 0.5, 5.), 0, S::from_f64(EPS)).is_err());
423    }
424    #[test]
425    fn zero_budget_is_an_error() {
426        for_all_scalars!(check_zero_budget_is_an_error);
427    }
428
429    /// Clipping narrows a transversal hit below `min_subdivision_size`.
430    fn check_result_is_tight<S: Scalar>() {
431        let s = wavy::<S>();
432        let p = s.evaluate(S::from_f64(0.3), S::from_f64(0.7)).unwrap();
433        let (u, v) = surface_could_contain(&s, &p, MAX, S::from_f64(EPS))
434            .unwrap()
435            .unwrap();
436        assert!(u.width().definitely_less(S::from_f64(EPS)), "{u:?}");
437        assert!(v.width().definitely_less(S::from_f64(EPS)), "{v:?}");
438    }
439    #[test]
440    fn result_is_tight() {
441        for_all_scalars!(check_result_is_tight);
442    }
443}