Skip to main content

symtropy_physics/
raycast.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
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().map_or(true, |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| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal));
127    hits
128}
129
130/// Analytical ray-sphere intersection.
131///
132/// Returns the distance `t` to the nearest intersection point, or `None` if the ray misses.
133/// Uses the standard quadratic formula: solve `|origin + t*dir - center|² = radius²`.
134fn ray_sphere_intersection<const D: usize>(
135    origin: &SVector<f64, D>,
136    dir: &SVector<f64, D>,    // must be unit length
137    center: &SVector<f64, D>,
138    radius: f64,
139) -> Option<f64> {
140    let oc = origin - center;
141    let a = dir.dot(dir);           // Should be ~1.0 for unit dir
142    let b = 2.0 * oc.dot(dir);
143    let c = oc.dot(&oc) - radius * radius;
144
145    let discriminant = b * b - 4.0 * a * c;
146    if discriminant < 0.0 {
147        return None;
148    }
149
150    let sqrt_disc = discriminant.sqrt();
151    let t1 = (-b - sqrt_disc) / (2.0 * a);
152    let t2 = (-b + sqrt_disc) / (2.0 * a);
153
154    // Return the nearest positive intersection
155    if t1 > 0.0 {
156        Some(t1)
157    } else if t2 > 0.0 {
158        Some(t2)
159    } else {
160        None // Ray starts inside or behind the sphere
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use symtropy_math::{Point, Sphere};
168
169    #[test]
170    fn ray_hits_sphere() {
171        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
172        let h = world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
173
174        let origin = SVector::from([0.0, 0.0, 0.0]);
175        let dir = SVector::from([1.0, 0.0, 0.0]);
176
177        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
178        assert_eq!(hit.body, h);
179        // Hit distance should be ~9.0 (sphere center at 10, radius 1)
180        assert!(
181            (hit.distance - 9.0).abs() < 0.1,
182            "hit distance = {}, expected ~9.0",
183            hit.distance
184        );
185    }
186
187    #[test]
188    fn ray_misses_sphere() {
189        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
190        world.add_sphere(Point::new([10.0, 5.0, 0.0]), 1.0, 1.0);
191
192        let origin = SVector::from([0.0, 0.0, 0.0]);
193        let dir = SVector::from([1.0, 0.0, 0.0]); // Shoots along X, sphere is at Y=5
194
195        let hit = raycast(&world, &origin, &dir, 100.0);
196        assert!(hit.is_none(), "ray should miss sphere at Y=5");
197    }
198
199    #[test]
200    fn ray_max_distance() {
201        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
202        world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
203
204        let origin = SVector::from([0.0, 0.0, 0.0]);
205        let dir = SVector::from([1.0, 0.0, 0.0]);
206
207        // Max distance 5 — sphere is at 10, should miss
208        let hit = raycast(&world, &origin, &dir, 5.0);
209        assert!(hit.is_none(), "ray should not reach sphere at distance 10");
210    }
211
212    #[test]
213    fn ray_closest_hit() {
214        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
215        let h1 = world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
216        let h2 = world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
217
218        let origin = SVector::from([0.0, 0.0, 0.0]);
219        let dir = SVector::from([1.0, 0.0, 0.0]);
220
221        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
222        assert_eq!(hit.body, h1, "should hit the closer sphere");
223    }
224
225    #[test]
226    fn raycast_all_returns_sorted() {
227        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
228        world.add_sphere(Point::new([10.0, 0.0, 0.0]), 1.0, 1.0);
229        world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
230
231        let origin = SVector::from([0.0, 0.0, 0.0]);
232        let dir = SVector::from([1.0, 0.0, 0.0]);
233
234        let hits = raycast_all(&world, &origin, &dir, 100.0);
235        assert_eq!(hits.len(), 2);
236        assert!(hits[0].distance < hits[1].distance, "hits should be sorted by distance");
237    }
238
239    #[test]
240    fn ray_skips_sensors() {
241        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
242        let h = world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
243        world.body_mut(h).unwrap().is_sensor = true;
244
245        let origin = SVector::from([0.0, 0.0, 0.0]);
246        let dir = SVector::from([1.0, 0.0, 0.0]);
247
248        let hit = raycast(&world, &origin, &dir, 100.0);
249        assert!(hit.is_none(), "ray should skip sensors");
250    }
251
252    #[test]
253    fn ray_hit_normal_points_outward() {
254        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
255        world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
256
257        let origin = SVector::from([0.0, 0.0, 0.0]);
258        let dir = SVector::from([1.0, 0.0, 0.0]);
259
260        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
261        // Normal should point back toward the ray origin (negative X)
262        assert!(hit.normal[0] < 0.0, "normal should face the ray origin");
263    }
264
265    #[test]
266    fn ray_4d() {
267        let mut world = PhysicsWorld::<4>::new(SVector::zeros());
268        world.add_sphere(Point::new([0.0, 0.0, 0.0, 5.0]), 1.0, 1.0);
269
270        let origin = SVector::from([0.0, 0.0, 0.0, 0.0]);
271        let dir = SVector::from([0.0, 0.0, 0.0, 1.0]); // Cast along W axis
272
273        let hit = raycast(&world, &origin, &dir, 100.0).unwrap();
274        assert!(
275            (hit.distance - 4.0).abs() < 0.1,
276            "4D ray hit distance = {}, expected ~4.0",
277            hit.distance
278        );
279    }
280
281    #[test]
282    fn ray_sphere_analytical_behind() {
283        // Ray starts inside sphere — should not report hit (t < 0 for entry)
284        let origin = SVector::from([0.0, 0.0, 0.0]);
285        let dir = SVector::from([1.0, 0.0, 0.0]);
286        let center = SVector::from([0.0, 0.0, 0.0]);
287        let radius = 5.0;
288
289        // Origin is at center — t1 < 0, t2 > 0
290        let t = ray_sphere_intersection(&origin, &dir, &center, radius);
291        // Should return t2 (exit point, forward along ray)
292        assert!(t.is_some());
293        assert!(t.unwrap() > 0.0);
294    }
295}