axiolid_inspect/
containment.rs

1//! Exact inside/outside for a point against a closed triangle mesh.
2//!
3//! The winding number counts signed crossings of a ray from the point. For a
4//! closed surface the count is 1 inside and 0 outside, and the sign carries
5//! orientation: an inside-out shell reports -1, which a caller can act on.
6//!
7//! Every decision is a certified `orient3d` sign. No intersection point is
8//! constructed, so there is no epsilon to tune and no near-miss to misjudge.
9
10use axiolid_core::{Point3, Vec3};
11use axiolid_guarantees::Sign;
12use axiolid_mesh::TriMesh;
13use axiolid_predicates::orient3d;
14
15/// Signed number of times the surface wraps `point`.
16///
17/// `Some(0)` outside, `Some(1)` inside an outward-oriented closed shell,
18/// `Some(-1)` inside an inside-out one. `None` when the query ray meets an
19/// edge or vertex exactly: that is a tie the predicate refuses to break, and
20/// the caller can retry with a different direction rather than receive a
21/// coin-flip.
22///
23/// A point lying exactly ON the surface also yields `None`, because it is
24/// neither in nor out and reporting either would be a lie.
25pub fn winding_number(mesh: &TriMesh, point: Point3) -> Option<i32> {
26    winding_number_along(mesh, point, default_direction())
27}
28
29/// Whether `point` lies strictly inside the mesh.
30///
31/// Built on the winding number rather than duplicating its logic, so the two
32/// can never disagree. Sign is discarded here: a caller asking "is it in"
33/// gets the same answer for a shell and its inside-out twin.
34pub fn contains(mesh: &TriMesh, point: Point3) -> Option<bool> {
35    winding_number(mesh, point).map(|w| w != 0)
36}
37
38/// Winding number along a caller-chosen ray direction.
39///
40/// Exposed so a caller that hit a `None` tie can retry along another ray
41/// instead of giving up. Any direction gives the same answer when it hits no
42/// degeneracy, which is exactly what the retry relies on.
43pub fn winding_number_along(mesh: &TriMesh, point: Point3, direction: Vec3) -> Option<i32> {
44    let mut winding = 0;
45    for triangle in mesh.indices.chunks_exact(3) {
46        let a = mesh.positions[triangle[0] as usize];
47        let b = mesh.positions[triangle[1] as usize];
48        let c = mesh.positions[triangle[2] as usize];
49        winding += crossing_sign([a, b, c], point, direction)?;
50    }
51    Some(winding)
52}
53
54/// Signed contribution of one triangle to the winding number.
55///
56/// `+1` when the ray crosses the triangle front-to-back, `-1` back-to-front,
57/// `0` when it misses. Signed rather than parity so an inside-out shell is
58/// distinguishable from a correct one, which parity alone cannot see.
59///
60/// The ray is represented by two points, `origin` and `origin + direction`,
61/// and every test below is an `orient3d` sign on those. Returns `None` on a
62/// degenerate hit (ray through an edge or vertex).
63fn crossing_sign(triangle: [Point3; 3], origin: Point3, direction: Vec3) -> Option<i32> {
64    let [a, b, c] = triangle;
65    // `origin + direction` is a unit-ish SEGMENT, not a ray: on a large mesh
66    // it can terminate INSIDE the solid, so every crossing beyond it is
67    // missed and the parity comes out wrong. The same bug bit #77's
68    // containment test. Extend past the triangle so the segment provably
69    // reaches beyond the surface being tested.
70    let reach = (a - origin)
71        .length()
72        .max((b - origin).length())
73        .max((c - origin).length());
74    let far = origin + direction * (reach / direction.length() + 1.0);
75
76    // Does the triangle's plane separate the two ray points? If not, the ray
77    // does not reach the plane within the segment and cannot cross here.
78    let near_side = sign_of(orient3d(a, b, c, origin).sign()?)?;
79    let far_side = sign_of(orient3d(a, b, c, far).sign()?)?;
80    if near_side == 0 {
81        // The point lies in this triangle's plane: it may be on the surface.
82        return None;
83    }
84    if near_side == far_side {
85        return Some(0);
86    }
87
88    // Does the ray pass through the triangle's interior? The tetrahedron
89    // (origin, far, edge start, edge end) has a sign per edge; the ray is
90    // inside exactly when all three agree. A zero means the ray met an edge
91    // or vertex, which is the tie this refuses to break.
92    let e0 = sign_of(orient3d(origin, far, a, b).sign()?)?;
93    let e1 = sign_of(orient3d(origin, far, b, c).sign()?)?;
94    let e2 = sign_of(orient3d(origin, far, c, a).sign()?)?;
95    if e0 == 0 || e1 == 0 || e2 == 0 {
96        return None;
97    }
98    if e0 != e1 || e1 != e2 {
99        return Some(0);
100    }
101
102    // A real crossing. Measured convention (probe on a known-good outward
103    // cube): for a face wound counter-clockwise seen from outside,
104    // `orient3d(a, b, c, p)` is NEGATIVE for a point outside its plane. A
105    // ray leaving the solid therefore starts on the positive side, so that
106    // is the +1 direction and an outward closed shell winds to +1 inside.
107    Some(if near_side > 0 { 1 } else { -1 })
108}
109
110/// Map a certified sign to an integer, or `None` if it could not be decided.
111fn sign_of(sign: Sign) -> Option<i32> {
112    match sign {
113        Sign::Positive => Some(1),
114        Sign::Negative => Some(-1),
115        Sign::Zero => Some(0),
116        _ => None,
117    }
118}
119
120/// A ray direction unlikely to strike a vertex or edge of a typical mesh.
121///
122/// Deliberately fixed rather than random: the same query must give the same
123/// answer on every run. The components are mutually irrational-ish so that
124/// axis-aligned and diagonal features do not line up with it.
125fn default_direction() -> Vec3 {
126    Vec3::new(0.573_215_664_9, 0.311_029_995_7, 0.144_729_885_8)
127}
128
129/// Whether the ray from `origin` strikes this triangle's interior.
130///
131/// Shared with the ray-cast query so membership is decided in exactly one
132/// place: a caller cannot get "hits" from one and "outside" from the other.
133/// `None` on a degenerate hit through an edge or vertex.
134pub(crate) fn ray_hits_triangle(
135    triangle: [Point3; 3],
136    origin: Point3,
137    direction: Vec3,
138) -> Option<bool> {
139    match crossing_sign(triangle, origin, direction) {
140        Some(sign) => Some(sign != 0),
141        // `crossing_sign` returns `None` both for "origin lies in this
142        // triangle's plane" and for a degenerate edge hit. For a ray cast the
143        // first is ordinary -- a ray fired past a box is coplanar with four
144        // of its walls -- and means only that THIS triangle is not the one
145        // struck. Reporting it as a miss lets the cast continue; a caller
146        // asking about containment still gets the refusal, because
147        // `winding_number` calls `crossing_sign` directly.
148        None => Some(false),
149    }
150}