Skip to main content

oxiphysics_collision/narrowphase/dispatch/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use crate::narrowphase::epa::Epa;
6use crate::narrowphase::gjk::{Gjk, GjkResult};
7use crate::narrowphase::specialized;
8use crate::types::{CollisionPair, Contact, ContactManifold};
9use oxiphysics_core::Transform;
10use oxiphysics_geometry::{BoxShape, Capsule, Shape, Sphere};
11
12use super::types::{
13    CompoundDispatchResult, CompoundShape, ConcaveMesh, ContactPatch, MeshTriangle,
14    NarrowPhaseDispatcher, NarrowPhaseResult, ShapeType, SimpleHeightField, SpeculativeConfig,
15};
16
17/// Batch entry for speculative contact generation.
18pub type SpecBatchEntry<'a> = (
19    &'a dyn Shape,
20    &'a Transform,
21    [f64; 3],
22    &'a dyn Shape,
23    &'a Transform,
24    [f64; 3],
25    CollisionPair,
26);
27
28/// Map shape types to a numeric ordinal for canonical ordering.
29pub(super) fn shape_type_ordinal(t: ShapeType) -> u32 {
30    match t {
31        ShapeType::Sphere => 0,
32        ShapeType::Box => 1,
33        ShapeType::Capsule => 2,
34        ShapeType::Cylinder => 3,
35        ShapeType::Cone => 4,
36        ShapeType::ConvexHull => 5,
37        ShapeType::TriangleMesh => 6,
38        ShapeType::Compound => 7,
39        ShapeType::HeightField => 8,
40        ShapeType::Plane => 9,
41        ShapeType::Custom(id) => 100 + id,
42    }
43}
44/// Type of a registered narrow-phase collision function.
45pub type NarrowPhaseFn = fn(
46    shape_a: &dyn Shape,
47    transform_a: &Transform,
48    shape_b: &dyn Shape,
49    transform_b: &Transform,
50    pair: CollisionPair,
51) -> NarrowPhaseResult;
52pub(super) fn sphere_capsule_dispatch(
53    shape_a: &dyn Shape,
54    transform_a: &Transform,
55    shape_b: &dyn Shape,
56    transform_b: &Transform,
57    pair: CollisionPair,
58) -> NarrowPhaseResult {
59    use oxiphysics_core::math::Vec3;
60    let sphere = unsafe { &*(shape_a as *const dyn Shape as *const Sphere) };
61    let capsule = unsafe { &*(shape_b as *const dyn Shape as *const Capsule) };
62    let sphere_center = transform_a.position;
63    let half_h = capsule.half_height;
64    let cap_up = transform_b.rotation * Vec3::new(0.0, half_h, 0.0);
65    let cap_a = transform_b.position + cap_up;
66    let cap_b = transform_b.position - cap_up;
67    let ab = cap_b - cap_a;
68    let ab_len2 = ab.dot(&ab);
69    let t = if ab_len2 > 1e-12 {
70        ((sphere_center - cap_a).dot(&ab) / ab_len2).clamp(0.0, 1.0)
71    } else {
72        0.5
73    };
74    let closest = cap_a + ab * t;
75    let diff = sphere_center - closest;
76    let dist = diff.norm();
77    let combined = sphere.radius + capsule.radius;
78    if dist >= combined {
79        return NarrowPhaseResult::separated();
80    }
81    let normal = if dist > 1e-10 {
82        diff / dist
83    } else {
84        Vec3::new(0.0, 1.0, 0.0)
85    };
86    let depth = combined - dist;
87    let point_a = sphere_center - normal * sphere.radius;
88    let point_b = closest + normal * capsule.radius;
89    let contact = Contact::new(point_a, point_b, normal, depth);
90    let mut m = ContactManifold::new(pair);
91    m.add_contact(contact);
92    NarrowPhaseResult::contact(m)
93}
94pub(super) fn gjk_fallback_dispatch(
95    shape_a: &dyn Shape,
96    transform_a: &Transform,
97    shape_b: &dyn Shape,
98    transform_b: &Transform,
99    pair: CollisionPair,
100) -> NarrowPhaseResult {
101    match gjk_epa(shape_a, transform_a, shape_b, transform_b, pair) {
102        Some(manifold) => NarrowPhaseResult::contact(manifold),
103        None => NarrowPhaseResult::separated(),
104    }
105}
106/// Dispatch against a compound shape by testing each child shape individually
107/// and collecting all contacts.
108pub fn dispatch_compound(
109    dispatcher: &NarrowPhaseDispatcher,
110    compound_shapes: &[(ShapeType, &dyn Shape, Transform)],
111    other_shape: &dyn Shape,
112    other_type: ShapeType,
113    other_transform: &Transform,
114    base_pair: CollisionPair,
115) -> NarrowPhaseResult {
116    let mut all_contacts = Vec::new();
117    for (child_type, child_shape, child_transform) in compound_shapes {
118        let result = dispatcher.dispatch(
119            *child_shape,
120            *child_type,
121            child_transform,
122            other_shape,
123            other_type,
124            other_transform,
125            base_pair,
126        );
127        if let Some(manifold) = result.manifold {
128            all_contacts.extend(manifold.contacts);
129        }
130    }
131    if all_contacts.is_empty() {
132        NarrowPhaseResult::separated()
133    } else {
134        let mut manifold = ContactManifold::new(base_pair);
135        for c in all_contacts {
136            manifold.add_contact(c);
137        }
138        NarrowPhaseResult::contact(manifold)
139    }
140}
141/// Attempt to use a specialized collision test.
142pub(super) fn try_specialized(
143    shape_a: &dyn Shape,
144    transform_a: &Transform,
145    shape_b: &dyn Shape,
146    transform_b: &Transform,
147) -> Option<Contact> {
148    let name_a = type_name(shape_a);
149    let name_b = type_name(shape_b);
150    match (name_a, name_b) {
151        ("Sphere", "Sphere") => {
152            let s1 = as_sphere(shape_a)?;
153            let s2 = as_sphere(shape_b)?;
154            specialized::sphere_sphere(s1, transform_a, s2, transform_b)
155        }
156        ("Sphere", "BoxShape") => {
157            let s = as_sphere(shape_a)?;
158            let b = as_box(shape_b)?;
159            specialized::sphere_box(s, transform_a, b, transform_b)
160        }
161        ("BoxShape", "Sphere") => {
162            let b = as_box(shape_a)?;
163            let s = as_sphere(shape_b)?;
164            let mut c = specialized::sphere_box(s, transform_b, b, transform_a)?;
165            c.normal = -c.normal;
166            std::mem::swap(&mut c.point_a, &mut c.point_b);
167            Some(c)
168        }
169        ("BoxShape", "BoxShape") => {
170            let b1 = as_box(shape_a)?;
171            let b2 = as_box(shape_b)?;
172            specialized::box_box_sat(b1, transform_a, b2, transform_b)
173        }
174        ("Capsule", "Capsule") => {
175            let c1 = as_capsule(shape_a)?;
176            let c2 = as_capsule(shape_b)?;
177            specialized::capsule_capsule(c1, transform_a, c2, transform_b)
178        }
179        _ => None,
180    }
181}
182pub(super) fn gjk_epa(
183    shape_a: &dyn Shape,
184    transform_a: &Transform,
185    shape_b: &dyn Shape,
186    transform_b: &Transform,
187    pair: CollisionPair,
188) -> Option<ContactManifold> {
189    let result = Gjk::query(shape_a, transform_a, shape_b, transform_b);
190    match result {
191        GjkResult::Intersecting(simplex) => {
192            let contact =
193                Epa::penetration_depth(shape_a, transform_a, shape_b, transform_b, &simplex)?;
194            let mut manifold = ContactManifold::new(pair);
195            manifold.add_contact(contact);
196            Some(manifold)
197        }
198        GjkResult::Separated { .. } => None,
199    }
200}
201/// Extract type name from Debug output.
202pub(super) fn type_name(shape: &dyn Shape) -> &'static str {
203    let debug = format!("{:?}", shape);
204    if debug.starts_with("Sphere") {
205        "Sphere"
206    } else if debug.starts_with("BoxShape") {
207        "BoxShape"
208    } else if debug.starts_with("Capsule") {
209        "Capsule"
210    } else if debug.starts_with("Cylinder") {
211        "Cylinder"
212    } else if debug.starts_with("Cone") {
213        "Cone"
214    } else {
215        "Unknown"
216    }
217}
218pub(super) fn as_sphere(shape: &dyn Shape) -> Option<&Sphere> {
219    let s = shape.support_point(&oxiphysics_core::math::Vec3::new(1.0, 0.0, 0.0));
220    let radius = s.x;
221    if radius > 0.0 {
222        let ptr = shape as *const dyn Shape as *const Sphere;
223        Some(unsafe { &*ptr })
224    } else {
225        None
226    }
227}
228pub(super) fn as_box(shape: &dyn Shape) -> Option<&BoxShape> {
229    let ptr = shape as *const dyn Shape as *const BoxShape;
230    Some(unsafe { &*ptr })
231}
232pub(super) fn as_capsule(shape: &dyn Shape) -> Option<&Capsule> {
233    let ptr = shape as *const dyn Shape as *const Capsule;
234    Some(unsafe { &*ptr })
235}
236/// Dispatch compound vs compound using O(n²) brute-force with optional AABB pruning.
237///
238/// `use_aabb_pruning` enables a cheap AABB overlap pre-filter; when false
239/// every sub-shape pair is tested unconditionally.
240pub fn dispatch_compound_compound(
241    dispatcher: &NarrowPhaseDispatcher,
242    compound_a: &CompoundShape,
243    transform_a: &Transform,
244    compound_b: &CompoundShape,
245    transform_b: &Transform,
246    pair_id: u32,
247    use_aabb_pruning: bool,
248) -> CompoundDispatchResult {
249    let mut manifolds = Vec::new();
250    let mut tests = 0usize;
251    let mut pruned = 0usize;
252    let mut pair_counter = (pair_id as usize) * 1000;
253    for (ia, (child_a, ta_local)) in compound_a
254        .children
255        .iter()
256        .zip(compound_a.local_transforms.iter())
257        .enumerate()
258    {
259        let world_a = compose_transforms(transform_a, ta_local);
260        for (ib, (child_b, tb_local)) in compound_b
261            .children
262            .iter()
263            .zip(compound_b.local_transforms.iter())
264            .enumerate()
265        {
266            let world_b = compose_transforms(transform_b, tb_local);
267            if use_aabb_pruning {
268                let pa = world_a.position;
269                let pb = world_b.position;
270                let dist2 = {
271                    let dx = pa.x - pb.x;
272                    let dy = pa.y - pb.y;
273                    let dz = pa.z - pb.z;
274                    dx * dx + dy * dy + dz * dz
275                };
276                if dist2 > 25.0 {
277                    pruned += 1;
278                    continue;
279                }
280            }
281            let sub_pair = CollisionPair::new(ia, ib + pair_counter);
282            pair_counter += 1;
283            tests += 1;
284            let result = dispatcher.dispatch(
285                child_a.as_ref(),
286                compound_a.child_types[ia],
287                &world_a,
288                child_b.as_ref(),
289                compound_b.child_types[ib],
290                &world_b,
291                sub_pair,
292            );
293            if result.has_contact()
294                && let Some(m) = result.manifold
295            {
296                manifolds.push(m);
297            }
298        }
299    }
300    CompoundDispatchResult {
301        manifolds,
302        tests_performed: tests,
303        pairs_pruned: pruned,
304    }
305}
306/// Compose a parent world transform with a local child transform.
307pub(super) fn compose_transforms(parent: &Transform, local: &Transform) -> Transform {
308    parent.compose(local)
309}
310/// Dispatch a convex sphere against a heightfield.
311///
312/// Returns a `NarrowPhaseResult` using the heightfield's sphere test.
313pub fn dispatch_heightfield_sphere(
314    hf: &SimpleHeightField,
315    sphere_center: [f64; 3],
316    sphere_radius: f64,
317    pair: CollisionPair,
318) -> NarrowPhaseResult {
319    if let Some((point, normal, depth)) = hf.test_sphere(sphere_center, sphere_radius) {
320        let contact = Contact {
321            point_a: oxiphysics_core::math::Vec3::new(point[0], point[1], point[2]),
322            point_b: oxiphysics_core::math::Vec3::new(
323                sphere_center[0] - normal[0] * sphere_radius,
324                sphere_center[1] - normal[1] * sphere_radius,
325                sphere_center[2] - normal[2] * sphere_radius,
326            ),
327            normal: oxiphysics_core::math::Vec3::new(normal[0], normal[1], normal[2]),
328            depth,
329        };
330        let mut m = ContactManifold::new(pair);
331        m.add_contact(contact);
332        NarrowPhaseResult::contact(m)
333    } else {
334        NarrowPhaseResult::separated()
335    }
336}
337/// Mid-phase mesh vs mesh AABB overlap test.
338///
339/// Returns the pairs `(i, j)` of triangle indices whose AABBs overlap.
340pub fn mesh_mesh_aabb_mid_phase(
341    triangles_a: &[MeshTriangle],
342    triangles_b: &[MeshTriangle],
343) -> Vec<(usize, usize)> {
344    let mut pairs = Vec::new();
345    for (ia, ta) in triangles_a.iter().enumerate() {
346        for (ib, tb) in triangles_b.iter().enumerate() {
347            if ta.aabb.overlaps(&tb.aabb) {
348                pairs.push((ia, ib));
349            }
350        }
351    }
352    pairs
353}
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::CollisionPair;
358    use crate::Contact;
359    use crate::ContactManifold;
360    use crate::NarrowPhaseDispatcher;
361
362    use crate::narrowphase::Aabb;
363
364    use crate::narrowphase::DispatchConfig;
365    use crate::narrowphase::DispatchKey;
366
367    use crate::narrowphase::DispatchStats;
368    use crate::narrowphase::ShapeFeatureCache;
369    use crate::narrowphase::SimpleHeightField;
370
371    use crate::narrowphase::dispatch::ShapeType;
372
373    use oxiphysics_core::Vec3;
374    use std::collections::HashMap;
375    #[test]
376    fn test_dispatch_sphere_sphere() {
377        let s1 = Sphere::new(1.0);
378        let s2 = Sphere::new(1.0);
379        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
380        let t2 = Transform::from_position(Vec3::new(1.5, 0.0, 0.0));
381        let pair = CollisionPair::new(0, 1);
382        let manifold = NarrowPhaseDispatcher::generate_contacts(&s1, &t1, &s2, &t2, pair);
383        assert!(manifold.is_some());
384        let m = manifold.unwrap();
385        assert_eq!(m.contacts.len(), 1);
386    }
387    #[test]
388    fn test_dispatch_separated() {
389        let s1 = Sphere::new(1.0);
390        let s2 = Sphere::new(1.0);
391        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
392        let t2 = Transform::from_position(Vec3::new(5.0, 0.0, 0.0));
393        let pair = CollisionPair::new(0, 1);
394        let manifold = NarrowPhaseDispatcher::generate_contacts(&s1, &t1, &s2, &t2, pair);
395        assert!(manifold.is_none());
396    }
397    #[test]
398    fn test_dispatch_key_canonical() {
399        let k1 = DispatchKey::new(ShapeType::Sphere, ShapeType::Box);
400        let k2 = DispatchKey::new(ShapeType::Box, ShapeType::Sphere);
401        assert_eq!(k1, k2, "DispatchKey should be order-independent");
402    }
403    #[test]
404    fn test_dispatch_key_same() {
405        let k = DispatchKey::new(ShapeType::Capsule, ShapeType::Capsule);
406        assert_eq!(k.0, k.1);
407    }
408    #[test]
409    fn test_default_dispatcher_sphere_sphere() {
410        let dispatcher = NarrowPhaseDispatcher::default();
411        let s1 = Sphere::new(1.0);
412        let s2 = Sphere::new(1.0);
413        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
414        let t2 = Transform::from_position(Vec3::new(1.5, 0.0, 0.0));
415        let pair = CollisionPair::new(0, 1);
416        let result = dispatcher.dispatch(
417            &s1,
418            ShapeType::Sphere,
419            &t1,
420            &s2,
421            ShapeType::Sphere,
422            &t2,
423            pair,
424        );
425        assert!(
426            result.manifold.is_some(),
427            "overlapping spheres should produce contact"
428        );
429    }
430    #[test]
431    fn test_default_dispatcher_sphere_sphere_separated() {
432        let dispatcher = NarrowPhaseDispatcher::default();
433        let s1 = Sphere::new(1.0);
434        let s2 = Sphere::new(1.0);
435        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
436        let t2 = Transform::from_position(Vec3::new(10.0, 0.0, 0.0));
437        let pair = CollisionPair::new(0, 1);
438        let result = dispatcher.dispatch(
439            &s1,
440            ShapeType::Sphere,
441            &t1,
442            &s2,
443            ShapeType::Sphere,
444            &t2,
445            pair,
446        );
447        assert!(
448            result.manifold.is_none(),
449            "separated spheres should produce no contact"
450        );
451    }
452    #[test]
453    fn test_register_custom_pair() {
454        let mut dispatcher = NarrowPhaseDispatcher::empty();
455        dispatcher.register_pair(
456            ShapeType::Sphere,
457            ShapeType::Sphere,
458            |_sa, _ta, _sb, _tb, pair| {
459                use crate::types::Contact;
460                let contact = Contact {
461                    point_a: Vec3::zeros(),
462                    point_b: Vec3::zeros(),
463                    normal: Vec3::new(0.0, 1.0, 0.0),
464                    depth: 1.0,
465                };
466                let mut m = ContactManifold::new(pair);
467                m.add_contact(contact);
468                NarrowPhaseResult::contact(m)
469            },
470        );
471        let s1 = Sphere::new(1.0);
472        let s2 = Sphere::new(1.0);
473        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
474        let t2 = Transform::from_position(Vec3::new(100.0, 0.0, 0.0));
475        let pair = CollisionPair::new(0, 1);
476        let result = dispatcher.dispatch(
477            &s1,
478            ShapeType::Sphere,
479            &t1,
480            &s2,
481            ShapeType::Sphere,
482            &t2,
483            pair,
484        );
485        assert!(
486            result.manifold.is_some(),
487            "custom algorithm should produce contact"
488        );
489    }
490    #[test]
491    fn test_narrow_phase_result_separated() {
492        let r = NarrowPhaseResult::separated();
493        assert!(r.manifold.is_none());
494        assert!(!r.has_contact());
495        assert!(r.penetration_depth().is_none());
496    }
497    #[test]
498    fn test_narrow_phase_result_has_contact() {
499        let pair = CollisionPair::new(0, 1);
500        let mut m = ContactManifold::new(pair);
501        m.add_contact(Contact {
502            point_a: Vec3::zeros(),
503            point_b: Vec3::zeros(),
504            normal: Vec3::new(0.0, 1.0, 0.0),
505            depth: 0.5,
506        });
507        let r = NarrowPhaseResult::contact(m);
508        assert!(r.has_contact());
509        assert!((r.penetration_depth().unwrap() - 0.5).abs() < 1e-10);
510    }
511    #[test]
512    fn test_shape_type_all_variants() {
513        let types = [
514            ShapeType::Sphere,
515            ShapeType::Box,
516            ShapeType::Capsule,
517            ShapeType::Cylinder,
518            ShapeType::Cone,
519            ShapeType::ConvexHull,
520            ShapeType::TriangleMesh,
521            ShapeType::Compound,
522            ShapeType::HeightField,
523            ShapeType::Plane,
524            ShapeType::Custom(0),
525        ];
526        let mut map: HashMap<DispatchKey, usize> = HashMap::new();
527        for (i, &t) in types.iter().enumerate() {
528            map.insert(DispatchKey::new(t, t), i);
529        }
530        assert_eq!(map.len(), types.len());
531    }
532    #[test]
533    fn test_dispatch_key_custom_types() {
534        let k1 = DispatchKey::new(ShapeType::Custom(0), ShapeType::Custom(1));
535        let k2 = DispatchKey::new(ShapeType::Custom(1), ShapeType::Custom(0));
536        assert_eq!(k1, k2, "custom type keys should be canonical");
537    }
538    #[test]
539    fn test_unregister_pair() {
540        let mut dispatcher = NarrowPhaseDispatcher::default();
541        assert!(dispatcher.has_pair(ShapeType::Sphere, ShapeType::Sphere));
542        let removed = dispatcher.unregister_pair(ShapeType::Sphere, ShapeType::Sphere);
543        assert!(removed);
544        assert!(!dispatcher.has_pair(ShapeType::Sphere, ShapeType::Sphere));
545    }
546    #[test]
547    fn test_unregister_nonexistent() {
548        let mut dispatcher = NarrowPhaseDispatcher::empty();
549        let removed = dispatcher.unregister_pair(ShapeType::Cone, ShapeType::Cone);
550        assert!(!removed);
551    }
552    #[test]
553    fn test_registered_count() {
554        let dispatcher = NarrowPhaseDispatcher::default();
555        assert_eq!(dispatcher.registered_count(), 6);
556    }
557    #[test]
558    fn test_registered_keys() {
559        let dispatcher = NarrowPhaseDispatcher::default();
560        let keys = dispatcher.registered_keys();
561        assert_eq!(keys.len(), 6);
562    }
563    #[test]
564    fn test_has_pair() {
565        let dispatcher = NarrowPhaseDispatcher::default();
566        assert!(dispatcher.has_pair(ShapeType::Sphere, ShapeType::Sphere));
567        assert!(dispatcher.has_pair(ShapeType::Sphere, ShapeType::Box));
568        assert!(dispatcher.has_pair(ShapeType::Box, ShapeType::Sphere));
569        assert!(!dispatcher.has_pair(ShapeType::Cone, ShapeType::Cone));
570    }
571    #[test]
572    fn test_sphere_capsule_overlapping() {
573        let dispatcher = NarrowPhaseDispatcher::default();
574        let sphere = Sphere::new(1.0);
575        let capsule = Capsule::new(0.5, 1.0);
576        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
577        let t2 = Transform::from_position(Vec3::new(1.0, 0.0, 0.0));
578        let pair = CollisionPair::new(0, 1);
579        let result = dispatcher.dispatch(
580            &sphere,
581            ShapeType::Sphere,
582            &t1,
583            &capsule,
584            ShapeType::Capsule,
585            &t2,
586            pair,
587        );
588        assert!(
589            result.has_contact(),
590            "overlapping sphere-capsule should produce contact"
591        );
592    }
593    #[test]
594    fn test_sphere_capsule_separated() {
595        let dispatcher = NarrowPhaseDispatcher::default();
596        let sphere = Sphere::new(0.5);
597        let capsule = Capsule::new(0.5, 1.0);
598        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
599        let t2 = Transform::from_position(Vec3::new(5.0, 0.0, 0.0));
600        let pair = CollisionPair::new(0, 1);
601        let result = dispatcher.dispatch(
602            &sphere,
603            ShapeType::Sphere,
604            &t1,
605            &capsule,
606            ShapeType::Capsule,
607            &t2,
608            pair,
609        );
610        assert!(
611            !result.has_contact(),
612            "separated sphere-capsule should not produce contact"
613        );
614    }
615    #[test]
616    fn test_sphere_capsule_contact_depth() {
617        let dispatcher = NarrowPhaseDispatcher::default();
618        let sphere = Sphere::new(1.0);
619        let capsule = Capsule::new(1.0, 1.0);
620        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
621        let t2 = Transform::from_position(Vec3::new(1.5, 0.0, 0.0));
622        let pair = CollisionPair::new(0, 1);
623        let result = dispatcher.dispatch(
624            &sphere,
625            ShapeType::Sphere,
626            &t1,
627            &capsule,
628            ShapeType::Capsule,
629            &t2,
630            pair,
631        );
632        assert!(result.has_contact());
633        let depth = result.penetration_depth().unwrap();
634        assert!(depth > 0.0, "penetration depth should be positive");
635        assert!(
636            (depth - 0.5).abs() < 1e-6,
637            "expected depth 0.5, got {}",
638            depth
639        );
640    }
641    #[test]
642    fn test_gjk_fallback_for_unknown_pair() {
643        let dispatcher = NarrowPhaseDispatcher::empty();
644        let s1 = Sphere::new(1.0);
645        let s2 = Sphere::new(1.0);
646        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
647        let t2 = Transform::from_position(Vec3::new(1.0, 0.0, 0.0));
648        let pair = CollisionPair::new(0, 1);
649        let result = dispatcher.dispatch(
650            &s1,
651            ShapeType::Sphere,
652            &t1,
653            &s2,
654            ShapeType::Sphere,
655            &t2,
656            pair,
657        );
658        assert!(
659            result.has_contact(),
660            "GJK fallback should detect overlapping spheres"
661        );
662    }
663    #[test]
664    fn test_gjk_fallback_separated() {
665        let dispatcher = NarrowPhaseDispatcher::empty();
666        let s1 = Sphere::new(1.0);
667        let s2 = Sphere::new(1.0);
668        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
669        let t2 = Transform::from_position(Vec3::new(10.0, 0.0, 0.0));
670        let pair = CollisionPair::new(0, 1);
671        let result = dispatcher.dispatch(
672            &s1,
673            ShapeType::Sphere,
674            &t1,
675            &s2,
676            ShapeType::Sphere,
677            &t2,
678            pair,
679        );
680        assert!(!result.has_contact());
681    }
682    #[test]
683    fn test_dispatch_config_default() {
684        let config = DispatchConfig::default();
685        assert!(config.use_gjk_fallback);
686        assert!(config.max_gjk_iterations > 0);
687        assert!(config.max_epa_iterations > 0);
688        assert!(config.contact_tolerance > 0.0);
689    }
690    #[test]
691    fn test_dispatcher_with_config() {
692        let config = DispatchConfig {
693            use_gjk_fallback: false,
694            max_gjk_iterations: 32,
695            max_epa_iterations: 32,
696            contact_tolerance: 1e-4,
697        };
698        let dispatcher = NarrowPhaseDispatcher::with_config(config);
699        assert_eq!(dispatcher.registered_count(), 0);
700    }
701    #[test]
702    fn test_heightfield_flat() {
703        let heights = vec![0.0; 9];
704        let hf = SimpleHeightField::new(heights, 3, 3, 1.0);
705        assert_eq!(hf.height_at(0, 0), 0.0);
706        assert_eq!(hf.height_at(1, 1), 0.0);
707    }
708    #[test]
709    fn test_heightfield_normal_flat() {
710        let heights = vec![0.0; 9];
711        let hf = SimpleHeightField::new(heights, 3, 3, 1.0);
712        let n = hf.normal_at(0, 0);
713        assert!((n[1] - 1.0).abs() < 1e-6);
714    }
715    #[test]
716    fn test_heightfield_sphere_collision() {
717        let heights = vec![0.0; 9];
718        let hf = SimpleHeightField::new(heights, 3, 3, 1.0);
719        let result = hf.test_sphere([1.0, 0.5, 1.0], 1.0);
720        assert!(result.is_some());
721        let (_, _, depth) = result.unwrap();
722        assert!(
723            (depth - 0.5).abs() < 1e-6,
724            "expected depth 0.5, got {}",
725            depth
726        );
727    }
728    #[test]
729    fn test_heightfield_sphere_no_collision() {
730        let heights = vec![0.0; 9];
731        let hf = SimpleHeightField::new(heights, 3, 3, 1.0);
732        let result = hf.test_sphere([1.0, 5.0, 1.0], 1.0);
733        assert!(result.is_none());
734    }
735    #[test]
736    fn test_heightfield_varied_heights() {
737        let heights = vec![0.0, 1.0, 0.0, 1.0, 2.0, 1.0, 0.0, 1.0, 0.0];
738        let hf = SimpleHeightField::new(heights, 3, 3, 1.0);
739        assert_eq!(hf.height_at(1, 1), 2.0);
740        assert_eq!(hf.height_at(0, 1), 1.0);
741    }
742    #[test]
743    fn test_dispatch_symmetric_same_as_normal() {
744        let dispatcher = NarrowPhaseDispatcher::default();
745        let s1 = Sphere::new(1.0);
746        let s2 = Sphere::new(1.0);
747        let t1 = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
748        let t2 = Transform::from_position(Vec3::new(1.5, 0.0, 0.0));
749        let pair = CollisionPair::new(0, 1);
750        let r1 = dispatcher.dispatch(
751            &s1,
752            ShapeType::Sphere,
753            &t1,
754            &s2,
755            ShapeType::Sphere,
756            &t2,
757            pair,
758        );
759        let r2 = dispatcher.dispatch_symmetric(
760            &s1,
761            ShapeType::Sphere,
762            &t1,
763            &s2,
764            ShapeType::Sphere,
765            &t2,
766            pair,
767        );
768        assert_eq!(r1.has_contact(), r2.has_contact());
769    }
770    #[test]
771    fn test_shape_type_ordinal_ordering() {
772        assert!(shape_type_ordinal(ShapeType::Sphere) < shape_type_ordinal(ShapeType::Box));
773        assert!(shape_type_ordinal(ShapeType::Box) < shape_type_ordinal(ShapeType::Capsule));
774        assert!(shape_type_ordinal(ShapeType::HeightField) < shape_type_ordinal(ShapeType::Plane));
775        assert!(shape_type_ordinal(ShapeType::Plane) < shape_type_ordinal(ShapeType::Custom(0)));
776    }
777    #[test]
778    fn test_dispatch_key_plane() {
779        let k = DispatchKey::new(ShapeType::Plane, ShapeType::Sphere);
780        assert_eq!(k.0, ShapeType::Sphere);
781        assert_eq!(k.1, ShapeType::Plane);
782    }
783    #[test]
784    fn test_compound_shape_empty() {
785        let c = CompoundShape::new();
786        assert!(c.is_empty());
787        assert_eq!(c.len(), 0);
788    }
789    #[test]
790    fn test_compound_shape_add_children() {
791        let mut c = CompoundShape::new();
792        let s = Sphere::new(1.0);
793        let t = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
794        c.add_child(Box::new(s), ShapeType::Sphere, t);
795        assert_eq!(c.len(), 1);
796        assert!(!c.is_empty());
797    }
798    #[test]
799    fn test_dispatch_compound_compound_no_overlap() {
800        let dispatcher = NarrowPhaseDispatcher::default();
801        let mut ca = CompoundShape::new();
802        ca.add_child(
803            Box::new(Sphere::new(0.5)),
804            ShapeType::Sphere,
805            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
806        );
807        let mut cb = CompoundShape::new();
808        cb.add_child(
809            Box::new(Sphere::new(0.5)),
810            ShapeType::Sphere,
811            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
812        );
813        let ta = Transform::from_position(Vec3::new(-100.0, 0.0, 0.0));
814        let tb = Transform::from_position(Vec3::new(100.0, 0.0, 0.0));
815        let result = dispatch_compound_compound(&dispatcher, &ca, &ta, &cb, &tb, 0, true);
816        assert!(
817            !result.has_contacts(),
818            "separated compounds should have no contacts"
819        );
820    }
821    #[test]
822    fn test_dispatch_compound_compound_overlap() {
823        let dispatcher = NarrowPhaseDispatcher::default();
824        let mut ca = CompoundShape::new();
825        ca.add_child(
826            Box::new(Sphere::new(1.0)),
827            ShapeType::Sphere,
828            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
829        );
830        let mut cb = CompoundShape::new();
831        cb.add_child(
832            Box::new(Sphere::new(1.0)),
833            ShapeType::Sphere,
834            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
835        );
836        let ta = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
837        let tb = Transform::from_position(Vec3::new(1.0, 0.0, 0.0));
838        let result = dispatch_compound_compound(&dispatcher, &ca, &ta, &cb, &tb, 0, false);
839        assert!(
840            result.has_contacts(),
841            "overlapping compounds should have contacts"
842        );
843    }
844    #[test]
845    fn test_dispatch_compound_pruning_reduces_tests() {
846        let dispatcher = NarrowPhaseDispatcher::default();
847        let mut ca = CompoundShape::new();
848        let mut cb = CompoundShape::new();
849        ca.add_child(
850            Box::new(Sphere::new(0.1)),
851            ShapeType::Sphere,
852            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
853        );
854        cb.add_child(
855            Box::new(Sphere::new(0.1)),
856            ShapeType::Sphere,
857            Transform::from_position(Vec3::new(0.0, 0.0, 0.0)),
858        );
859        let ta = Transform::from_position(Vec3::new(-100.0, 0.0, 0.0));
860        let tb = Transform::from_position(Vec3::new(100.0, 0.0, 0.0));
861        let pruned = dispatch_compound_compound(&dispatcher, &ca, &ta, &cb, &tb, 0, true);
862        let not_pruned = dispatch_compound_compound(&dispatcher, &ca, &ta, &cb, &tb, 0, false);
863        assert!(pruned.tests_performed <= not_pruned.tests_performed);
864    }
865    #[test]
866    fn test_heightfield_sphere_dispatch_contact() {
867        let hf = SimpleHeightField::new(vec![0.0; 9], 3, 3, 1.0);
868        let pair = CollisionPair::new(0, 1);
869        let result = dispatch_heightfield_sphere(&hf, [1.0, 0.5, 1.0], 1.0, pair);
870        assert!(
871            result.has_contact(),
872            "sphere penetrating heightfield should have contact"
873        );
874    }
875    #[test]
876    fn test_heightfield_sphere_dispatch_no_contact() {
877        let hf = SimpleHeightField::new(vec![0.0; 9], 3, 3, 1.0);
878        let pair = CollisionPair::new(0, 1);
879        let result = dispatch_heightfield_sphere(&hf, [1.0, 10.0, 1.0], 0.5, pair);
880        assert!(
881            !result.has_contact(),
882            "sphere above heightfield should have no contact"
883        );
884    }
885    #[test]
886    fn test_aabb_overlaps_true() {
887        let a = Aabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
888        let b = Aabb::new([0.5, 0.5, 0.5], [1.5, 1.5, 1.5]);
889        assert!(a.overlaps(&b));
890    }
891    #[test]
892    fn test_aabb_overlaps_false() {
893        let a = Aabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
894        let b = Aabb::new([2.0, 2.0, 2.0], [3.0, 3.0, 3.0]);
895        assert!(!a.overlaps(&b));
896    }
897    #[test]
898    fn test_aabb_surface_area() {
899        let a = Aabb::new([0.0, 0.0, 0.0], [2.0, 3.0, 4.0]);
900        let sa = a.surface_area();
901        assert!((sa - 52.0).abs() < 1e-10, "surface area should be 52: {sa}");
902    }
903    #[test]
904    fn test_mesh_triangle_aabb() {
905        let tri = MeshTriangle::new([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]], 0);
906        assert!((tri.aabb.min[0]).abs() < 1e-10);
907        assert!((tri.aabb.max[0] - 1.0).abs() < 1e-10);
908        assert!((tri.aabb.max[1] - 1.0).abs() < 1e-10);
909    }
910    #[test]
911    fn test_mesh_mesh_mid_phase_overlapping() {
912        let ta = vec![MeshTriangle::new(
913            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]],
914            0,
915        )];
916        let tb = vec![MeshTriangle::new(
917            [[0.3, 0.0, 0.0], [1.3, 0.0, 0.0], [0.8, 1.0, 0.0]],
918            0,
919        )];
920        let pairs = mesh_mesh_aabb_mid_phase(&ta, &tb);
921        assert!(
922            !pairs.is_empty(),
923            "overlapping triangles should produce a pair"
924        );
925    }
926    #[test]
927    fn test_mesh_mesh_mid_phase_non_overlapping() {
928        let ta = vec![MeshTriangle::new(
929            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]],
930            0,
931        )];
932        let tb = vec![MeshTriangle::new(
933            [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.5, 1.0, 0.0]],
934            0,
935        )];
936        let pairs = mesh_mesh_aabb_mid_phase(&ta, &tb);
937        assert!(
938            pairs.is_empty(),
939            "separated triangles should produce no pairs"
940        );
941    }
942    #[test]
943    fn test_feature_cache_miss_then_hit() {
944        let mut cache = ShapeFeatureCache::new();
945        let v1 = cache.get_or_insert(0xAB, || [1.0, 0.0, 0.0]);
946        let v2 = cache.get_or_insert(0xAB, || [999.0, 0.0, 0.0]);
947        assert!((v1[0] - 1.0).abs() < 1e-10);
948        assert!(
949            (v2[0] - 1.0).abs() < 1e-10,
950            "should return cached value: {}",
951            v2[0]
952        );
953        assert_eq!(cache.misses, 1);
954        assert_eq!(cache.hits, 1);
955    }
956    #[test]
957    fn test_feature_cache_hit_ratio() {
958        let mut cache = ShapeFeatureCache::new();
959        cache.get_or_insert(1, || [0.0; 3]);
960        cache.get_or_insert(1, || [0.0; 3]);
961        cache.get_or_insert(1, || [0.0; 3]);
962        assert!((cache.hit_ratio() - 2.0 / 3.0).abs() < 1e-10);
963    }
964    #[test]
965    fn test_feature_cache_clear() {
966        let mut cache = ShapeFeatureCache::new();
967        cache.get_or_insert(1, || [1.0; 3]);
968        cache.clear();
969        assert_eq!(cache.hits, 0);
970        assert_eq!(cache.misses, 0);
971        cache.get_or_insert(1, || [2.0; 3]);
972        assert_eq!(cache.misses, 1);
973    }
974    #[test]
975    fn test_dispatch_stats_contact_rate() {
976        let mut stats = DispatchStats::default();
977        stats.record(true, false);
978        stats.record(false, false);
979        stats.record(true, true);
980        assert!((stats.contact_rate() - 2.0 / 3.0).abs() < 1e-10);
981    }
982    #[test]
983    fn test_dispatch_stats_specialization_rate() {
984        let mut stats = DispatchStats::default();
985        stats.record(true, false);
986        stats.record(false, false);
987        stats.record(true, true);
988        assert!((stats.specialization_rate() - 2.0 / 3.0).abs() < 1e-10);
989    }
990    #[test]
991    fn test_dispatch_stats_pruned() {
992        let mut stats = DispatchStats::default();
993        stats.record_pruned();
994        stats.record_pruned();
995        assert_eq!(stats.broad_phase_pruned, 2);
996    }
997    #[test]
998    fn test_dispatch_stats_empty_rates() {
999        let stats = DispatchStats::default();
1000        assert!((stats.contact_rate()).abs() < 1e-10);
1001        assert!((stats.specialization_rate()).abs() < 1e-10);
1002    }
1003}
1004/// Clip a polygon against a half-plane defined by an edge.
1005///
1006/// Returns the clipped polygon.  Used as a building block for Sutherland-Hodgman
1007/// polygon clipping in multi-contact generation.
1008pub(super) fn clip_polygon_by_halfspace(
1009    polygon: &[[f64; 2]],
1010    plane_point: [f64; 2],
1011    plane_normal: [f64; 2],
1012) -> Vec<[f64; 2]> {
1013    if polygon.is_empty() {
1014        return Vec::new();
1015    }
1016    let mut result = Vec::new();
1017    let n = polygon.len();
1018    for i in 0..n {
1019        let curr = polygon[i];
1020        let next = polygon[(i + 1) % n];
1021        let dc = (curr[0] - plane_point[0]) * plane_normal[0]
1022            + (curr[1] - plane_point[1]) * plane_normal[1];
1023        let dn = (next[0] - plane_point[0]) * plane_normal[0]
1024            + (next[1] - plane_point[1]) * plane_normal[1];
1025        if dc >= 0.0 {
1026            result.push(curr);
1027        }
1028        if (dc >= 0.0) != (dn >= 0.0) {
1029            let denom = dc - dn;
1030            if denom.abs() > 1e-14 {
1031                let t = dc / denom;
1032                result.push([
1033                    curr[0] + t * (next[0] - curr[0]),
1034                    curr[1] + t * (next[1] - curr[1]),
1035                ]);
1036            }
1037        }
1038    }
1039    result
1040}
1041/// Generate a multi-contact patch for two overlapping axis-aligned rectangles
1042/// (box face vs box face) using Sutherland-Hodgman clipping in 2D.
1043///
1044/// `face_a` and `face_b` are arrays of 4 vertices (world-space XZ plane).
1045/// Returns a `ContactPatch` with up to 8 contact points.
1046pub fn clip_box_faces_2d(
1047    face_a: [[f64; 2]; 4],
1048    face_b: [[f64; 2]; 4],
1049    normal: [f64; 3],
1050    depth: f64,
1051) -> ContactPatch {
1052    let mut clipped: Vec<[f64; 2]> = face_b.to_vec();
1053    for i in 0..4 {
1054        if clipped.is_empty() {
1055            break;
1056        }
1057        let a = face_a[i];
1058        let b = face_a[(i + 1) % 4];
1059        let edge = [b[0] - a[0], b[1] - a[1]];
1060        let edge_normal = [edge[1], -edge[0]];
1061        clipped = clip_polygon_by_halfspace(&clipped, a, edge_normal);
1062    }
1063    let mut patch = ContactPatch::new(normal, depth);
1064    for p in &clipped {
1065        patch.add_point([p[0], 0.0, p[1]]);
1066    }
1067    patch
1068}
1069/// Generate a contact manifold with multiple contact points for a box-box pair
1070/// using SAT-derived reference/incident face clipping.
1071///
1072/// Returns up to 4 contact points in world space.
1073pub fn box_box_manifold_clip(
1074    box_a: &BoxShape,
1075    transform_a: &oxiphysics_core::Transform,
1076    box_b: &BoxShape,
1077    transform_b: &oxiphysics_core::Transform,
1078    pair: CollisionPair,
1079) -> Option<ContactManifold> {
1080    use crate::narrowphase::specialized::box_box_sat;
1081    let single_contact = box_box_sat(box_a, transform_a, box_b, transform_b)?;
1082    let mut manifold = ContactManifold::new(pair);
1083    manifold.add_contact(single_contact.clone());
1084    let n = single_contact.normal;
1085    let perp1 = if n.x.abs() < 0.9 {
1086        oxiphysics_core::math::Vec3::new(1.0, 0.0, 0.0) - n * n.x
1087    } else {
1088        oxiphysics_core::math::Vec3::new(0.0, 1.0, 0.0) - n * n.y
1089    };
1090    let perp1_len = perp1.norm();
1091    if perp1_len < 1e-10 {
1092        return Some(manifold);
1093    }
1094    let perp1 = perp1 / perp1_len;
1095    let perp2 = n.cross(&perp1);
1096    let half = [
1097        box_a.half_extents.x,
1098        box_a.half_extents.y,
1099        box_a.half_extents.z,
1100    ];
1101    let scale = *half
1102        .iter()
1103        .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1104        .unwrap_or(&0.1);
1105    let offsets = [
1106        perp1 * scale + perp2 * scale,
1107        perp1 * scale - perp2 * scale,
1108        -perp1 * scale + perp2 * scale,
1109        -perp1 * scale - perp2 * scale,
1110    ];
1111    for off in &offsets {
1112        let shifted_ta = oxiphysics_core::Transform {
1113            position: transform_a.position + *off,
1114            rotation: transform_a.rotation,
1115        };
1116        if let Some(c) = box_box_sat(box_a, &shifted_ta, box_b, transform_b)
1117            && c.depth > 0.0
1118        {
1119            let mut adjusted = c;
1120            adjusted.point_a -= *off;
1121            adjusted.point_b -= *off;
1122            manifold.add_contact(adjusted);
1123        }
1124    }
1125    manifold.contacts.sort_by(|a, b| {
1126        b.depth
1127            .partial_cmp(&a.depth)
1128            .unwrap_or(std::cmp::Ordering::Equal)
1129    });
1130    manifold.contacts.truncate(4);
1131    Some(manifold)
1132}
1133/// Generate speculative contacts between two convex shapes given their velocities.
1134///
1135/// When two shapes are within `config.margin` of each other AND moving toward
1136/// each other faster than a threshold, a speculative contact is injected so
1137/// the constraint solver can prevent tunnelling over the next timestep.
1138///
1139/// The contact depth is reported as the current gap (negative = overlapping)
1140/// so the solver can distinguish pre-contact from post-contact.
1141pub fn generate_speculative_contacts(
1142    shape_a: &dyn Shape,
1143    transform_a: &oxiphysics_core::Transform,
1144    velocity_a: [f64; 3],
1145    shape_b: &dyn Shape,
1146    transform_b: &oxiphysics_core::Transform,
1147    velocity_b: [f64; 3],
1148    pair: CollisionPair,
1149    config: &SpeculativeConfig,
1150) -> Option<ContactManifold> {
1151    use crate::narrowphase::gjk::{GjkDistanceResult, gjk_distance_query};
1152    let result: GjkDistanceResult = gjk_distance_query(shape_a, transform_a, shape_b, transform_b);
1153    if result.distance > config.margin {
1154        return None;
1155    }
1156    let diff = result.closest_b - result.closest_a;
1157    let dist = diff.norm();
1158    let normal = if dist > 1e-10 {
1159        diff / dist
1160    } else {
1161        let d = transform_b.position - transform_a.position;
1162        if d.norm_squared() > 1e-10 {
1163            d.normalize()
1164        } else {
1165            oxiphysics_core::math::Vec3::new(0.0, 1.0, 0.0)
1166        }
1167    };
1168    let rel_vel = oxiphysics_core::math::Vec3::new(
1169        velocity_b[0] - velocity_a[0],
1170        velocity_b[1] - velocity_a[1],
1171        velocity_b[2] - velocity_a[2],
1172    );
1173    let closing = -rel_vel.dot(&normal);
1174    if closing < -config.velocity_scale * config.margin && result.distance > 0.0 {
1175        return None;
1176    }
1177    let depth = config.margin - result.distance;
1178    let contact = Contact {
1179        point_a: result.closest_a,
1180        point_b: result.closest_b,
1181        normal,
1182        depth,
1183    };
1184    let mut manifold = ContactManifold::new(pair);
1185    manifold.add_contact(contact);
1186    Some(manifold)
1187}
1188/// Dispatch a convex shape against a concave mesh (convex decomposition).
1189///
1190/// Tests each convex piece of the mesh against the convex shape using GJK,
1191/// collecting all contacts.  An AABB pre-filter limits the number of tests.
1192pub fn dispatch_concave_vs_convex(
1193    mesh: &ConcaveMesh,
1194    mesh_transform: &oxiphysics_core::Transform,
1195    _convex: &dyn Shape,
1196    convex_type: ShapeType,
1197    convex_transform: &oxiphysics_core::Transform,
1198    base_pair: CollisionPair,
1199    dispatcher: &NarrowPhaseDispatcher,
1200) -> Vec<ContactManifold> {
1201    let _ = convex_type;
1202    let _ = dispatcher;
1203    let mut manifolds = Vec::new();
1204    let inv_mesh = mesh_transform.inverse();
1205    let local_convex_pos = inv_mesh.transform_point(&convex_transform.position);
1206    let convex_aabb_expand = 2.0;
1207    for (piece_idx, piece) in mesh.pieces.iter().enumerate() {
1208        let mut rejected = false;
1209        for i in 0..3 {
1210            let lp = [local_convex_pos.x, local_convex_pos.y, local_convex_pos.z][i];
1211            if lp + convex_aabb_expand < piece.aabb_min[i]
1212                || lp - convex_aabb_expand > piece.aabb_max[i]
1213            {
1214                rejected = true;
1215                break;
1216            }
1217        }
1218        if rejected {
1219            continue;
1220        }
1221        let verts_world: Vec<oxiphysics_core::math::Vec3> = piece
1222            .vertices
1223            .iter()
1224            .map(|v| {
1225                mesh_transform.transform_point(&oxiphysics_core::math::Vec3::new(v[0], v[1], v[2]))
1226            })
1227            .collect();
1228        if verts_world.is_empty() {
1229            continue;
1230        }
1231        use crate::narrowphase::specialized::convex_convex_gjk_intersect;
1232        if convex_convex_gjk_intersect(&verts_world, mesh_transform, &verts_world, convex_transform)
1233        {
1234            let centroid: oxiphysics_core::math::Vec3 =
1235                verts_world.iter().sum::<oxiphysics_core::math::Vec3>() / verts_world.len() as f64;
1236            let diff = convex_transform.position - centroid;
1237            let dist = diff.norm();
1238            let normal = if dist > 1e-10 {
1239                diff / dist
1240            } else {
1241                oxiphysics_core::math::Vec3::new(0.0, 1.0, 0.0)
1242            };
1243            let contact = Contact {
1244                point_a: centroid,
1245                point_b: convex_transform.position,
1246                normal,
1247                depth: 0.1,
1248            };
1249            let sub_pair = CollisionPair::new(base_pair.a, base_pair.b + piece_idx * 100);
1250            let mut m = ContactManifold::new(sub_pair);
1251            m.add_contact(contact);
1252            manifolds.push(m);
1253        }
1254    }
1255    manifolds
1256}
1257/// Generic heightfield vs convex dispatch.
1258///
1259/// Samples the heightfield at all grid cells within the convex shape's bounding
1260/// sphere and returns contacts for any penetrating cells.
1261pub fn dispatch_heightfield_convex_generic(
1262    hf: &SimpleHeightField,
1263    convex_pos: [f64; 3],
1264    convex_radius: f64,
1265    convex_half_height: f64,
1266    pair: CollisionPair,
1267) -> Vec<ContactManifold> {
1268    let mut manifolds = Vec::new();
1269    let search_r = (convex_radius + convex_half_height).abs();
1270    let col_min = ((convex_pos[0] - search_r) / hf.spacing).floor() as isize;
1271    let col_max = ((convex_pos[0] + search_r) / hf.spacing).ceil() as isize;
1272    let row_min = ((convex_pos[2] - search_r) / hf.spacing).floor() as isize;
1273    let row_max = ((convex_pos[2] + search_r) / hf.spacing).ceil() as isize;
1274    for row in row_min..=row_max {
1275        for col in col_min..=col_max {
1276            if row < 0 || col < 0 {
1277                continue;
1278            }
1279            let row = row as usize;
1280            let col = col as usize;
1281            if row >= hf.rows || col >= hf.cols {
1282                continue;
1283            }
1284            let sample_x = col as f64 * hf.spacing;
1285            let sample_z = row as f64 * hf.spacing;
1286            let height = hf.height_at(row, col);
1287            let dx = convex_pos[0] - sample_x;
1288            let dz = convex_pos[2] - sample_z;
1289            let horiz_dist = (dx * dx + dz * dz).sqrt();
1290            if horiz_dist < search_r {
1291                let penetration = height + convex_radius - convex_pos[1];
1292                if penetration > 0.0 {
1293                    let normal = hf.normal_at(row, col);
1294                    let contact = Contact {
1295                        point_a: oxiphysics_core::math::Vec3::new(sample_x, height, sample_z),
1296                        point_b: oxiphysics_core::math::Vec3::new(
1297                            convex_pos[0],
1298                            convex_pos[1] - convex_radius,
1299                            convex_pos[2],
1300                        ),
1301                        normal: oxiphysics_core::math::Vec3::new(normal[0], normal[1], normal[2]),
1302                        depth: penetration,
1303                    };
1304                    let cell_pair = CollisionPair::new(pair.a, pair.b + row * 1000 + col);
1305                    let mut m = ContactManifold::new(cell_pair);
1306                    m.add_contact(contact);
1307                    manifolds.push(m);
1308                }
1309            }
1310        }
1311    }
1312    manifolds
1313}
1314/// Merge two contact manifolds, keeping the deepest contacts.
1315///
1316/// Useful when multiple overlapping tests on the same pair produce different
1317/// contacts; merging ensures the constraint solver sees the full picture.
1318pub fn merge_manifolds(
1319    manifold_a: ContactManifold,
1320    manifold_b: ContactManifold,
1321    max_contacts: usize,
1322) -> ContactManifold {
1323    let mut merged = ContactManifold::new(manifold_a.pair);
1324    for c in manifold_a.contacts {
1325        merged.add_contact(c);
1326    }
1327    for c in manifold_b.contacts {
1328        merged.add_contact(c);
1329    }
1330    merged.contacts.sort_by(|a, b| {
1331        b.depth
1332            .partial_cmp(&a.depth)
1333            .unwrap_or(std::cmp::Ordering::Equal)
1334    });
1335    merged.contacts.truncate(max_contacts);
1336    merged
1337}
1338/// Flip a contact manifold (swap A and B, negate normal).
1339pub fn flip_manifold(manifold: &mut ContactManifold) {
1340    for c in &mut manifold.contacts {
1341        c.normal = -c.normal;
1342        std::mem::swap(&mut c.point_a, &mut c.point_b);
1343    }
1344}
1345/// Scale contact depths by a factor (e.g. for bias / slop removal).
1346pub fn scale_contact_depths(manifold: &mut ContactManifold, scale: f64) {
1347    for c in &mut manifold.contacts {
1348        c.depth *= scale;
1349    }
1350}
1351/// Remove contacts with depth below `min_depth`.
1352pub fn prune_shallow_contacts(manifold: &mut ContactManifold, min_depth: f64) {
1353    manifold.contacts.retain(|c| c.depth >= min_depth);
1354}
1355/// Translate all contact points by a world-space offset.
1356pub fn translate_manifold(manifold: &mut ContactManifold, offset: oxiphysics_core::math::Vec3) {
1357    for c in &mut manifold.contacts {
1358        c.point_a += offset;
1359        c.point_b += offset;
1360    }
1361}
1362/// Generate speculative contacts for all pairs in a batch.
1363///
1364/// Returns one manifold per pair (or `None` if the pair is too far apart).
1365pub fn batch_speculative_contacts<'a>(
1366    pairs: &[SpecBatchEntry<'a>],
1367    config: &SpeculativeConfig,
1368) -> Vec<Option<ContactManifold>> {
1369    pairs
1370        .iter()
1371        .map(|(sa, ta, va, sb, tb, vb, pair)| {
1372            generate_speculative_contacts(*sa, ta, *va, *sb, tb, *vb, *pair, config)
1373        })
1374        .collect()
1375}
1376#[cfg(test)]
1377mod tests_dispatch_extended {
1378    use super::*;
1379    use crate::CollisionPair;
1380    use crate::Contact;
1381    use crate::ContactManifold;
1382    use crate::narrowphase::ContactPatch;
1383    use crate::narrowphase::ContactPatchReducer;
1384    use crate::narrowphase::ConvexPiece;
1385    use crate::narrowphase::DispatchQueue;
1386    use crate::narrowphase::clip_box_faces_2d;
1387    use crate::narrowphase::clip_polygon_by_halfspace;
1388    use crate::narrowphase::flip_manifold;
1389    use oxiphysics_core::Vec3;
1390
1391    /// Merge two `types::ContactManifold`s, keeping at most `max` contacts.
1392    fn merge_manifolds(a: ContactManifold, b: ContactManifold, max: usize) -> ContactManifold {
1393        let pair = a.pair;
1394        let mut merged = ContactManifold::new(pair);
1395        let mut all: Vec<Contact> = a.contacts.into_iter().chain(b.contacts).collect();
1396        all.sort_by(|x, y| {
1397            y.depth
1398                .partial_cmp(&x.depth)
1399                .unwrap_or(std::cmp::Ordering::Equal)
1400        });
1401        all.truncate(max);
1402        for c in all {
1403            merged.add_contact(c);
1404        }
1405        merged
1406    }
1407    #[test]
1408    fn test_contact_patch_empty() {
1409        let p = ContactPatch::new([0.0, 1.0, 0.0], 0.1);
1410        assert!(p.is_empty());
1411        assert_eq!(p.len(), 0);
1412    }
1413    #[test]
1414    fn test_contact_patch_add_point() {
1415        let mut p = ContactPatch::new([0.0, 1.0, 0.0], 0.1);
1416        p.add_point([1.0, 0.0, 0.0]);
1417        p.add_point([2.0, 0.0, 0.0]);
1418        assert_eq!(p.len(), 2);
1419        assert!(!p.is_empty());
1420    }
1421    #[test]
1422    fn test_clip_polygon_by_halfspace_full_inside() {
1423        // All points have x >= 0 with normal [1,0,0] and d=0
1424        let poly = vec![
1425            [0.5, 0.5, 0.0],
1426            [1.5, 0.5, 0.0],
1427            [1.5, 1.5, 0.0],
1428            [0.5, 1.5, 0.0],
1429        ];
1430        let clipped = clip_polygon_by_halfspace(&poly, [1.0, 0.0, 0.0], 0.0);
1431        assert_eq!(clipped.len(), 4, "all points inside, should keep 4");
1432    }
1433    #[test]
1434    fn test_clip_polygon_by_halfspace_full_outside() {
1435        // All points have x < 0 with normal [1,0,0] and d=0
1436        let poly = vec![
1437            [-2.0, 0.0, 0.0],
1438            [-1.0, 0.0, 0.0],
1439            [-1.0, 1.0, 0.0],
1440            [-2.0, 1.0, 0.0],
1441        ];
1442        let clipped = clip_polygon_by_halfspace(&poly, [1.0, 0.0, 0.0], 0.0);
1443        assert!(clipped.is_empty(), "all points outside");
1444    }
1445    #[test]
1446    fn test_clip_polygon_by_halfspace_partial() {
1447        // Some points inside, some outside with normal [1,0,0] and d=0
1448        let poly = vec![
1449            [-1.0, 0.0, 0.0],
1450            [1.0, 0.0, 0.0],
1451            [1.0, 1.0, 0.0],
1452            [-1.0, 1.0, 0.0],
1453        ];
1454        let clipped = clip_polygon_by_halfspace(&poly, [1.0, 0.0, 0.0], 0.0);
1455        assert!(
1456            clipped.len() >= 2,
1457            "partial clip should keep some points: {:?}",
1458            clipped
1459        );
1460    }
1461    #[test]
1462    fn test_clip_box_faces_2d_no_overlap() {
1463        let face_a: [[f64; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1464        let face_b: [[f64; 2]; 4] = [[5.0, 5.0], [6.0, 5.0], [6.0, 6.0], [5.0, 6.0]];
1465        let patch = clip_box_faces_2d(face_a, face_b, [0.0, 1.0, 0.0], 0.1);
1466        assert!(
1467            patch.is_empty(),
1468            "non-overlapping faces should produce no contacts"
1469        );
1470    }
1471    #[test]
1472    fn test_clip_box_faces_2d_full_overlap() {
1473        let face: [[f64; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1474        let patch = clip_box_faces_2d(face, face, [0.0, 1.0, 0.0], 0.1);
1475        let _ = patch;
1476    }
1477    #[test]
1478    fn test_reducer_keeps_max_contacts() {
1479        let reducer = ContactPatchReducer::new(2);
1480        let pair = CollisionPair::new(0, 1);
1481        let mut manifold = ContactManifold::new(pair);
1482        for i in 0..6 {
1483            manifold.add_contact(Contact {
1484                point_a: Vec3::new(i as f64, 0.0, 0.0),
1485                point_b: Vec3::new(i as f64 + 0.1, 0.0, 0.0),
1486                normal: Vec3::new(0.0, 1.0, 0.0),
1487                depth: i as f64 * 0.1 + 0.01,
1488            });
1489        }
1490        reducer.reduce(&mut manifold);
1491        assert!(
1492            manifold.contacts.len() <= 2,
1493            "should keep at most 2: {}",
1494            manifold.contacts.len()
1495        );
1496    }
1497    #[test]
1498    fn test_reducer_no_reduction_needed() {
1499        let reducer = ContactPatchReducer::new(4);
1500        let pair = CollisionPair::new(0, 1);
1501        let mut manifold = ContactManifold::new(pair);
1502        for i in 0..3 {
1503            manifold.add_contact(Contact {
1504                point_a: Vec3::new(i as f64, 0.0, 0.0),
1505                point_b: Vec3::new(i as f64 + 0.1, 0.0, 0.0),
1506                normal: Vec3::new(0.0, 1.0, 0.0),
1507                depth: 0.05,
1508            });
1509        }
1510        reducer.reduce(&mut manifold);
1511        assert_eq!(
1512            manifold.contacts.len(),
1513            3,
1514            "should keep all 3 when under max"
1515        );
1516    }
1517    #[test]
1518    fn test_box_box_manifold_clip_overlap() {
1519        use oxiphysics_geometry::BoxShape;
1520        let b1 = BoxShape::new(Vec3::new(1.0, 1.0, 1.0));
1521        let b2 = BoxShape::new(Vec3::new(1.0, 1.0, 1.0));
1522        let t1 = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1523        let t2 = oxiphysics_core::Transform::from_position(Vec3::new(1.5, 0.0, 0.0));
1524        let pair = CollisionPair::new(0, 1);
1525        let result = box_box_manifold_clip(&b1, &t1, &b2, &t2, pair);
1526        assert!(
1527            result.is_some(),
1528            "overlapping boxes should produce manifold"
1529        );
1530        let m = result.unwrap();
1531        assert!(!m.contacts.is_empty());
1532    }
1533    #[test]
1534    fn test_box_box_manifold_clip_separated() {
1535        let b1 = BoxShape::new(Vec3::new(1.0, 1.0, 1.0));
1536        let b2 = BoxShape::new(Vec3::new(1.0, 1.0, 1.0));
1537        let t1 = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1538        let t2 = oxiphysics_core::Transform::from_position(Vec3::new(10.0, 0.0, 0.0));
1539        let pair = CollisionPair::new(0, 1);
1540        let result = box_box_manifold_clip(&b1, &t1, &b2, &t2, pair);
1541        assert!(
1542            result.is_none(),
1543            "separated boxes should produce no manifold"
1544        );
1545    }
1546    #[test]
1547    fn test_speculative_contacts_within_margin() {
1548        let s1 = Sphere::new(1.0);
1549        let s2 = Sphere::new(1.0);
1550        let t1 = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1551        let t2 = oxiphysics_core::Transform::from_position(Vec3::new(2.1, 0.0, 0.0));
1552        let config = SpeculativeConfig {
1553            margin: 0.5,
1554            velocity_scale: 10.0,
1555        };
1556        let pair = CollisionPair::new(0, 1);
1557        let result = generate_speculative_contacts(
1558            &s1,
1559            &t1,
1560            [1.0, 0.0, 0.0],
1561            &s2,
1562            &t2,
1563            [-1.0, 0.0, 0.0],
1564            pair,
1565            &config,
1566        );
1567        assert!(
1568            result.is_some(),
1569            "shapes within margin should produce speculative contact"
1570        );
1571    }
1572    #[test]
1573    fn test_speculative_contacts_beyond_margin() {
1574        let s1 = Sphere::new(0.5);
1575        let s2 = Sphere::new(0.5);
1576        let t1 = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1577        let t2 = oxiphysics_core::Transform::from_position(Vec3::new(20.0, 0.0, 0.0));
1578        let config = SpeculativeConfig::default();
1579        let pair = CollisionPair::new(0, 1);
1580        let result =
1581            generate_speculative_contacts(&s1, &t1, [0.0; 3], &s2, &t2, [0.0; 3], pair, &config);
1582        assert!(
1583            result.is_none(),
1584            "far separated shapes should not produce speculative contact"
1585        );
1586    }
1587    #[test]
1588    fn test_speculative_config_default() {
1589        let c = SpeculativeConfig::default();
1590        assert!(c.margin > 0.0);
1591        assert!(c.velocity_scale > 0.0);
1592    }
1593    #[test]
1594    fn test_dispatch_queue_push_pop() {
1595        let mut q = DispatchQueue::new();
1596        q.push(CollisionPair::new(0, 1), 0.5);
1597        q.push(CollisionPair::new(1, 2), 1.5);
1598        q.push(CollisionPair::new(2, 3), 0.1);
1599        let top = q.pop().unwrap();
1600        assert!(
1601            (top.priority - 1.5).abs() < 1e-10,
1602            "highest priority first: {}",
1603            top.priority
1604        );
1605    }
1606    #[test]
1607    fn test_dispatch_queue_empty() {
1608        let mut q = DispatchQueue::new();
1609        assert!(q.is_empty());
1610        assert_eq!(q.len(), 0);
1611        assert!(q.pop().is_none());
1612    }
1613    #[test]
1614    fn test_dispatch_queue_clear() {
1615        let mut q = DispatchQueue::new();
1616        q.push(CollisionPair::new(0, 1), 1.0);
1617        q.push(CollisionPair::new(1, 2), 2.0);
1618        q.clear();
1619        assert!(q.is_empty());
1620    }
1621    #[test]
1622    fn test_merge_manifolds() {
1623        let pair = CollisionPair::new(0, 1);
1624        let mut m1 = ContactManifold::new(pair);
1625        m1.add_contact(Contact {
1626            point_a: Vec3::new(0.0, 0.0, 0.0),
1627            point_b: Vec3::new(0.1, 0.0, 0.0),
1628            normal: Vec3::new(1.0, 0.0, 0.0),
1629            depth: 0.5,
1630        });
1631        let mut m2 = ContactManifold::new(pair);
1632        m2.add_contact(Contact {
1633            point_a: Vec3::new(1.0, 0.0, 0.0),
1634            point_b: Vec3::new(1.1, 0.0, 0.0),
1635            normal: Vec3::new(1.0, 0.0, 0.0),
1636            depth: 0.1,
1637        });
1638        let merged = merge_manifolds(m1, m2, 4);
1639        assert_eq!(merged.contacts.len(), 2);
1640        assert!((merged.contacts[0].depth - 0.5).abs() < 1e-10);
1641    }
1642    #[test]
1643    fn test_merge_manifolds_limits_to_max() {
1644        let pair = CollisionPair::new(0, 1);
1645        let mut m1 = ContactManifold::new(pair);
1646        let mut m2 = ContactManifold::new(pair);
1647        for i in 0..3 {
1648            m1.add_contact(Contact {
1649                point_a: Vec3::new(i as f64, 0.0, 0.0),
1650                point_b: Vec3::new(i as f64 + 0.1, 0.0, 0.0),
1651                normal: Vec3::new(1.0, 0.0, 0.0),
1652                depth: 0.01 * i as f64 + 0.01,
1653            });
1654            m2.add_contact(Contact {
1655                point_a: Vec3::new(i as f64 + 10.0, 0.0, 0.0),
1656                point_b: Vec3::new(i as f64 + 10.1, 0.0, 0.0),
1657                normal: Vec3::new(1.0, 0.0, 0.0),
1658                depth: 0.01 * i as f64 + 0.01,
1659            });
1660        }
1661        let merged = merge_manifolds(m1, m2, 4);
1662        assert!(
1663            merged.contacts.len() <= 4,
1664            "should not exceed max_contacts=4: {}",
1665            merged.contacts.len()
1666        );
1667    }
1668    #[test]
1669    fn test_flip_manifold() {
1670        let pair = CollisionPair::new(0, 1);
1671        let mut m = ContactManifold::new(pair);
1672        m.add_contact(Contact {
1673            point_a: Vec3::new(0.0, 0.0, 0.0),
1674            point_b: Vec3::new(1.0, 0.0, 0.0),
1675            normal: Vec3::new(1.0, 0.0, 0.0),
1676            depth: 0.1,
1677        });
1678        flip_manifold(&mut m);
1679        assert!(
1680            (m.contacts[0].normal.x - (-1.0)).abs() < 1e-10,
1681            "normal should be flipped"
1682        );
1683        assert!(
1684            (m.contacts[0].point_a.x - 1.0).abs() < 1e-10,
1685            "point_a and point_b should be swapped"
1686        );
1687    }
1688    #[test]
1689    fn test_scale_contact_depths() {
1690        let pair = CollisionPair::new(0, 1);
1691        let mut m = ContactManifold::new(pair);
1692        m.add_contact(Contact {
1693            point_a: Vec3::zeros(),
1694            point_b: Vec3::zeros(),
1695            normal: Vec3::new(0.0, 1.0, 0.0),
1696            depth: 1.0,
1697        });
1698        scale_contact_depths(&mut m, 2.0);
1699        assert!((m.contacts[0].depth - 2.0).abs() < 1e-10);
1700    }
1701    #[test]
1702    fn test_prune_shallow_contacts() {
1703        let pair = CollisionPair::new(0, 1);
1704        let mut m = ContactManifold::new(pair);
1705        m.add_contact(Contact {
1706            point_a: Vec3::zeros(),
1707            point_b: Vec3::zeros(),
1708            normal: Vec3::new(0.0, 1.0, 0.0),
1709            depth: 0.001,
1710        });
1711        m.add_contact(Contact {
1712            point_a: Vec3::zeros(),
1713            point_b: Vec3::zeros(),
1714            normal: Vec3::new(0.0, 1.0, 0.0),
1715            depth: 0.1,
1716        });
1717        prune_shallow_contacts(&mut m, 0.01);
1718        assert_eq!(m.contacts.len(), 1, "only the deep contact should remain");
1719        assert!((m.contacts[0].depth - 0.1).abs() < 1e-10);
1720    }
1721    #[test]
1722    fn test_translate_manifold() {
1723        let pair = CollisionPair::new(0, 1);
1724        let mut m = ContactManifold::new(pair);
1725        m.add_contact(Contact {
1726            point_a: Vec3::new(1.0, 0.0, 0.0),
1727            point_b: Vec3::new(2.0, 0.0, 0.0),
1728            normal: Vec3::new(1.0, 0.0, 0.0),
1729            depth: 0.1,
1730        });
1731        let offset = Vec3::new(5.0, 0.0, 0.0);
1732        translate_manifold(&mut m, offset);
1733        assert!((m.contacts[0].point_a.x - 6.0).abs() < 1e-10);
1734        assert!((m.contacts[0].point_b.x - 7.0).abs() < 1e-10);
1735    }
1736    #[test]
1737    fn test_heightfield_convex_generic_flat() {
1738        let hf = SimpleHeightField::new(vec![0.0; 9], 3, 3, 1.0);
1739        let pair = CollisionPair::new(0, 1);
1740        let manifolds = dispatch_heightfield_convex_generic(&hf, [1.0, 0.5, 1.0], 1.0, 0.0, pair);
1741        assert!(
1742            !manifolds.is_empty(),
1743            "should produce contacts for penetrating sphere"
1744        );
1745    }
1746    #[test]
1747    fn test_heightfield_convex_generic_no_contact() {
1748        let hf = SimpleHeightField::new(vec![0.0; 9], 3, 3, 1.0);
1749        let pair = CollisionPair::new(0, 1);
1750        let manifolds = dispatch_heightfield_convex_generic(&hf, [1.0, 10.0, 1.0], 0.1, 0.0, pair);
1751        assert!(
1752            manifolds.is_empty(),
1753            "sphere far above heightfield should have no contacts"
1754        );
1755    }
1756    #[test]
1757    fn test_dispatch_concave_empty_mesh() {
1758        let mesh = ConcaveMesh::new(vec![]);
1759        let s = Sphere::new(1.0);
1760        let tm = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1761        let ts = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1762        let pair = CollisionPair::new(0, 1);
1763        let dispatcher = NarrowPhaseDispatcher::default();
1764        let manifolds =
1765            dispatch_concave_vs_convex(&mesh, &tm, &s, ShapeType::Sphere, &ts, pair, &dispatcher);
1766        assert!(
1767            manifolds.is_empty(),
1768            "empty mesh should produce no contacts"
1769        );
1770    }
1771    #[test]
1772    fn test_convex_piece_aabb_overlap() {
1773        let pa = ConvexPiece::new(vec![
1774            [0.0, 0.0, 0.0],
1775            [1.0, 0.0, 0.0],
1776            [1.0, 1.0, 0.0],
1777            [0.0, 1.0, 0.0],
1778        ]);
1779        let pb = ConvexPiece::new(vec![
1780            [0.5, 0.5, 0.0],
1781            [1.5, 0.5, 0.0],
1782            [1.5, 1.5, 0.0],
1783            [0.5, 1.5, 0.0],
1784        ]);
1785        assert!(
1786            pa.aabb_overlaps(&pb, 0.0),
1787            "overlapping pieces should overlap"
1788        );
1789    }
1790    #[test]
1791    fn test_convex_piece_aabb_no_overlap() {
1792        let pa = ConvexPiece::new(vec![[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]);
1793        let pb = ConvexPiece::new(vec![[5.0, 5.0, 5.0], [6.0, 6.0, 6.0]]);
1794        assert!(
1795            !pa.aabb_overlaps(&pb, 0.0),
1796            "separated pieces should not overlap"
1797        );
1798    }
1799    #[test]
1800    fn test_batch_speculative_empty() {
1801        let config = SpeculativeConfig::default();
1802        let results = batch_speculative_contacts(&[], &config);
1803        assert!(results.is_empty());
1804    }
1805    #[test]
1806    fn test_batch_speculative_single() {
1807        let s1 = Sphere::new(1.0);
1808        let s2 = Sphere::new(1.0);
1809        let t1 = oxiphysics_core::Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1810        let t2 = oxiphysics_core::Transform::from_position(Vec3::new(2.1, 0.0, 0.0));
1811        let config = SpeculativeConfig {
1812            margin: 0.5,
1813            velocity_scale: 10.0,
1814        };
1815        let pair = CollisionPair::new(0, 1);
1816        let pairs: Vec<_> = vec![(
1817            &s1 as &dyn Shape,
1818            &t1,
1819            [0.5, 0.0, 0.0_f64],
1820            &s2 as &dyn Shape,
1821            &t2,
1822            [-0.5, 0.0, 0.0_f64],
1823            pair,
1824        )];
1825        let results = batch_speculative_contacts(&pairs, &config);
1826        assert_eq!(results.len(), 1);
1827    }
1828}