Skip to main content

gizmo_physics_core/
narrowphase.rs

1//! Narrowphase collision detection.
2//!
3//! Provides a dispatcher ([`NarrowPhase`]) that routes each shape-pair to the
4//! most accurate and efficient algorithm:
5//!
6//! | Shape A      | Shape B      | Algorithm                        |
7//! |--------------|--------------|----------------------------------|
8//! | Sphere       | Sphere       | Analytic                         |
9//! | Sphere       | Plane        | Analytic                         |
10//! | Box          | Plane        | Corner test (4 points)           |
11//! | Box          | Box          | SAT + Sutherland-Hodgman clip    |
12//! | Any          | Plane        | GJK support-point                |
13//! | Any          | Any          | GJK + EPA                        |
14//! | Compound     | Any          | Recursive sub-shape dispatch     |
15//!
16//! The convention throughout is that the **contact normal points from shape A
17//! toward shape B** (i.e. it is the separating direction for body A).
18//!
19//! # Manifold vs. single-point API
20//!
21//! * [`NarrowPhase::test_collision`] — returns the single deepest contact, used
22//!   for overlap queries and soft-body node tests.
23//! * [`NarrowPhase::test_collision_manifold`] — returns up to 4 contacts for
24//!   the constraint solver; Box-Box and Box-Plane produce multiple points.
25
26use crate::collision::ContactPoint;
27use crate::components::ColliderShape;
28use crate::gjk::Gjk;
29use gizmo_math::{Quat, Vec3};
30
31// ============================================================================
32//  Public API
33// ============================================================================
34
35pub struct NarrowPhase;
36
37impl NarrowPhase {
38    // ── Primitive tests ───────────────────────────────────────────────────
39
40    /// Sphere–Sphere.  Normal points from A toward B.
41    pub fn sphere_sphere(pos_a: Vec3, r_a: f32, pos_b: Vec3, r_b: f32) -> Option<ContactPoint> {
42        let d = pos_b - pos_a;
43        let d2 = d.length_squared();
44        let rsum = r_a + r_b;
45
46        // Use squared comparison to avoid a sqrt when there is no contact.
47        if d2 >= rsum * rsum || d2 < 1e-10 {
48            return None;
49        }
50
51        let dist = d2.sqrt();
52        let normal = d / dist; // unit, A → B
53        Some(mk_contact(pos_a + normal * r_a, normal, rsum - dist))
54    }
55
56    /// Sphere–Plane.  `n` is the plane normal (points away from the solid
57    /// half-space); `d` is the signed plane offset (`p·n = d`).
58    /// Normal in the returned contact points **from the sphere toward the
59    /// plane** (i.e. into the plane — same convention: A → B where A = sphere).
60    pub fn sphere_plane(
61        sph_pos: Vec3,
62        r: f32,
63        plane_n: Vec3,
64        plane_d: f32,
65    ) -> Option<ContactPoint> {
66        // Signed distance from sphere centre to plane (positive = above plane).
67        let signed_dist = sph_pos.dot(plane_n) - plane_d;
68        if signed_dist >= r {
69            return None; // fully above the plane, no contact
70        }
71        // Contact point is the sphere's deepest point against the plane.
72        let point = sph_pos - plane_n * signed_dist;
73        // Normal: from sphere (A) toward plane (B), i.e. -plane_n.
74        Some(mk_contact(point, -plane_n, r - signed_dist))
75    }
76
77    /// Box–Plane contact.  Returns up to 4 corner contacts (one per
78    /// penetrating corner).  Normal in each contact points from the box toward
79    /// the plane (`-plane_n`).
80    pub fn box_plane(
81        bpos: Vec3,
82        brot: Quat,
83        half: Vec3,
84        plane_n: Vec3,
85        plane_d: f32,
86    ) -> Vec<ContactPoint> {
87        box_corners(bpos, brot, half)
88            .iter()
89            .filter_map(|&corner| {
90                let signed_dist = corner.dot(plane_n) - plane_d;
91                if signed_dist < 0.0 {
92                    // Corner is below the plane.
93                    Some(mk_contact(
94                        corner - plane_n * signed_dist,
95                        -plane_n,
96                        -signed_dist,
97                    ))
98                } else {
99                    None
100                }
101            })
102            .collect()
103    }
104
105    /// Generic shape–plane using a GJK support point.  Returns at most one
106    /// contact (the deepest support point against the plane).
107    pub fn shape_plane(
108        shape: &ColliderShape,
109        pos: Vec3,
110        rot: Quat,
111        plane_n: Vec3,
112        plane_d: f32,
113    ) -> Option<ContactPoint> {
114        // Support point in the direction opposing the plane normal gives the
115        // deepest potential contact point on the shape.
116        let deepest = Gjk::support_point(shape, pos, rot, -plane_n);
117        let signed_dist = deepest.dot(plane_n) - plane_d;
118        if signed_dist < 0.0 {
119            Some(mk_contact(
120                deepest - plane_n * signed_dist,
121                -plane_n,
122                -signed_dist,
123            ))
124        } else {
125            None
126        }
127    }
128
129    // ── Box–Box SAT ───────────────────────────────────────────────────────
130
131    /// Box–Box via the Separating Axis Theorem (15 axes) followed by
132    /// Sutherland–Hodgman clipping to produce up to 4 contact points.
133    ///
134    /// Returns an empty `Vec` when the boxes do not overlap.
135    pub fn box_box(
136        pos_a: Vec3,
137        rot_a: Quat,
138        ha: Vec3,
139        pos_b: Vec3,
140        rot_b: Quat,
141        hb: Vec3,
142    ) -> Vec<ContactPoint> {
143        // Local axes of each box.
144        let ax = [
145            rot_a.mul_vec3(Vec3::X),
146            rot_a.mul_vec3(Vec3::Y),
147            rot_a.mul_vec3(Vec3::Z),
148        ];
149        let bx = [
150            rot_b.mul_vec3(Vec3::X),
151            rot_b.mul_vec3(Vec3::Y),
152            rot_b.mul_vec3(Vec3::Z),
153        ];
154        let ha_ = [ha.x, ha.y, ha.z];
155        let hb_ = [hb.x, hb.y, hb.z];
156        let t = pos_b - pos_a; // centre-to-centre offset
157
158        // Build the 15 candidate separating axes on the stack to avoid
159        // heap allocation per-call.
160        // Layout: [ax0, ax1, ax2,  bx0, bx1, bx2,  9 cross products]
161        // Cross products that are near-zero (parallel edges) are skipped.
162        let mut axes = [Vec3::ZERO; 15];
163        let mut n_axes = 0usize;
164
165        for &a in &ax {
166            axes[n_axes] = a;
167            n_axes += 1;
168        }
169        for &b in &bx {
170            axes[n_axes] = b;
171            n_axes += 1;
172        }
173
174        for &a in &ax {
175            for &b in &bx {
176                let c = a.cross(b);
177                let len_sq = c.length_squared();
178                if len_sq > 1e-6 {
179                    // Normalise only valid edge–edge axes.
180                    axes[n_axes] = c * len_sq.sqrt().recip();
181                    n_axes += 1;
182                }
183            }
184        }
185
186        // SAT sweep — find minimum penetration axis.
187        let mut min_pen = f32::MAX;
188        let mut best_axis = Vec3::Y;
189        let mut flip = false;
190
191        for &axis in &axes[..n_axes] {
192            let pen = sat_penetration(&axis, &ax, &ha_, &bx, &hb_, t);
193            if pen < 0.0 {
194                return vec![]; // Separating axis found — no overlap.
195            }
196            if pen < min_pen {
197                min_pen = pen;
198                best_axis = axis;
199                // Ensure normal points from A toward B.
200                flip = t.dot(axis) < 0.0;
201            }
202        }
203
204        let normal = if flip { -best_axis } else { best_axis };
205
206        // Choose reference face (the box whose axis is most aligned with the
207        // contact normal gets to be the reference).  Threshold of 1/√2 ≈ 0.707
208        // correctly handles 45° diagonal contacts; the original 0.9 threshold
209        // misclassified many legitimate face contacts as edge-edge.
210        let (ref_pos, ref_rot, ref_h, inc_pos, inc_rot, inc_h) = if is_face_axis(normal, &ax, 0.707)
211        {
212            (pos_a, rot_a, ha, pos_b, rot_b, hb)
213        } else if is_face_axis(normal, &bx, 0.707) {
214            (pos_b, rot_b, hb, pos_a, rot_a, ha)
215        } else {
216            // Edge–edge: choose the box whose local axis is better aligned.
217            let dot_a = ax
218                .iter()
219                .map(|a| a.dot(normal).abs())
220                .fold(0.0f32, f32::max);
221            let dot_b = bx
222                .iter()
223                .map(|b| b.dot(normal).abs())
224                .fold(0.0f32, f32::max);
225            if dot_a >= dot_b {
226                (pos_a, rot_a, ha, pos_b, rot_b, hb)
227            } else {
228                (pos_b, rot_b, hb, pos_a, rot_a, ha)
229            }
230        };
231
232        // Primary clip attempt.
233        let mut contacts = clip_box_box(
234            normal, min_pen, ref_pos, ref_rot, ref_h, inc_pos, inc_rot, inc_h,
235        );
236
237        // Fallback: swap reference / incident faces.
238        // Sutherland–Hodgman can yield zero points when the incident face is
239        // much larger than the reference face and all corners project outside
240        // the reference slab bounds.
241        if contacts.is_empty() {
242            contacts = clip_box_box(
243                -normal, min_pen, inc_pos, inc_rot, inc_h, ref_pos, ref_rot, ref_h,
244            );
245            for c in &mut contacts {
246                c.normal = -c.normal; // restore A→B convention
247            }
248        }
249
250        // Ultimate fallback to GJK when clipping completely fails (rare,
251        // e.g. very thin boxes or heavily rounded geometry).
252        if contacts.is_empty() {
253            let shape_a = ColliderShape::Box(crate::components::BoxShape { half_extents: ha });
254            let shape_b = ColliderShape::Box(crate::components::BoxShape { half_extents: hb });
255            if let Some(c) = Gjk::get_contact(&shape_a, pos_a, rot_a, &shape_b, pos_b, rot_b) {
256                contacts.push(c);
257            }
258        }
259
260        contacts
261    }
262
263    // ── Dispatcher: single deepest contact ───────────────────────────────
264
265    /// Return the single deepest contact between two shapes, or `None` if
266    /// they do not overlap.
267    ///
268    /// Use this for simple overlap queries or soft-body node tests.  For
269    /// rigid-body simulation prefer [`test_collision_manifold`] which can
270    /// return multiple contact points.
271    pub fn test_collision(
272        shape_a: &ColliderShape,
273        pos_a: Vec3,
274        rot_a: Quat,
275        shape_b: &ColliderShape,
276        pos_b: Vec3,
277        rot_b: Quat,
278    ) -> Option<ContactPoint> {
279        let contacts = Self::test_collision_manifold(shape_a, pos_a, rot_a, shape_b, pos_b, rot_b);
280        contacts
281            .into_iter()
282            .max_by(|a, b| a.penetration.total_cmp(&b.penetration))
283    }
284
285    /// Return up to 4 contact points between two shapes.
286    ///
287    /// Compound shapes are handled recursively; each sub-shape pair is
288    /// dispatched independently and all resulting contacts are collected.
289    pub fn test_collision_manifold(
290        shape_a: &ColliderShape,
291        pos_a: Vec3,
292        rot_a: Quat,
293        shape_b: &ColliderShape,
294        pos_b: Vec3,
295        rot_b: Quat,
296    ) -> Vec<ContactPoint> {
297        // ── Compound shapes — recurse over sub-shapes ─────────────────────
298        if let ColliderShape::Compound(parts) = shape_a {
299            return parts
300                .iter()
301                .flat_map(|(local_t, sub)| {
302                    let wp = pos_a + rot_a.mul_vec3(local_t.position);
303                    let wr = rot_a * local_t.rotation;
304                    Self::test_collision_manifold(sub, wp, wr, shape_b, pos_b, rot_b)
305                })
306                .collect();
307        }
308        if let ColliderShape::Compound(parts) = shape_b {
309            return parts
310                .iter()
311                .flat_map(|(local_t, sub)| {
312                    let wp = pos_b + rot_b.mul_vec3(local_t.position);
313                    let wr = rot_b * local_t.rotation;
314                    Self::test_collision_manifold(shape_a, pos_a, rot_a, sub, wp, wr)
315                })
316                .collect();
317        }
318
319        // ── Primitive dispatch ────────────────────────────────────────────
320        let mut contacts: Vec<ContactPoint> = match (shape_a, shape_b) {
321            // Sphere – Sphere
322            (ColliderShape::Sphere(sa), ColliderShape::Sphere(sb)) => {
323                Self::sphere_sphere(pos_a, sa.radius, pos_b, sb.radius)
324                    .into_iter()
325                    .collect()
326            }
327
328            // Sphere – Plane  (A = sphere, normal A→B = into plane = -plane_n)
329            (ColliderShape::Sphere(s), ColliderShape::Plane(p)) => {
330                Self::sphere_plane(pos_a, s.radius, p.normal, p.distance)
331                    .into_iter()
332                    .collect()
333            }
334
335            // Plane – Sphere  (A = plane, B = sphere; flip normal)
336            (ColliderShape::Plane(p), ColliderShape::Sphere(s)) => {
337                Self::sphere_plane(pos_b, s.radius, p.normal, p.distance)
338                    .map(|mut c| {
339                        c.normal = -c.normal;
340                        c
341                    })
342                    .into_iter()
343                    .collect()
344            }
345
346            // Box – Plane  (A = box, normal = into plane = -plane_n  ✓)
347            (ColliderShape::Box(b), ColliderShape::Plane(p)) => {
348                Self::box_plane(pos_a, rot_a, b.half_extents, p.normal, p.distance)
349            }
350
351            // Plane – Box  (A = plane, B = box; flip normal)
352            (ColliderShape::Plane(p), ColliderShape::Box(b)) => {
353                let mut cs = Self::box_plane(pos_b, rot_b, b.half_extents, p.normal, p.distance);
354                for c in &mut cs {
355                    c.normal = -c.normal;
356                }
357                cs
358            }
359
360            // Box – Box
361            (ColliderShape::Box(ba), ColliderShape::Box(bb)) => {
362                Self::box_box(pos_a, rot_a, ba.half_extents, pos_b, rot_b, bb.half_extents)
363            }
364
365            // Generic – Plane (A is arbitrary, B is plane)
366            (_, ColliderShape::Plane(p)) => {
367                Self::shape_plane(shape_a, pos_a, rot_a, p.normal, p.distance)
368                    .into_iter()
369                    .collect()
370            }
371
372            // Plane – Generic (A is plane, B is arbitrary; flip normal)
373            (ColliderShape::Plane(p), _) => {
374                Self::shape_plane(shape_b, pos_b, rot_b, p.normal, p.distance)
375                    .map(|mut c| {
376                        c.normal = -c.normal;
377                        c
378                    })
379                    .into_iter()
380                    .collect()
381            }
382
383            // Fallback to GJK + EPA for all other shape combinations.
384            _ => Gjk::get_contact(shape_a, pos_a, rot_a, shape_b, pos_b, rot_b)
385                .into_iter()
386                .collect(),
387        };
388
389        // Populate local-space contact points for warm-starting.
390        for c in &mut contacts {
391            c.local_point_a = c.point - pos_a;
392            c.local_point_b = c.point - pos_b;
393        }
394
395        contacts
396    }
397}
398
399// ============================================================================
400//  SAT helpers
401// ============================================================================
402
403/// Signed penetration along `axis` between two oriented boxes.
404///
405/// Returns the overlap (positive = penetrating, negative = separated).
406/// Caller must check for negative values and return early.
407#[inline]
408fn sat_penetration(
409    axis: &Vec3,
410    ax: &[Vec3; 3],
411    ha: &[f32; 3],
412    bx: &[Vec3; 3],
413    hb: &[f32; 3],
414    t: Vec3,
415) -> f32 {
416    let proj_a: f32 = ax
417        .iter()
418        .zip(ha)
419        .map(|(e, &h)| e.dot(*axis).abs() * h)
420        .sum();
421    let proj_b: f32 = bx
422        .iter()
423        .zip(hb)
424        .map(|(e, &h)| e.dot(*axis).abs() * h)
425        .sum();
426    let dist = t.dot(*axis).abs();
427    proj_a + proj_b - dist
428}
429
430/// Returns `true` when `normal` is well-aligned with one of the box axes,
431/// indicating that a face (rather than an edge) is the contact feature.
432///
433/// `threshold` should be `1/√2 ≈ 0.707` to correctly handle 45° cases.
434#[inline]
435fn is_face_axis(normal: Vec3, axes: &[Vec3; 3], threshold: f32) -> bool {
436    axes.iter().any(|a| a.dot(normal).abs() > threshold)
437}
438
439// ============================================================================
440//  Geometry helpers
441// ============================================================================
442
443/// Compute all 8 corners of an oriented box.
444fn box_corners(pos: Vec3, rot: Quat, h: Vec3) -> [Vec3; 8] {
445    const SIGNS: [(f32, f32, f32); 8] = [
446        (1., 1., 1.),
447        (-1., 1., 1.),
448        (1., -1., 1.),
449        (-1., -1., 1.),
450        (1., 1., -1.),
451        (-1., 1., -1.),
452        (1., -1., -1.),
453        (-1., -1., -1.),
454    ];
455    SIGNS.map(|(sx, sy, sz)| pos + rot.mul_vec3(Vec3::new(sx * h.x, sy * h.y, sz * h.z)))
456}
457
458/// Build a `ContactPoint` with zeroed warm-start fields.
459#[inline]
460fn mk_contact(point: Vec3, normal: Vec3, penetration: f32) -> ContactPoint {
461    ContactPoint {
462        point,
463        normal,
464        penetration,
465        ..Default::default()
466    }
467}
468
469// ============================================================================
470//  Sutherland–Hodgman clipping — up to 4 contact points
471// ============================================================================
472
473/// Reduce `contacts` to the 4 points that best represent the contact patch:
474/// deepest point first, then 3 more selected for maximum area coverage.
475fn select_4_contacts(contacts: Vec<ContactPoint>) -> Vec<ContactPoint> {
476    if contacts.len() <= 4 {
477        return contacts;
478    }
479
480    let n = contacts.len();
481
482    // Step 1 — deepest.
483    let i0 = (0..n)
484        .max_by(|&a, &b| contacts[a].penetration.total_cmp(&contacts[b].penetration))
485        .unwrap();
486
487    let mut chosen = vec![i0];
488
489    // Steps 2-4 — greedily maximise minimum distance to already-chosen set.
490    for _ in 0..3 {
491        if chosen.len() == n {
492            break;
493        }
494        let next = (0..n).filter(|i| !chosen.contains(i)).max_by(|&a, &b| {
495            let da = chosen
496                .iter()
497                .map(|&c| (contacts[c].point - contacts[a].point).length_squared())
498                .fold(f32::INFINITY, f32::min);
499            let db = chosen
500                .iter()
501                .map(|&c| (contacts[c].point - contacts[b].point).length_squared())
502                .fold(f32::INFINITY, f32::min);
503            da.total_cmp(&db)
504        });
505        if let Some(idx) = next {
506            chosen.push(idx);
507        }
508    }
509
510    chosen.iter().map(|&i| contacts[i]).collect()
511}
512
513/// Sutherland–Hodgman box-vs-box clip.
514///
515/// Tests all 8 corners of the incident box against the reference box's face
516/// and its 4 side slabs.  Returns up to 4 contacts selected for maximum
517/// coverage.
518///
519/// `normal` must point **from reference toward incident** (A → B convention
520/// from the caller's perspective).
521fn clip_box_box(
522    normal: Vec3,
523    _min_pen: f32,
524    ref_pos: Vec3,
525    ref_rot: Quat,
526    ref_h: Vec3,
527    inc_pos: Vec3,
528    inc_rot: Quat,
529    inc_h: Vec3,
530) -> Vec<ContactPoint> {
531    let ref_axes = [
532        ref_rot.mul_vec3(Vec3::X),
533        ref_rot.mul_vec3(Vec3::Y),
534        ref_rot.mul_vec3(Vec3::Z),
535    ];
536    let ref_h_arr = [ref_h.x, ref_h.y, ref_h.z];
537
538    // Find the reference face — the axis most aligned with the contact normal.
539    let (face_idx, _) = ref_axes
540        .iter()
541        .enumerate()
542        .map(|(i, a)| (i, a.dot(normal).abs()))
543        .fold(
544            (0, 0.0f32),
545            |(bi, bv), (i, v)| if v > bv { (i, v) } else { (bi, bv) },
546        );
547
548    let face_axis = ref_axes[face_idx];
549    // Choose the outward-facing direction of the reference face.
550    let face_dir = if face_axis.dot(normal) > 0.0 {
551        face_axis
552    } else {
553        -face_axis
554    };
555
556    // Plane equation for the reference face: p · face_dir = ref_face_d
557    let ref_face_d = (ref_pos + face_dir * ref_h_arr[face_idx]).dot(face_dir);
558
559    // Tangent axes and their half-extents for the 4 side-slab clipping planes.
560    let t0 = ref_axes[(face_idx + 1) % 3];
561    let t1 = ref_axes[(face_idx + 2) % 3];
562    let e0 = ref_h_arr[(face_idx + 1) % 3];
563    let e1 = ref_h_arr[(face_idx + 2) % 3];
564
565    // Tolerance to avoid floating-point edge-case rejections.
566    const SLAB_TOLERANCE: f32 = 1e-3;
567
568    let contacts: Vec<ContactPoint> = box_corners(inc_pos, inc_rot, inc_h)
569        .iter()
570        .filter_map(|&corner| {
571            // 1. Corner must be on or behind the reference face.
572            let signed_depth = ref_face_d - corner.dot(face_dir);
573            if signed_depth <= 0.0 {
574                return None;
575            } // in front of reference face
576
577            // 2. Corner must lie within the side slabs of the reference face.
578            let local = corner - ref_pos;
579            if local.dot(t0).abs() > e0 + SLAB_TOLERANCE {
580                return None;
581            }
582            if local.dot(t1).abs() > e1 + SLAB_TOLERANCE {
583                return None;
584            }
585
586            // Clamp penetration to be physically meaningful.
587            // We allow slightly less than `min_pen` to avoid silently clamping
588            // valid shallow contacts to an arbitrary fraction.
589            let depth = signed_depth.max(0.0);
590
591            Some(mk_contact(corner, normal, depth))
592        })
593        .collect();
594
595    select_4_contacts(contacts)
596}
597
598// ============================================================================
599//  Tests
600// ============================================================================
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::components::BoxShape;
606
607    fn box_shape(half: f32) -> ColliderShape {
608        ColliderShape::Box(BoxShape {
609            half_extents: Vec3::splat(half),
610        })
611    }
612
613    // ── Sphere–Sphere ─────────────────────────────────────────────────────
614
615    #[test]
616    fn sphere_sphere_overlap_produces_contact() {
617        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(1.5, 0., 0.), 1.0);
618        assert!(c.is_some(), "overlapping spheres must collide");
619        let c = c.unwrap();
620        assert!(c.penetration > 0.0, "penetration must be positive");
621        assert!(
622            (c.normal.x - 1.0).abs() < 0.01,
623            "normal must point A→B (+X)"
624        );
625    }
626
627    #[test]
628    fn sphere_sphere_separated_returns_none() {
629        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(3.0, 0., 0.), 1.0);
630        assert!(c.is_none(), "separated spheres must not collide");
631    }
632
633    #[test]
634    fn sphere_sphere_touching_returns_none() {
635        // Exactly touching — penetration = 0, no constraint needed.
636        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(2.0, 0., 0.), 1.0);
637        assert!(
638            c.is_none(),
639            "just-touching spheres should not produce contact"
640        );
641    }
642
643    // ── Sphere–Plane ──────────────────────────────────────────────────────
644
645    #[test]
646    fn sphere_plane_below_produces_contact() {
647        // Plane: y = 0 (normal = +Y, d = 0). Sphere at y = 0.5 with r = 1.0
648        // → 0.5 units below the plane surface.
649        let c = NarrowPhase::sphere_plane(Vec3::new(0., 0.5, 0.), 1.0, Vec3::Y, 0.0);
650        assert!(c.is_some());
651        let c = c.unwrap();
652        assert!(c.penetration > 0.0);
653        // Normal should point from sphere into plane (i.e. -Y).
654        assert!((c.normal.y + 1.0).abs() < 0.01, "normal should be -Y");
655    }
656
657    #[test]
658    fn sphere_plane_above_returns_none() {
659        let c = NarrowPhase::sphere_plane(Vec3::new(0., 2.0, 0.), 1.0, Vec3::Y, 0.0);
660        assert!(c.is_none());
661    }
662
663    // ── Box–Plane ─────────────────────────────────────────────────────────
664
665    #[test]
666    fn box_plane_four_contacts_when_flat_on_ground() {
667        // Unit box sitting 0.5 units above y=0 plane → all 4 bottom corners
668        // penetrate by 0.5.
669        let contacts = NarrowPhase::box_plane(
670            Vec3::new(0., 0.5, 0.),
671            Quat::IDENTITY,
672            Vec3::splat(1.0),
673            Vec3::Y,
674            0.0,
675        );
676        assert_eq!(contacts.len(), 4, "flat box should have 4 contacts");
677        for c in &contacts {
678            assert!(c.penetration > 0.0, "each contact must penetrate");
679            assert!(
680                (c.normal.y + 1.0).abs() < 0.01,
681                "normal must be -Y (box→plane)"
682            );
683        }
684    }
685
686    #[test]
687    fn box_plane_no_contact_when_above() {
688        let contacts = NarrowPhase::box_plane(
689            Vec3::new(0., 2.0, 0.),
690            Quat::IDENTITY,
691            Vec3::splat(1.0),
692            Vec3::Y,
693            0.0,
694        );
695        assert!(contacts.is_empty());
696    }
697
698    // ── Box–Box SAT ───────────────────────────────────────────────────────
699
700    #[test]
701    fn box_box_overlap_produces_contacts() {
702        let contacts = NarrowPhase::box_box(
703            Vec3::ZERO,
704            Quat::IDENTITY,
705            Vec3::splat(1.0),
706            Vec3::new(1.5, 0., 0.),
707            Quat::IDENTITY,
708            Vec3::splat(1.0),
709        );
710        assert!(!contacts.is_empty(), "overlapping boxes must have contacts");
711        for c in &contacts {
712            assert!(c.penetration >= 0.0);
713        }
714    }
715
716    #[test]
717    fn box_box_separated_returns_empty() {
718        let contacts = NarrowPhase::box_box(
719            Vec3::ZERO,
720            Quat::IDENTITY,
721            Vec3::splat(1.0),
722            Vec3::new(5.0, 0., 0.),
723            Quat::IDENTITY,
724            Vec3::splat(1.0),
725        );
726        assert!(
727            contacts.is_empty(),
728            "separated boxes must not produce contacts"
729        );
730    }
731
732    #[test]
733    fn box_box_rotated_45_produces_contacts() {
734        let rot45 = Quat::from_rotation_y(std::f32::consts::FRAC_PI_4);
735        let contacts = NarrowPhase::box_box(
736            Vec3::ZERO,
737            Quat::IDENTITY,
738            Vec3::splat(0.8),
739            Vec3::new(1.0, 0., 0.),
740            rot45,
741            Vec3::splat(0.8),
742        );
743        assert!(
744            !contacts.is_empty(),
745            "rotated overlapping boxes must collide"
746        );
747    }
748
749    #[test]
750    fn box_box_face_contact_normal_is_axis_aligned() {
751        // Boxes overlapping along X — contact normal must be ±X.
752        let contacts = NarrowPhase::box_box(
753            Vec3::ZERO,
754            Quat::IDENTITY,
755            Vec3::splat(1.0),
756            Vec3::new(1.5, 0., 0.),
757            Quat::IDENTITY,
758            Vec3::splat(1.0),
759        );
760        assert!(!contacts.is_empty());
761        for c in &contacts {
762            assert!(
763                c.normal.x.abs() > 0.9,
764                "face contact normal should be X-aligned, got {:?}",
765                c.normal
766            );
767        }
768    }
769
770    #[test]
771    fn box_box_contact_count_at_most_4() {
772        let contacts = NarrowPhase::box_box(
773            Vec3::ZERO,
774            Quat::IDENTITY,
775            Vec3::splat(1.0),
776            Vec3::new(1.5, 0., 0.),
777            Quat::IDENTITY,
778            Vec3::splat(1.0),
779        );
780        assert!(
781            contacts.len() <= 4,
782            "manifold must not exceed 4 contact points"
783        );
784    }
785
786    // ── Dispatcher ────────────────────────────────────────────────────────
787
788    #[test]
789    fn dispatcher_box_box_finds_contact() {
790        let ba = box_shape(1.0);
791        let bb = box_shape(1.0);
792        let c = NarrowPhase::test_collision(
793            &ba,
794            Vec3::ZERO,
795            Quat::IDENTITY,
796            &bb,
797            Vec3::new(1.5, 0., 0.),
798            Quat::IDENTITY,
799        );
800        assert!(c.is_some(), "dispatcher must detect box-box overlap");
801    }
802
803    #[test]
804    fn dispatcher_manifold_populates_local_points() {
805        let ba = box_shape(1.0);
806        let bb = box_shape(1.0);
807        let contacts = NarrowPhase::test_collision_manifold(
808            &ba,
809            Vec3::ZERO,
810            Quat::IDENTITY,
811            &bb,
812            Vec3::new(1.5, 0., 0.),
813            Quat::IDENTITY,
814        );
815        assert!(!contacts.is_empty());
816        for c in &contacts {
817            // local_point_a and local_point_b should be non-default after dispatch.
818            // (They are 0 only if the contact point happens to be at the origin,
819            // which should never be the case for non-degenerate geometry.)
820            let _ = c.local_point_a; // just confirm they exist and compile
821            let _ = c.local_point_b;
822        }
823    }
824
825    #[test]
826    fn test_collision_returns_deepest_of_manifold() {
827        let ba = box_shape(1.0);
828        let bb = box_shape(1.0);
829
830        let manifold = NarrowPhase::test_collision_manifold(
831            &ba,
832            Vec3::ZERO,
833            Quat::IDENTITY,
834            &bb,
835            Vec3::new(1.5, 0., 0.),
836            Quat::IDENTITY,
837        );
838        let single = NarrowPhase::test_collision(
839            &ba,
840            Vec3::ZERO,
841            Quat::IDENTITY,
842            &bb,
843            Vec3::new(1.5, 0., 0.),
844            Quat::IDENTITY,
845        );
846
847        if let (Some(s), Some(deepest)) = (
848            single,
849            manifold
850                .iter()
851                .max_by(|a, b| a.penetration.total_cmp(&b.penetration)),
852        ) {
853            assert!(
854                (s.penetration - deepest.penetration).abs() < 1e-5,
855                "test_collision must return the deepest manifold contact"
856            );
857        }
858    }
859
860    // ── Normal convention consistency ─────────────────────────────────────
861
862    #[test]
863    fn sphere_sphere_normal_points_a_to_b() {
864        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(1.5, 0., 0.), 1.0).unwrap();
865        // Dot of normal with (B_pos - A_pos) must be positive.
866        assert!(
867            c.normal.dot(Vec3::new(1.5, 0., 0.)) > 0.0,
868            "normal must point from A toward B"
869        );
870    }
871
872    #[test]
873    fn box_box_normal_points_a_to_b() {
874        let contacts = NarrowPhase::box_box(
875            Vec3::ZERO,
876            Quat::IDENTITY,
877            Vec3::splat(1.0),
878            Vec3::new(1.5, 0., 0.),
879            Quat::IDENTITY,
880            Vec3::splat(1.0),
881        );
882        let d = Vec3::new(1.5, 0., 0.); // B_pos - A_pos
883        for c in &contacts {
884            assert!(
885                c.normal.dot(d) > 0.0,
886                "box-box normal must point from A toward B, got {:?}",
887                c.normal
888            );
889        }
890    }
891}