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().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
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| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal));
127 hits
128}
129
130fn ray_sphere_intersection<const D: usize>(
135 origin: &SVector<f64, D>,
136 dir: &SVector<f64, D>, center: &SVector<f64, D>,
138 radius: f64,
139) -> Option<f64> {
140 let oc = origin - center;
141 let a = dir.dot(dir); 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 if t1 > 0.0 {
156 Some(t1)
157 } else if t2 > 0.0 {
158 Some(t2)
159 } else {
160 None }
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 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]); 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 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 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]); 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 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 let t = ray_sphere_intersection(&origin, &dir, ¢er, radius);
291 assert!(t.is_some());
293 assert!(t.unwrap() > 0.0);
294 }
295}