axiolid_ray_mesh/lib.rs
1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3
4//! Narrow-phase ray/triangle-mesh nearest-hit intersection.
5//!
6//! # Why this is its own package
7//!
8//! `axiolid-spatial` already owns the broad phase: [`SpatialIndex::visit_ray`]
9//! walks a BVH and yields candidate keys. Without a narrow phase a caller gets
10//! boxes and has to write ray/triangle themselves, which is how tolerance
11//! policy fragments across consumers. This package closes that seam and owns
12//! nothing else.
13//!
14//! It deliberately does not depend on `axiolid-spatial`: the narrow phase is
15//! useful without an index, and an index is useful without this. Composition
16//! happens at the call site by feeding candidate triangle indices into
17//! [`nearest_hit_among`].
18//!
19//! # Boundary
20//!
21//! This package owns the intersection and the hit record. It does not own what
22//! a ray *means*: sampling patterns, camera rigs, entity identity, or whether a
23//! hit counts as an obstruction stay with the caller.
24//!
25//! # Fail closed, never silently miss
26//!
27//! A degenerate (zero-area) triangle has no well-defined ray intersection. This
28//! package refuses with [`RayMeshError::DegenerateTriangle`] rather than
29//! reporting a miss, because a silent miss is indistinguishable from real empty
30//! space and quietly corrupts containment and visibility answers built on it.
31//!
32//! Ray direction is not required to be normalised, so the reported `t` is in
33//! units of the supplied direction vector. That is stated rather than fixed up,
34//! because normalising a caller's ray silently changes the meaning of every
35//! distance they compare against.
36//!
37//! # What the tolerance decides
38//!
39//! `Tolerance::linear()` bounds only the parallel-ray rejection and the
40//! barycentric slack that keeps edge and vertex hits. It never decides the
41//! front/back branch: [`FaceSide`] comes from the certified `orient3d` sign.
42//!
43//! [`SpatialIndex::visit_ray`]: https://docs.rs/axiolid-spatial
44
45use core::fmt;
46
47use axiolid_core::{Point3, Ray3, Scalar, Tolerance};
48use axiolid_guarantees::Sign;
49use axiolid_mesh::TriangleMeshView;
50use axiolid_predicates::orient3d;
51
52/// Which side of a triangle the ray arrived from.
53///
54/// Determined by the certified orientation of the ray origin against the
55/// triangle plane, not by the sign of a floating-point dot product, so a
56/// grazing ray does not flip sides on rounding.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum FaceSide {
59 /// The origin lies on the positive side of the triangle's winding normal.
60 Front,
61 /// The origin lies on the negative side of the triangle's winding normal.
62 Back,
63 /// The origin lies exactly in the triangle's plane.
64 Coplanar,
65}
66
67/// One nearest-hit record.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct RayHit3 {
70 /// Parametric distance along the supplied (possibly unnormalised) direction.
71 pub t: Scalar,
72 /// Index of the hit triangle in the source mesh.
73 pub triangle: usize,
74 /// Barycentric coordinates `(u, v, w)` with `w = 1 - u - v`, ordered to
75 /// match the triangle's stored corner order.
76 pub barycentric: [Scalar; 3],
77 /// Side the ray origin was on.
78 pub side: FaceSide,
79 /// Hit position reconstructed as `origin + direction * t`.
80 pub point: Point3,
81}
82
83/// Fail-closed reasons a ray/mesh query cannot produce an answer.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum RayMeshError {
86 /// A ray or mesh coordinate is NaN or infinite.
87 NonFiniteInput,
88 /// The ray direction is exactly zero, so no parametric distance exists.
89 ZeroDirection,
90 /// The tolerance policy is not usable for a parametric query.
91 InvalidTolerance,
92 /// A triangle references a position outside the mesh's position buffer.
93 PositionIndexOutOfRange {
94 /// Offending triangle.
95 triangle: usize,
96 },
97 /// A triangle has zero area, so it has no defined ray intersection.
98 ///
99 /// Reported rather than skipped: a silent miss is indistinguishable from
100 /// empty space.
101 DegenerateTriangle {
102 /// Offending triangle.
103 triangle: usize,
104 },
105 /// A candidate triangle index is at or beyond the mesh's triangle count.
106 ///
107 /// Reported rather than skipped: a broad phase built over a different
108 /// mesh would otherwise answer "no hit" for triangles it never tested.
109 TriangleIndexOutOfRange {
110 /// Offending candidate index.
111 triangle: usize,
112 /// Triangles in the mesh.
113 triangle_count: usize,
114 },
115}
116
117impl fmt::Display for RayMeshError {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::NonFiniteInput => formatter.write_str("ray and mesh coordinates must be finite"),
121 Self::ZeroDirection => formatter.write_str("ray direction must be non-zero"),
122 Self::InvalidTolerance => {
123 formatter.write_str("ray/mesh tolerance must be finite and non-negative")
124 }
125 Self::TriangleIndexOutOfRange {
126 triangle,
127 triangle_count,
128 } => write!(
129 formatter,
130 "triangle {triangle} is out of range for a mesh of {triangle_count} triangles"
131 ),
132 Self::PositionIndexOutOfRange { triangle } => {
133 write!(
134 formatter,
135 "triangle {triangle} references a missing position"
136 )
137 }
138 Self::DegenerateTriangle { triangle } => {
139 write!(formatter, "triangle {triangle} has zero area")
140 }
141 }
142 }
143}
144
145impl std::error::Error for RayMeshError {}
146
147/// Nearest hit over every triangle of `mesh`.
148///
149/// Prefer [`nearest_hit_among`] when a broad phase has already rejected most
150/// triangles; this scans all of them.
151pub fn nearest_hit(
152 mesh: &impl TriangleMeshView,
153 ray: &Ray3,
154 tolerance: Tolerance,
155) -> Result<Option<RayHit3>, RayMeshError> {
156 nearest_hit_among(mesh, ray, tolerance, 0..mesh.triangle_count())
157}
158
159/// Nearest hit over caller-supplied candidate triangles.
160///
161/// This is the composition point with a broad phase: feed it the triangle
162/// indices a BVH walk produced. Candidates may repeat and may arrive in any
163/// order; the result does not depend on that order. A candidate index at or
164/// beyond `mesh.triangle_count()` is refused with
165/// [`RayMeshError::TriangleIndexOutOfRange`], and a triangle that references
166/// a missing position with [`RayMeshError::PositionIndexOutOfRange`].
167///
168/// # Determinism
169///
170/// Hits are ordered by `t`, then by triangle index. Two coplanar triangles
171/// sharing an edge therefore resolve to the same triangle on every run and on
172/// every platform, instead of depending on traversal order.
173pub fn nearest_hit_among(
174 mesh: &impl TriangleMeshView,
175 ray: &Ray3,
176 tolerance: Tolerance,
177 candidates: impl IntoIterator<Item = usize>,
178) -> Result<Option<RayHit3>, RayMeshError> {
179 validate_ray(ray)?;
180 validate_tolerance(tolerance)?;
181
182 let mut best: Option<RayHit3> = None;
183 for triangle in candidates {
184 let Some(hit) = triangle_hit(mesh, ray, tolerance, triangle)? else {
185 continue;
186 };
187 if best.is_none_or(|current| is_closer(&hit, ¤t)) {
188 best = Some(hit);
189 }
190 }
191 Ok(best)
192}
193
194/// Intersect one triangle of `mesh`, reporting the hit or a certified miss.
195pub fn triangle_hit(
196 mesh: &impl TriangleMeshView,
197 ray: &Ray3,
198 tolerance: Tolerance,
199 triangle: usize,
200) -> Result<Option<RayHit3>, RayMeshError> {
201 validate_ray(ray)?;
202 validate_tolerance(tolerance)?;
203 let corners = corners(mesh, triangle)?;
204 intersect_triangle(ray, corners, tolerance, triangle)
205}
206
207/// Intersect a ray with a standalone triangle.
208///
209/// `triangle_index` only labels diagnostics; it is not used for geometry.
210pub fn intersect_triangle(
211 ray: &Ray3,
212 corners: [Point3; 3],
213 tolerance: Tolerance,
214 triangle_index: usize,
215) -> Result<Option<RayHit3>, RayMeshError> {
216 validate_ray(ray)?;
217 validate_tolerance(tolerance)?;
218 if !corners.iter().all(|corner| corner.is_finite()) {
219 return Err(RayMeshError::NonFiniteInput);
220 }
221
222 let [a, b, c] = corners;
223 let edge1 = b - a;
224 let edge2 = c - a;
225 let normal = edge1.cross(edge2);
226 // Exact zero area is a representation fact, not a tolerance question: a
227 // degenerate triangle has no plane to intersect at any tolerance.
228 if normal.length_squared() == 0.0 {
229 return Err(RayMeshError::DegenerateTriangle {
230 triangle: triangle_index,
231 });
232 }
233
234 // Möller-Trumbore, double-sided. The determinant is compared against the
235 // caller's linear tolerance scaled by the operand magnitudes, so a
236 // parallel-in-plane ray is rejected in the model's units instead of against
237 // a hidden epsilon.
238 let pvec = ray.direction.cross(edge2);
239 let determinant = edge1.dot(pvec);
240 let parallel_bound = tolerance.linear() * edge1.length() * pvec.length();
241 if determinant.abs() <= parallel_bound {
242 return Ok(None);
243 }
244
245 let inverse = 1.0 / determinant;
246 let tvec = ray.origin - a;
247 let u = tvec.dot(pvec) * inverse;
248 let qvec = tvec.cross(edge1);
249 let v = ray.direction.dot(qvec) * inverse;
250 let w = 1.0 - u - v;
251
252 // Edge and vertex hits are kept: a ray grazing a shared edge must hit the
253 // surface, not fall through it. The barycentric slack is the caller's
254 // tolerance, not an invented constant.
255 let slack = tolerance.linear();
256 if u < -slack || v < -slack || w < -slack {
257 return Ok(None);
258 }
259
260 let t = edge2.dot(qvec) * inverse;
261 if t < 0.0 {
262 return Ok(None);
263 }
264 if !t.is_finite() || !u.is_finite() || !v.is_finite() {
265 return Err(RayMeshError::NonFiniteInput);
266 }
267
268 Ok(Some(RayHit3 {
269 t,
270 triangle: triangle_index,
271 barycentric: [w, u, v],
272 side: side_of(ray.origin, corners),
273 point: ray.origin + ray.direction * t,
274 }))
275}
276
277/// Certified side classification of the ray origin against a triangle plane.
278///
279/// `orient3d(a, b, c, d)` is positive when `d` lies opposite the side the
280/// winding normal points to, so a positive origin sign means the ray reaches
281/// the triangle from behind and strikes its back face.
282fn side_of(origin: Point3, corners: [Point3; 3]) -> FaceSide {
283 let [a, b, c] = corners;
284 match orient3d(a, b, c, origin).sign() {
285 Some(Sign::Positive) => FaceSide::Back,
286 Some(Sign::Negative) => FaceSide::Front,
287 Some(Sign::Zero) => FaceSide::Coplanar,
288 // Non-finite coordinates are rejected before this point, and `Sign` is
289 // `#[non_exhaustive]`, so anything unrecognised must not be guessed at.
290 _ => FaceSide::Coplanar,
291 }
292}
293
294fn is_closer(candidate: &RayHit3, current: &RayHit3) -> bool {
295 match candidate.t.partial_cmp(¤t.t) {
296 Some(core::cmp::Ordering::Less) => true,
297 Some(core::cmp::Ordering::Equal) => candidate.triangle < current.triangle,
298 _ => false,
299 }
300}
301
302fn corners(mesh: &impl TriangleMeshView, triangle: usize) -> Result<[Point3; 3], RayMeshError> {
303 let triangle_count = mesh.triangle_count();
304 if triangle >= triangle_count {
305 return Err(RayMeshError::TriangleIndexOutOfRange {
306 triangle,
307 triangle_count,
308 });
309 }
310 let indices = mesh.triangle(triangle);
311 let mut corners = [Point3::ZERO; 3];
312 for (slot, index) in corners.iter_mut().zip(indices) {
313 let index = usize::try_from(index)
314 .map_err(|_| RayMeshError::PositionIndexOutOfRange { triangle })?;
315 if index >= mesh.position_count() {
316 return Err(RayMeshError::PositionIndexOutOfRange { triangle });
317 }
318 *slot = mesh.position(index);
319 }
320 if !corners.iter().all(|corner| corner.is_finite()) {
321 return Err(RayMeshError::NonFiniteInput);
322 }
323 Ok(corners)
324}
325
326fn validate_ray(ray: &Ray3) -> Result<(), RayMeshError> {
327 if !ray.origin.is_finite() || !ray.direction.is_finite() {
328 return Err(RayMeshError::NonFiniteInput);
329 }
330 if ray.direction.length_squared() == 0.0 {
331 return Err(RayMeshError::ZeroDirection);
332 }
333 Ok(())
334}
335
336fn validate_tolerance(tolerance: Tolerance) -> Result<(), RayMeshError> {
337 let linear = tolerance.linear();
338 if !linear.is_finite() || linear < 0.0 {
339 return Err(RayMeshError::InvalidTolerance);
340 }
341 Ok(())
342}