Skip to main content

alice_physics/
raycast.rs

1//! Raycasting and Shape Casting
2//!
3//! Deterministic ray queries against physics bodies and BVH.
4//!
5//! # Features
6//!
7//! - Ray-Sphere, Ray-AABB, Ray-Capsule intersection
8//! - BVH-accelerated ray queries
9//! - Closest hit and any-hit modes
10//! - Shape casting (swept sphere)
11
12use crate::collider::{Capsule, Sphere, AABB};
13use crate::math::{Fix128, Vec3Fix};
14
15#[cfg(not(feature = "std"))]
16use alloc::vec::Vec;
17
18/// A ray defined by origin and direction
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct Ray {
21    /// Ray origin point
22    pub origin: Vec3Fix,
23    /// Ray direction (normalized internally)
24    pub direction: Vec3Fix,
25}
26
27/// Ray-parallel epsilon threshold
28const RAY_PARALLEL_EPSILON: Fix128 = Fix128 {
29    hi: 0,
30    lo: 0x0000000100000000,
31};
32
33impl Ray {
34    /// Create a new ray (direction is normalized internally)
35    #[inline]
36    #[must_use]
37    pub fn new(origin: Vec3Fix, direction: Vec3Fix) -> Self {
38        let len = direction.length();
39        Self {
40            origin,
41            direction: if len.is_zero() {
42                Vec3Fix::UNIT_X
43            } else {
44                direction / len
45            },
46        }
47    }
48
49    /// Get point along the ray at parameter t
50    #[inline]
51    #[must_use]
52    pub fn at(&self, t: Fix128) -> Vec3Fix {
53        self.origin + self.direction * t
54    }
55}
56
57/// Result of a ray intersection test
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub struct RayHit {
60    /// Parameter along the ray (distance from origin)
61    pub t: Fix128,
62    /// World-space hit point
63    pub point: Vec3Fix,
64    /// Surface normal at hit point
65    pub normal: Vec3Fix,
66    /// Index of the hit body (if applicable)
67    pub body_index: usize,
68}
69
70/// Ray-Sphere intersection
71///
72/// Returns parameter t along the ray, or None if no hit.
73#[inline]
74#[must_use]
75pub fn ray_sphere(ray: &Ray, sphere: &Sphere, max_t: Fix128) -> Option<RayHit> {
76    let oc = ray.origin - sphere.center;
77    let b = oc.dot(ray.direction);
78    let c = oc.dot(oc) - sphere.radius * sphere.radius;
79    let discriminant = b * b - c;
80
81    if discriminant < Fix128::ZERO {
82        return None;
83    }
84
85    let sqrt_d = discriminant.sqrt();
86
87    // Try near intersection first
88    let t = -b - sqrt_d;
89    if t >= Fix128::ZERO && t <= max_t {
90        let point = ray.at(t);
91        let normal = (point - sphere.center).normalize();
92        return Some(RayHit {
93            t,
94            point,
95            normal,
96            body_index: 0,
97        });
98    }
99
100    // Try far intersection
101    let t = -b + sqrt_d;
102    if t >= Fix128::ZERO && t <= max_t {
103        let point = ray.at(t);
104        let normal = (point - sphere.center).normalize();
105        return Some(RayHit {
106            t,
107            point,
108            normal,
109            body_index: 0,
110        });
111    }
112
113    None
114}
115
116/// Swept-sphere contact against a (Minkowski-expanded) sphere.
117///
118/// Same closed form as [`ray_sphere`] for an origin outside the sphere (the
119/// near root is the first contact). When the origin already lies inside or on
120/// the sphere the cast starts in an initial overlap, and the far root (the
121/// exit point) is never reported as a contact:
122///
123/// - origin at the centre (`oc = 0`): a contact at `t = 0` at the origin with
124///   normal `-d`;
125/// - moving deeper (`oc . d < 0`): a contact at `t = 0` at the origin, with
126///   the normal pointing from the sphere centre to the origin, so the normal
127///   opposes the cast direction;
128/// - moving outward or tangentially (`oc . d >= 0`, `oc != 0`): no contact,
129///   the cast is leaving the overlap.
130///
131/// Shared by [`sweep_sphere`], [`crate::query::sphere_cast`] and the
132/// character controller so they resolve the initial-overlap case the same way.
133/// Plain raycasts ([`ray_sphere`], [`raycast_spheres`]) keep reporting the far
134/// exit for an origin inside the sphere.
135#[inline]
136#[must_use]
137pub(crate) fn sweep_ray_sphere(ray: &Ray, sphere: &Sphere, max_t: Fix128) -> Option<RayHit> {
138    let oc = ray.origin - sphere.center;
139    let c = oc.dot(oc) - sphere.radius * sphere.radius;
140    if c > Fix128::ZERO {
141        // Outside: c > 0 means both roots share a sign, so ray_sphere can
142        // only return the near root here.
143        return ray_sphere(ray, sphere, max_t);
144    }
145    // Coincident centres: every direction starts in the deepest overlap, so
146    // this is checked before the moving-out test (where `b = 0` would
147    // otherwise classify it as leaving).
148    let normal = if oc.length_squared().is_zero() {
149        -ray.direction
150    } else {
151        if oc.dot(ray.direction) >= Fix128::ZERO {
152            return None;
153        }
154        oc.normalize()
155    };
156    Some(RayHit {
157        t: Fix128::ZERO,
158        point: ray.origin,
159        normal,
160        body_index: 0,
161    })
162}
163
164/// Ray-AABB intersection (slab method)
165///
166/// Returns (`t_min`, `t_max`) interval or None if no hit.
167#[inline]
168#[must_use]
169pub fn ray_aabb(ray: &Ray, aabb: &AABB, max_t: Fix128) -> Option<RayHit> {
170    let (t_min, t_max) = ray_aabb_interval(ray, aabb, max_t)?;
171
172    if t_min > max_t || t_max < Fix128::ZERO {
173        return None;
174    }
175
176    let t = if t_min >= Fix128::ZERO { t_min } else { t_max };
177    if t > max_t {
178        return None;
179    }
180
181    let point = ray.at(t);
182
183    // Compute normal from closest face
184    let normal = aabb_hit_normal(point, aabb);
185
186    Some(RayHit {
187        t,
188        point,
189        normal,
190        body_index: 0,
191    })
192}
193
194/// Ray-AABB interval computation (returns (`t_min`, `t_max`))
195///
196/// The slab interval is seeded from the caller's own `max_t` (and its
197/// negation) rather than a fixed constant, so a box whose entry/exit `t`
198/// exceeds a hard-coded bound is not silently missed or clamped.
199#[inline]
200fn ray_aabb_interval(ray: &Ray, aabb: &AABB, max_t: Fix128) -> Option<(Fix128, Fix128)> {
201    let mut t_min = -max_t;
202    let mut t_max = max_t;
203
204    // X slab
205    if !ray.direction.x.is_zero() {
206        let inv_d = Fix128::ONE / ray.direction.x;
207        let mut t0 = (aabb.min.x - ray.origin.x) * inv_d;
208        let mut t1 = (aabb.max.x - ray.origin.x) * inv_d;
209        if t0 > t1 {
210            core::mem::swap(&mut t0, &mut t1);
211        }
212        t_min = if t0 > t_min { t0 } else { t_min };
213        t_max = if t1 < t_max { t1 } else { t_max };
214        if t_min > t_max {
215            return None;
216        }
217    } else if ray.origin.x < aabb.min.x || ray.origin.x > aabb.max.x {
218        return None;
219    }
220
221    // Y slab
222    if !ray.direction.y.is_zero() {
223        let inv_d = Fix128::ONE / ray.direction.y;
224        let mut t0 = (aabb.min.y - ray.origin.y) * inv_d;
225        let mut t1 = (aabb.max.y - ray.origin.y) * inv_d;
226        if t0 > t1 {
227            core::mem::swap(&mut t0, &mut t1);
228        }
229        t_min = if t0 > t_min { t0 } else { t_min };
230        t_max = if t1 < t_max { t1 } else { t_max };
231        if t_min > t_max {
232            return None;
233        }
234    } else if ray.origin.y < aabb.min.y || ray.origin.y > aabb.max.y {
235        return None;
236    }
237
238    // Z slab
239    if !ray.direction.z.is_zero() {
240        let inv_d = Fix128::ONE / ray.direction.z;
241        let mut t0 = (aabb.min.z - ray.origin.z) * inv_d;
242        let mut t1 = (aabb.max.z - ray.origin.z) * inv_d;
243        if t0 > t1 {
244            core::mem::swap(&mut t0, &mut t1);
245        }
246        t_min = if t0 > t_min { t0 } else { t_min };
247        t_max = if t1 < t_max { t1 } else { t_max };
248        if t_min > t_max {
249            return None;
250        }
251    } else if ray.origin.z < aabb.min.z || ray.origin.z > aabb.max.z {
252        return None;
253    }
254
255    Some((t_min, t_max))
256}
257
258/// Compute AABB hit normal from the closest face
259fn aabb_hit_normal(point: Vec3Fix, aabb: &AABB) -> Vec3Fix {
260    let center = Vec3Fix::new(
261        (aabb.min.x + aabb.max.x).half(),
262        (aabb.min.y + aabb.max.y).half(),
263        (aabb.min.z + aabb.max.z).half(),
264    );
265    let half = Vec3Fix::new(
266        (aabb.max.x - aabb.min.x).half(),
267        (aabb.max.y - aabb.min.y).half(),
268        (aabb.max.z - aabb.min.z).half(),
269    );
270    let local = point - center;
271
272    // Find axis with largest relative penetration
273    let dx = (local.x.abs() - half.x).abs();
274    let dy = (local.y.abs() - half.y).abs();
275    let dz = (local.z.abs() - half.z).abs();
276
277    if dx < dy && dx < dz {
278        Vec3Fix::new(
279            if local.x >= Fix128::ZERO {
280                Fix128::ONE
281            } else {
282                Fix128::NEG_ONE
283            },
284            Fix128::ZERO,
285            Fix128::ZERO,
286        )
287    } else if dy < dz {
288        Vec3Fix::new(
289            Fix128::ZERO,
290            if local.y >= Fix128::ZERO {
291                Fix128::ONE
292            } else {
293                Fix128::NEG_ONE
294            },
295            Fix128::ZERO,
296        )
297    } else {
298        Vec3Fix::new(
299            Fix128::ZERO,
300            Fix128::ZERO,
301            if local.z >= Fix128::ZERO {
302                Fix128::ONE
303            } else {
304                Fix128::NEG_ONE
305            },
306        )
307    }
308}
309
310/// Ray-Capsule intersection
311#[must_use]
312pub fn ray_capsule(ray: &Ray, capsule: &Capsule, max_t: Fix128) -> Option<RayHit> {
313    // Test against the infinite cylinder first, then cap with hemispheres
314    let ab = capsule.b - capsule.a;
315    let ao = ray.origin - capsule.a;
316
317    // Project ray direction and origin offset perpendicular to capsule axis
318    let ab_dot_ab = ab.dot(ab);
319    if ab_dot_ab.is_zero() {
320        // Degenerate capsule = sphere
321        let sphere = Sphere::new(capsule.a, capsule.radius);
322        return ray_sphere(ray, &sphere, max_t);
323    }
324
325    let ab_dot_d = ab.dot(ray.direction);
326    let ab_dot_ao = ab.dot(ao);
327
328    let d_perp = ray.direction - ab * (ab_dot_d / ab_dot_ab);
329    let ao_perp = ao - ab * (ab_dot_ao / ab_dot_ab);
330
331    let a_coeff = d_perp.dot(d_perp);
332    let b_coeff = d_perp.dot(ao_perp).double();
333    let c_coeff = ao_perp.dot(ao_perp) - capsule.radius * capsule.radius;
334
335    let discriminant = b_coeff * b_coeff - Fix128::from_int(4) * a_coeff * c_coeff;
336    if discriminant < Fix128::ZERO {
337        // Try hemisphere caps
338        let sphere_a = Sphere::new(capsule.a, capsule.radius);
339        let sphere_b = Sphere::new(capsule.b, capsule.radius);
340        let hit_a = ray_sphere(ray, &sphere_a, max_t);
341        let hit_b = ray_sphere(ray, &sphere_b, max_t);
342        return closer_hit(hit_a, hit_b);
343    }
344
345    let sqrt_d = discriminant.sqrt();
346    let two_a = a_coeff.double();
347    if two_a.is_zero() {
348        // Parallel to the axis: inside the radius the ray meets an end cap
349        // (outside it, both cap spheres miss as well)
350        let sphere_a = Sphere::new(capsule.a, capsule.radius);
351        let sphere_b = Sphere::new(capsule.b, capsule.radius);
352        return closer_hit(
353            ray_sphere(ray, &sphere_a, max_t),
354            ray_sphere(ray, &sphere_b, max_t),
355        );
356    }
357
358    let t = (-b_coeff - sqrt_d) / two_a;
359    if t >= Fix128::ZERO && t <= max_t {
360        let point = ray.at(t);
361        // Check if within cylinder bounds
362        let ap = point - capsule.a;
363        let proj = ap.dot(ab) / ab_dot_ab;
364        if proj >= Fix128::ZERO && proj <= Fix128::ONE {
365            let center_on_axis = capsule.a + ab * proj;
366            let normal = (point - center_on_axis).normalize();
367            return Some(RayHit {
368                t,
369                point,
370                normal,
371                body_index: 0,
372            });
373        }
374    }
375
376    // Test hemisphere caps
377    let sphere_a = Sphere::new(capsule.a, capsule.radius);
378    let sphere_b = Sphere::new(capsule.b, capsule.radius);
379    let hit_a = ray_sphere(ray, &sphere_a, max_t);
380    let hit_b = ray_sphere(ray, &sphere_b, max_t);
381    closer_hit(hit_a, hit_b)
382}
383
384/// Return the closer of two optional hits
385#[inline]
386fn closer_hit(a: Option<RayHit>, b: Option<RayHit>) -> Option<RayHit> {
387    match (a, b) {
388        (Some(ha), Some(hb)) => {
389            if ha.t < hb.t {
390                Some(ha)
391            } else {
392                Some(hb)
393            }
394        }
395        (Some(h), None) | (None, Some(h)) => Some(h),
396        (None, None) => None,
397    }
398}
399
400/// Ray-Plane intersection
401///
402/// Plane defined by normal and offset: dot(normal, point) = offset
403#[must_use]
404pub fn ray_plane(
405    ray: &Ray,
406    plane_normal: Vec3Fix,
407    plane_offset: Fix128,
408    max_t: Fix128,
409) -> Option<RayHit> {
410    let denom = ray.direction.dot(plane_normal);
411
412    if denom.abs() < RAY_PARALLEL_EPSILON {
413        return None; // Ray parallel to plane
414    }
415
416    let t = (plane_offset - ray.origin.dot(plane_normal)) / denom;
417
418    if t >= Fix128::ZERO && t <= max_t {
419        let point = ray.at(t);
420        let normal = if denom < Fix128::ZERO {
421            plane_normal
422        } else {
423            -plane_normal
424        };
425        Some(RayHit {
426            t,
427            point,
428            normal,
429            body_index: 0,
430        })
431    } else {
432        None
433    }
434}
435
436/// Cast a ray against multiple AABBs and return the closest hit; among hits
437/// at the same `t` the first in `aabbs` wins (the head of [`raycast_all_aabbs`]).
438#[must_use]
439pub fn raycast_aabbs(ray: &Ray, aabbs: &[(AABB, usize)], max_t: Fix128) -> Option<RayHit> {
440    let mut closest: Option<RayHit> = None;
441    let mut best_t = max_t;
442
443    for &(ref aabb, body_idx) in aabbs {
444        if let Some(mut hit) = ray_aabb(ray, aabb, best_t) {
445            // an equal t keeps the earlier candidate, as `raycast_all_*` does
446            if closest.is_some() && hit.t >= best_t {
447                continue;
448            }
449            hit.body_index = body_idx;
450            best_t = hit.t;
451            closest = Some(hit);
452        }
453    }
454
455    closest
456}
457
458/// Cast a ray against multiple Spheres and return the closest hit; among hits
459/// at the same `t` the first in `spheres` wins (the head of
460/// [`raycast_all_spheres`]).
461#[must_use]
462pub fn raycast_spheres(ray: &Ray, spheres: &[(Sphere, usize)], max_t: Fix128) -> Option<RayHit> {
463    let mut closest: Option<RayHit> = None;
464    let mut best_t = max_t;
465
466    for &(ref sphere, body_idx) in spheres {
467        if let Some(mut hit) = ray_sphere(ray, sphere, best_t) {
468            // an equal t keeps the earlier candidate, as `raycast_all_*` does
469            if closest.is_some() && hit.t >= best_t {
470                continue;
471            }
472            hit.body_index = body_idx;
473            best_t = hit.t;
474            closest = Some(hit);
475        }
476    }
477
478    closest
479}
480
481/// Cast a ray against multiple Spheres and return ALL hits (sorted by t, closest first)
482#[must_use]
483pub fn raycast_all_spheres(ray: &Ray, spheres: &[(Sphere, usize)], max_t: Fix128) -> Vec<RayHit> {
484    let mut hits = Vec::new();
485
486    for &(ref sphere, body_idx) in spheres {
487        if let Some(mut hit) = ray_sphere(ray, sphere, max_t) {
488            hit.body_index = body_idx;
489            hits.push(hit);
490        }
491    }
492
493    // Deterministic stable sort by t
494    hits.sort_by_key(|a| a.t);
495    hits
496}
497
498/// Cast a ray against multiple AABBs and return ALL hits (sorted by t, closest first)
499#[must_use]
500pub fn raycast_all_aabbs(ray: &Ray, aabbs: &[(AABB, usize)], max_t: Fix128) -> Vec<RayHit> {
501    let mut hits = Vec::new();
502
503    for &(ref aabb, body_idx) in aabbs {
504        if let Some(mut hit) = ray_aabb(ray, aabb, max_t) {
505            hit.body_index = body_idx;
506            hits.push(hit);
507        }
508    }
509
510    hits.sort_by_key(|a| a.t);
511    hits
512}
513
514/// Test if ANY sphere is hit by the ray (early-out on first hit)
515#[inline]
516#[must_use]
517pub fn raycast_any_spheres(ray: &Ray, spheres: &[(Sphere, usize)], max_t: Fix128) -> bool {
518    for (sphere, _) in spheres {
519        if ray_sphere(ray, sphere, max_t).is_some() {
520            return true;
521        }
522    }
523    false
524}
525
526/// Test if ANY AABB is hit by the ray (early-out on first hit)
527#[inline]
528#[must_use]
529pub fn raycast_any_aabbs(ray: &Ray, aabbs: &[(AABB, usize)], max_t: Fix128) -> bool {
530    for (aabb, _) in aabbs {
531        if ray_aabb(ray, aabb, max_t).is_some() {
532            return true;
533        }
534    }
535    false
536}
537
538/// Swept sphere (shape cast): move a sphere along a ray direction
539///
540/// `t` is the distance travelled along the normalised `direction` until the
541/// spheres first touch. A sweep that starts with the spheres already
542/// overlapping or touching is resolved as in [`crate::query::sphere_cast`]: an
543/// initial overlap at `t = 0` (point = start centre, normal from the target
544/// centre to the start centre, `-direction` when the centres coincide) when
545/// moving deeper, and no hit when moving apart. Unlike the plain raycast
546/// [`ray_sphere`], the far (exit) root is never reported.
547#[must_use]
548pub fn sweep_sphere(
549    sphere: &Sphere,
550    direction: Vec3Fix,
551    target_sphere: &Sphere,
552    max_t: Fix128,
553) -> Option<RayHit> {
554    // Equivalent to ray vs expanded sphere
555    let expanded = Sphere::new(target_sphere.center, target_sphere.radius + sphere.radius);
556    let ray = Ray::new(sphere.center, direction);
557    sweep_ray_sphere(&ray, &expanded, max_t)
558}
559
560#[cfg(all(test, feature = "std"))]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn test_ray_sphere_hit() {
566        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
567        let sphere = Sphere::new(Vec3Fix::ZERO, Fix128::ONE);
568
569        let hit = ray_sphere(&ray, &sphere, Fix128::from_int(100)).unwrap();
570        // Should hit at x = -1 (near side of unit sphere at origin)
571        let expected_t = Fix128::from_int(4); // -5 + 4 = -1
572        let error = (hit.t - expected_t).abs();
573        assert!(error < Fix128::ONE, "t should be ~4, got {:?}", hit.t);
574    }
575
576    #[test]
577    fn test_ray_sphere_miss() {
578        let ray = Ray::new(Vec3Fix::from_int(-5, 5, 0), Vec3Fix::UNIT_X);
579        let sphere = Sphere::new(Vec3Fix::ZERO, Fix128::ONE);
580
581        assert!(ray_sphere(&ray, &sphere, Fix128::from_int(100)).is_none());
582    }
583
584    #[test]
585    fn test_ray_aabb_hit() {
586        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
587        let aabb = AABB::new(Vec3Fix::from_int(-1, -1, -1), Vec3Fix::from_int(1, 1, 1));
588
589        let hit = ray_aabb(&ray, &aabb, Fix128::from_int(100)).unwrap();
590        let expected_t = Fix128::from_int(4); // -5 + 4 = -1
591        let error = (hit.t - expected_t).abs();
592        assert!(error < Fix128::ONE, "t should be ~4");
593    }
594
595    #[test]
596    fn test_ray_aabb_miss() {
597        let ray = Ray::new(Vec3Fix::from_int(-5, 5, 0), Vec3Fix::UNIT_X);
598        let aabb = AABB::new(Vec3Fix::from_int(-1, -1, -1), Vec3Fix::from_int(1, 1, 1));
599
600        assert!(ray_aabb(&ray, &aabb, Fix128::from_int(100)).is_none());
601    }
602
603    #[test]
604    fn test_ray_plane() {
605        let ray = Ray::new(
606            Vec3Fix::from_int(0, 10, 0),
607            Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO), // downward
608        );
609
610        let hit = ray_plane(&ray, Vec3Fix::UNIT_Y, Fix128::ZERO, Fix128::from_int(100)).unwrap();
611        let expected_t = Fix128::from_int(10);
612        let error = (hit.t - expected_t).abs();
613        assert!(error < Fix128::ONE, "Should hit ground plane at t=10");
614    }
615
616    #[test]
617    fn test_sweep_sphere() {
618        let moving = Sphere::new(Vec3Fix::from_int(-5, 0, 0), Fix128::ONE);
619        let target = Sphere::new(Vec3Fix::ZERO, Fix128::ONE);
620
621        let hit = sweep_sphere(&moving, Vec3Fix::UNIT_X, &target, Fix128::from_int(100));
622        assert!(hit.is_some(), "Swept sphere should hit target");
623        let t = hit.unwrap().t;
624        // Should hit at distance 5 - 2 = 3 (center distance minus combined radii)
625        let error = (t - Fix128::from_int(3)).abs();
626        assert!(error < Fix128::ONE, "Should hit at approximately t=3");
627    }
628
629    #[test]
630    fn test_raycast_all_spheres() {
631        let spheres = vec![
632            (Sphere::new(Vec3Fix::from_int(3, 0, 0), Fix128::ONE), 0),
633            (Sphere::new(Vec3Fix::from_int(6, 0, 0), Fix128::ONE), 1),
634            (Sphere::new(Vec3Fix::from_int(9, 0, 0), Fix128::ONE), 2),
635        ];
636        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
637        let hits = raycast_all_spheres(&ray, &spheres, Fix128::from_int(100));
638        assert_eq!(hits.len(), 3, "Should hit all 3 spheres");
639        // Sorted by t — closest first
640        assert_eq!(hits[0].body_index, 0);
641        assert_eq!(hits[1].body_index, 1);
642        assert_eq!(hits[2].body_index, 2);
643    }
644
645    #[test]
646    fn test_raycast_any_spheres() {
647        let spheres = vec![(Sphere::new(Vec3Fix::from_int(3, 0, 0), Fix128::ONE), 0)];
648        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
649        assert!(raycast_any_spheres(&ray, &spheres, Fix128::from_int(100)));
650
651        let miss_ray = Ray::new(Vec3Fix::from_int(-5, 10, 0), Vec3Fix::UNIT_X);
652        assert!(!raycast_any_spheres(
653            &miss_ray,
654            &spheres,
655            Fix128::from_int(100)
656        ));
657    }
658
659    #[test]
660    fn test_raycast_all_aabbs() {
661        let aabbs = vec![
662            (
663                AABB::new(Vec3Fix::from_int(2, -1, -1), Vec3Fix::from_int(4, 1, 1)),
664                0,
665            ),
666            (
667                AABB::new(Vec3Fix::from_int(6, -1, -1), Vec3Fix::from_int(8, 1, 1)),
668                1,
669            ),
670        ];
671        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
672        let hits = raycast_all_aabbs(&ray, &aabbs, Fix128::from_int(100));
673        assert_eq!(hits.len(), 2, "Should hit both AABBs");
674        assert_eq!(hits[0].body_index, 0);
675        assert_eq!(hits[1].body_index, 1);
676    }
677
678    #[test]
679    fn test_ray_capsule() {
680        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
681        let capsule = Capsule::new(
682            Vec3Fix::from_int(0, -2, 0),
683            Vec3Fix::from_int(0, 2, 0),
684            Fix128::ONE,
685        );
686
687        let hit = ray_capsule(&ray, &capsule, Fix128::from_int(100));
688        assert!(hit.is_some(), "Ray should hit capsule");
689    }
690
691    #[test]
692    fn raycast_spheres_returns_nearest_hit_regardless_of_list_order() {
693        // 原点 (-5, 0, 0) から +X: 各球の閉形式 t (= |oc| 成分 - sqrt(disc) は全て整数)
694        //   idx 7: center (10,0,0) r 2 → t = 15 - 2 = 13
695        //   idx 3: center (0,5,0)  r 1 → miss (軸から 5 離れている)
696        //   idx 1: center (0,0,0)  r 1 → t = 5 - 1 = 4
697        //   idx 9: center (-3,0,0) r 1 → t = 2 - 1 = 1  (最近)
698        //   idx 4: center (-20,0,0) r 1 → 後方 (t < 0) → 無視
699        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
700        let far = (
701            Sphere::new(Vec3Fix::from_int(10, 0, 0), Fix128::from_int(2)),
702            7usize,
703        );
704        let off = (Sphere::new(Vec3Fix::from_int(0, 5, 0), Fix128::ONE), 3usize);
705        let mid = (Sphere::new(Vec3Fix::from_int(0, 0, 0), Fix128::ONE), 1usize);
706        let near = (
707            Sphere::new(Vec3Fix::from_int(-3, 0, 0), Fix128::ONE),
708            9usize,
709        );
710        let behind = (
711            Sphere::new(Vec3Fix::from_int(-20, 0, 0), Fix128::ONE),
712            4usize,
713        );
714        let spheres = [far, off, mid, near, behind];
715        let max_t = Fix128::from_int(100);
716
717        let hit = raycast_spheres(&ray, &spheres, max_t);
718        assert_eq!(
719            hit,
720            Some(RayHit {
721                t: Fix128::ONE,
722                point: Vec3Fix::from_int(-4, 0, 0),
723                normal: Vec3Fix::from_int(-1, 0, 0),
724                body_index: 9,
725            })
726        );
727        // 順序を逆にしても同じ最近傍
728        let mut reversed = spheres;
729        reversed.reverse();
730        assert_eq!(raycast_spheres(&ray, &reversed, max_t), hit);
731        // raycast_all の先頭 (t 昇順) と一致
732        let all = raycast_all_spheres(&ray, &spheres, max_t);
733        assert_eq!(all.first().copied(), hit);
734        assert_eq!(
735            all.iter().map(|h| h.body_index).collect::<Vec<_>>(),
736            vec![9, 1, 7]
737        );
738
739        // 最近傍を外すと次 (idx 1、t = 4) になる
740        let without_near = [far, off, mid, behind];
741        let second = raycast_spheres(&ray, &without_near, max_t);
742        assert_eq!(
743            second.map(|h| (h.t, h.body_index)),
744            Some((Fix128::from_int(4), 1))
745        );
746        assert_eq!(second.map(|h| h.point), Some(Vec3Fix::from_int(-1, 0, 0)));
747        assert_eq!(second.map(|h| h.normal), Some(Vec3Fix::from_int(-1, 0, 0)));
748
749        // max_t で切る: 1 未満なら None、[1, 4) なら idx 9 だけ
750        assert!(raycast_spheres(&ray, &spheres, Fix128::from_ratio(1, 2)).is_none());
751        assert_eq!(
752            raycast_spheres(&ray, &spheres, Fix128::from_int(3)).map(|h| h.body_index),
753            Some(9)
754        );
755        // 全部 miss / 空 list
756        assert!(raycast_spheres(&ray, &[off, behind], max_t).is_none());
757        assert!(raycast_spheres(&ray, &[], max_t).is_none());
758    }
759
760    #[test]
761    fn raycast_aabbs_returns_nearest_hit_regardless_of_list_order() {
762        // 原点 (-5, 0, 0) から +X: slab の t は整数
763        //   idx 0: [2,4]  → t = 7
764        //   idx 1: [6,8]  → t = 11
765        //   idx 2: [2,4] × y [3,5] → 軸外 miss
766        //   idx 5: [-1,1] → t = 4 (最近)、面法線 -X
767        //   idx 8: [-9,-7] → 後方 → 無視
768        let ray = Ray::new(Vec3Fix::from_int(-5, 0, 0), Vec3Fix::UNIT_X);
769        let bx = |x0: i64, x1: i64, y0: i64, y1: i64| {
770            AABB::new(Vec3Fix::from_int(x0, y0, -1), Vec3Fix::from_int(x1, y1, 1))
771        };
772        let a = (bx(2, 4, -1, 1), 0usize);
773        let b = (bx(6, 8, -1, 1), 1usize);
774        let off = (bx(2, 4, 3, 5), 2usize);
775        let near = (bx(-1, 1, -1, 1), 5usize);
776        let behind = (bx(-9, -7, -1, 1), 8usize);
777        let aabbs = [a, b, off, near, behind];
778        let max_t = Fix128::from_int(100);
779
780        let hit = raycast_aabbs(&ray, &aabbs, max_t);
781        assert_eq!(
782            hit,
783            Some(RayHit {
784                t: Fix128::from_int(4),
785                point: Vec3Fix::from_int(-1, 0, 0),
786                normal: Vec3Fix::from_int(-1, 0, 0),
787                body_index: 5,
788            })
789        );
790        let mut reversed = aabbs;
791        reversed.reverse();
792        assert_eq!(raycast_aabbs(&ray, &reversed, max_t), hit);
793        let all = raycast_all_aabbs(&ray, &aabbs, max_t);
794        assert_eq!(all.first().copied(), hit);
795        assert_eq!(
796            all.iter().map(|h| h.body_index).collect::<Vec<_>>(),
797            vec![5, 0, 1]
798        );
799
800        // 最近傍を外すと idx 0 (t = 7)
801        let second = raycast_aabbs(&ray, &[a, b, off, behind], max_t);
802        assert_eq!(
803            second.map(|h| (h.t, h.body_index)),
804            Some((Fix128::from_int(7), 0))
805        );
806        // 原点が箱の内部なら t_max 側 (出口) が返る: [-6,-4] → t = 1、法線 +X
807        let inside = (bx(-6, -4, -1, 1), 6usize);
808        let exit = raycast_aabbs(&ray, &[inside], max_t);
809        assert_eq!(
810            exit.map(|h| (h.t, h.body_index, h.normal)),
811            Some((Fix128::ONE, 6, Vec3Fix::from_int(1, 0, 0)))
812        );
813        // max_t: 4 未満なら None、[4, 7) なら idx 5 のみ
814        assert!(raycast_aabbs(&ray, &aabbs, Fix128::from_int(3)).is_none());
815        assert_eq!(
816            raycast_aabbs(&ray, &aabbs, Fix128::from_int(5)).map(|h| h.body_index),
817            Some(5)
818        );
819        assert!(raycast_aabbs(&ray, &[off, behind], max_t).is_none());
820        assert!(raycast_aabbs(&ray, &[], max_t).is_none());
821    }
822}