Skip to main content

alice_physics/
query.rs

1//! Shape Cast and Overlap Queries
2//!
3//! Spatial queries for game logic: sphere/capsule cast, overlap tests.
4//! BVH-accelerated broad phase with exact narrow phase.
5//!
6//! # Features
7//!
8//! - `sphere_cast`: Sweep a sphere along a direction
9//! - `capsule_cast`: Sweep a capsule along a direction
10//! - `overlap_sphere`: Find all bodies overlapping a sphere
11//! - `overlap_aabb`: Find all bodies overlapping an AABB
12//!
13//! Author: Moroya Sakamoto
14
15use crate::collider::{Sphere, AABB};
16use crate::math::{Fix128, Vec3Fix};
17use crate::raycast::{sweep_ray_sphere, Ray};
18use crate::solver::RigidBody;
19
20#[cfg(not(feature = "std"))]
21use alloc::vec::Vec;
22
23#[cfg(feature = "parallel")]
24use rayon::prelude::*;
25
26// ============================================================================
27// Query Results
28// ============================================================================
29
30/// Result of a shape cast query
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct ShapeCastHit {
33    /// Distance along the cast direction
34    pub t: Fix128,
35    /// World-space hit point
36    pub point: Vec3Fix,
37    /// Surface normal at hit point
38    pub normal: Vec3Fix,
39    /// Index of the hit body
40    pub body_index: usize,
41}
42
43/// Result of an overlap query
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct OverlapResult {
46    /// Index of the overlapping body
47    pub body_index: usize,
48    /// Overlap depth (penetration distance)
49    pub depth: Fix128,
50}
51
52// ============================================================================
53// Shape Cast Functions
54// ============================================================================
55
56/// Cast a sphere along a direction against rigid bodies.
57///
58/// Returns the closest hit, or None if no intersection.
59///
60/// This is equivalent to a "thick raycast" — useful for character collision,
61/// projectile sweeps, and visibility checks.
62#[inline]
63#[must_use]
64pub fn sphere_cast(
65    origin: Vec3Fix,
66    radius: Fix128,
67    direction: Vec3Fix,
68    max_distance: Fix128,
69    bodies: &[RigidBody],
70    body_radius: Fix128,
71) -> Option<ShapeCastHit> {
72    let dir_len = direction.length();
73    if dir_len.is_zero() {
74        return None;
75    }
76    let dir_norm = direction / dir_len;
77    let ray = Ray::new(origin, dir_norm);
78
79    let mut closest: Option<ShapeCastHit> = None;
80    let mut best_t = max_distance;
81
82    for (i, body) in bodies.iter().enumerate() {
83        // Expand the body sphere by the cast sphere radius (Minkowski sum)
84        let expanded = Sphere::new(body.position, body_radius + radius);
85
86        if let Some(hit) = sweep_ray_sphere(&ray, &expanded, best_t) {
87            best_t = hit.t;
88            closest = Some(ShapeCastHit {
89                t: hit.t,
90                point: hit.point,
91                normal: hit.normal,
92                body_index: i,
93            });
94        }
95    }
96
97    closest
98}
99
100/// Cast a capsule along a direction against rigid bodies.
101///
102/// The capsule (segment `capsule_a`–`capsule_b`, radius `capsule_radius`) is
103/// swept along `direction` up to `max_distance`; each body is a sphere of
104/// `body_radius` at its position. The hit is the earliest `t` at which the
105/// swept segment comes within `capsule_radius + body_radius` of a body centre
106/// — the exact time of impact, computed as a ray from the body centre along
107/// `−direction` against the capsule grown by `body_radius`: its cylinder
108/// (a quadratic in the plane across the axis) and its two end spheres. A
109/// body already within reach at `t = 0` is hit at `t = 0` if the sweep moves
110/// the capsule towards it, as for [`sphere_cast`].
111///
112/// `point` is the point of the moved axis nearest the body centre (the centre
113/// of the cast sphere that touches it) and `normal` points from the body
114/// centre to it; ties in `t` go to the lowest body index.
115// LIMITATION(COV-RIGID-098): Bodies are treated as their bounding spheres of `body_radius`, not their shapes.
116#[must_use]
117pub fn capsule_cast(
118    capsule_a: Vec3Fix,
119    capsule_b: Vec3Fix,
120    capsule_radius: Fix128,
121    direction: Vec3Fix,
122    max_distance: Fix128,
123    bodies: &[RigidBody],
124    body_radius: Fix128,
125) -> Option<ShapeCastHit> {
126    let dir_len = direction.length();
127    if dir_len.is_zero() {
128        return None;
129    }
130    let d = direction / dir_len;
131    let reach = capsule_radius + body_radius;
132    let axis = capsule_b - capsule_a;
133    let len = axis.length();
134    if len.is_zero() {
135        return sphere_cast(
136            capsule_a,
137            capsule_radius,
138            direction,
139            max_distance,
140            bodies,
141            body_radius,
142        );
143    }
144    let u = axis / len;
145
146    let mut best: Option<ShapeCastHit> = None;
147    for (i, body) in bodies.iter().enumerate() {
148        let limit = best.map_or(max_distance, |b| b.t);
149        let Some(t) = capsule_toi(capsule_a, capsule_b, u, len, reach, d, body.position, limit)
150        else {
151            continue;
152        };
153        if best.is_some_and(|b| t >= b.t) {
154            continue;
155        }
156        // the moved axis point nearest the body centre
157        let c = body.position;
158        let s = ((c - capsule_a - d * t).dot(u)).max(Fix128::ZERO).min(len);
159        let point = capsule_a + d * t + u * s;
160        let to = point - c;
161        let normal = if to.length_squared().is_zero() {
162            -d
163        } else {
164            to.normalize()
165        };
166        best = Some(ShapeCastHit {
167            t,
168            point,
169            normal,
170            body_index: i,
171        });
172    }
173    best
174}
175
176/// Earliest `t` in `[0, max_t]` at which the segment `a + t d`–`b + t d`
177/// (unit axis `u`, length `len`) comes within `reach` of `c`: a ray from `c`
178/// along `−d` against the capsule of radius `reach` around `a`–`b`.
179#[allow(clippy::too_many_arguments)]
180fn capsule_toi(
181    a: Vec3Fix,
182    b: Vec3Fix,
183    u: Vec3Fix,
184    len: Fix128,
185    reach: Fix128,
186    d: Vec3Fix,
187    c: Vec3Fix,
188    max_t: Fix128,
189) -> Option<Fix128> {
190    // Already within reach at t = 0: the distance from c to the moving segment
191    // is convex in t, so the sweep either closes in now (contact at t = 0) or
192    // never comes back within reach (no contact), the rule of sphere_cast;
193    // the end spheres would otherwise report a later "entry" from inside
194    let s0 = (c - a).dot(u).max(Fix128::ZERO).min(len);
195    let to_body = c - (a + u * s0);
196    if to_body.length_squared() <= reach * reach {
197        return if to_body.length_squared().is_zero() || to_body.dot(d) > Fix128::ZERO {
198            Some(Fix128::ZERO)
199        } else {
200            None
201        };
202    }
203    let ray = Ray::new(c, -d);
204    let mut best: Option<Fix128> = None;
205    let mut take = |t: Fix128| {
206        if t >= Fix128::ZERO && t <= max_t && best.is_none_or(|b| t < b) {
207            best = Some(t);
208        }
209    };
210    // the end spheres (their overlap rule at t = 0 is sphere_cast's)
211    for end in [a, b] {
212        if let Some(h) = sweep_ray_sphere(&ray, &Sphere::new(end, reach), max_t) {
213            take(h.t);
214        }
215    }
216    // the cylinder: components across the axis
217    let rd = -d;
218    let oa = c - a;
219    let oa_p = oa - u * oa.dot(u);
220    let rd_p = rd - u * rd.dot(u);
221    let qa = rd_p.dot(rd_p);
222    let qb = oa_p.dot(rd_p);
223    let qc = oa_p.dot(oa_p) - reach * reach;
224    let along = |t: Fix128| oa.dot(u) + rd.dot(u) * t;
225    let on_side = |t: Fix128| {
226        let y = along(t);
227        y >= Fix128::ZERO && y <= len
228    };
229    if qc > Fix128::ZERO && !qa.is_zero() {
230        let disc = qb * qb - qa * qc;
231        if disc >= Fix128::ZERO {
232            let t = (-qb - disc.sqrt()) / qa;
233            if on_side(t) {
234                take(t);
235            }
236        }
237    }
238    best
239}
240
241// ============================================================================
242// Overlap Functions
243// ============================================================================
244
245/// Find all bodies whose bounding sphere overlaps with the given sphere.
246// LIMITATION(COV-RIGID-100): Find all bodies whose bounding sphere overlaps with the given sphere.
247///
248/// `body_radius` is the assumed radius for each body.
249#[inline]
250#[must_use]
251pub fn overlap_sphere(
252    center: Vec3Fix,
253    radius: Fix128,
254    bodies: &[RigidBody],
255    body_radius: Fix128,
256) -> Vec<OverlapResult> {
257    let mut results = Vec::new();
258    let combined_radius = radius + body_radius;
259    let combined_sq = combined_radius * combined_radius;
260
261    for (i, body) in bodies.iter().enumerate() {
262        let delta = body.position - center;
263        let dist_sq = delta.length_squared();
264
265        if dist_sq < combined_sq {
266            let dist = dist_sq.sqrt();
267            let depth = combined_radius - dist;
268            results.push(OverlapResult {
269                body_index: i,
270                depth,
271            });
272        }
273    }
274
275    results
276}
277
278/// Find all bodies whose position falls within the given AABB.
279///
280/// Uses the body position as a point test (no body extent considered).
281/// For volume overlap, expand the AABB by the body radius first.
282#[inline]
283#[must_use]
284pub fn overlap_aabb(aabb: &AABB, bodies: &[RigidBody]) -> Vec<OverlapResult> {
285    let mut results = Vec::new();
286
287    for (i, body) in bodies.iter().enumerate() {
288        let p = body.position;
289        if p.x >= aabb.min.x
290            && p.x <= aabb.max.x
291            && p.y >= aabb.min.y
292            && p.y <= aabb.max.y
293            && p.z >= aabb.min.z
294            && p.z <= aabb.max.z
295        {
296            results.push(OverlapResult {
297                body_index: i,
298                depth: Fix128::ZERO, // Point-in-AABB doesn't have a depth
299            });
300        }
301    }
302
303    results
304}
305
306/// Find all bodies overlapping an AABB, with body radius consideration.
307///
308/// Each body is treated as a sphere of `body_radius`: it overlaps when the
309/// point of the AABB nearest its centre is within `body_radius` (boundary
310/// included). `depth` is zero, as for [`overlap_aabb`].
311#[must_use]
312pub fn overlap_aabb_expanded(
313    aabb: &AABB,
314    bodies: &[RigidBody],
315    body_radius: Fix128,
316) -> Vec<OverlapResult> {
317    let r_sq = body_radius * body_radius;
318    bodies
319        .iter()
320        .enumerate()
321        .filter(|(_, body)| {
322            let p = body.position;
323            let nearest = Vec3Fix::new(
324                p.x.max(aabb.min.x).min(aabb.max.x),
325                p.y.max(aabb.min.y).min(aabb.max.y),
326                p.z.max(aabb.min.z).min(aabb.max.z),
327            );
328            (p - nearest).length_squared() <= r_sq
329        })
330        .map(|(i, _)| OverlapResult {
331            body_index: i,
332            depth: Fix128::ZERO,
333        })
334        .collect()
335}
336
337// ============================================================================
338// Batch Queries
339// ============================================================================
340
341/// A single raycast query for batch execution
342#[derive(Clone, Copy, Debug, PartialEq, Eq)]
343pub struct BatchRayQuery {
344    /// Ray origin
345    pub origin: Vec3Fix,
346    /// Ray direction (will be normalized)
347    pub direction: Vec3Fix,
348    /// Maximum cast distance
349    pub max_distance: Fix128,
350}
351
352/// Execute multiple raycasts in batch against rigid bodies.
353///
354/// Returns one result per query (closest hit or None).
355/// Each ray is treated as a zero-radius sphere cast.
356/// When `parallel` feature is enabled, queries execute in parallel via Rayon.
357///
358/// Every body is a sphere of `body_radius` (a bounding-sphere approximation): the
359/// body's own shape is not consulted. To cast against the geometry a
360/// [`PhysicsWorld`](crate::solver::PhysicsWorld) collides with, use
361/// [`PhysicsWorld::ray_caster`](crate::solver::PhysicsWorld::ray_caster) and cast
362/// each ray with the BVH built once.
363#[must_use]
364pub fn batch_raycast(
365    queries: &[BatchRayQuery],
366    bodies: &[RigidBody],
367    body_radius: Fix128,
368) -> Vec<Option<ShapeCastHit>> {
369    #[cfg(feature = "parallel")]
370    {
371        queries
372            .par_iter()
373            .map(|q| {
374                sphere_cast(
375                    q.origin,
376                    Fix128::ZERO,
377                    q.direction,
378                    q.max_distance,
379                    bodies,
380                    body_radius,
381                )
382            })
383            .collect()
384    }
385
386    #[cfg(not(feature = "parallel"))]
387    {
388        queries
389            .iter()
390            .map(|q| {
391                sphere_cast(
392                    q.origin,
393                    Fix128::ZERO,
394                    q.direction,
395                    q.max_distance,
396                    bodies,
397                    body_radius,
398                )
399            })
400            .collect()
401    }
402}
403
404/// Execute multiple sphere casts in batch against rigid bodies.
405///
406/// `origins` and `directions` must have the same length.
407/// Returns one result per query (closest hit or None).
408/// When `parallel` feature is enabled, queries execute in parallel via Rayon.
409///
410/// # Panics
411///
412/// Panics if `origins` and `directions` have different lengths.
413#[must_use]
414pub fn batch_sphere_cast(
415    origins: &[Vec3Fix],
416    radius: Fix128,
417    directions: &[Vec3Fix],
418    max_distance: Fix128,
419    bodies: &[RigidBody],
420    body_radius: Fix128,
421) -> Vec<Option<ShapeCastHit>> {
422    assert_eq!(origins.len(), directions.len());
423
424    #[cfg(feature = "parallel")]
425    {
426        origins
427            .par_iter()
428            .zip(directions.par_iter())
429            .map(|(origin, direction)| {
430                sphere_cast(
431                    *origin,
432                    radius,
433                    *direction,
434                    max_distance,
435                    bodies,
436                    body_radius,
437                )
438            })
439            .collect()
440    }
441
442    #[cfg(not(feature = "parallel"))]
443    {
444        origins
445            .iter()
446            .zip(directions.iter())
447            .map(|(origin, direction)| {
448                sphere_cast(
449                    *origin,
450                    radius,
451                    *direction,
452                    max_distance,
453                    bodies,
454                    body_radius,
455                )
456            })
457            .collect()
458    }
459}
460
461// ============================================================================
462// BVH-Accelerated Queries
463// ============================================================================
464
465/// Find all bodies overlapping a sphere, using a BVH for broad-phase pruning.
466///
467/// The BVH must be built from per-body AABBs (expanded by `body_radius`).
468/// Only candidates whose BVH leaf overlaps the query sphere AABB are tested
469/// in the narrow phase, reducing cost from O(n) to O(log n + k).
470#[must_use]
471pub fn overlap_sphere_bvh(
472    center: Vec3Fix,
473    radius: Fix128,
474    bodies: &[RigidBody],
475    body_radius: Fix128,
476    bvh: &crate::bvh::LinearBvh,
477) -> Vec<OverlapResult> {
478    let combined_radius = radius + body_radius;
479    let query_aabb = AABB::new(
480        Vec3Fix::new(
481            center.x - combined_radius,
482            center.y - combined_radius,
483            center.z - combined_radius,
484        ),
485        Vec3Fix::new(
486            center.x + combined_radius,
487            center.y + combined_radius,
488            center.z + combined_radius,
489        ),
490    );
491
492    let candidates = bvh.query(&query_aabb);
493    let combined_sq = combined_radius * combined_radius;
494    let mut results = Vec::new();
495
496    for &idx in &candidates {
497        let i = idx as usize;
498        if i >= bodies.len() {
499            continue;
500        }
501        let delta = bodies[i].position - center;
502        let dist_sq = delta.length_squared();
503        if dist_sq < combined_sq {
504            let dist = dist_sq.sqrt();
505            let depth = combined_radius - dist;
506            results.push(OverlapResult {
507                body_index: i,
508                depth,
509            });
510        }
511    }
512
513    results
514}
515
516/// Find all bodies overlapping an AABB, using a BVH for broad-phase pruning.
517///
518/// Only candidates whose BVH leaf overlaps the query AABB are tested,
519/// reducing cost from O(n) to O(log n + k).
520#[must_use]
521pub fn overlap_aabb_bvh(
522    aabb: &AABB,
523    bodies: &[RigidBody],
524    bvh: &crate::bvh::LinearBvh,
525) -> Vec<OverlapResult> {
526    let candidates = bvh.query(aabb);
527    let mut results = Vec::new();
528
529    for &idx in &candidates {
530        let i = idx as usize;
531        if i >= bodies.len() {
532            continue;
533        }
534        let p = bodies[i].position;
535        if p.x >= aabb.min.x
536            && p.x <= aabb.max.x
537            && p.y >= aabb.min.y
538            && p.y <= aabb.max.y
539            && p.z >= aabb.min.z
540            && p.z <= aabb.max.z
541        {
542            results.push(OverlapResult {
543                body_index: i,
544                depth: Fix128::ZERO,
545            });
546        }
547    }
548
549    results
550}
551
552// ============================================================================
553// Tests
554// ============================================================================
555
556#[cfg(all(test, feature = "std"))]
557mod tests {
558    use super::*;
559
560    fn make_bodies() -> Vec<RigidBody> {
561        vec![
562            RigidBody::new_static(Vec3Fix::from_int(0, 0, 0)),
563            RigidBody::new_static(Vec3Fix::from_int(5, 0, 0)),
564            RigidBody::new_static(Vec3Fix::from_int(10, 0, 0)),
565            RigidBody::new_static(Vec3Fix::from_int(0, 5, 0)),
566        ]
567    }
568
569    #[test]
570    fn test_sphere_cast_hit() {
571        let bodies = make_bodies();
572        let hit = sphere_cast(
573            Vec3Fix::from_int(-10, 0, 0),
574            Fix128::from_ratio(1, 2), // radius 0.5
575            Vec3Fix::UNIT_X,
576            Fix128::from_int(100),
577            &bodies,
578            Fix128::ONE,
579        );
580        assert!(hit.is_some(), "Sphere cast should hit body at origin");
581        let h = hit.expect("sphere cast should hit");
582        assert_eq!(h.body_index, 0);
583        // Should hit at approximately t = 10 - 1.5 = 8.5 (distance minus combined radii)
584        assert!(h.t > Fix128::from_int(5), "Hit should be before the body");
585    }
586
587    #[test]
588    fn test_sphere_cast_miss() {
589        let bodies = make_bodies();
590        let hit = sphere_cast(
591            Vec3Fix::from_int(-10, 10, 0), // above all bodies
592            Fix128::from_ratio(1, 2),
593            Vec3Fix::UNIT_X,
594            Fix128::from_int(100),
595            &bodies,
596            Fix128::ONE,
597        );
598        assert!(hit.is_none(), "Sphere cast should miss (y offset)");
599    }
600
601    #[test]
602    fn test_capsule_cast() {
603        let bodies = make_bodies();
604        let hit = capsule_cast(
605            Vec3Fix::from_int(-10, -1, 0),
606            Vec3Fix::from_int(-10, 1, 0),
607            Fix128::from_ratio(1, 2),
608            Vec3Fix::UNIT_X,
609            Fix128::from_int(100),
610            &bodies,
611            Fix128::ONE,
612        );
613        assert!(hit.is_some(), "Capsule cast should hit");
614    }
615
616    #[test]
617    fn test_overlap_sphere() {
618        let bodies = make_bodies();
619        let results = overlap_sphere(
620            Vec3Fix::from_int(0, 0, 0),
621            Fix128::from_int(3),
622            &bodies,
623            Fix128::ONE,
624        );
625        // Body 0 at origin (dist=0 < 4), body 3 at (0,5,0) (dist=5 < 4? no, 5>4)
626        // Body 1 at (5,0,0) (dist=5 < 4? no)
627        // Only body 0 should overlap
628        assert_eq!(results.len(), 1);
629        assert_eq!(results[0].body_index, 0);
630    }
631
632    #[test]
633    fn test_overlap_sphere_multiple() {
634        let bodies = make_bodies();
635        let results = overlap_sphere(
636            Vec3Fix::from_int(2, 0, 0),
637            Fix128::from_int(5),
638            &bodies,
639            Fix128::ONE,
640        );
641        // Body 0 at (0,0,0): dist=2, combined=6 → overlap
642        // Body 1 at (5,0,0): dist=3, combined=6 → overlap
643        // Body 2 at (10,0,0): dist=8, combined=6 → no
644        // Body 3 at (0,5,0): dist=sqrt(4+25)≈5.4, combined=6 → overlap
645        assert!(
646            results.len() >= 2,
647            "Should find at least 2 overlaps, got {}",
648            results.len()
649        );
650    }
651
652    #[test]
653    fn test_overlap_aabb() {
654        let bodies = make_bodies();
655        let aabb = AABB::new(Vec3Fix::from_int(-1, -1, -1), Vec3Fix::from_int(6, 1, 1));
656        let results = overlap_aabb(&aabb, &bodies);
657        // Body 0 at (0,0,0) → inside
658        // Body 1 at (5,0,0) → inside
659        // Body 2 at (10,0,0) → outside
660        // Body 3 at (0,5,0) → outside
661        assert_eq!(results.len(), 2);
662    }
663
664    #[test]
665    fn test_batch_raycast() {
666        let bodies = make_bodies();
667        let queries = vec![
668            BatchRayQuery {
669                origin: Vec3Fix::from_int(-10, 0, 0),
670                direction: Vec3Fix::UNIT_X,
671                max_distance: Fix128::from_int(100),
672            },
673            BatchRayQuery {
674                origin: Vec3Fix::from_int(-10, 10, 0), // misses all
675                direction: Vec3Fix::UNIT_X,
676                max_distance: Fix128::from_int(100),
677            },
678        ];
679        let results = batch_raycast(&queries, &bodies, Fix128::ONE);
680        assert_eq!(results.len(), 2);
681        assert!(results[0].is_some(), "First ray should hit");
682        assert!(results[1].is_none(), "Second ray should miss");
683    }
684
685    #[test]
686    fn test_batch_sphere_cast() {
687        let bodies = make_bodies();
688        let origins = vec![Vec3Fix::from_int(-10, 0, 0), Vec3Fix::from_int(-10, 0, 0)];
689        let directions = vec![
690            Vec3Fix::UNIT_X,
691            Vec3Fix::UNIT_Y, // up — misses bodies at y=0
692        ];
693        let results = batch_sphere_cast(
694            &origins,
695            Fix128::from_ratio(1, 2),
696            &directions,
697            Fix128::from_int(100),
698            &bodies,
699            Fix128::ONE,
700        );
701        assert_eq!(results.len(), 2);
702        assert!(results[0].is_some(), "X-direction should hit");
703    }
704
705    #[test]
706    fn test_overlap_aabb_expanded() {
707        let bodies = make_bodies();
708        let aabb = AABB::new(Vec3Fix::from_int(4, -1, -1), Vec3Fix::from_int(6, 1, 1));
709        let results = overlap_aabb_expanded(&aabb, &bodies, Fix128::from_int(2));
710        // Expanded by 2: (2,-3,-3) to (8,3,3)
711        // Body 0 at (0,0,0) → outside (x=0 < 2)
712        // Body 1 at (5,0,0) → inside
713        // Body 2 at (10,0,0) → outside (x=10 > 8)
714        // Body 3 at (0,5,0) → outside
715        assert!(!results.is_empty(), "Should find body at (5,0,0)");
716    }
717
718    #[test]
719    fn test_overlap_sphere_bvh() {
720        use crate::bvh::{BvhPrimitive, LinearBvh};
721
722        let bodies = make_bodies();
723        let body_radius = Fix128::ONE;
724
725        // Build BVH from body AABBs
726        let prims: Vec<BvhPrimitive> = bodies
727            .iter()
728            .enumerate()
729            .map(|(i, b)| BvhPrimitive {
730                aabb: AABB::new(
731                    Vec3Fix::new(
732                        b.position.x - body_radius,
733                        b.position.y - body_radius,
734                        b.position.z - body_radius,
735                    ),
736                    Vec3Fix::new(
737                        b.position.x + body_radius,
738                        b.position.y + body_radius,
739                        b.position.z + body_radius,
740                    ),
741                ),
742                index: i as u32,
743                morton: 0,
744            })
745            .collect();
746        let bvh = LinearBvh::build(prims);
747
748        let results = overlap_sphere_bvh(
749            Vec3Fix::from_int(0, 0, 0),
750            Fix128::from_int(3),
751            &bodies,
752            body_radius,
753            &bvh,
754        );
755        // Same as the linear test: only body 0 overlaps
756        assert_eq!(results.len(), 1);
757        assert_eq!(results[0].body_index, 0);
758    }
759
760    #[test]
761    fn test_overlap_aabb_bvh() {
762        use crate::bvh::{BvhPrimitive, LinearBvh};
763
764        let bodies = make_bodies();
765        let body_radius = Fix128::ONE;
766
767        let prims: Vec<BvhPrimitive> = bodies
768            .iter()
769            .enumerate()
770            .map(|(i, b)| BvhPrimitive {
771                aabb: AABB::new(
772                    Vec3Fix::new(
773                        b.position.x - body_radius,
774                        b.position.y - body_radius,
775                        b.position.z - body_radius,
776                    ),
777                    Vec3Fix::new(
778                        b.position.x + body_radius,
779                        b.position.y + body_radius,
780                        b.position.z + body_radius,
781                    ),
782                ),
783                index: i as u32,
784                morton: 0,
785            })
786            .collect();
787        let bvh = LinearBvh::build(prims);
788
789        let aabb = AABB::new(Vec3Fix::from_int(-1, -1, -1), Vec3Fix::from_int(6, 1, 1));
790        let results = overlap_aabb_bvh(&aabb, &bodies, &bvh);
791        // Bodies at (0,0,0) and (5,0,0) are inside
792        assert_eq!(results.len(), 2);
793    }
794
795    /// The capsule `(−1, 0, 0)`–`(1, 0, 0)` of radius 1/2 cast along
796    /// `direction` up to `max` against `bodies` of radius 1/2 (reach 1).
797    fn cast(direction: Vec3Fix, max: i64, bodies: &[RigidBody]) -> Option<ShapeCastHit> {
798        let half = Fix128::from_ratio(1, 2);
799        capsule_cast(
800            Vec3Fix::from_int(-1, 0, 0),
801            Vec3Fix::from_int(1, 0, 0),
802            half,
803            direction,
804            Fix128::from_int(max),
805            bodies,
806            half,
807        )
808    }
809
810    fn body_at(p: Vec3Fix) -> RigidBody {
811        RigidBody::new_static(p)
812    }
813
814    #[test]
815    fn capsule_cast_starting_in_overlap_hits_at_zero_only_when_closing() {
816        // the body sits 0.8 above the axis, inside the reach of 1
817        let bodies = [body_at(Vec3Fix::new(
818            Fix128::ZERO,
819            Fix128::from_ratio(4, 5),
820            Fix128::ZERO,
821        ))];
822        // moving away (−y): the distance only grows, so no contact
823        assert_eq!(cast(Vec3Fix::from_int(0, -1, 0), 10, &bodies), None);
824        // moving towards it (+y): contact at t = 0, at the axis point under it
825        let hit = cast(Vec3Fix::from_int(0, 1, 0), 10, &bodies).expect("closing overlap");
826        assert_eq!(hit.t, Fix128::ZERO);
827        assert_eq!(hit.point, Vec3Fix::ZERO);
828        // the unit normal (0, −1, 0), to the rounding of the normalisation
829        let off = (hit.normal - Vec3Fix::from_int(0, -1, 0)).length_squared();
830        assert!(
831            off < Fix128::from_ratio(1, 1_000_000_000_000),
832            "{:?}",
833            hit.normal
834        );
835        // a body centred on the axis is hit at t = 0, normal against the sweep
836        let on_axis = [body_at(Vec3Fix::ZERO)];
837        let hit = cast(Vec3Fix::from_int(0, 0, 1), 10, &on_axis).expect("on axis");
838        assert_eq!(hit.t, Fix128::ZERO);
839        assert_eq!(hit.normal, Vec3Fix::from_int(0, 0, -1));
840    }
841
842    #[test]
843    fn capsule_cast_side_and_end_cap_times_of_impact() {
844        // side: a body 5 above the axis is reached when the axis is 1 below
845        // it, t = 4, at (0, 4, 0) with the normal back down
846        let side = [body_at(Vec3Fix::from_int(0, 5, 0))];
847        let hit = cast(Vec3Fix::from_int(0, 1, 0), 10, &side).expect("side hit");
848        assert_eq!(hit.t, Fix128::from_int(4));
849        assert_eq!(hit.point, Vec3Fix::from_int(0, 4, 0));
850        assert_eq!(hit.normal, Vec3Fix::from_int(0, -1, 0));
851        // beyond max_distance: no hit
852        assert_eq!(cast(Vec3Fix::from_int(0, 1, 0), 3, &side), None);
853        // end cap: a body at x = 4 on the axis meets the end (1, 0, 0) at t = 2
854        let cap = [body_at(Vec3Fix::from_int(4, 0, 0))];
855        let hit = cast(Vec3Fix::from_int(1, 0, 0), 10, &cap).expect("cap hit");
856        assert_eq!(hit.t, Fix128::from_int(2));
857        assert_eq!(hit.point, Vec3Fix::from_int(3, 0, 0));
858        assert_eq!(hit.normal, Vec3Fix::from_int(-1, 0, 0));
859    }
860
861    #[test]
862    fn capsule_cast_picks_the_earliest_body_and_ties_go_to_the_lowest_index() {
863        let p = |y| body_at(Vec3Fix::from_int(0, y, 0));
864        // the nearer body comes second in the list
865        let hit = cast(Vec3Fix::from_int(0, 1, 0), 10, &[p(8), p(5)]).expect("hit");
866        assert_eq!((hit.body_index, hit.t), (1, Fix128::from_int(4)));
867        // equal t: the lower index is kept
868        let hit = cast(Vec3Fix::from_int(0, 1, 0), 10, &[p(5), p(5)]).expect("hit");
869        assert_eq!(hit.body_index, 0);
870    }
871
872    #[test]
873    fn capsule_cast_degenerate_inputs() {
874        let bodies = [body_at(Vec3Fix::from_int(0, 5, 0))];
875        // zero direction: no sweep
876        assert_eq!(cast(Vec3Fix::ZERO, 10, &bodies), None);
877        // zero-length capsule: the sphere cast of radius 1/2, t = 5 − 1 = 4
878        let half = Fix128::from_ratio(1, 2);
879        let hit = capsule_cast(
880            Vec3Fix::ZERO,
881            Vec3Fix::ZERO,
882            half,
883            Vec3Fix::from_int(0, 1, 0),
884            Fix128::from_int(10),
885            &bodies,
886            half,
887        )
888        .expect("sphere fallback");
889        assert_eq!(hit.t, Fix128::from_int(4));
890        // and the sphere cast with a zero direction is None as well
891        assert_eq!(
892            sphere_cast(
893                Vec3Fix::ZERO,
894                half,
895                Vec3Fix::ZERO,
896                Fix128::ONE,
897                &bodies,
898                half
899            ),
900            None
901        );
902    }
903}