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}