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::{Body, CoedgeGeometry, EdgeId, FaceId, Model, ShellId, SolidId, VertexId};
18use geop_core_geometry::{
19    contains::{curve::curve_could_contain, surface::surface_could_contain},
20    intersection::{
21        curve_curve_intersect, curve_surface_intersect, refine_crossing,
22        refine_curve_curve_crossing,
23    },
24    nurb_curve::NurbCurve3D,
25};
26use geop_core_math::{
27    geop_error::{GeopError, GeopResult},
28    scalars::Scalar,
29    solvers::probe::{Probe, attempts},
30    vector::{Vector2, Vector3},
31};
32
33use super::{
34    face::{PointClassification as FaceClassification, face_contains},
35    rng::Rng,
36};
37
38/// Result of classifying a query point against a shell's boundary.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum PointClassification {
41    /// The query point coincides with a vertex.
42    OnVertex,
43    /// The query point lies on an edge, away from its endpoints.
44    OnEdge,
45    /// The query point lies on a face's interior surface.
46    OnFace,
47    /// The query point is strictly inside the shell.
48    Inside,
49    /// The query point is strictly outside the shell.
50    Outside,
51}
52
53const MAX_RAY_ATTEMPTS: usize = 64;
54
55/// A shell of a model as rays are cast against it: the distinct vertices
56/// and edges its faces reference, found once for every ray from a point.
57pub(crate) struct ShellBoundary<'m> {
58    pub(crate) model: &'m Model,
59    pub(crate) shell_id: ShellId,
60    pub(crate) vertices: Vec<VertexId>,
61    pub(crate) edges: Vec<EdgeId>,
62}
63
64impl<'m> ShellBoundary<'m> {
65    /// The boundary of `shell_id`: the distinct vertices and edges its
66    /// faces reference.
67    pub(crate) fn of(model: &'m Model, shell_id: ShellId) -> Self {
68        let (vertices, edges) = shell_vertices_and_edges(model, shell_id);
69        Self {
70            model,
71            shell_id,
72            vertices,
73            edges,
74        }
75    }
76
77    /// A ray length that clears the shell from `point` (see
78    /// [`ray_length_for`]).
79    pub(crate) fn ray_length(&self, point: &Vector3) -> GeopResult<Scalar> {
80        ray_length_for(self.model, &self.vertices, point)
81    }
82}
83
84/// The distinct vertices and edges referenced by `shell_id`'s faces.
85fn shell_vertices_and_edges(model: &Model, shell_id: ShellId) -> (Vec<VertexId>, Vec<EdgeId>) {
86    let shell = &model.shells[&shell_id];
87    let mut vertex_ids = Vec::new();
88    let mut edge_ids = Vec::new();
89    let mut seen_v = HashSet::new();
90    let mut seen_e = HashSet::new();
91    for &face_id in &shell.faces {
92        for coedge_id in model.iterate_face_coedges(face_id) {
93            let coedge = &model.coedges[&coedge_id];
94            match coedge.geometry {
95                CoedgeGeometry::Edge(edge_id) => {
96                    if seen_e.insert(edge_id) {
97                        edge_ids.push(edge_id);
98                    }
99                    let edge = &model.edges[&edge_id];
100                    if seen_v.insert(edge.start_vertex) {
101                        vertex_ids.push(edge.start_vertex);
102                    }
103                    if seen_v.insert(edge.end_vertex) {
104                        vertex_ids.push(edge.end_vertex);
105                    }
106                }
107                CoedgeGeometry::Vertex(vertex_id) => {
108                    if seen_v.insert(vertex_id) {
109                        vertex_ids.push(vertex_id);
110                    }
111                }
112            }
113        }
114    }
115    (vertex_ids, edge_ids)
116}
117
118/// A ray length guaranteed to clear `vertex_ids`' whole spatial footprint
119/// from `point` — long enough that a ray this long finding zero crossings
120/// unambiguously means "missed the shell entirely" (i.e. outside), not
121/// "wasn't cast far enough".
122fn ray_length_for(model: &Model, vertex_ids: &[VertexId], point: &Vector3) -> GeopResult<Scalar> {
123    let mut max_dist_sq = Scalar::ONE;
124    for &vertex_id in vertex_ids {
125        let d = model.vertices[&vertex_id].point.sub(point).norm_sq();
126        if d.could_be_greater(max_dist_sq) {
127            max_dist_sq = d;
128        }
129    }
130    Ok(max_dist_sq
131        .sqrt()?
132        .mul(Scalar::from_f64(3.0))
133        .add(Scalar::ONE))
134}
135
136/// Casts a single ray from `point` in `direction` against `boundary`'s
137/// vertices/edges/faces (`ray_length` as [`ShellBoundary::ray_length`]
138/// gives it) and
139/// returns what it crossed ([`Ray`]) — or `Ok(Err(why))` if
140/// this particular direction is degenerate (grazes a vertex or edge, or
141/// hits a face right on its own trim boundary) and the caller should retry
142/// with a different one. Factored out of [`shell_contains`] so other
143/// callers (see `validation`'s manifold check) can cast several different
144/// directions from the same point without redoing its per-point setup.
145///
146/// `leaving` is a face of the shell that `point` lies inside of, for a ray
147/// cast from a point on a face (see [`solid_contains_beside`]): the ray's
148/// start on that face is where it leaves it, not a crossing, and every
149/// crossing past it counts.
150///
151/// `seed` seeds the `face_contains` sub-calls used to classify face-interior
152/// hits; callers casting multiple rays from one call site should vary it
153/// per ray (e.g. by direction or attempt index) so those sub-calls don't all
154/// retry along the identical degenerate path.
155#[allow(clippy::too_many_arguments)]
156pub(crate) fn cast_ray(
157    boundary: &ShellBoundary,
158    point: Vector3,
159    direction: Vector3,
160    ray_length: Scalar,
161    leaving: Option<FaceId>,
162    max_nodes: usize,
163    epsilon: Scalar,
164    seed: u64,
165) -> GeopResult<Result<Ray, String>> {
166    let ShellBoundary {
167        model,
168        shell_id,
169        vertices: vertex_ids,
170        edges: edge_ids,
171    } = boundary;
172    let (model, shell_id) = (*model, *shell_id);
173    let shell = &model.shells[&shell_id];
174    let far = point.add(&direction.prod_scalar(ray_length));
175    let ray = NurbCurve3D::line(point, far)?;
176
177    // A ray grazing a vertex is ambiguous (shared by several edges).
178    for &vertex_id in vertex_ids {
179        let vp = model.vertices[&vertex_id].point;
180        if curve_could_contain(&ray, &vp, max_nodes, epsilon)?.is_some() {
181            return Ok(Err(format!(
182                "it could pass through vertex {vertex_id} at {vp:?}"
183            )));
184        }
185    }
186
187    // A ray crossing an edge is ambiguous (shared by two faces), and so is a
188    // ray lying along one. An `Err` (the search exhausted its node budget) is
189    // no answer at all, so it too asks the caller to retry. One point per
190    // hit is all this needs, so `max_solutions` is 1: a coincident ray is
191    // rejected either way, and a larger cap would only buy samples of it.
192    for &edge_id in edge_ids {
193        let full_curve = &model.edges[&edge_id].curve;
194        let hits = match curve_curve_intersect(&ray, full_curve, 1, max_nodes, epsilon) {
195            Ok(hits) => hits,
196            Err(e) => {
197                return Ok(Err(format!(
198                    "its search against edge {edge_id} failed: {e}"
199                )));
200            }
201        };
202        if hits.is_coincident() {
203            return Ok(Err(format!("it runs along edge {edge_id}")));
204        }
205        for (t_hit, s_hit) in hits.into_vec() {
206            // Only a crossing *ahead* of the query point is a graze. The point
207            // is not on this edge (checked by the caller), so a hit the
208            // search cannot place beyond it — refined, in case it is just
209            // close — is its own resolution around the query, not a crossing;
210            // and were it one, the faces meeting along the edge would be hit
211            // right on their trim, which they reject below.
212            let (t_hit, _) = if t_hit.definitely_greater(Scalar::ZERO) {
213                (t_hit, s_hit)
214            } else {
215                refine_curve_curve_crossing(&ray, full_curve, t_hit, s_hit)
216            };
217            if t_hit.definitely_greater(Scalar::ZERO) {
218                return Ok(Err(format!("it could cross edge {edge_id} at t={t_hit:?}")));
219            }
220        }
221    }
222
223    let mut crossings = Vec::new();
224    for &face_id in &shell.faces {
225        let surface = &model.faces[&face_id].surface;
226        // A ray lying in a face is ambiguous, and an `Err` (node budget
227        // exhausted) is no answer; both ask the caller to retry. Every
228        // crossing is needed for the parity count, so the cap is `max_nodes`
229        // — the most crossings a search of that budget could ever report.
230        // But a ray lying in the surface is rejected whatever its points, and
231        // asked for `max_nodes` of them it gets that many samples of the
232        // overlap: so it is asked for one first, and for every crossing only
233        // if it found one and lies in no surface.
234        let search = |max_solutions: usize| {
235            curve_surface_intersect(&ray, surface, max_solutions, max_nodes, epsilon)
236                .map_err(|e| format!("its search against face {face_id} failed: {e}"))
237        };
238        let hits = match search(1) {
239            Ok(hits) if hits.is_coincident() => {
240                return Ok(Err(format!("it lies in face {face_id}'s surface")));
241            }
242            Ok(hits) if hits.len() == 1 => search(max_nodes),
243            other => other,
244        };
245        let hits = match hits {
246            Ok(hits) => hits,
247            Err(why) => return Ok(Err(why)),
248        };
249        for (t_hit, uv) in hits.into_vec() {
250            // As in `face::loops_contain`: a crossing next to the query point
251            // is still a crossing, so one the search cannot place beyond it
252            // is refined, not dropped.
253            let (t_hit, uv) = if t_hit.definitely_greater(Scalar::ZERO) {
254                (t_hit, uv)
255            } else {
256                refine_crossing(&ray, surface, t_hit, uv)
257            };
258            // Sharpen — `curve_surface_intersect` honestly returns the
259            // whole surviving span of its converged leaf, not an
260            // arbitrarily narrowed midpoint.
261            let (u, v) = (uv[0].midpoint(), uv[1].midpoint());
262            let face_seed = seed ^ face_id.0;
263            // A crossing that could be at the ray's far end could be on
264            // either side of it. A ray cast past the shell never meets one
265            // there; one ending at a point already classified (see
266            // `shell_contains_from`) meets one only if that point is all but
267            // on the boundary.
268            if !t_hit.definitely_less(Scalar::ONE) {
269                return Ok(Err(format!(
270                    "its hit on face {face_id} at t={t_hit:?} could be at its far end"
271                )));
272            }
273            match face_contains(model, face_id, u, v, max_nodes, epsilon, face_seed) {
274                Ok(FaceClassification::Inside) if t_hit.definitely_greater(Scalar::ZERO) => {
275                    crossings.push((face_id, t_hit, uv))
276                }
277                // The ray's own start, on the face it leaves.
278                Ok(FaceClassification::Inside) if leaving == Some(face_id) => {}
279                // A hit inside the trim that still cannot be told from the
280                // query point: the query is on this face to within what the
281                // numbers can tell, though the sharp query passed the caller's
282                // own check (see `face::loops_contain`).
283                Ok(FaceClassification::Inside) => return Ok(Ok(Ray::OnFace)),
284                // Outside the trim, wherever along the ray: not a crossing.
285                Ok(FaceClassification::Outside) => {}
286                // A hit right on this face's own trim boundary, or an
287                // outright error resolving it, is the same ambiguity as a
288                // vertex/edge graze one dimension down.
289                other => {
290                    return Ok(Err(format!(
291                        "its hit on face {face_id} at uv=({u:?}, {v:?}) classified as {other:?}"
292                    )));
293                }
294            }
295        }
296    }
297    Ok(Ok(Ray::Crossings(crossings)))
298}
299
300/// What a clean ray found (see [`cast_ray`]).
301#[derive(Clone, Debug)]
302pub(crate) enum Ray {
303    /// Its start is on a face of the shell.
304    OnFace,
305    /// The faces it crosses, each at its parameter along the ray, in the
306    /// shell's order of faces.
307    Crossings(Vec<(FaceId, Scalar, Vector2)>),
308}
309
310impl Ray {
311    /// The classification of the ray's start: inside the shell if it
312    /// crossed the boundary an odd number of times.
313    ///
314    /// Unlike `face_contains` (whose ray is guaranteed to cross the outer
315    /// trim loop for any query point within the surface's own bounded
316    /// parameter domain), a shell occupies a bounded region of otherwise
317    /// unbounded 3-D space: a ray past the shell that finds zero crossings
318    /// has, by construction, missed the shell's spatial footprint entirely,
319    /// which can only happen when the query point is outside — zero is a
320    /// legitimate (even) count, not degenerate.
321    pub(crate) fn classification(&self) -> PointClassification {
322        match self {
323            Ray::OnFace => PointClassification::OnFace,
324            Ray::Crossings(crossings) if crossings.len() % 2 == 1 => PointClassification::Inside,
325            Ray::Crossings(_) => PointClassification::Outside,
326        }
327    }
328}
329
330/// Classify `point` against `shell_id`'s boundary: [`PointClassification::OnVertex`] /
331/// [`PointClassification::OnEdge`] / [`PointClassification::OnFace`] if the
332/// query point itself coincides with the boundary, else
333/// [`PointClassification::Inside`]/[`PointClassification::Outside`] via ray
334/// casting.
335///
336/// The ray direction is drawn from a seeded PRNG (see [`Rng`]) and retried
337/// (up to a bounded number of attempts, see [`cast_ray`]) until one resolves
338/// cleanly. The even/odd parity of that crossing count then determines
339/// inside/outside, with no dependence on any face's normal orientation
340/// (faces in this crate aren't guaranteed to wind consistently).
341///
342/// `max_nodes` bounds both the BFS containment searches and the DFS
343/// intersection searches; `epsilon` is the shared geometric tolerance;
344/// `seed` seeds the direction PRNG.
345pub fn shell_contains(
346    model: &Model,
347    shell_id: ShellId,
348    point: Vector3,
349    max_nodes: usize,
350    epsilon: Scalar,
351    seed: u64,
352) -> GeopResult<PointClassification> {
353    classify(model, shell_id, point, None, max_nodes, epsilon, seed)
354}
355
356/// Like [`shell_contains`], from `from`: a point already classified against
357/// `shell_id`, strictly inside it (`from_inside`) or strictly outside.
358///
359/// The first path tried ends there, and `point` is then on the same side
360/// as `from` exactly when that path crosses the boundary an even number of
361/// times — the same parity argument as a ray cast past the whole shell,
362/// with the answer at the far end known instead of "outside". A point near
363/// `from` — another point of the same face, say — makes that path short,
364/// and a short path rules out nearly every face of the shell before any
365/// search runs (see `curve_could_meet_aabb`), where a ray cast past the
366/// shell is tested against all of them. A degenerate one (grazing a vertex
367/// or an edge, hitting a face at its trim or at an end of the path) falls
368/// back to the rays of [`shell_contains`].
369#[allow(clippy::too_many_arguments)]
370pub fn shell_contains_from(
371    model: &Model,
372    shell_id: ShellId,
373    point: Vector3,
374    from: Vector3,
375    from_inside: bool,
376    max_nodes: usize,
377    epsilon: Scalar,
378    seed: u64,
379) -> GeopResult<PointClassification> {
380    classify(
381        model,
382        shell_id,
383        point,
384        Some((from, from_inside)),
385        max_nodes,
386        epsilon,
387        seed,
388    )
389}
390
391/// [`shell_contains`] and [`shell_contains_from`]: the coincidence checks,
392/// then the ray to `from` if there is one, then random rays.
393fn classify(
394    model: &Model,
395    shell_id: ShellId,
396    point: Vector3,
397    from: Option<(Vector3, bool)>,
398    max_nodes: usize,
399    epsilon: Scalar,
400    seed: u64,
401) -> GeopResult<PointClassification> {
402    let boundary = ShellBoundary::of(model, shell_id);
403    let (vertex_ids, edge_ids) = (&boundary.vertices, &boundary.edges);
404
405    // Coincidence pre-check: vertex, then edge, then face — each a full
406    // pass, so higher-priority coincidences are deterministic regardless of
407    // iteration order.
408    for &vertex_id in vertex_ids {
409        if model.vertices[&vertex_id].point.could_be_equal(&point) {
410            return Ok(PointClassification::OnVertex);
411        }
412    }
413    for &edge_id in edge_ids {
414        if curve_could_contain(&model.edges[&edge_id].curve, &point, max_nodes, epsilon)?.is_some()
415        {
416            return Ok(PointClassification::OnEdge);
417        }
418    }
419    if shell_face_at(model, shell_id, &point, max_nodes, epsilon, seed)?.is_some() {
420        return Ok(PointClassification::OnFace);
421    }
422
423    let mut last_rejection = String::new();
424    if let Some((from, from_inside)) = from {
425        // `point` is inside if it is on `from`'s side and `from` is, or on
426        // the other side and `from` is not.
427        let inside = |other_side: bool| match other_side != from_inside {
428            true => PointClassification::Inside,
429            false => PointClassification::Outside,
430        };
431        // Two boxes that overlap, neither touching the boundary, are one
432        // connected region clear of it: the same side.
433        if point.could_be_equal(&from) {
434            return Ok(inside(false));
435        }
436        // Not straight to `from`, but by way of a point `via` off to a
437        // random side, as far from the middle as the two are apart: a
438        // straight segment between two points of one face lies in that
439        // face's plane, and in any face of the shell flush with it; and
440        // points that structured, the corners of a symmetric face, say, can
441        // as well graze a cylinder, a crossing that counts once and is
442        // none. A random detour is as clear of these as a random ray is.
443        let mut rng = Rng::new(seed);
444        let gap = from.sub(&point).norm().div(Scalar::TWO)?;
445        let via = point
446            .add(&from)
447            .prod_scalar(Scalar::ONE.div(Scalar::TWO)?)
448            .add(&rng.next_direction3().prod_scalar(gap))
449            .sharpen();
450        let leg = |start: Vector3| {
451            cast_ray(
452                &boundary,
453                start,
454                via.sub(&start),
455                Scalar::ONE,
456                None,
457                max_nodes,
458                epsilon,
459                seed,
460            )
461        };
462        // The leg from `point` may find it on the boundary; the one from
463        // `from`, already classified clear of it, finding it there is
464        // ambiguous.
465        match (
466            leg(point)?.map(|ray| ray.classification()),
467            leg(from)?.map(|ray| ray.classification()),
468        ) {
469            (
470                Ok(
471                    on @ (PointClassification::OnFace
472                    | PointClassification::OnEdge
473                    | PointClassification::OnVertex),
474                ),
475                _,
476            ) => {
477                return Ok(on);
478            }
479            (
480                Ok(first),
481                Ok(second @ (PointClassification::Inside | PointClassification::Outside)),
482            ) => {
483                let odd = |c: PointClassification| c == PointClassification::Inside;
484                return Ok(inside(odd(first) != odd(second)));
485            }
486            (Err(reason), _) | (_, Err(reason)) => {
487                last_rejection = format!("the detour to {from:?} was rejected: {reason}");
488            }
489            (_, Ok(on)) => {
490                last_rejection =
491                    format!("the detour to {from:?} found that point {on:?}, though classified");
492            }
493        }
494    }
495
496    let ray_length = boundary.ray_length(&point)?;
497
498    let mut rng = Rng::new(seed);
499    // Directions tried in turn until one is clear.
500    let found = attempts(MAX_RAY_ATTEMPTS, |&attempt| -> GeopResult<_> {
501        let direction = rng.next_direction3();
502        let attempt_seed = seed ^ (attempt as u64).wrapping_mul(0x9E3779B97F4A7C15);
503        match cast_ray(
504            &boundary,
505            point,
506            direction,
507            ray_length,
508            None,
509            max_nodes,
510            epsilon,
511            attempt_seed,
512        )? {
513            Ok(ray) => Ok(Probe::Found(ray.classification())),
514            Err(reason) => {
515                last_rejection = format!("the ray along {direction:?} was rejected: {reason}");
516                Ok(Probe::Next)
517            }
518        }
519    })?;
520    if let Some(classification) = found {
521        return Ok(classification);
522    }
523    Err(GeopError::new(format!(
524        "shell_contains: could not find a ray direction clear of every vertex and edge after many \
525         attempts; the last one was rejected because {last_rejection}"
526    )))
527}
528
529/// What [`solid_contains_beside`] found: whether the solid is on the
530/// normal's side of the face, and the ray that decided it, for the record.
531#[derive(Clone, Debug)]
532pub struct Beside {
533    pub inside: bool,
534    pub direction: Vector3,
535    /// The faces the ray crossed past its start, each at its parameter
536    /// along the ray.
537    pub crossings: Vec<(FaceId, Scalar, Vector2)>,
538}
539
540/// Classifies the points of the solid `solid_id` just off its face
541/// `face_id` (of its shell `shell_id`) on the side `normal` points to, at
542/// `point`, a point inside the face: `inside` if the solid is on that side
543/// of the face there. Other arguments as for [`shell_contains`].
544///
545/// Decided by a ray from `point` in a random direction into the normal's
546/// half space: every point of the ray just past the face is on that side,
547/// and the parity of the ray's crossings past the face, over every shell of
548/// the solid, classifies them all at once (see [`cast_ray`], which does not
549/// count the ray's start on the face it leaves). No point a chosen distance
550/// off the face is asked about: that asks about a point that far away, and
551/// on a non-convex solid a distance that looked small reaches through the
552/// local material into another part of it. A bore 18 from the side of a
553/// block, probed at 29 both ways, has the block on the normal's side and
554/// the air past the block behind, and its wall reads as inverted.
555#[allow(clippy::too_many_arguments)]
556pub fn solid_contains_beside(
557    model: &Model,
558    solid_id: SolidId,
559    shell_id: ShellId,
560    face_id: FaceId,
561    point: Vector3,
562    normal: Vector3,
563    max_nodes: usize,
564    epsilon: Scalar,
565    seed: u64,
566) -> GeopResult<Beside> {
567    let boundaries: Vec<ShellBoundary> = model
568        .get_solid(solid_id)?
569        .shells
570        .iter()
571        .map(|&s| ShellBoundary::of(model, s))
572        .collect();
573    let mut rng = Rng::new(seed);
574    let mut last_rejection = String::new();
575    let found = attempts(MAX_RAY_ATTEMPTS, |&attempt| -> GeopResult<_> {
576        let drawn = rng.next_direction3();
577        let along = drawn.prod_dot(&normal);
578        // Turned into the normal's half space. One that could run along
579        // the face leaves it to neither side.
580        let direction = if along.definitely_greater(Scalar::ZERO) {
581            drawn
582        } else if along.definitely_less(Scalar::ZERO) {
583            drawn.neg()
584        } else {
585            last_rejection = format!("the direction {drawn:?} could run along the face");
586            return Ok(Probe::Next);
587        };
588        let attempt_seed = seed ^ (attempt as u64).wrapping_mul(0x9E3779B97F4A7C15);
589        let mut crossings = Vec::new();
590        for boundary in &boundaries {
591            let leaving = (boundary.shell_id == shell_id).then_some(face_id);
592            match cast_ray(
593                boundary,
594                point,
595                direction,
596                boundary.ray_length(&point)?,
597                leaving,
598                max_nodes,
599                epsilon,
600                attempt_seed,
601            )? {
602                Ok(Ray::Crossings(crossed)) => crossings.extend(crossed),
603                Ok(Ray::OnFace) => {
604                    last_rejection = format!(
605                        "the ray along {direction:?} found the point on a face of shell {}",
606                        boundary.shell_id
607                    );
608                    return Ok(Probe::Next);
609                }
610                Err(reason) => {
611                    last_rejection = format!("the ray along {direction:?} was rejected: {reason}");
612                    return Ok(Probe::Next);
613                }
614            }
615        }
616        // Inside an odd number of shells: the shells of one solid never
617        // cross, so that is inside the outer shell and no void.
618        Ok(Probe::Found(Beside {
619            inside: crossings.len() % 2 == 1,
620            direction,
621            crossings,
622        }))
623    })?;
624    found.ok_or_else(|| {
625        GeopError::new(format!(
626            "solid_contains_beside: no ray from {point:?} off face {face_id} of solid {solid_id} \
627             was clear of every vertex and edge after {MAX_RAY_ATTEMPTS} attempts; the last one \
628             was rejected because {last_rejection}"
629        ))
630    })
631}
632
633/// Whether `point`, a point inside face `face_id` of the solid `solid_id`,
634/// is on the solid's boundary anywhere else as well: on a vertex, on an
635/// edge, or on another face, of any of its shells. A body touching the face
636/// at a point leaves a vertex of its own in the face's interior with no
637/// topology between them: an inscribed sphere's pole on the cube's face.
638/// Other arguments as for [`shell_contains`].
639pub fn on_solid_boundary_besides(
640    model: &Model,
641    solid_id: SolidId,
642    face_id: FaceId,
643    point: &Vector3,
644    max_nodes: usize,
645    epsilon: Scalar,
646    seed: u64,
647) -> GeopResult<bool> {
648    for &shell_id in &model.get_solid(solid_id)?.shells {
649        let boundary = ShellBoundary::of(model, shell_id);
650        for &vertex_id in &boundary.vertices {
651            if model.get_vertex(vertex_id)?.point.could_be_equal(point) {
652                return Ok(true);
653            }
654        }
655        for &edge_id in &boundary.edges {
656            if curve_could_contain(&model.get_edge(edge_id)?.curve, point, max_nodes, epsilon)?
657                .is_some()
658            {
659                return Ok(true);
660            }
661        }
662        for &other in &model.get_shell(shell_id)?.faces {
663            if other == face_id {
664                continue;
665            }
666            let Some((u, v)) =
667                surface_could_contain(&model.get_face(other)?.surface, point, max_nodes, epsilon)?
668            else {
669                continue;
670            };
671            if !matches!(
672                face_contains(model, other, u, v, max_nodes, epsilon, seed ^ other.0)?,
673                FaceClassification::Outside
674            ) {
675                return Ok(true);
676            }
677        }
678    }
679    Ok(false)
680}
681
682/// The face of `shell_id` that `point` lies on, if any, and where on its
683/// surface: the first, in the shell's order.
684///
685/// Proximity to a face's *untrimmed* surface is not membership (see
686/// AGENTS.md): a face's surface generally extends well past its own trim
687/// loop (two faces split from one larger one still share that surface), so
688/// a point can sit squarely on the surface nowhere near the face. The face
689/// has to contain the point within its trim, or on it.
690pub fn shell_face_at(
691    model: &Model,
692    shell_id: ShellId,
693    point: &Vector3,
694    max_nodes: usize,
695    epsilon: Scalar,
696    seed: u64,
697) -> GeopResult<Option<(FaceId, Scalar, Scalar)>> {
698    for &face_id in &model.get_shell(shell_id)?.faces {
699        let Some((u, v)) =
700            surface_could_contain(&model.faces[&face_id].surface, point, max_nodes, epsilon)?
701        else {
702            continue;
703        };
704        if !matches!(
705            face_contains(model, face_id, u, v, max_nodes, epsilon, seed ^ face_id.0)?,
706            FaceClassification::Outside
707        ) {
708            return Ok(Some((face_id, u, v)));
709        }
710    }
711    Ok(None)
712}
713
714/// Points already classified against shells, each strictly inside or
715/// outside: what [`solid_contains`] classifies later points from, along a
716/// short path from the nearest (see [`shell_contains_from`]) instead of a
717/// ray across the whole shell.
718#[derive(Clone, Debug, Default)]
719pub struct Classified {
720    points: Vec<(ShellId, Vector3, bool)>,
721}
722
723impl Classified {
724    /// The point classified against `shell` nearest to `point`, and whether
725    /// it is inside: which one is a free choice, so it is chosen by the
726    /// midpoints.
727    fn nearest(&self, shell: ShellId, point: &Vector3) -> Option<(Vector3, bool)> {
728        let distance = |p: &Vector3| p.sub(point).norm_sq().to_f64();
729        self.points
730            .iter()
731            .filter(|(s, _, _)| *s == shell)
732            .min_by(|a, b| distance(&a.1).total_cmp(&distance(&b.1)))
733            .map(|&(_, p, inside)| (p, inside))
734    }
735}
736
737/// The lumps of the solid `solid_id`: each shell bounding material from
738/// outside, with the voids in it. A shell is a void when inside an odd
739/// number of the solid's other shells, and a void belongs to the
740/// innermost shell around it. Asked of each shell at one of its vertices
741/// off the other shell: shells of one solid never cross, so where one
742/// vertex is, all of it is. A solid of one shell is one lump without
743/// voids. Lumps are in the order of their shells in the solid. Other
744/// arguments as for [`shell_contains`].
745pub fn solid_lumps(
746    model: &Model,
747    solid_id: SolidId,
748    max_nodes: usize,
749    epsilon: Scalar,
750    seed: u64,
751) -> GeopResult<Vec<(ShellId, Vec<ShellId>)>> {
752    let shells = model.get_solid(solid_id)?.shells.clone();
753    if shells.len() == 1 {
754        return Ok(vec![(shells[0], Vec::new())]);
755    }
756    // Which other shells hold each shell.
757    let mut around: Vec<Vec<usize>> = vec![Vec::new(); shells.len()];
758    for (i, &shell) in shells.iter().enumerate() {
759        let vertices: Vec<VertexId> = model.iter_body_vertices(Body::Sheet(shell))?.collect();
760        for (j, &other) in shells.iter().enumerate() {
761            if i == j {
762                continue;
763            }
764            let mut decided = None;
765            for &vertex in &vertices {
766                let point = model.get_vertex(vertex)?.point;
767                match shell_contains(model, other, point, max_nodes, epsilon, seed)? {
768                    PointClassification::Inside => decided = Some(true),
769                    PointClassification::Outside => decided = Some(false),
770                    _ => continue,
771                }
772                break;
773            }
774            match decided {
775                Some(true) => around[i].push(j),
776                Some(false) => {}
777                None => {
778                    return Err(GeopError::new(format!(
779                        "solid_lumps: every vertex of shell {shell} of solid {solid_id} lies on shell {other}: the two touch everywhere, and neither is inside the other"
780                    )));
781                }
782            }
783        }
784    }
785    let depth: Vec<usize> = around.iter().map(Vec::len).collect();
786    let mut lumps: Vec<(ShellId, Vec<ShellId>)> = Vec::new();
787    let mut lump_of: Vec<Option<usize>> = vec![None; shells.len()];
788    for i in 0..shells.len() {
789        if depth[i].is_multiple_of(2) {
790            lump_of[i] = Some(lumps.len());
791            lumps.push((shells[i], Vec::new()));
792        }
793    }
794    for i in 0..shells.len() {
795        if !depth[i].is_multiple_of(2) {
796            let parent = around[i]
797                .iter()
798                .copied()
799                .find(|&j| depth[j] + 1 == depth[i])
800                .and_then(|j| lump_of[j])
801                .ok_or_else(|| {
802                    GeopError::new(format!(
803                        "solid_lumps: shell {} of solid {solid_id} is a void inside no lump",
804                        shells[i]
805                    ))
806                })?;
807            lumps[parent].1.push(shells[i]);
808        }
809    }
810    Ok(lumps)
811}
812
813/// Classify `point` against the solid `solid_id`: on its boundary if on any
814/// of its shells', else inside exactly when inside an odd number of them —
815/// inside the outer shell and no void, say — for the shells of one solid
816/// never cross. Asking each shell alone would put a point in a void inside
817/// the solid. Each shell is asked from the nearest point `classified`
818/// already holds for it, if any (see [`shell_contains_from`]), and a point
819/// found strictly inside or outside a shell is added to it. Other arguments
820/// as for [`shell_contains`].
821pub fn solid_contains(
822    model: &Model,
823    solid_id: SolidId,
824    point: Vector3,
825    max_nodes: usize,
826    epsilon: Scalar,
827    seed: u64,
828    classified: &mut Classified,
829) -> GeopResult<PointClassification> {
830    let mut inside = false;
831    for &shell_id in &model.get_solid(solid_id)?.shells {
832        let from = classified.nearest(shell_id, &point);
833        let class = classify(model, shell_id, point, from, max_nodes, epsilon, seed)?;
834        match class {
835            PointClassification::Inside | PointClassification::Outside => {
836                let is_inside = class == PointClassification::Inside;
837                classified.points.push((shell_id, point, is_inside));
838                inside ^= is_inside;
839            }
840            on => return Ok(on),
841        }
842    }
843    Ok(if inside {
844        PointClassification::Inside
845    } else {
846        PointClassification::Outside
847    })
848}
849
850/// The face of the solid `solid_id` that `point` lies on, if any, and where
851/// on its surface (see [`shell_face_at`]).
852pub fn solid_face_at(
853    model: &Model,
854    solid_id: SolidId,
855    point: &Vector3,
856    max_nodes: usize,
857    epsilon: Scalar,
858    seed: u64,
859) -> GeopResult<Option<(FaceId, Scalar, Scalar)>> {
860    for &shell_id in &model.get_solid(solid_id)?.shells {
861        if let Some(at) = shell_face_at(model, shell_id, point, max_nodes, epsilon, seed)? {
862            return Ok(Some(at));
863        }
864    }
865    Ok(None)
866}
867
868#[cfg(test)]
869mod tests {
870    use super::{PointClassification, shell_contains, shell_contains_from};
871    use crate::{
872        Coedge, CoedgeGeometry, CoedgeId, Edge, EdgeId, Face, FaceId, Model, Sense, Shell, ShellId,
873        Vertex, VertexId, boundary::BoundaryType,
874    };
875    use geop_core_geometry::{
876        nurb_curve::{NurbCurve, NurbCurve2D, NurbCurve3D},
877        nurb_surface::NurbSurface3D,
878    };
879    use geop_core_math::{
880        scalars::Scalar,
881        vector::{Vector3, Vector4},
882    };
883
884    const MAX: usize = 200;
885    const MIN_SUBDIVISION_SIZE: f64 = 1e-3;
886    const SEED: u64 = 424_242;
887
888    type P3 = (f64, f64, f64);
889
890    /// A single planar quad face, corners given in trim-loop order
891    /// (`p00 -> p10 -> p11 -> p01`), each face independently vertexed/edged
892    /// (geometrically watertight, topologically not shared — fine for pure
893    /// containment testing).
894    fn quad_face(model: &mut Model, shell: ShellId, p00: P3, p10: P3, p11: P3, p01: P3) -> FaceId {
895        let p4 = |p: P3| {
896            Vector4::from_array([
897                Scalar::from_f64(p.0),
898                Scalar::from_f64(p.1),
899                Scalar::from_f64(p.2),
900                Scalar::ONE,
901            ])
902        };
903        let surface = NurbSurface3D::try_new(
904            1,
905            1,
906            vec![p4(p00), p4(p01), p4(p10), p4(p11)],
907            vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
908            vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
909        )
910        .unwrap();
911        let face_id = model.insert_face(Face {
912            surface,
913            outer: BoundaryType::Vertex(crate::VertexId(0)),
914            holes: Vec::new(),
915            shell,
916        });
917
918        let corners = [p00, p10, p11, p01];
919        let verts: Vec<VertexId> = corners
920            .iter()
921            .map(|&(x, y, z)| {
922                model.insert_vertex(Vertex {
923                    point: Vector3::from_array([
924                        Scalar::from_f64(x),
925                        Scalar::from_f64(y),
926                        Scalar::from_f64(z),
927                    ]),
928                })
929            })
930            .collect();
931        let edges: Vec<EdgeId> = (0..4)
932            .map(|i| {
933                model.insert_edge(Edge {
934                    curve: NurbCurve3D::try_new(
935                        1,
936                        vec![p4(corners[i]), p4(corners[(i + 1) % 4])],
937                        vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
938                    )
939                    .unwrap(),
940                    start_vertex: verts[i],
941                    end_vertex: verts[(i + 1) % 4],
942                })
943            })
944            .collect();
945        let uv = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
946        let pc = |a: (f64, f64), b: (f64, f64)| -> NurbCurve2D {
947            NurbCurve::try_new(
948                1,
949                vec![
950                    Vector3::from_array([
951                        Scalar::from_f64(a.0),
952                        Scalar::from_f64(a.1),
953                        Scalar::ONE,
954                    ]),
955                    Vector3::from_array([
956                        Scalar::from_f64(b.0),
957                        Scalar::from_f64(b.1),
958                        Scalar::ONE,
959                    ]),
960                ],
961                vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
962            )
963            .unwrap()
964        };
965        let coedges: Vec<CoedgeId> = (0..4)
966            .map(|i| {
967                model.insert_coedge(Coedge {
968                    geometry: CoedgeGeometry::Edge(edges[i]),
969                    sense: Sense::Forward,
970                    pcurve: pc(uv[i], uv[(i + 1) % 4]),
971                    next: CoedgeId(0),
972                    prev: CoedgeId(0),
973                    face: face_id,
974                })
975            })
976            .collect();
977        for i in 0..4 {
978            model.coedges.get_mut(&coedges[i]).unwrap().next = coedges[(i + 1) % 4];
979            model.coedges.get_mut(&coedges[i]).unwrap().prev = coedges[(i + 3) % 4];
980        }
981        model.faces.get_mut(&face_id).unwrap().outer = BoundaryType::Loop(coedges[0]);
982
983        face_id
984    }
985
986    /// The unit cube `[0,1]^3`, as six independently-vertexed quad faces
987    /// (mixed winding across faces on purpose — containment must not depend
988    /// on consistent orientation).
989    fn unit_cube(model: &mut Model) -> ShellId {
990        let shell_id = model.insert_shell(Shell {
991            faces: vec![],
992            solid: None,
993        });
994        let faces = vec![
995            quad_face(
996                model,
997                shell_id,
998                (0., 0., 1.),
999                (1., 0., 1.),
1000                (1., 1., 1.),
1001                (0., 1., 1.),
1002            ), // +Z
1003            quad_face(
1004                model,
1005                shell_id,
1006                (0., 0., 0.),
1007                (0., 1., 0.),
1008                (1., 1., 0.),
1009                (1., 0., 0.),
1010            ), // -Z (reversed winding)
1011            quad_face(
1012                model,
1013                shell_id,
1014                (1., 0., 0.),
1015                (1., 1., 0.),
1016                (1., 1., 1.),
1017                (1., 0., 1.),
1018            ), // +X
1019            quad_face(
1020                model,
1021                shell_id,
1022                (0., 0., 0.),
1023                (0., 0., 1.),
1024                (0., 1., 1.),
1025                (0., 1., 0.),
1026            ), // -X (reversed winding)
1027            quad_face(
1028                model,
1029                shell_id,
1030                (0., 1., 0.),
1031                (1., 1., 0.),
1032                (1., 1., 1.),
1033                (0., 1., 1.),
1034            ), // +Y
1035            quad_face(
1036                model,
1037                shell_id,
1038                (0., 0., 0.),
1039                (1., 0., 0.),
1040                (1., 0., 1.),
1041                (0., 0., 1.),
1042            ), // -Y
1043        ];
1044        model.shells.get_mut(&shell_id).unwrap().faces = faces;
1045        shell_id
1046    }
1047
1048    #[test]
1049    fn center_is_inside() {
1050        let mut model = Model::new();
1051        let shell_id = unit_cube(&mut model);
1052        let p = Vector3::from_array([Scalar::from_f64(0.5); 3]);
1053        assert_eq!(
1054            shell_contains(
1055                &model,
1056                shell_id,
1057                p,
1058                MAX,
1059                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1060                SEED
1061            )
1062            .unwrap(),
1063            PointClassification::Inside
1064        );
1065    }
1066
1067    #[test]
1068    fn far_point_is_outside() {
1069        let mut model = Model::new();
1070        let shell_id = unit_cube(&mut model);
1071        let p = Vector3::from_array([
1072            Scalar::from_f64(-5.0),
1073            Scalar::from_f64(0.5),
1074            Scalar::from_f64(0.5),
1075        ]);
1076        assert_eq!(
1077            shell_contains(
1078                &model,
1079                shell_id,
1080                p,
1081                MAX,
1082                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1083                SEED
1084            )
1085            .unwrap(),
1086            PointClassification::Outside
1087        );
1088    }
1089
1090    #[test]
1091    fn point_just_outside_face_is_outside() {
1092        let mut model = Model::new();
1093        let shell_id = unit_cube(&mut model);
1094        let p = Vector3::from_array([
1095            Scalar::from_f64(-0.1),
1096            Scalar::from_f64(0.5),
1097            Scalar::from_f64(0.5),
1098        ]);
1099        assert_eq!(
1100            shell_contains(
1101                &model,
1102                shell_id,
1103                p,
1104                MAX,
1105                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1106                SEED
1107            )
1108            .unwrap(),
1109            PointClassification::Outside
1110        );
1111    }
1112
1113    #[test]
1114    fn face_point_is_on_face() {
1115        let mut model = Model::new();
1116        let shell_id = unit_cube(&mut model);
1117        let p = Vector3::from_array([Scalar::ZERO, Scalar::from_f64(0.5), Scalar::from_f64(0.5)]);
1118        assert_eq!(
1119            shell_contains(
1120                &model,
1121                shell_id,
1122                p,
1123                MAX,
1124                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1125                SEED
1126            )
1127            .unwrap(),
1128            PointClassification::OnFace
1129        );
1130    }
1131
1132    #[test]
1133    fn edge_point_is_on_edge() {
1134        let mut model = Model::new();
1135        let shell_id = unit_cube(&mut model);
1136        let p = Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::from_f64(0.5)]);
1137        assert_eq!(
1138            shell_contains(
1139                &model,
1140                shell_id,
1141                p,
1142                MAX,
1143                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1144                SEED
1145            )
1146            .unwrap(),
1147            PointClassification::OnEdge
1148        );
1149    }
1150
1151    #[test]
1152    fn vertex_point_is_on_vertex() {
1153        let mut model = Model::new();
1154        let shell_id = unit_cube(&mut model);
1155        let p = Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::ZERO]);
1156        assert_eq!(
1157            shell_contains(
1158                &model,
1159                shell_id,
1160                p,
1161                MAX,
1162                Scalar::from_f64(MIN_SUBDIVISION_SIZE),
1163                SEED
1164            )
1165            .unwrap(),
1166            PointClassification::OnVertex
1167        );
1168    }
1169
1170    /// Classified from a point already classified, inside or out, every
1171    /// point gets what `shell_contains` gives it — points in the cube's
1172    /// plane of symmetry and on its faces' planes among them, whose
1173    /// straight segments to each other lie in a face's plane or run
1174    /// through an edge.
1175    #[test]
1176    fn classified_from_a_known_point() {
1177        let mut model = Model::new();
1178        let shell_id = unit_cube(&mut model);
1179        let p = |x: f64, y: f64, z: f64| Vector3::from_array([x, y, z].map(Scalar::from_f64));
1180        let points = [
1181            p(0.5, 0.5, 0.5),
1182            p(0.25, 0.5, 0.5),
1183            p(-0.5, 0.5, 0.5),
1184            p(1.5, 0.5, 0.5),
1185            p(-0.5, -0.5, 0.5),
1186            p(0.5, 0.5, 2.0),
1187            p(-0.5, 0.0, 0.0),
1188            p(0.0, -0.5, 0.0),
1189            p(0.0, 0.0, 0.0),
1190            p(0.0, 0.5, 0.5),
1191        ];
1192        let eps = Scalar::from_f64(MIN_SUBDIVISION_SIZE);
1193        for from in &points[..6] {
1194            let from_class = shell_contains(&model, shell_id, *from, MAX, eps, SEED).unwrap();
1195            let from_inside = from_class == PointClassification::Inside;
1196            for (k, point) in points.iter().enumerate() {
1197                let want = shell_contains(&model, shell_id, *point, MAX, eps, SEED).unwrap();
1198                let got = shell_contains_from(
1199                    &model,
1200                    shell_id,
1201                    *point,
1202                    *from,
1203                    from_inside,
1204                    MAX,
1205                    eps,
1206                    SEED ^ k as u64,
1207                )
1208                .unwrap();
1209                assert_eq!(got, want, "{point:?} from {from:?}");
1210            }
1211        }
1212    }
1213}