Skip to main content

geop_core_topology/contains/
shell.rs

1//! Point-in-solid containment via ray casting against a shell's vertices,
2//! edges, and faces in 3-D — the 3-D analog of `super::face::face_contains`.
3//!
4//! Coincidence with the boundary is checked first (vertex, then edge, then
5//! face — each a full pass, so a higher-priority coincidence is never
6//! shadowed by iteration order). Otherwise a ray is cast from the query
7//! point in a random direction (drawn from a seeded PRNG, see
8//! [`super::rng::Rng`]), retried with a fresh direction whenever it grazes a
9//! vertex or an edge (both ambiguous to count reliably — an edge is shared
10//! by two faces, a vertex by several edges), until one is found whose only
11//! crossings are clean face-interior hits. The even/odd parity of that
12//! crossing count then gives inside/outside — this needs no consistently
13//! oriented face normal, only a clean ray.
14
15use std::collections::HashSet;
16
17use crate::{CoedgeGeometry, EdgeId, Model, ShellId, VertexId};
18use geop_core_geometry::{
19    contains::{curve::curve_could_contain, surface::surface_could_contain},
20    intersection::{curve_curve_intersect, curve_surface_intersect},
21    nurb_curve::NurbCurve3D,
22};
23use geop_core_math::{
24    geop_error::{GeopError, GeopResult},
25    scalars::Scalar,
26    vector::{Vector3, Vector4},
27};
28
29use super::{
30    face::{PointClassification as FaceClassification, face_contains},
31    rng::Rng,
32};
33
34/// Result of classifying a query point against a shell's boundary.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum PointClassification {
37    /// The query point coincides with a vertex.
38    OnVertex,
39    /// The query point lies on an edge, away from its endpoints.
40    OnEdge,
41    /// The query point lies on a face's interior surface.
42    OnFace,
43    /// The query point is strictly inside the shell.
44    Inside,
45    /// The query point is strictly outside the shell.
46    Outside,
47}
48
49const MAX_RAY_ATTEMPTS: usize = 64;
50
51fn line3<S: Scalar>(a: Vector3<S>, b: Vector3<S>) -> GeopResult<NurbCurve3D<S>> {
52    NurbCurve3D::try_new(
53        1,
54        vec![
55            Vector4::from_array([a[0], a[1], a[2], S::ONE]),
56            Vector4::from_array([b[0], b[1], b[2], S::ONE]),
57        ],
58        vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
59    )
60}
61
62/// The distinct vertices and edges referenced by `shell_id`'s faces.
63pub(crate) fn shell_vertices_and_edges<S: Scalar>(
64    model: &Model<S>,
65    shell_id: ShellId,
66) -> (Vec<VertexId>, Vec<EdgeId>) {
67    let shell = &model.shells[&shell_id];
68    let mut vertex_ids = Vec::new();
69    let mut edge_ids = Vec::new();
70    let mut seen_v = HashSet::new();
71    let mut seen_e = HashSet::new();
72    for &face_id in &shell.faces {
73        for coedge_id in model.iterate_face_coedges(face_id) {
74            let coedge = &model.coedges[&coedge_id];
75            match coedge.geometry {
76                CoedgeGeometry::Edge(edge_id) => {
77                    if seen_e.insert(edge_id) {
78                        edge_ids.push(edge_id);
79                    }
80                    let edge = &model.edges[&edge_id];
81                    if seen_v.insert(edge.start_vertex) {
82                        vertex_ids.push(edge.start_vertex);
83                    }
84                    if seen_v.insert(edge.end_vertex) {
85                        vertex_ids.push(edge.end_vertex);
86                    }
87                }
88                CoedgeGeometry::Vertex(vertex_id) => {
89                    if seen_v.insert(vertex_id) {
90                        vertex_ids.push(vertex_id);
91                    }
92                }
93            }
94        }
95    }
96    (vertex_ids, edge_ids)
97}
98
99/// A ray length guaranteed to clear `vertex_ids`' whole spatial footprint
100/// from `point` — long enough that a ray this long finding zero crossings
101/// unambiguously means "missed the shell entirely" (i.e. outside), not
102/// "wasn't cast far enough".
103pub(crate) fn ray_length_for<S: Scalar>(
104    model: &Model<S>,
105    vertex_ids: &[VertexId],
106    point: &Vector3<S>,
107) -> GeopResult<S> {
108    let mut max_dist_sq = S::ONE;
109    for &vertex_id in vertex_ids {
110        let d = model.vertices[&vertex_id].point.sub(point).norm_sq();
111        if d.could_be_greater(max_dist_sq) {
112            max_dist_sq = d;
113        }
114    }
115    Ok(max_dist_sq.sqrt()?.mul(S::from_f64(3.0)).add(S::ONE))
116}
117
118/// Casts a single ray from `point` in `direction` against `shell_id`'s
119/// vertices/edges/faces (`vertex_ids`/`edge_ids` as returned by
120/// [`shell_vertices_and_edges`], `ray_length` by [`ray_length_for`]) and
121/// returns its even/odd crossing-parity classification — or `Ok(Err(why))` if
122/// this particular direction is degenerate (grazes a vertex or edge, or
123/// hits a face right on its own trim boundary) and the caller should retry
124/// with a different one. Factored out of [`shell_contains`] so other
125/// callers (see `validation`'s manifold check) can cast several different
126/// directions from the same point without redoing its per-point setup.
127///
128/// `seed` seeds the `face_contains` sub-calls used to classify face-interior
129/// hits; callers casting multiple rays from one call site should vary it
130/// per ray (e.g. by direction or attempt index) so those sub-calls don't all
131/// retry along the identical degenerate path.
132pub(crate) fn cast_ray<S: Scalar>(
133    model: &Model<S>,
134    shell_id: ShellId,
135    point: Vector3<S>,
136    direction: Vector3<S>,
137    vertex_ids: &[VertexId],
138    edge_ids: &[EdgeId],
139    ray_length: S,
140    max_nodes: usize,
141    epsilon: S,
142    seed: u64,
143) -> GeopResult<Result<PointClassification, String>> {
144    let shell = &model.shells[&shell_id];
145    let t_epsilon = epsilon.div(ray_length)?;
146    let far = point.add(&direction.prod_scalar(ray_length));
147    let ray = line3(point, far)?;
148
149    // A ray grazing a vertex is ambiguous (shared by several edges).
150    for &vertex_id in vertex_ids {
151        let vp = model.vertices[&vertex_id].point;
152        if curve_could_contain(&ray, &vp, max_nodes, epsilon)?.is_some() {
153            return Ok(Err(format!(
154                "it could pass through vertex {vertex_id} at {vp:?}"
155            )));
156        }
157    }
158
159    // A ray crossing an edge is ambiguous (shared by two faces), and so is a
160    // ray lying along one. An `Err` (the search exhausted its node budget) is
161    // no answer at all, so it too asks the caller to retry. One point per
162    // hit is all this needs, so `max_solutions` is 1: a coincident ray is
163    // rejected either way, and a larger cap would only buy samples of it.
164    for &edge_id in edge_ids {
165        let full_curve = &model.edges[&edge_id].curve;
166        let hits = match curve_curve_intersect(&ray, full_curve, 1, max_nodes, epsilon) {
167            Ok(hits) => hits,
168            Err(e) => {
169                return Ok(Err(format!(
170                    "its search against edge {edge_id} failed: {e}"
171                )));
172            }
173        };
174        if hits.is_coincident() {
175            return Ok(Err(format!("it runs along edge {edge_id}")));
176        }
177        for (t_hit, _mid) in hits.into_vec() {
178            if t_hit.definitely_greater(t_epsilon) {
179                return Ok(Err(format!("it could cross edge {edge_id} at t={t_hit:?}")));
180            }
181        }
182    }
183
184    let mut count = 0usize;
185    for &face_id in &shell.faces {
186        let surface = &model.faces[&face_id].surface;
187        // A ray lying in a face is ambiguous, and an `Err` (node budget
188        // exhausted) is no answer; both ask the caller to retry. Every
189        // crossing is needed for the parity count, so the cap is `max_nodes`
190        // — the most crossings a search of that budget could ever report.
191        let hits = match curve_surface_intersect(&ray, surface, max_nodes, max_nodes, epsilon) {
192            Ok(hits) => hits,
193            Err(e) => {
194                return Ok(Err(format!(
195                    "its search against face {face_id} failed: {e}"
196                )));
197            }
198        };
199        if hits.is_coincident() {
200            return Ok(Err(format!("it lies in face {face_id}'s surface")));
201        }
202        for (t_hit, uv) in hits.into_vec() {
203            if !t_hit.definitely_greater(t_epsilon) {
204                continue;
205            }
206            // Sharpen — `curve_surface_intersect` honestly returns the
207            // whole surviving span of its converged leaf, not an
208            // arbitrarily narrowed midpoint.
209            let (u, v) = (uv[0].midpoint(), uv[1].midpoint());
210            let face_seed = seed ^ face_id.0;
211            match face_contains(model, face_id, u, v, max_nodes, epsilon, face_seed) {
212                Ok(FaceClassification::Inside) => count += 1,
213                Ok(FaceClassification::Outside) => {}
214                // A hit right on this face's own trim boundary, or an
215                // outright error resolving it, is the same ambiguity as a
216                // vertex/edge graze one dimension down.
217                other => {
218                    return Ok(Err(format!(
219                        "its hit on face {face_id} at uv=({u:?}, {v:?}) classified as {other:?}"
220                    )));
221                }
222            }
223        }
224    }
225    // Unlike `face_contains` (whose ray is guaranteed to cross the outer
226    // trim loop for any query point within the surface's own bounded
227    // parameter domain), a shell occupies a bounded region of otherwise
228    // unbounded 3-D space: a ray this long that finds zero crossings has,
229    // by construction, missed the shell's spatial footprint entirely, which
230    // can only happen when the query point is outside — zero is a
231    // legitimate (even) count, not degenerate.
232    Ok(Ok(if count % 2 == 1 {
233        PointClassification::Inside
234    } else {
235        PointClassification::Outside
236    }))
237}
238
239/// Classify `point` against `shell_id`'s boundary: [`PointClassification::OnVertex`] /
240/// [`PointClassification::OnEdge`] / [`PointClassification::OnFace`] if the
241/// query point itself coincides with the boundary, else
242/// [`PointClassification::Inside`]/[`PointClassification::Outside`] via ray
243/// casting.
244///
245/// The ray direction is drawn from a seeded PRNG (see [`Rng`]) and retried
246/// (up to a bounded number of attempts, see [`cast_ray`]) until one resolves
247/// cleanly. The even/odd parity of that crossing count then determines
248/// inside/outside, with no dependence on any face's normal orientation
249/// (faces in this crate aren't guaranteed to wind consistently).
250///
251/// `max_nodes` bounds both the BFS containment searches and the DFS
252/// intersection searches; `epsilon` is the shared geometric tolerance;
253/// `seed` seeds the direction PRNG.
254pub fn shell_contains<S: Scalar>(
255    model: &Model<S>,
256    shell_id: ShellId,
257    point: Vector3<S>,
258    max_nodes: usize,
259    epsilon: S,
260    seed: u64,
261) -> GeopResult<PointClassification> {
262    let shell = &model.shells[&shell_id];
263    let (vertex_ids, edge_ids) = shell_vertices_and_edges(model, shell_id);
264
265    // Coincidence pre-check: vertex, then edge, then face — each a full
266    // pass, so higher-priority coincidences are deterministic regardless of
267    // iteration order.
268    for &vertex_id in &vertex_ids {
269        if model.vertices[&vertex_id].point.could_be_equal(&point) {
270            return Ok(PointClassification::OnVertex);
271        }
272    }
273    for &edge_id in &edge_ids {
274        if curve_could_contain(&model.edges[&edge_id].curve, &point, max_nodes, epsilon)?.is_some()
275        {
276            return Ok(PointClassification::OnEdge);
277        }
278    }
279    for &face_id in &shell.faces {
280        // Proximity to the face's *untrimmed* supporting surface is not
281        // membership (see AGENTS.md) — a face's surface generally extends
282        // well past its own trim loop (e.g. two faces split from one
283        // larger one still share that one surface), so a point can sit
284        // squarely on the surface while lying nowhere near this face's
285        // actual boundary. Require `face_contains` to agree the point is
286        // actually within the trim (or on it) before calling it a match —
287        // exactly the check `shell_normal_at` already makes for the same
288        // reason.
289        let Some((u, v)) =
290            surface_could_contain(&model.faces[&face_id].surface, &point, max_nodes, epsilon)?
291        else {
292            continue;
293        };
294        if !matches!(
295            face_contains(model, face_id, u, v, max_nodes, epsilon, seed ^ face_id.0)?,
296            FaceClassification::Outside
297        ) {
298            return Ok(PointClassification::OnFace);
299        }
300    }
301
302    let ray_length = ray_length_for(model, &vertex_ids, &point)?;
303
304    let mut rng = Rng::new(seed);
305    let mut last_rejection = String::new();
306    for attempt in 0..MAX_RAY_ATTEMPTS {
307        let direction = rng.next_direction3::<S>();
308        let attempt_seed = seed ^ (attempt as u64).wrapping_mul(0x9E3779B97F4A7C15);
309        match cast_ray(
310            model,
311            shell_id,
312            point,
313            direction,
314            &vertex_ids,
315            &edge_ids,
316            ray_length,
317            max_nodes,
318            epsilon,
319            attempt_seed,
320        )? {
321            Ok(classification) => return Ok(classification),
322            Err(reason) => {
323                last_rejection = format!("the ray along {direction:?} was rejected: {reason}")
324            }
325        }
326    }
327    Err(GeopError::new(format!(
328        "shell_contains: could not find a ray direction clear of every vertex and edge after many \
329         attempts; the last one was rejected because {last_rejection}"
330    )))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::{PointClassification, shell_contains};
336    use crate::{
337        Coedge, CoedgeGeometry, CoedgeId, Edge, EdgeId, Face, FaceId, Model, Sense, Shell, ShellId,
338        SolidId, Vertex, VertexId, boundary::BoundaryType,
339    };
340    use geop_core_geometry::{
341        nurb_curve::{NurbCurve, NurbCurve2D, NurbCurve3D},
342        nurb_surface::NurbSurface3D,
343    };
344    use geop_core_math::{
345        for_all_scalars,
346        scalars::Scalar,
347        vector::{Vector3, Vector4},
348    };
349
350    const MAX: usize = 200;
351    const EPS: f64 = 1e-3;
352    const SEED: u64 = 424_242;
353
354    type P3 = (f64, f64, f64);
355
356    /// A single planar quad face, corners given in trim-loop order
357    /// (`p00 -> p10 -> p11 -> p01`), each face independently vertexed/edged
358    /// (geometrically watertight, topologically not shared — fine for pure
359    /// containment testing).
360    fn quad_face<S: Scalar>(
361        model: &mut Model<S>,
362        shell: ShellId,
363        p00: P3,
364        p10: P3,
365        p11: P3,
366        p01: P3,
367    ) -> FaceId {
368        let p4 = |p: P3| {
369            Vector4::from_array([S::from_f64(p.0), S::from_f64(p.1), S::from_f64(p.2), S::ONE])
370        };
371        let surface = NurbSurface3D::try_new(
372            1,
373            1,
374            vec![p4(p00), p4(p01), p4(p10), p4(p11)],
375            vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
376            vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
377        )
378        .unwrap();
379        let face_id = model.insert_face(Face {
380            surface,
381            outer: BoundaryType::Vertex(crate::VertexId(0)),
382            holes: Vec::new(),
383            shell,
384        });
385
386        let corners = [p00, p10, p11, p01];
387        let verts: Vec<VertexId> = corners
388            .iter()
389            .map(|&(x, y, z)| {
390                model.insert_vertex(Vertex {
391                    point: Vector3::from_array([S::from_f64(x), S::from_f64(y), S::from_f64(z)]),
392                })
393            })
394            .collect();
395        let edges: Vec<EdgeId> = (0..4)
396            .map(|i| {
397                model.insert_edge(Edge {
398                    curve: NurbCurve3D::try_new(
399                        1,
400                        vec![p4(corners[i]), p4(corners[(i + 1) % 4])],
401                        vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
402                    )
403                    .unwrap(),
404                    start_vertex: verts[i],
405                    end_vertex: verts[(i + 1) % 4],
406                })
407            })
408            .collect();
409        let uv = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
410        let pc = |a: (f64, f64), b: (f64, f64)| -> NurbCurve2D<S> {
411            NurbCurve::try_new(
412                1,
413                vec![
414                    Vector3::from_array([S::from_f64(a.0), S::from_f64(a.1), S::ONE]),
415                    Vector3::from_array([S::from_f64(b.0), S::from_f64(b.1), S::ONE]),
416                ],
417                vec![S::ZERO, S::ZERO, S::ONE, S::ONE],
418            )
419            .unwrap()
420        };
421        let coedges: Vec<CoedgeId> = (0..4)
422            .map(|i| {
423                model.insert_coedge(Coedge {
424                    geometry: CoedgeGeometry::Edge(edges[i]),
425                    sense: Sense::Forward,
426                    pcurve: pc(uv[i], uv[(i + 1) % 4]),
427                    next: CoedgeId(0),
428                    prev: CoedgeId(0),
429                    face: face_id,
430                })
431            })
432            .collect();
433        for i in 0..4 {
434            model.coedges.get_mut(&coedges[i]).unwrap().next = coedges[(i + 1) % 4];
435            model.coedges.get_mut(&coedges[i]).unwrap().prev = coedges[(i + 3) % 4];
436        }
437        model.faces.get_mut(&face_id).unwrap().outer = BoundaryType::Loop(coedges[0]);
438
439        face_id
440    }
441
442    /// The unit cube `[0,1]^3`, as six independently-vertexed quad faces
443    /// (mixed winding across faces on purpose — containment must not depend
444    /// on consistent orientation).
445    fn unit_cube<S: Scalar>(model: &mut Model<S>) -> ShellId {
446        let shell_id = model.insert_shell(Shell {
447            faces: vec![],
448            solid: SolidId(999),
449        });
450        let faces = vec![
451            quad_face(
452                model,
453                shell_id,
454                (0., 0., 1.),
455                (1., 0., 1.),
456                (1., 1., 1.),
457                (0., 1., 1.),
458            ), // +Z
459            quad_face(
460                model,
461                shell_id,
462                (0., 0., 0.),
463                (0., 1., 0.),
464                (1., 1., 0.),
465                (1., 0., 0.),
466            ), // -Z (reversed winding)
467            quad_face(
468                model,
469                shell_id,
470                (1., 0., 0.),
471                (1., 1., 0.),
472                (1., 1., 1.),
473                (1., 0., 1.),
474            ), // +X
475            quad_face(
476                model,
477                shell_id,
478                (0., 0., 0.),
479                (0., 0., 1.),
480                (0., 1., 1.),
481                (0., 1., 0.),
482            ), // -X (reversed winding)
483            quad_face(
484                model,
485                shell_id,
486                (0., 1., 0.),
487                (1., 1., 0.),
488                (1., 1., 1.),
489                (0., 1., 1.),
490            ), // +Y
491            quad_face(
492                model,
493                shell_id,
494                (0., 0., 0.),
495                (1., 0., 0.),
496                (1., 0., 1.),
497                (0., 0., 1.),
498            ), // -Y
499        ];
500        model.shells.get_mut(&shell_id).unwrap().faces = faces;
501        shell_id
502    }
503
504    fn check_center_is_inside<S: Scalar>() {
505        let mut model = Model::<S>::new();
506        let shell_id = unit_cube(&mut model);
507        let p = Vector3::from_array([S::from_f64(0.5); 3]);
508        assert_eq!(
509            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
510            PointClassification::Inside
511        );
512    }
513    #[test]
514    fn center_is_inside() {
515        for_all_scalars!(check_center_is_inside);
516    }
517
518    fn check_far_point_is_outside<S: Scalar>() {
519        let mut model = Model::<S>::new();
520        let shell_id = unit_cube(&mut model);
521        let p = Vector3::from_array([S::from_f64(-5.0), S::from_f64(0.5), S::from_f64(0.5)]);
522        assert_eq!(
523            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
524            PointClassification::Outside
525        );
526    }
527    #[test]
528    fn far_point_is_outside() {
529        for_all_scalars!(check_far_point_is_outside);
530    }
531
532    fn check_point_just_outside_face_is_outside<S: Scalar>() {
533        let mut model = Model::<S>::new();
534        let shell_id = unit_cube(&mut model);
535        let p = Vector3::from_array([S::from_f64(-0.1), S::from_f64(0.5), S::from_f64(0.5)]);
536        assert_eq!(
537            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
538            PointClassification::Outside
539        );
540    }
541    #[test]
542    fn point_just_outside_face_is_outside() {
543        for_all_scalars!(check_point_just_outside_face_is_outside);
544    }
545
546    fn check_face_point_is_on_face<S: Scalar>() {
547        let mut model = Model::<S>::new();
548        let shell_id = unit_cube(&mut model);
549        let p = Vector3::from_array([S::ZERO, S::from_f64(0.5), S::from_f64(0.5)]);
550        assert_eq!(
551            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
552            PointClassification::OnFace
553        );
554    }
555    #[test]
556    fn face_point_is_on_face() {
557        for_all_scalars!(check_face_point_is_on_face);
558    }
559
560    fn check_edge_point_is_on_edge<S: Scalar>() {
561        let mut model = Model::<S>::new();
562        let shell_id = unit_cube(&mut model);
563        let p = Vector3::from_array([S::ZERO, S::ZERO, S::from_f64(0.5)]);
564        assert_eq!(
565            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
566            PointClassification::OnEdge
567        );
568    }
569    #[test]
570    fn edge_point_is_on_edge() {
571        for_all_scalars!(check_edge_point_is_on_edge);
572    }
573
574    fn check_vertex_point_is_on_vertex<S: Scalar>() {
575        let mut model = Model::<S>::new();
576        let shell_id = unit_cube(&mut model);
577        let p = Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]);
578        assert_eq!(
579            shell_contains(&model, shell_id, p, MAX, S::from_f64(EPS), SEED).unwrap(),
580            PointClassification::OnVertex
581        );
582    }
583    #[test]
584    fn vertex_point_is_on_vertex() {
585        for_all_scalars!(check_vertex_point_is_on_vertex);
586    }
587}