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