Skip to main content

symtropy_physics/
raycast.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4//! Ray casting: find the first intersection of a ray with physics bodies.
5//!
6//! Supports two modes:
7//! - **Analytical**: Exact ray-sphere intersection (O(1) per body)
8//! - **GJK-based**: Ray vs any `Shape<D>` via Minkowski cast (future)
9//!
10//! # Usage
11//! ```ignore
12//! let hit = raycast(&world, &origin, &direction, 100.0);
13//! if let Some(hit) = hit {
14//!     println!("hit body {:?} at distance {}", hit.body, hit.distance);
15//! }
16//! ```
17
18use nalgebra::SVector;
19
20use crate::body::BodyHandle;
21use crate::world::PhysicsWorld;
22
23/// Result of a ray cast against the physics world.
24#[derive(Clone, Debug)]
25pub struct RayHit<const D: usize> {
26    /// The body that was hit.
27    pub body: BodyHandle,
28    /// Distance from the ray origin to the hit point.
29    pub distance: f64,
30    /// Hit point in world space.
31    pub point: SVector<f64, D>,
32    /// Surface normal at the hit point (pointing toward the ray origin).
33    pub normal: SVector<f64, D>,
34}
35
36/// Cast a ray through the physics world and return the closest hit.
37///
38/// `origin`: ray start point
39/// `direction`: ray direction (will be normalized internally)
40/// `max_distance`: maximum ray length (for performance and game design)
41///
42/// Returns `None` if no body is hit within `max_distance`.
43/// Skips sleeping bodies and sensors by default.
44pub fn raycast<const D: usize>(
45    world: &PhysicsWorld<D>,
46    origin: &SVector<f64, D>,
47    direction: &SVector<f64, D>,
48    max_distance: f64,
49) -> Option<RayHit<D>> {
50    let dir_norm = direction.norm();
51    if dir_norm < 1e-15 {
52        return None;
53    }
54    let dir = direction / dir_norm;
55
56    let mut closest: Option<RayHit<D>> = None;
57
58    for body in &world.bodies {
59        if body.is_sensor {
60            continue;
61        }
62
63        // Quick bounding sphere test first
64        let (bsphere_center, bsphere_radius) = body.collider.bounding_sphere();
65        let world_center = body.transform.transform_point(&bsphere_center).0;
66
67        if let Some(t) = ray_sphere_intersection(origin, &dir, &world_center, bsphere_radius) {
68            if t > 0.0 && t <= max_distance {
69                // For spheres, the bounding sphere IS the shape — compute exact hit
70                let hit_point = origin + dir * t;
71                let normal = (hit_point - world_center).normalize();
72
73                let is_closer = closest.as_ref().is_none_or(|c| t < c.distance);
74                if is_closer {
75                    closest = Some(RayHit {
76                        body: body.handle,
77                        distance: t,
78                        point: hit_point,
79                        normal,
80                    });
81                }
82            }
83        }
84    }
85
86    closest
87}
88
89/// Cast a ray and return ALL hits (not just the closest), sorted by distance.
90pub fn raycast_all<const D: usize>(
91    world: &PhysicsWorld<D>,
92    origin: &SVector<f64, D>,
93    direction: &SVector<f64, D>,
94    max_distance: f64,
95) -> Vec<RayHit<D>> {
96    let dir_norm = direction.norm();
97    if dir_norm < 1e-15 {
98        return Vec::new();
99    }
100    let dir = direction / dir_norm;
101
102    let mut hits = Vec::new();
103
104    for body in &world.bodies {
105        if body.is_sensor {
106            continue;
107        }
108
109        let (bsphere_center, bsphere_radius) = body.collider.bounding_sphere();
110        let world_center = body.transform.transform_point(&bsphere_center).0;
111
112        if let Some(t) = ray_sphere_intersection(origin, &dir, &world_center, bsphere_radius) {
113            if t > 0.0 && t <= max_distance {
114                let hit_point = origin + dir * t;
115                let normal = (hit_point - world_center).normalize();
116                hits.push(RayHit {
117                    body: body.handle,
118                    distance: t,
119                    point: hit_point,
120                    normal,
121                });
122            }
123        }
124    }
125
126    hits.sort_by(|a, b| {
127        a.distance
128            .partial_cmp(&b.distance)
129            .unwrap_or(std::cmp::Ordering::Equal)
130    });
131    hits
132}
133
134/// Analytical ray-sphere intersection.
135///
136/// Returns the distance `t` to the nearest intersection point, or `None` if the ray misses.
137/// Uses the standard quadratic formula: solve `|origin + t*dir - center|² = radius²`.
138fn ray_sphere_intersection<const D: usize>(
139    origin: &SVector<f64, D>,
140    dir: &SVector<f64, D>, // must be unit length
141    center: &SVector<f64, D>,
142    radius: f64,
143) -> Option<f64> {
144    let oc = origin - center;
145    let a = dir.dot(dir); // Should be ~1.0 for unit dir
146    let b = 2.0 * oc.dot(dir);
147    let c = oc.dot(&oc) - radius * radius;
148
149    let discriminant = b * b - 4.0 * a * c;
150    if discriminant < 0.0 {
151        return None;
152    }
153
154    let sqrt_disc = discriminant.sqrt();
155    let t1 = (-b - sqrt_disc) / (2.0 * a);
156    let t2 = (-b + sqrt_disc) / (2.0 * a);
157
158    // Return the nearest positive intersection
159    if t1 > 0.0 {
160        Some(t1)
161    } else if t2 > 0.0 {
162        Some(t2)
163    } else {
164        None // Ray starts inside or behind the sphere
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use symtropy_math::{Point, Sphere};
172
173    #[test]
174    fn ray_hits_sphere() {
175        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
176        let h = world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
177
178        let origin = SVector::from([0.0, 0.0, 0.0]);
179        let dir = SVector::from([1.0, 0.0, 0.0]);
180
181        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
182        assert_eq!(hit.body, h);
183        // Hit distance should be ~9.0 (sphere center at 10, radius 1)
184        assert!(
185            (hit.distance - 9.0).abs() < 0.1,
186            "hit distance = {}, expected ~9.0",
187            hit.distance
188        );
189    }
190
191    #[test]
192    fn ray_misses_sphere() {
193        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
194        world.add_sphere(Point::new([10.0, 5.0, 0.0]), 1.0, 1.0);
195
196        let origin = SVector::from([0.0, 0.0, 0.0]);
197        let dir = SVector::from([1.0, 0.0, 0.0]); // Shoots along X, sphere is at Y=5
198
199        let hit = raycast(&world, &origin, &dir, 100.0);
200        assert!(hit.is_none(), "ray should miss sphere at Y=5");
201    }
202
203    #[test]
204    fn ray_max_distance() {
205        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
206        world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
207
208        let origin = SVector::from([0.0, 0.0, 0.0]);
209        let dir = SVector::from([1.0, 0.0, 0.0]);
210
211        // Max distance 5 — sphere is at 10, should miss
212        let hit = raycast(&world, &origin, &dir, 5.0);
213        assert!(hit.is_none(), "ray should not reach sphere at distance 10");
214    }
215
216    #[test]
217    fn ray_closest_hit() {
218        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
219        let h1 = world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
220        let h2 = world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
221
222        let origin = SVector::from([0.0, 0.0, 0.0]);
223        let dir = SVector::from([1.0, 0.0, 0.0]);
224
225        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
226        assert_eq!(hit.body, h1, "should hit the closer sphere");
227    }
228
229    #[test]
230    fn raycast_all_returns_sorted() {
231        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
232        world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
233        world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
234
235        let origin = SVector::from([0.0, 0.0, 0.0]);
236        let dir = SVector::from([1.0, 0.0, 0.0]);
237
238        let hits = raycast_all(&world, &origin, &dir, 100.0);
239        assert_eq!(hits.len(), 2);
240        assert!(
241            hits[0].distance < hits[1].distance,
242            "hits should be sorted by distance"
243        );
244    }
245
246    #[test]
247    fn ray_skips_sensors() {
248        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
249        let h = world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
250        world.body_mut(h).unwrap().is_sensor = true;
251
252        let origin = SVector::from([0.0, 0.0, 0.0]);
253        let dir = SVector::from([1.0, 0.0, 0.0]);
254
255        let hit = raycast(&world, &origin, &dir, 100.0);
256        assert!(hit.is_none(), "ray should skip sensors");
257    }
258
259    #[test]
260    fn ray_hit_normal_points_outward() {
261        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
262        world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
263
264        let origin = SVector::from([0.0, 0.0, 0.0]);
265        let dir = SVector::from([1.0, 0.0, 0.0]);
266
267        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
268        // Normal should point back toward the ray origin (negative X)
269        assert!(hit.normal[0] < 0.0, "normal should face the ray origin");
270    }
271
272    #[test]
273    fn ray_4d() {
274        let mut world = PhysicsWorld::<4>::new(SVector::zeros());
275        world.add_sphere(Point::new([0.0, 0.0, 0.0, 5.0]), 1.0, 1.0);
276
277        let origin = SVector::from([0.0, 0.0, 0.0, 0.0]);
278        let dir = SVector::from([0.0, 0.0, 0.0, 1.0]); // Cast along W axis
279
280        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
281        assert!(
282            (hit.distance - 4.0).abs() < 0.1,
283            "4D ray hit distance = {}, expected ~4.0",
284            hit.distance
285        );
286    }
287
288    #[test]
289    fn ray_sphere_analytical_behind() {
290        // Ray starts inside sphere — should not report hit (t < 0 for entry)
291        let origin = SVector::from([0.0, 0.0, 0.0]);
292        let dir = SVector::from([1.0, 0.0, 0.0]);
293        let center = SVector::from([0.0, 0.0, 0.0]);
294        let radius = 5.0;
295
296        // Origin is at center — t1 < 0, t2 > 0
297        let t = ray_sphere_intersection(&origin, &dir, &center, radius);
298        // Should return t2 (exit point, forward along ray)
299        assert!(t.is_some());
300        assert!(t.unwrap() > 0.0);
301    }
302}