Skip to main content

brepkit_math/
ray_triangle.rs

1//! Watertight ray-triangle intersection (Woop, Benthin, Wald 2013).
2//!
3//! This module implements the watertight ray-triangle intersection algorithm
4//! that guarantees no cracks or double-hits on shared edges between adjacent
5//! triangles. The key idea is to transform coordinates so the ray is
6//! axis-aligned along +Z, then evaluate edge functions using identical
7//! floating-point operations on shared vertices.
8
9use crate::vec::{Point3, Vec3};
10
11/// Result of a watertight ray-triangle intersection test.
12#[derive(Debug, Clone, Copy)]
13pub struct RayTriangleHit {
14    /// Distance along the ray to the hit point (t parameter).
15    pub t: f64,
16    /// Barycentric coordinate u.
17    pub u: f64,
18    /// Barycentric coordinate v.
19    pub v: f64,
20}
21
22/// Deterministic tie-breaking for zero-valued edge functions.
23///
24/// When a 2D edge function `p x q` evaluates to exactly zero, this function
25/// returns a small positive or negative value based on the edge direction.
26/// The sign is chosen deterministically so that for the complementary edge
27/// (`q x p`), the opposite sign is returned. This ensures shared edges are
28/// assigned to exactly one triangle.
29fn edge_tiebreak(px: f64, py: f64, qx: f64, qy: f64) -> f64 {
30    // Use the signs of the constituent products as a tiebreaker.
31    // For edge function `px*qy - py*qx`, when result is zero, look at
32    // the individual terms to pick a deterministic sign.
33    let a = px * qy;
34    let b = py * qx;
35    if a > b {
36        f64::MIN_POSITIVE
37    } else if a < b {
38        -f64::MIN_POSITIVE
39    } else {
40        // Both products are equal (and possibly zero). Use coordinate signs.
41        // This order is deterministic: we break ties by comparing coordinates.
42        if px > qx {
43            f64::MIN_POSITIVE
44        } else if px < qx {
45            -f64::MIN_POSITIVE
46        } else if py > qy {
47            f64::MIN_POSITIVE
48        } else {
49            -f64::MIN_POSITIVE
50        }
51    }
52}
53
54/// Watertight ray-triangle intersection (Woop, Benthin, Wald 2013).
55///
56/// Tests whether a ray from `origin` in direction `dir` hits the triangle
57/// `(v0, v1, v2)`. Returns `Some(hit)` with the parametric distance and
58/// barycentric coordinates, or `None` if no intersection.
59///
60/// This algorithm guarantees watertight results on shared edges: a ray
61/// hitting a shared edge between two triangles will report exactly one
62/// intersection, with no cracks or double-hits. This is achieved by using
63/// identical floating-point operations on shared vertices.
64///
65/// The barycentric coordinates satisfy `hit_point = (1-u-v)*v0 + u*v1 + v*v2`.
66#[must_use]
67#[allow(clippy::many_single_char_names)]
68pub fn watertight_ray_triangle_intersect(
69    origin: Point3,
70    dir: Vec3,
71    v0: Point3,
72    v1: Point3,
73    v2: Point3,
74) -> Option<RayTriangleHit> {
75    let d = dir.0;
76
77    // Step 1: Find the largest absolute component of dir (kz), then set kx, ky.
78    let abs_x = d[0].abs();
79    let abs_y = d[1].abs();
80    let abs_z = d[2].abs();
81
82    let kz = if abs_x > abs_y && abs_x > abs_z {
83        0
84    } else if abs_y > abs_z {
85        1
86    } else {
87        2
88    };
89    let mut kx = (kz + 1) % 3;
90    let mut ky = (kx + 1) % 3;
91
92    // Swap kx and ky if dir[kz] is negative to preserve winding order.
93    if d[kz] < 0.0 {
94        std::mem::swap(&mut kx, &mut ky);
95    }
96
97    // Step 2: Shear constants.
98    let sz = 1.0 / d[kz];
99    let sx = d[kx] * sz;
100    let sy = d[ky] * sz;
101
102    // Step 3: Translate vertices relative to ray origin.
103    let a = (v0 - origin).0;
104    let b = (v1 - origin).0;
105    let c = (v2 - origin).0;
106
107    // Step 4: Shear and permute.
108    let ax = a[kx] - sx * a[kz];
109    let ay = a[ky] - sy * a[kz];
110    let bx = b[kx] - sx * b[kz];
111    let by = b[ky] - sy * b[kz];
112    let cx = c[kx] - sx * c[kz];
113    let cy = c[ky] - sy * c[kz];
114
115    // Step 5: Edge function values (2D cross products).
116    let mut u = cx.mul_add(by, -(cy * bx));
117    let mut v = ax.mul_add(cy, -(ay * cx));
118    let mut w = bx.mul_add(ay, -(by * ax));
119
120    // Step 6: Deterministic tie-breaking for zero edge functions.
121    // When an edge function is exactly zero, assign a deterministic sign
122    // based on the edge direction to guarantee that shared edges/vertices
123    // are claimed by exactly one triangle.
124    if u == 0.0 {
125        u = edge_tiebreak(cx, cy, bx, by);
126    }
127    if v == 0.0 {
128        v = edge_tiebreak(ax, ay, cx, cy);
129    }
130    if w == 0.0 {
131        w = edge_tiebreak(bx, by, ax, ay);
132    }
133
134    // Step 7: Sign check — U, V, W must all share the same sign.
135    if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) {
136        return None;
137    }
138
139    // Step 8: Determinant.
140    let det = u + v + w;
141    if det == 0.0 {
142        return None;
143    }
144
145    // Step 9: Compute scaled t.
146    let az = sz * a[kz];
147    let bz = sz * b[kz];
148    let cz = sz * c[kz];
149    let t_scaled = u.mul_add(az, v.mul_add(bz, w * cz));
150
151    // Step 10: Check that t is positive (ray goes forward).
152    // If det > 0, t_scaled must be > 0; if det < 0, t_scaled must be < 0.
153    if (det > 0.0 && t_scaled <= 0.0) || (det < 0.0 && t_scaled >= 0.0) {
154        return None;
155    }
156
157    // Step 11: Final values.
158    let inv_det = 1.0 / det;
159    Some(RayTriangleHit {
160        t: t_scaled * inv_det,
161        u: v * inv_det,
162        v: w * inv_det,
163    })
164}
165
166#[cfg(test)]
167#[allow(
168    clippy::unwrap_used,
169    clippy::expect_used,
170    clippy::cast_lossless,
171    clippy::suboptimal_flops
172)]
173mod tests {
174    use super::*;
175
176    const EPS: f64 = 1e-12;
177
178    fn tri() -> (Point3, Point3, Point3) {
179        (
180            Point3::new(-1.0, -1.0, 0.0),
181            Point3::new(1.0, -1.0, 0.0),
182            Point3::new(0.0, 1.0, 0.0),
183        )
184    }
185
186    #[test]
187    fn ray_hits_triangle() {
188        let (v0, v1, v2) = tri();
189        let origin = Point3::new(0.0, 0.0, -1.0);
190        let dir = Vec3::new(0.0, 0.0, 1.0);
191
192        let hit = watertight_ray_triangle_intersect(origin, dir, v0, v1, v2).expect("should hit");
193        assert!((hit.t - 1.0).abs() < EPS);
194    }
195
196    #[test]
197    fn ray_misses_triangle() {
198        let (v0, v1, v2) = tri();
199        let origin = Point3::new(10.0, 10.0, -1.0);
200        let dir = Vec3::new(0.0, 0.0, 1.0);
201
202        assert!(watertight_ray_triangle_intersect(origin, dir, v0, v1, v2).is_none());
203    }
204
205    #[test]
206    fn ray_parallel_to_triangle() {
207        let (v0, v1, v2) = tri();
208        let origin = Point3::new(0.0, 0.0, 0.0);
209        let dir = Vec3::new(1.0, 0.0, 0.0);
210
211        assert!(watertight_ray_triangle_intersect(origin, dir, v0, v1, v2).is_none());
212    }
213
214    #[test]
215    fn shared_edge_exactly_one_hit() {
216        // Two triangles sharing edge from (0,0,0) to (1,0,0).
217        let shared_a = Point3::new(0.0, 0.0, 0.0);
218        let shared_b = Point3::new(1.0, 0.0, 0.0);
219        let tri1_c = Point3::new(0.5, 1.0, 0.0);
220        let tri2_c = Point3::new(0.5, -1.0, 0.0);
221
222        // Ray aimed at the midpoint of the shared edge.
223        let origin = Point3::new(0.5, 0.0, -1.0);
224        let dir = Vec3::new(0.0, 0.0, 1.0);
225
226        let hit1 = watertight_ray_triangle_intersect(origin, dir, shared_a, shared_b, tri1_c);
227        let hit2 = watertight_ray_triangle_intersect(origin, dir, shared_a, shared_b, tri2_c);
228
229        let count = hit1.is_some() as u32 + hit2.is_some() as u32;
230        assert_eq!(count, 1, "shared edge must report exactly one hit");
231    }
232
233    #[test]
234    fn ray_hits_vertex() {
235        // Four triangles forming a complete fan around the origin vertex,
236        // covering all quadrants so the fan is closed.
237        let shared = Point3::new(0.0, 0.0, 0.0);
238        let tris = [
239            (
240                shared,
241                Point3::new(1.0, 0.0, 0.0),
242                Point3::new(0.0, 1.0, 0.0),
243            ),
244            (
245                shared,
246                Point3::new(0.0, 1.0, 0.0),
247                Point3::new(-1.0, 0.0, 0.0),
248            ),
249            (
250                shared,
251                Point3::new(-1.0, 0.0, 0.0),
252                Point3::new(0.0, -1.0, 0.0),
253            ),
254            (
255                shared,
256                Point3::new(0.0, -1.0, 0.0),
257                Point3::new(1.0, 0.0, 0.0),
258            ),
259        ];
260
261        let origin = Point3::new(0.0, 0.0, -1.0);
262        let dir = Vec3::new(0.0, 0.0, 1.0);
263
264        let count: u32 = tris
265            .iter()
266            .map(|&(a, b, c)| {
267                watertight_ray_triangle_intersect(origin, dir, a, b, c).is_some() as u32
268            })
269            .sum();
270
271        assert_eq!(
272            count, 1,
273            "vertex shared by 4 triangles must report exactly one hit"
274        );
275    }
276
277    #[test]
278    fn backface_not_hit() {
279        let (v0, v1, v2) = tri();
280        // Ray going away from the triangle.
281        let origin = Point3::new(0.0, 0.0, -1.0);
282        let dir = Vec3::new(0.0, 0.0, -1.0);
283
284        assert!(watertight_ray_triangle_intersect(origin, dir, v0, v1, v2).is_none());
285    }
286
287    #[test]
288    fn barycentric_coordinates_valid() {
289        let (v0, v1, v2) = tri();
290        let origin = Point3::new(0.0, 0.0, -1.0);
291        let dir = Vec3::new(0.0, 0.0, 1.0);
292
293        let hit = watertight_ray_triangle_intersect(origin, dir, v0, v1, v2).expect("should hit");
294
295        assert!(hit.u >= -EPS, "u = {} should be >= 0", hit.u);
296        assert!(hit.v >= -EPS, "v = {} should be >= 0", hit.v);
297        assert!(
298            hit.u + hit.v <= 1.0 + EPS,
299            "u + v = {} should be <= 1",
300            hit.u + hit.v,
301        );
302
303        // Verify that barycentric coords reconstruct the hit point.
304        let w = 1.0 - hit.u - hit.v;
305        let px = w * v0.x() + hit.u * v1.x() + hit.v * v2.x();
306        let py = w * v0.y() + hit.u * v1.y() + hit.v * v2.y();
307        let pz = w * v0.z() + hit.u * v1.z() + hit.v * v2.z();
308
309        let expected = origin.0[2] + hit.t * dir.0[2];
310        assert!(
311            (pz - expected).abs() < EPS,
312            "pz = {pz}, expected = {expected}"
313        );
314        // The hit point should be at origin + t*dir.
315        assert!((px - origin.x()).abs() < EPS);
316        assert!((py - origin.y()).abs() < EPS);
317    }
318}