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, &current)) {
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(&current.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}