1use nalgebra::SVector;
19
20use crate::body::BodyHandle;
21use crate::world::PhysicsWorld;
22
23#[derive(Clone, Debug)]
25pub struct RayHit<const D: usize> {
26 pub body: BodyHandle,
28 pub distance: f64,
30 pub point: SVector<f64, D>,
32 pub normal: SVector<f64, D>,
34}
35
36pub 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 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 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
89pub 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
134fn ray_sphere_intersection<const D: usize>(
139 origin: &SVector<f64, D>,
140 dir: &SVector<f64, D>, center: &SVector<f64, D>,
142 radius: f64,
143) -> Option<f64> {
144 let oc = origin - center;
145 let a = dir.dot(dir); 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 if t1 > 0.0 {
160 Some(t1)
161 } else if t2 > 0.0 {
162 Some(t2)
163 } else {
164 None }
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 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]); 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 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 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]); 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 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 let t = ray_sphere_intersection(&origin, &dir, ¢er, radius);
298 assert!(t.is_some());
300 assert!(t.unwrap() > 0.0);
301 }
302}