Skip to main content

symtropy_physics/
broadphase.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4//! Broadphase collision detection via LBVH (Linear Bounding Volume Hierarchy).
5//!
6//! Uses Morton codes (Z-order curves) for spatial sorting, then builds a BVH
7//! from the sorted order. The Morton encoding is the only D-dependent code;
8//! the hierarchy is dimension-agnostic. Falls back to brute-force for <50 bodies.
9//!
10//! # Determinism
11//! Morton codes use integer quantization, avoiding floating-point heuristics.
12//! Pair list sorted by (BodyHandle, BodyHandle) for identical resolution order.
13
14use crate::body::{BodyHandle, BodyType, RigidBody};
15use nalgebra::SVector;
16
17/// Pair of body handles that may be colliding.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct BroadphasePair(pub BodyHandle, pub BodyHandle);
20
21/// Axis-aligned bounding box in D dimensions.
22#[derive(Clone, Debug)]
23pub struct Aabb<const D: usize> {
24    pub min: SVector<f64, D>,
25    pub max: SVector<f64, D>,
26}
27
28impl<const D: usize> Aabb<D> {
29    pub fn empty() -> Self {
30        Self {
31            min: SVector::from_element(f64::MAX),
32            max: SVector::from_element(f64::MIN),
33        }
34    }
35    pub fn union(&self, other: &Self) -> Self {
36        let mut min = self.min;
37        let mut max = self.max;
38        for i in 0..D {
39            if other.min[i] < min[i] {
40                min[i] = other.min[i];
41            }
42            if other.max[i] > max[i] {
43                max[i] = other.max[i];
44            }
45        }
46        Self { min, max }
47    }
48    pub fn overlaps(&self, other: &Self) -> bool {
49        for i in 0..D {
50            if self.max[i] < other.min[i] || self.min[i] > other.max[i] {
51                return false;
52            }
53        }
54        true
55    }
56    pub fn from_sphere(center: &SVector<f64, D>, radius: f64) -> Self {
57        Self {
58            min: center - SVector::from_element(radius),
59            max: center + SVector::from_element(radius),
60        }
61    }
62}
63
64// Morton codes
65fn quantize(value: f64, world_min: f64, world_max: f64, bits: usize) -> u32 {
66    let range = world_max - world_min;
67    if range <= 0.0 {
68        return 0;
69    }
70    let max_val = ((1u64 << bits) - 1) as f64;
71    let normalized = ((value - world_min) / range).clamp(0.0, 1.0);
72    (normalized * max_val) as u32
73}
74
75/// Encode a D-dimensional position into a 64-bit Morton code.
76pub fn morton_encode<const D: usize>(
77    position: &SVector<f64, D>,
78    world_min: &SVector<f64, D>,
79    world_max: &SVector<f64, D>,
80) -> u64 {
81    let bits = 64 / D;
82    let mut code: u64 = 0;
83    for bit in 0..bits {
84        for axis in 0..D {
85            let q = quantize(position[axis], world_min[axis], world_max[axis], bits);
86            code |= (((q >> bit) & 1) as u64) << (bit * D + axis);
87        }
88    }
89    code
90}
91
92/// Extract top prefix_bits from a Morton code (for spatial authority zones).
93pub fn morton_prefix(code: u64, prefix_bits: usize) -> u64 {
94    if prefix_bits >= 64 {
95        code
96    } else {
97        code >> (64 - prefix_bits)
98    }
99}
100
101// LBVH
102const BRUTE_FORCE_THRESHOLD: usize = 50;
103const LEAF_BIT: u32 = 0x8000_0000;
104fn is_leaf(idx: u32) -> bool {
105    idx & LEAF_BIT != 0
106}
107fn leaf_index(idx: u32) -> usize {
108    (idx & !LEAF_BIT) as usize
109}
110
111#[derive(Clone, Debug)]
112struct BvhNode<const D: usize> {
113    aabb: Aabb<D>,
114    left: u32,
115    right: u32,
116}
117
118/// Linear Bounding Volume Hierarchy.
119pub struct Lbvh<const D: usize> {
120    sorted_indices: Vec<usize>,
121    leaf_aabbs: Vec<Aabb<D>>,
122    nodes: Vec<BvhNode<D>>,
123}
124
125impl<const D: usize> Lbvh<D> {
126    pub fn build(bodies: &[RigidBody<D>]) -> Self {
127        let n = bodies.len();
128        let mut aabbs = Vec::with_capacity(n);
129        let mut world_min = SVector::<f64, D>::from_element(f64::MAX);
130        let mut world_max = SVector::<f64, D>::from_element(f64::MIN);
131        for body in bodies {
132            let (c_local, r) = body.collider.bounding_sphere();
133            let c_world = body.transform.transform_point(&c_local).0;
134            let aabb = Aabb::from_sphere(&c_world, r);
135            for i in 0..D {
136                if aabb.min[i] < world_min[i] {
137                    world_min[i] = aabb.min[i];
138                }
139                if aabb.max[i] > world_max[i] {
140                    world_max[i] = aabb.max[i];
141                }
142            }
143            aabbs.push(aabb);
144        }
145        for i in 0..D {
146            if world_max[i] - world_min[i] < 1e-6 {
147                world_min[i] -= 1.0;
148                world_max[i] += 1.0;
149            }
150        }
151        let morton_codes: Vec<u64> = aabbs
152            .iter()
153            .map(|a| {
154                let c = (a.min + a.max) * 0.5;
155                morton_encode::<D>(&c, &world_min, &world_max)
156            })
157            .collect();
158        let mut sorted_indices: Vec<usize> = (0..n).collect();
159        sorted_indices.sort_by_key(|&i| morton_codes[i]);
160        let sorted_aabbs: Vec<Aabb<D>> = sorted_indices.iter().map(|&i| aabbs[i].clone()).collect();
161        let nodes = if n <= 1 {
162            Vec::new()
163        } else {
164            Self::build_nodes(
165                &sorted_indices
166                    .iter()
167                    .map(|&i| morton_codes[i])
168                    .collect::<Vec<_>>(),
169                &sorted_aabbs,
170            )
171        };
172        Self {
173            sorted_indices,
174            leaf_aabbs: sorted_aabbs,
175            nodes,
176        }
177    }
178
179    fn find_split(codes: &[u64], first: usize, last: usize) -> usize {
180        if codes[first] == codes[last] {
181            return (first + last) / 2;
182        }
183        let cp = (codes[first] ^ codes[last]).leading_zeros();
184        let mut split = first;
185        let mut step = last - first;
186        loop {
187            step = step.div_ceil(2);
188            let ns = split + step;
189            if ns < last && (codes[first] ^ codes[ns]).leading_zeros() > cp {
190                split = ns;
191            }
192            if step <= 1 {
193                break;
194            }
195        }
196        split
197    }
198
199    fn build_nodes(codes: &[u64], aabbs: &[Aabb<D>]) -> Vec<BvhNode<D>> {
200        let mut nodes = Vec::with_capacity(codes.len() - 1);
201        Self::build_rec(codes, aabbs, 0, codes.len() - 1, &mut nodes);
202        nodes
203    }
204
205    fn build_rec(
206        codes: &[u64],
207        aabbs: &[Aabb<D>],
208        first: usize,
209        last: usize,
210        nodes: &mut Vec<BvhNode<D>>,
211    ) -> u32 {
212        if first == last {
213            return LEAF_BIT | (first as u32);
214        }
215        let split = Self::find_split(codes, first, last);
216        let left = Self::build_rec(codes, aabbs, first, split, nodes);
217        let right = Self::build_rec(codes, aabbs, split + 1, last, nodes);
218        let la = if is_leaf(left) {
219            aabbs[leaf_index(left)].clone()
220        } else {
221            nodes[left as usize].aabb.clone()
222        };
223        let ra = if is_leaf(right) {
224            aabbs[leaf_index(right)].clone()
225        } else {
226            nodes[right as usize].aabb.clone()
227        };
228        let idx = nodes.len() as u32;
229        nodes.push(BvhNode {
230            aabb: la.union(&ra),
231            left,
232            right,
233        });
234        idx
235    }
236
237    pub fn query_pairs(&self, bodies: &[RigidBody<D>]) -> Vec<BroadphasePair> {
238        let n = self.sorted_indices.len();
239        if n <= 1 {
240            return Vec::new();
241        }
242        let mut pairs = Vec::new();
243        let root = (self.nodes.len() - 1) as u32;
244        for li in 0..n {
245            let la = &self.leaf_aabbs[li];
246            let lt = bodies[self.sorted_indices[li]].body_type;
247            let mut stack = arrayvec::ArrayVec::<u32, 64>::new();
248            stack.push(root);
249            while let Some(ni) = stack.pop() {
250                if is_leaf(ni) {
251                    let oi = leaf_index(ni);
252                    if oi > li {
253                        let ot = bodies[self.sorted_indices[oi]].body_type;
254                        if lt == BodyType::Static && ot == BodyType::Static {
255                            continue;
256                        }
257                        if !should_collide(
258                            &bodies[self.sorted_indices[li]],
259                            &bodies[self.sorted_indices[oi]],
260                        ) {
261                            continue;
262                        }
263                        if la.overlaps(&self.leaf_aabbs[oi]) {
264                            let (ha, hb) = (
265                                bodies[self.sorted_indices[li]].handle,
266                                bodies[self.sorted_indices[oi]].handle,
267                            );
268                            if ha < hb {
269                                pairs.push(BroadphasePair(ha, hb));
270                            } else {
271                                pairs.push(BroadphasePair(hb, ha));
272                            }
273                        }
274                    }
275                    continue;
276                }
277                let node = &self.nodes[ni as usize];
278                if la.overlaps(&node.aabb) {
279                    stack.push(node.left);
280                    stack.push(node.right);
281                }
282            }
283        }
284        pairs.sort_unstable_by_key(|p| (p.0, p.1));
285        pairs.dedup();
286        pairs
287    }
288}
289
290/// Check if two bodies should collide based on collision group/mask filters.
291/// Both bodies must accept each other: `(a.group & b.mask != 0) && (b.group & a.mask != 0)`.
292#[inline]
293fn should_collide<const D: usize>(a: &RigidBody<D>, b: &RigidBody<D>) -> bool {
294    (a.collision_group & b.collision_mask != 0) && (b.collision_group & a.collision_mask != 0)
295}
296
297/// Find all potentially colliding pairs. Uses LBVH for ≥50 bodies, brute-force otherwise.
298pub fn find_pairs<const D: usize>(bodies: &[RigidBody<D>]) -> Vec<BroadphasePair> {
299    if bodies.len() < BRUTE_FORCE_THRESHOLD {
300        find_pairs_brute(bodies)
301    } else {
302        Lbvh::build(bodies).query_pairs(bodies)
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Incremental broadphase: cache static/kinematic AABBs across frames
308// ---------------------------------------------------------------------------
309
310/// Pre-computed broadphase descriptor for a single body (AABB + sphere + filter).
311///
312/// Decoupled from `RigidBody<D>` so it can be cached without holding references.
313/// Stores both the AABB (for static-cache queries) and the bounding sphere center
314/// and radius (for dynamic–dynamic sphere-distance test, matching `find_pairs_brute`).
315#[derive(Clone, Debug)]
316pub struct BpEntry<const D: usize> {
317    pub handle: BodyHandle,
318    pub aabb: Aabb<D>,
319    /// World-space bounding sphere center.
320    pub sphere_center: SVector<f64, D>,
321    /// Bounding sphere radius.
322    pub sphere_radius: f64,
323    pub body_type: BodyType,
324    pub collision_group: u32,
325    pub collision_mask: u32,
326}
327
328impl<const D: usize> BpEntry<D> {
329    pub fn from_body(body: &RigidBody<D>) -> Self {
330        let (c_local, r) = body.collider.bounding_sphere();
331        let c_world = body.transform.transform_point(&c_local).0;
332        BpEntry {
333            handle: body.handle,
334            aabb: Aabb::from_sphere(&c_world, r),
335            sphere_center: c_world,
336            sphere_radius: r,
337            body_type: body.body_type,
338            collision_group: body.collision_group,
339            collision_mask: body.collision_mask,
340        }
341    }
342}
343
344/// Cached broadphase state for Static and Kinematic bodies.
345///
346/// Rebuilt only when a static/kinematic body is added or removed (`dirty` flag).
347/// This avoids including immovable geometry in the per-frame LBVH rebuild,
348/// which is O(n log n) in the total body count.
349///
350/// For scenes with many static bodies (terrain, walls) and few dynamic bodies,
351/// this typically halves or better the broadphase cost.
352pub struct StaticBroadphase<const D: usize> {
353    entries: Vec<BpEntry<D>>,
354}
355
356impl<const D: usize> StaticBroadphase<D> {
357    pub fn new() -> Self {
358        Self {
359            entries: Vec::new(),
360        }
361    }
362
363    /// Rebuild the cache from all bodies in the world.
364    ///
365    /// Call this when `static_tree_dirty` is set on `PhysicsWorld`.
366    pub fn rebuild(&mut self, bodies: &[RigidBody<D>]) {
367        self.entries = bodies
368            .iter()
369            .filter(|b| b.body_type == BodyType::Static || b.body_type == BodyType::Kinematic)
370            .map(BpEntry::from_body)
371            .collect();
372    }
373
374    pub fn is_empty(&self) -> bool {
375        self.entries.is_empty()
376    }
377
378    pub fn len(&self) -> usize {
379        self.entries.len()
380    }
381
382    /// Enumerate pairs between a dynamic entry and all cached static entries.
383    ///
384    /// Uses sphere-distance test (same as `find_pairs_brute`) for exact equivalence.
385    fn query_against<'a>(
386        &'a self,
387        dyn_entry: &'a BpEntry<D>,
388    ) -> impl Iterator<Item = BroadphasePair> + 'a {
389        self.entries.iter().filter_map(move |s| {
390            let group_ok = (dyn_entry.collision_group & s.collision_mask != 0)
391                && (s.collision_group & dyn_entry.collision_mask != 0);
392            if group_ok {
393                let dist = (dyn_entry.sphere_center - s.sphere_center).norm();
394                if dist <= dyn_entry.sphere_radius + s.sphere_radius {
395                    let (ha, hb) = (dyn_entry.handle, s.handle);
396                    let pair = if ha < hb {
397                        BroadphasePair(ha, hb)
398                    } else {
399                        BroadphasePair(hb, ha)
400                    };
401                    return Some(pair);
402                }
403            }
404            None
405        })
406    }
407}
408
409impl<const D: usize> Default for StaticBroadphase<D> {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415/// Find all potentially colliding pairs using the incremental static cache.
416///
417/// Separates the body list into dynamic and non-dynamic entries:
418/// - Dynamic–dynamic: brute-force O(n_dyn²), acceptable for typical games
419///   where the number of actively moving bodies is small.
420/// - Dynamic–static: linear scan against the prebuilt `StaticBroadphase`.
421///   For large static geometry this avoids re-packing static bodies into the
422///   LBVH every frame.
423///
424/// For very large dynamic counts (≥ BRUTE_FORCE_THRESHOLD) the existing
425/// `find_pairs` (full LBVH over all bodies) is called instead, since it is
426/// superior at scale.
427pub fn find_pairs_incremental<const D: usize>(
428    bodies: &[RigidBody<D>],
429    static_bp: &StaticBroadphase<D>,
430) -> Vec<BroadphasePair> {
431    // Fast paths
432    if bodies.is_empty() {
433        return Vec::new();
434    }
435    if static_bp.is_empty() {
436        // No static cache: identical to the standard path.
437        return find_pairs(bodies);
438    }
439
440    // Compute AABB entries for dynamic bodies only
441    let dynamic: Vec<BpEntry<D>> = bodies
442        .iter()
443        .filter(|b| b.body_type == BodyType::Dynamic)
444        .map(BpEntry::from_body)
445        .collect();
446
447    // For large dynamic counts fall back to the full LBVH path which is more
448    // efficient at scale. The static cache saves rebuilding the full tree.
449    if dynamic.len() >= BRUTE_FORCE_THRESHOLD {
450        return find_pairs(bodies);
451    }
452
453    let mut pairs = Vec::new();
454
455    // Dynamic–dynamic pairs (brute force, sphere distance test matching find_pairs_brute)
456    for i in 0..dynamic.len() {
457        for j in (i + 1)..dynamic.len() {
458            let a = &dynamic[i];
459            let b = &dynamic[j];
460            let group_ok = (a.collision_group & b.collision_mask != 0)
461                && (b.collision_group & a.collision_mask != 0);
462            if group_ok {
463                let dist = (a.sphere_center - b.sphere_center).norm();
464                if dist <= a.sphere_radius + b.sphere_radius {
465                    let (ha, hb) = (a.handle, b.handle);
466                    if ha < hb {
467                        pairs.push(BroadphasePair(ha, hb));
468                    } else {
469                        pairs.push(BroadphasePair(hb, ha));
470                    }
471                }
472            }
473        }
474    }
475
476    // Dynamic–static pairs (linear scan against cached static AABB entries)
477    for dyn_entry in &dynamic {
478        pairs.extend(static_bp.query_against(dyn_entry));
479    }
480
481    pairs.sort_unstable_by_key(|p| (p.0, p.1));
482    pairs.dedup();
483    pairs
484}
485
486fn find_pairs_brute<const D: usize>(bodies: &[RigidBody<D>]) -> Vec<BroadphasePair> {
487    let mut pairs = Vec::new();
488    for i in 0..bodies.len() {
489        let ti = bodies[i].body_type;
490        for j in (i + 1)..bodies.len() {
491            if ti == BodyType::Static && bodies[j].body_type == BodyType::Static {
492                continue;
493            }
494            if !should_collide(&bodies[i], &bodies[j]) {
495                continue;
496            }
497            let (ci, ri) = bodies[i].collider.bounding_sphere();
498            let (cj, rj) = bodies[j].collider.bounding_sphere();
499            let wi = bodies[i].transform.transform_point(&ci);
500            let wj = bodies[j].transform.transform_point(&cj);
501            if wi.distance(&wj) <= ri + rj {
502                pairs.push(BroadphasePair(bodies[i].handle, bodies[j].handle));
503            }
504        }
505    }
506    pairs
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::body::RigidBody;
513    use symtropy_math::{Point, Sphere};
514
515    #[test]
516    fn overlapping_pair_detected() {
517        let bodies = vec![
518            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 0.0, 0.0]), 1.0, 1.0),
519            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
520        ];
521        assert_eq!(find_pairs(&bodies).len(), 1);
522    }
523
524    #[test]
525    fn separated_pair_not_detected() {
526        let bodies = vec![
527            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 0.0, 0.0]), 1.0, 1.0),
528            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([5.0, 0.0, 0.0]), 1.0, 1.0),
529        ];
530        assert!(find_pairs(&bodies).is_empty());
531    }
532
533    #[test]
534    fn static_static_ignored() {
535        let bodies = vec![
536            RigidBody::<3>::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::unit())),
537            RigidBody::<3>::static_body(
538                BodyHandle(1),
539                Point::new([0.5, 0.0, 0.0]),
540                Box::new(Sphere::unit()),
541            ),
542        ];
543        assert!(find_pairs(&bodies).is_empty());
544    }
545
546    #[test]
547    fn static_dynamic_detected() {
548        let bodies = vec![
549            RigidBody::<3>::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::unit())),
550            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
551        ];
552        assert_eq!(find_pairs(&bodies).len(), 1);
553    }
554
555    #[test]
556    fn multiple_pairs() {
557        let bodies = vec![
558            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 0.0, 0.0]), 1.0, 1.0),
559            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
560            RigidBody::<3>::dynamic_sphere(BodyHandle(2), Point::new([10.0, 0.0, 0.0]), 1.0, 1.0),
561            RigidBody::<3>::dynamic_sphere(BodyHandle(3), Point::new([1.0, 1.0, 0.0]), 1.0, 1.0),
562        ];
563        assert!(find_pairs(&bodies).len() >= 2);
564    }
565
566    #[test]
567    fn morton_encode_2d_origin() {
568        let pos = SVector::<f64, 2>::from([0.0, 0.0]);
569        let min = SVector::<f64, 2>::from([0.0, 0.0]);
570        let max = SVector::<f64, 2>::from([100.0, 100.0]);
571        assert_eq!(morton_encode::<2>(&pos, &min, &max), 0);
572    }
573
574    #[test]
575    fn morton_locality() {
576        let min = SVector::<f64, 3>::from([0.0; 3]);
577        let max = SVector::<f64, 3>::from([100.0; 3]);
578        let a = morton_encode::<3>(&SVector::from([10.0, 10.0, 10.0]), &min, &max);
579        let b = morton_encode::<3>(&SVector::from([11.0, 10.0, 10.0]), &min, &max);
580        let far = morton_encode::<3>(&SVector::from([90.0, 90.0, 90.0]), &min, &max);
581        assert!((a as i64 - b as i64).unsigned_abs() < (a as i64 - far as i64).unsigned_abs());
582    }
583
584    #[test]
585    fn lbvh_matches_brute_force() {
586        let mut bodies = Vec::new();
587        for i in 0..(BRUTE_FORCE_THRESHOLD + 10) {
588            let x = (i as f64) * 0.5;
589            bodies.push(RigidBody::<2>::dynamic_sphere(
590                BodyHandle(i),
591                Point::new([x, 0.0]),
592                1.0,
593                1.0,
594            ));
595        }
596        let mut brute = find_pairs_brute(&bodies);
597        brute.sort_by_key(|p| (p.0, p.1));
598        let lbvh = find_pairs(&bodies);
599        for pair in &brute {
600            assert!(lbvh.contains(pair), "LBVH missing pair {:?}", pair);
601        }
602    }
603
604    #[test]
605    fn lbvh_deterministic() {
606        let bodies = vec![
607            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 0.0, 0.0]), 1.0, 1.0),
608            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
609            RigidBody::<3>::dynamic_sphere(BodyHandle(2), Point::new([0.5, 0.5, 0.5]), 0.8, 1.0),
610        ];
611        assert_eq!(find_pairs(&bodies), find_pairs(&bodies));
612    }
613
614    #[test]
615    fn aabb_overlap() {
616        let a = Aabb::<2> {
617            min: SVector::from([0.0, 0.0]),
618            max: SVector::from([2.0, 2.0]),
619        };
620        let b = Aabb::<2> {
621            min: SVector::from([1.0, 1.0]),
622            max: SVector::from([3.0, 3.0]),
623        };
624        let c = Aabb::<2> {
625            min: SVector::from([5.0, 5.0]),
626            max: SVector::from([6.0, 6.0]),
627        };
628        assert!(a.overlaps(&b));
629        assert!(!a.overlaps(&c));
630    }
631
632    // --- Incremental broadphase tests ---
633
634    #[test]
635    fn incremental_dynamic_static_detected() {
636        let bodies = vec![
637            RigidBody::<3>::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::unit())),
638            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
639        ];
640        let mut static_bp = super::StaticBroadphase::new();
641        static_bp.rebuild(&bodies);
642
643        let pairs = super::find_pairs_incremental(&bodies, &static_bp);
644        assert_eq!(pairs.len(), 1);
645        let p = pairs[0];
646        assert!(
647            (p.0 == BodyHandle(0) && p.1 == BodyHandle(1))
648                || (p.0 == BodyHandle(1) && p.1 == BodyHandle(0))
649        );
650    }
651
652    #[test]
653    fn incremental_static_static_not_produced() {
654        let bodies = vec![
655            RigidBody::<3>::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::unit())),
656            RigidBody::<3>::static_body(
657                BodyHandle(1),
658                Point::new([0.5, 0.0, 0.0]),
659                Box::new(Sphere::unit()),
660            ),
661        ];
662        let mut static_bp = super::StaticBroadphase::new();
663        static_bp.rebuild(&bodies);
664
665        let pairs = super::find_pairs_incremental(&bodies, &static_bp);
666        assert!(pairs.is_empty(), "static–static must never collide");
667    }
668
669    #[test]
670    fn incremental_matches_standard_find_pairs() {
671        let bodies = vec![
672            RigidBody::<3>::static_body(
673                BodyHandle(0),
674                Point::new([0.0, 0.0, 0.0]),
675                Box::new(Sphere::unit()),
676            ),
677            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
678            RigidBody::<3>::dynamic_sphere(BodyHandle(2), Point::new([0.0, 1.5, 0.0]), 1.0, 1.0),
679            RigidBody::<3>::dynamic_sphere(BodyHandle(3), Point::new([10.0, 0.0, 0.0]), 1.0, 1.0),
680        ];
681        let mut static_bp = super::StaticBroadphase::new();
682        static_bp.rebuild(&bodies);
683
684        let mut incremental = super::find_pairs_incremental(&bodies, &static_bp);
685        let mut standard = find_pairs(&bodies);
686        incremental.sort_by_key(|p| (p.0, p.1));
687        standard.sort_by_key(|p| (p.0, p.1));
688        assert_eq!(
689            incremental, standard,
690            "incremental must match standard broadphase"
691        );
692    }
693
694    #[test]
695    fn incremental_no_static_degrades_to_standard() {
696        let bodies = vec![
697            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 0.0, 0.0]), 1.0, 1.0),
698            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
699        ];
700        let empty_bp = super::StaticBroadphase::<3>::new(); // no rebuild
701        let pairs = super::find_pairs_incremental(&bodies, &empty_bp);
702        assert_eq!(pairs.len(), 1);
703    }
704
705    #[test]
706    fn static_broadphase_rebuild_updates_cache() {
707        let bodies = vec![
708            RigidBody::<3>::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::unit())),
709            RigidBody::<3>::dynamic_sphere(BodyHandle(1), Point::new([1.5, 0.0, 0.0]), 1.0, 1.0),
710        ];
711        let mut static_bp = super::StaticBroadphase::<3>::new();
712        assert_eq!(static_bp.len(), 0);
713
714        static_bp.rebuild(&bodies);
715        assert_eq!(static_bp.len(), 1, "should cache the one static body");
716    }
717}