Skip to main content

phyz_collision/
raycast.rs

1//! Analytic ray/shape intersection.
2//!
3//! GJK answers "how far apart are these two convex bodies"; a rangefinder needs
4//! "how far along this ray do I first hit something". Marching a ray with GJK is
5//! both slow and inexact, so each primitive gets a closed-form test here.
6//!
7//! Every shape is defined in its own local frame (cylinders and capsules run
8//! along local +Z, boxes are axis-aligned about their centre) and placed by a
9//! world position `pos` plus a rotation `rot` that maps **local → world**.
10
11use crate::geometry::Geometry;
12use phyz_math::{Mat3, Vec3};
13
14/// A ray with a normalized direction.
15#[derive(Debug, Clone, Copy)]
16pub struct Ray {
17    /// Ray origin in world coordinates.
18    pub origin: Vec3,
19    /// Unit direction in world coordinates.
20    pub dir: Vec3,
21}
22
23impl Ray {
24    /// Create a ray, normalizing `dir`.
25    ///
26    /// Returns `None` if `dir` is (numerically) the zero vector, since a ray
27    /// with no direction has no meaningful intersection.
28    pub fn new(origin: Vec3, dir: Vec3) -> Option<Self> {
29        let n = dir.norm();
30        if !n.is_finite() || n < 1e-12 {
31            return None;
32        }
33        Some(Self {
34            origin,
35            dir: dir / n,
36        })
37    }
38
39    /// Point at parameter `t` along the ray.
40    pub fn at(&self, t: f64) -> Vec3 {
41        self.origin + self.dir * t
42    }
43}
44
45/// A ray/shape intersection.
46#[derive(Debug, Clone, Copy)]
47pub struct RayHit {
48    /// Distance along the ray to the first intersection.
49    pub distance: f64,
50    /// Intersection point in world coordinates.
51    pub point: Vec3,
52    /// Outward surface normal at the intersection, in world coordinates.
53    pub normal: Vec3,
54}
55
56/// Rays starting exactly on a surface must not immediately re-hit it.
57const T_EPS: f64 = 1e-9;
58
59/// Intersect a ray with a geometry placed at `pos` with orientation `rot`.
60///
61/// `rot` maps local shape coordinates into world coordinates. Returns the
62/// nearest intersection with `distance >= 0`, or `None` if the ray misses.
63/// A ray originating inside a solid reports the exit point.
64pub fn ray_intersect(geom: &Geometry, pos: &Vec3, rot: &Mat3, ray: &Ray) -> Option<RayHit> {
65    // Work in the shape's local frame: world → local is rot^T.
66    let inv = rot.transpose();
67    let o = inv.mul_vec(ray.origin - *pos);
68    let d = inv.mul_vec(ray.dir);
69
70    let (t, local_normal) = match geom {
71        Geometry::Sphere { radius } => sphere_hit(&o, &d, *radius)?,
72        Geometry::Box { half_extents } => box_hit(&o, &d, half_extents)?,
73        Geometry::Cylinder { radius, height } => cylinder_hit(&o, &d, *radius, *height * 0.5)?,
74        // A capsule's `length` is the length of the cylindrical section; the
75        // hemispherical caps extend `radius` beyond each end.
76        Geometry::Capsule { radius, length } => capsule_hit(&o, &d, *radius, *length * 0.5)?,
77        Geometry::Plane { normal } => plane_hit(&o, &d, normal)?,
78        Geometry::Mesh { vertices, faces } => mesh_hit(&o, &d, vertices, faces)?,
79    };
80
81    Some(RayHit {
82        distance: t,
83        point: ray.at(t),
84        normal: rot.mul_vec(local_normal),
85    })
86}
87
88/// Cast a ray against many placed geometries, returning the nearest hit within
89/// `max_distance` along with the index of the geometry that was hit.
90pub fn ray_cast<'a, I>(shapes: I, ray: &Ray, max_distance: f64) -> Option<(usize, RayHit)>
91where
92    I: IntoIterator<Item = (&'a Geometry, Vec3, Mat3)>,
93{
94    let mut best: Option<(usize, RayHit)> = None;
95    for (idx, (geom, pos, rot)) in shapes.into_iter().enumerate() {
96        let Some(hit) = ray_intersect(geom, &pos, &rot, ray) else {
97            continue;
98        };
99        if hit.distance > max_distance {
100            continue;
101        }
102        if best.is_none_or(|(_, b)| hit.distance < b.distance) {
103            best = Some((idx, hit));
104        }
105    }
106    best
107}
108
109/// Pick the nearest non-negative root of a quadratic, given both roots.
110fn nearest_root(t0: f64, t1: f64) -> Option<f64> {
111    let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
112    if lo >= T_EPS {
113        Some(lo)
114    } else if hi >= T_EPS {
115        Some(hi)
116    } else {
117        None
118    }
119}
120
121fn sphere_hit(o: &Vec3, d: &Vec3, radius: f64) -> Option<(f64, Vec3)> {
122    // |o + t d|² = r², with |d| = 1
123    let b = o.dot(d);
124    let c = o.norm_sq() - radius * radius;
125    let disc = b * b - c;
126    if disc < 0.0 {
127        return None;
128    }
129    let sq = disc.sqrt();
130    let t = nearest_root(-b - sq, -b + sq)?;
131    let p = *o + *d * t;
132    Some((t, p / radius))
133}
134
135fn box_hit(o: &Vec3, d: &Vec3, half: &Vec3) -> Option<(f64, Vec3)> {
136    // Slab method, tracking which axis produced the entry/exit plane.
137    let mut t_min = f64::NEG_INFINITY;
138    let mut t_max = f64::INFINITY;
139    let mut min_axis = 0usize;
140    let mut max_axis = 0usize;
141    let mut min_sign = 1.0;
142    let mut max_sign = 1.0;
143
144    let oa = [o.x, o.y, o.z];
145    let da = [d.x, d.y, d.z];
146    let ha = [half.x, half.y, half.z];
147
148    for axis in 0..3 {
149        if da[axis].abs() < 1e-12 {
150            // Parallel to this slab: miss unless already inside it.
151            if oa[axis] < -ha[axis] || oa[axis] > ha[axis] {
152                return None;
153            }
154            continue;
155        }
156        let inv = 1.0 / da[axis];
157        let mut t1 = (-ha[axis] - oa[axis]) * inv;
158        let mut t2 = (ha[axis] - oa[axis]) * inv;
159        let mut s1 = -1.0;
160        let mut s2 = 1.0;
161        if t1 > t2 {
162            std::mem::swap(&mut t1, &mut t2);
163            std::mem::swap(&mut s1, &mut s2);
164        }
165        if t1 > t_min {
166            t_min = t1;
167            min_axis = axis;
168            min_sign = s1;
169        }
170        if t2 < t_max {
171            t_max = t2;
172            max_axis = axis;
173            max_sign = s2;
174        }
175        if t_min > t_max {
176            return None;
177        }
178    }
179
180    let (t, axis, sign) = if t_min >= T_EPS {
181        (t_min, min_axis, min_sign)
182    } else if t_max >= T_EPS {
183        (t_max, max_axis, max_sign)
184    } else {
185        return None;
186    };
187
188    let mut n = Vec3::zeros();
189    match axis {
190        0 => n.x = sign,
191        1 => n.y = sign,
192        _ => n.z = sign,
193    }
194    Some((t, n))
195}
196
197/// Candidate hit on the infinite cylinder of radius `r` about local Z,
198/// restricted to |z| <= half_h. Returns (t, normal) for the nearest valid root.
199fn cylinder_side(o: &Vec3, d: &Vec3, r: f64, half_h: f64) -> Option<(f64, Vec3)> {
200    let a = d.x * d.x + d.y * d.y;
201    if a < 1e-12 {
202        return None; // ray parallel to the axis: only the caps can be hit
203    }
204    let b = o.x * d.x + o.y * d.y;
205    let c = o.x * o.x + o.y * o.y - r * r;
206    let disc = b * b - a * c;
207    if disc < 0.0 {
208        return None;
209    }
210    let sq = disc.sqrt();
211    for t in [(-b - sq) / a, (-b + sq) / a] {
212        if t < T_EPS {
213            continue;
214        }
215        let z = o.z + d.z * t;
216        if z.abs() <= half_h {
217            let p = *o + *d * t;
218            return Some((t, Vec3::new(p.x / r, p.y / r, 0.0)));
219        }
220    }
221    None
222}
223
224/// Nearest hit on the two disc caps at z = ±half_h.
225fn cylinder_caps(o: &Vec3, d: &Vec3, r: f64, half_h: f64) -> Option<(f64, Vec3)> {
226    if d.z.abs() < 1e-12 {
227        return None;
228    }
229    let mut best: Option<(f64, Vec3)> = None;
230    for sign in [-1.0, 1.0] {
231        let t = (sign * half_h - o.z) / d.z;
232        if t < T_EPS {
233            continue;
234        }
235        let p = *o + *d * t;
236        if p.x * p.x + p.y * p.y <= r * r && best.is_none_or(|(bt, _)| t < bt) {
237            best = Some((t, Vec3::new(0.0, 0.0, sign)));
238        }
239    }
240    best
241}
242
243fn cylinder_hit(o: &Vec3, d: &Vec3, r: f64, half_h: f64) -> Option<(f64, Vec3)> {
244    match (
245        cylinder_side(o, d, r, half_h),
246        cylinder_caps(o, d, r, half_h),
247    ) {
248        (Some(a), Some(b)) => Some(if a.0 <= b.0 { a } else { b }),
249        (Some(a), None) => Some(a),
250        (None, b) => b,
251    }
252}
253
254fn capsule_hit(o: &Vec3, d: &Vec3, r: f64, half_h: f64) -> Option<(f64, Vec3)> {
255    let mut best: Option<(f64, Vec3)> = cylinder_side(o, d, r, half_h);
256
257    // Hemispherical caps: sphere tests, accepted only on the correct side.
258    for sign in [-1.0, 1.0] {
259        let centre = Vec3::new(0.0, 0.0, sign * half_h);
260        let oc = *o - centre;
261        let b = oc.dot(d);
262        let c = oc.norm_sq() - r * r;
263        let disc = b * b - c;
264        if disc < 0.0 {
265            continue;
266        }
267        let sq = disc.sqrt();
268        for t in [-b - sq, -b + sq] {
269            if t < T_EPS {
270                continue;
271            }
272            let p = *o + *d * t;
273            if (p.z - sign * half_h) * sign < 0.0 {
274                continue; // belongs to the cylindrical section, not this cap
275            }
276            if best.is_none_or(|(bt, _)| t < bt) {
277                best = Some((t, (p - centre) / r));
278            }
279            break;
280        }
281    }
282    best
283}
284
285fn plane_hit(o: &Vec3, d: &Vec3, normal: &Vec3) -> Option<(f64, Vec3)> {
286    let n = normal.normalize();
287    let denom = d.dot(n);
288    if denom.abs() < 1e-12 {
289        return None; // parallel
290    }
291    // Plane passes through the shape origin (already subtracted from `o`).
292    let t = -o.dot(n) / denom;
293    if t < T_EPS {
294        return None;
295    }
296    // Report the normal facing back along the ray.
297    Some((t, if denom < 0.0 { n } else { -n }))
298}
299
300fn mesh_hit(o: &Vec3, d: &Vec3, vertices: &[Vec3], faces: &[[usize; 3]]) -> Option<(f64, Vec3)> {
301    let mut best: Option<(f64, Vec3)> = None;
302    for f in faces {
303        let (Some(&v0), Some(&v1), Some(&v2)) =
304            (vertices.get(f[0]), vertices.get(f[1]), vertices.get(f[2]))
305        else {
306            continue;
307        };
308        if let Some(t) = triangle_hit(o, d, &v0, &v1, &v2)
309            && best.is_none_or(|(bt, _)| t < bt)
310        {
311            let n = (v1 - v0).cross(v2 - v0);
312            let n = n.normalize();
313            best = Some((t, if n.dot(d) < 0.0 { n } else { -n }));
314        }
315    }
316    best
317}
318
319/// Möller–Trumbore, two-sided.
320fn triangle_hit(o: &Vec3, d: &Vec3, v0: &Vec3, v1: &Vec3, v2: &Vec3) -> Option<f64> {
321    const EPS: f64 = 1e-12;
322    let e1 = *v1 - *v0;
323    let e2 = *v2 - *v0;
324    let p = d.cross(e2);
325    let det = e1.dot(p);
326    if det.abs() < EPS {
327        return None;
328    }
329    let inv_det = 1.0 / det;
330    let tvec = *o - *v0;
331    let u = tvec.dot(p) * inv_det;
332    if !(0.0..=1.0).contains(&u) {
333        return None;
334    }
335    let q = tvec.cross(e1);
336    let v = d.dot(q) * inv_det;
337    if v < 0.0 || u + v > 1.0 {
338        return None;
339    }
340    let t = e2.dot(q) * inv_det;
341    if t < T_EPS { None } else { Some(t) }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    const EPS: f64 = 1e-9;
349
350    fn ray(o: [f64; 3], d: [f64; 3]) -> Ray {
351        Ray::new(Vec3::new(o[0], o[1], o[2]), Vec3::new(d[0], d[1], d[2])).unwrap()
352    }
353
354    #[test]
355    fn zero_direction_rejected() {
356        assert!(Ray::new(Vec3::zeros(), Vec3::zeros()).is_none());
357    }
358
359    #[test]
360    fn sphere_known_distance() {
361        // Sphere of radius 1 centred 5 m down +X; ray from the origin along +X
362        // must report exactly 4.0 to the near surface.
363        let g = Geometry::Sphere { radius: 1.0 };
364        let hit = ray_intersect(
365            &g,
366            &Vec3::new(5.0, 0.0, 0.0),
367            &Mat3::identity(),
368            &ray([0.0; 3], [1.0, 0.0, 0.0]),
369        )
370        .expect("ray should hit sphere");
371        assert!((hit.distance - 4.0).abs() < EPS, "got {}", hit.distance);
372        assert!((hit.point.x - 4.0).abs() < EPS);
373        // Outward normal at the near face points back at the ray origin.
374        assert!((hit.normal.x + 1.0).abs() < EPS);
375    }
376
377    #[test]
378    fn sphere_tangent_miss() {
379        let g = Geometry::Sphere { radius: 1.0 };
380        // Offset by 1.5 in Y: clean miss.
381        let hit = ray_intersect(
382            &g,
383            &Vec3::new(5.0, 1.5, 0.0),
384            &Mat3::identity(),
385            &ray([0.0; 3], [1.0, 0.0, 0.0]),
386        );
387        assert!(hit.is_none());
388    }
389
390    #[test]
391    fn sphere_behind_ray_misses() {
392        let g = Geometry::Sphere { radius: 1.0 };
393        let hit = ray_intersect(
394            &g,
395            &Vec3::new(-5.0, 0.0, 0.0),
396            &Mat3::identity(),
397            &ray([0.0; 3], [1.0, 0.0, 0.0]),
398        );
399        assert!(hit.is_none());
400    }
401
402    #[test]
403    fn ray_from_inside_sphere_exits() {
404        let g = Geometry::Sphere { radius: 2.0 };
405        let hit = ray_intersect(
406            &g,
407            &Vec3::zeros(),
408            &Mat3::identity(),
409            &ray([0.0; 3], [1.0, 0.0, 0.0]),
410        )
411        .unwrap();
412        assert!((hit.distance - 2.0).abs() < EPS);
413    }
414
415    #[test]
416    fn box_face_and_corner() {
417        let g = Geometry::Box {
418            half_extents: Vec3::new(1.0, 1.0, 1.0),
419        };
420        let hit = ray_intersect(
421            &g,
422            &Vec3::new(0.0, 0.0, 10.0),
423            &Mat3::identity(),
424            &ray([0.0; 3], [0.0, 0.0, 1.0]),
425        )
426        .unwrap();
427        assert!((hit.distance - 9.0).abs() < EPS, "got {}", hit.distance);
428        assert!((hit.normal.z + 1.0).abs() < EPS);
429
430        // Rotating the box 45° about Z turns the +X face into an edge that is
431        // sqrt(2) from the centre.
432        let rot = Mat3::rotation_z(std::f64::consts::FRAC_PI_4);
433        let hit = ray_intersect(
434            &g,
435            &Vec3::new(10.0, 0.0, 0.0),
436            &rot,
437            &ray([0.0; 3], [1.0, 0.0, 0.0]),
438        )
439        .unwrap();
440        assert!(
441            (hit.distance - (10.0 - 2f64.sqrt())).abs() < 1e-9,
442            "got {}",
443            hit.distance
444        );
445    }
446
447    #[test]
448    fn cylinder_side_and_cap() {
449        let g = Geometry::Cylinder {
450            radius: 0.5,
451            height: 2.0,
452        };
453        // Side hit.
454        let hit = ray_intersect(
455            &g,
456            &Vec3::new(3.0, 0.0, 0.0),
457            &Mat3::identity(),
458            &ray([0.0; 3], [1.0, 0.0, 0.0]),
459        )
460        .unwrap();
461        assert!((hit.distance - 2.5).abs() < EPS, "got {}", hit.distance);
462
463        // Cap hit from directly below (cylinder runs along local Z, half-height 1).
464        let hit = ray_intersect(
465            &g,
466            &Vec3::new(0.0, 0.0, 5.0),
467            &Mat3::identity(),
468            &ray([0.0; 3], [0.0, 0.0, 1.0]),
469        )
470        .unwrap();
471        assert!((hit.distance - 4.0).abs() < EPS, "got {}", hit.distance);
472        assert!((hit.normal.z + 1.0).abs() < EPS);
473
474        // Aimed above the end cap: misses entirely.
475        assert!(
476            ray_intersect(
477                &g,
478                &Vec3::new(3.0, 0.0, 0.0),
479                &Mat3::identity(),
480                &ray([0.0, 0.0, 1.5], [1.0, 0.0, 0.0]),
481            )
482            .is_none()
483        );
484    }
485
486    #[test]
487    fn capsule_cap_is_rounded() {
488        // Cylindrical section 2 long (half 1), radius 0.5: the cap tip sits at
489        // z = 1.5, so a downward ray from z = 5 travels 3.5.
490        let g = Geometry::Capsule {
491            radius: 0.5,
492            length: 2.0,
493        };
494        let hit = ray_intersect(
495            &g,
496            &Vec3::zeros(),
497            &Mat3::identity(),
498            &ray([0.0, 0.0, 5.0], [0.0, 0.0, -1.0]),
499        )
500        .unwrap();
501        assert!((hit.distance - 3.5).abs() < EPS, "got {}", hit.distance);
502    }
503
504    #[test]
505    fn plane_hit_distance() {
506        let g = Geometry::Plane {
507            normal: Vec3::new(0.0, 0.0, 1.0),
508        };
509        let hit = ray_intersect(
510            &g,
511            &Vec3::zeros(),
512            &Mat3::identity(),
513            &ray([0.0, 0.0, 3.0], [0.0, 0.0, -1.0]),
514        )
515        .unwrap();
516        assert!((hit.distance - 3.0).abs() < EPS);
517        assert!((hit.normal.z - 1.0).abs() < EPS);
518    }
519
520    #[test]
521    fn mesh_triangle_hit() {
522        let g = Geometry::Mesh {
523            vertices: vec![
524                Vec3::new(-1.0, -1.0, 0.0),
525                Vec3::new(1.0, -1.0, 0.0),
526                Vec3::new(0.0, 1.0, 0.0),
527            ],
528            faces: vec![[0, 1, 2]],
529        };
530        let hit = ray_intersect(
531            &g,
532            &Vec3::new(0.0, 0.0, 2.0),
533            &Mat3::identity(),
534            &ray([0.0, 0.0, 10.0], [0.0, 0.0, -1.0]),
535        )
536        .unwrap();
537        assert!((hit.distance - 8.0).abs() < EPS, "got {}", hit.distance);
538    }
539
540    #[test]
541    fn ray_cast_picks_nearest_within_range() {
542        let near = Geometry::Sphere { radius: 0.5 };
543        let far = Geometry::Sphere { radius: 0.5 };
544        let shapes = vec![
545            (&far, Vec3::new(8.0, 0.0, 0.0), Mat3::identity()),
546            (&near, Vec3::new(3.0, 0.0, 0.0), Mat3::identity()),
547        ];
548        let r = ray([0.0; 3], [1.0, 0.0, 0.0]);
549
550        let (idx, hit) = ray_cast(shapes.clone(), &r, 100.0).unwrap();
551        assert_eq!(idx, 1);
552        assert!((hit.distance - 2.5).abs() < EPS);
553
554        // Range cut shorter than the near sphere: nothing in range.
555        assert!(ray_cast(shapes.clone(), &r, 1.0).is_none());
556        // Range that only reaches the far sphere's side of the near one still
557        // finds the near sphere.
558        let (idx, _) = ray_cast(shapes, &r, 3.0).unwrap();
559        assert_eq!(idx, 1);
560    }
561}