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, ref_is_a) =
211            if is_face_axis(normal, &ax, 0.707) {
212                (pos_a, rot_a, ha, pos_b, rot_b, hb, true)
213            } else if is_face_axis(normal, &bx, 0.707) {
214                (pos_b, rot_b, hb, pos_a, rot_a, ha, false)
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, true)
227                } else {
228                    (pos_b, rot_b, hb, pos_a, rot_a, ha, false)
229                }
230            };
231
232        // `clip_box_box` measures depth along a normal that must point reference→incident,
233        // but `normal` follows the A→B convention. When B is the reference those are
234        // opposite, so flip the normal going in and flip the contacts back to A→B coming
235        // out. Without this the primary path sampled the reference box's FAR face in
236        // `ref_face_d`, so every penetration came out inflated by ~2·(ref extent) — a
237        // rotated box resting on an axis-aligned one got blown apart by the solver. The
238        // empty-result fallback below already did this flip; the primary path did not.
239        let clip_normal = if ref_is_a { normal } else { -normal };
240        let mut contacts = clip_box_box(
241            clip_normal, min_pen, ref_pos, ref_rot, ref_h, inc_pos, inc_rot, inc_h,
242        );
243        if !ref_is_a {
244            for c in &mut contacts {
245                c.normal = -c.normal; // restore A→B convention
246            }
247        }
248
249        // Fallback: swap reference / incident faces.
250        // Sutherland–Hodgman can yield zero points when the incident face is
251        // much larger than the reference face and all corners project outside
252        // the reference slab bounds.
253        if contacts.is_empty() {
254            contacts = clip_box_box(
255                -clip_normal, min_pen, inc_pos, inc_rot, inc_h, ref_pos, ref_rot, ref_h,
256            );
257            // The swapped clip tags contacts with `-clip_normal`; convert back to A→B.
258            if ref_is_a {
259                for c in &mut contacts {
260                    c.normal = -c.normal;
261                }
262            }
263        }
264
265        // Ultimate fallback to GJK when clipping completely fails (rare,
266        // e.g. very thin boxes or heavily rounded geometry).
267        if contacts.is_empty() {
268            let shape_a = ColliderShape::Box(crate::components::BoxShape { half_extents: ha });
269            let shape_b = ColliderShape::Box(crate::components::BoxShape { half_extents: hb });
270            if let Some(c) = Gjk::get_contact(&shape_a, pos_a, rot_a, &shape_b, pos_b, rot_b) {
271                contacts.push(c);
272            }
273        }
274
275        contacts
276    }
277
278    // ── Dispatcher: single deepest contact ───────────────────────────────
279
280    /// Return the single deepest contact between two shapes, or `None` if
281    /// they do not overlap.
282    ///
283    /// Use this for simple overlap queries or soft-body node tests.  For
284    /// rigid-body simulation prefer [`test_collision_manifold`] which can
285    /// return multiple contact points.
286    pub fn test_collision(
287        shape_a: &ColliderShape,
288        pos_a: Vec3,
289        rot_a: Quat,
290        shape_b: &ColliderShape,
291        pos_b: Vec3,
292        rot_b: Quat,
293    ) -> Option<ContactPoint> {
294        let contacts = Self::test_collision_manifold(shape_a, pos_a, rot_a, shape_b, pos_b, rot_b);
295        contacts
296            .into_iter()
297            .max_by(|a, b| a.penetration.total_cmp(&b.penetration))
298    }
299
300    /// Return up to 4 contact points between two shapes.
301    ///
302    /// Compound shapes are handled recursively; each sub-shape pair is
303    /// dispatched independently and all resulting contacts are collected.
304    pub fn test_collision_manifold(
305        shape_a: &ColliderShape,
306        pos_a: Vec3,
307        rot_a: Quat,
308        shape_b: &ColliderShape,
309        pos_b: Vec3,
310        rot_b: Quat,
311    ) -> Vec<ContactPoint> {
312        // ── Compound shapes — recurse over sub-shapes ─────────────────────
313        if let ColliderShape::Compound(parts) = shape_a {
314            return parts
315                .iter()
316                .flat_map(|(local_t, sub)| {
317                    let wp = pos_a + rot_a.mul_vec3(local_t.position);
318                    let wr = rot_a * local_t.rotation;
319                    Self::test_collision_manifold(sub, wp, wr, shape_b, pos_b, rot_b)
320                })
321                .collect();
322        }
323        if let ColliderShape::Compound(parts) = shape_b {
324            return parts
325                .iter()
326                .flat_map(|(local_t, sub)| {
327                    let wp = pos_b + rot_b.mul_vec3(local_t.position);
328                    let wr = rot_b * local_t.rotation;
329                    Self::test_collision_manifold(shape_a, pos_a, rot_a, sub, wp, wr)
330                })
331                .collect();
332        }
333
334        // ── Primitive dispatch ────────────────────────────────────────────
335        let mut contacts: Vec<ContactPoint> = match (shape_a, shape_b) {
336            // Sphere – Sphere
337            (ColliderShape::Sphere(sa), ColliderShape::Sphere(sb)) => {
338                Self::sphere_sphere(pos_a, sa.radius, pos_b, sb.radius)
339                    .into_iter()
340                    .collect()
341            }
342
343            // Sphere – Plane  (A = sphere, normal A→B = into plane = -plane_n)
344            (ColliderShape::Sphere(s), ColliderShape::Plane(p)) => {
345                Self::sphere_plane(pos_a, s.radius, p.normal, p.distance)
346                    .into_iter()
347                    .collect()
348            }
349
350            // Plane – Sphere  (A = plane, B = sphere; flip normal)
351            (ColliderShape::Plane(p), ColliderShape::Sphere(s)) => {
352                Self::sphere_plane(pos_b, s.radius, p.normal, p.distance)
353                    .map(|mut c| {
354                        c.normal = -c.normal;
355                        c
356                    })
357                    .into_iter()
358                    .collect()
359            }
360
361            // Box – Plane  (A = box, normal = into plane = -plane_n  ✓)
362            (ColliderShape::Box(b), ColliderShape::Plane(p)) => {
363                Self::box_plane(pos_a, rot_a, b.half_extents, p.normal, p.distance)
364            }
365
366            // Plane – Box  (A = plane, B = box; flip normal)
367            (ColliderShape::Plane(p), ColliderShape::Box(b)) => {
368                let mut cs = Self::box_plane(pos_b, rot_b, b.half_extents, p.normal, p.distance);
369                for c in &mut cs {
370                    c.normal = -c.normal;
371                }
372                cs
373            }
374
375            // Box – Box
376            (ColliderShape::Box(ba), ColliderShape::Box(bb)) => {
377                Self::box_box(pos_a, rot_a, ba.half_extents, pos_b, rot_b, bb.half_extents)
378            }
379
380            // Generic – Plane (A is arbitrary, B is plane)
381            (_, ColliderShape::Plane(p)) => {
382                Self::shape_plane(shape_a, pos_a, rot_a, p.normal, p.distance)
383                    .into_iter()
384                    .collect()
385            }
386
387            // Plane – Generic (A is plane, B is arbitrary; flip normal)
388            (ColliderShape::Plane(p), _) => {
389                Self::shape_plane(shape_b, pos_b, rot_b, p.normal, p.distance)
390                    .map(|mut c| {
391                        c.normal = -c.normal;
392                        c
393                    })
394                    .into_iter()
395                    .collect()
396            }
397
398            // Fallback to GJK + EPA for all other shape combinations.
399            _ => Gjk::get_contact(shape_a, pos_a, rot_a, shape_b, pos_b, rot_b)
400                .into_iter()
401                .collect(),
402        };
403
404        // Populate local-space contact points for warm-starting.
405        for c in &mut contacts {
406            c.local_point_a = c.point - pos_a;
407            c.local_point_b = c.point - pos_b;
408        }
409
410        contacts
411    }
412}
413
414// ============================================================================
415//  SAT helpers
416// ============================================================================
417
418/// Signed penetration along `axis` between two oriented boxes.
419///
420/// Returns the overlap (positive = penetrating, negative = separated).
421/// Caller must check for negative values and return early.
422#[inline]
423fn sat_penetration(
424    axis: &Vec3,
425    ax: &[Vec3; 3],
426    ha: &[f32; 3],
427    bx: &[Vec3; 3],
428    hb: &[f32; 3],
429    t: Vec3,
430) -> f32 {
431    let proj_a: f32 = ax
432        .iter()
433        .zip(ha)
434        .map(|(e, &h)| e.dot(*axis).abs() * h)
435        .sum();
436    let proj_b: f32 = bx
437        .iter()
438        .zip(hb)
439        .map(|(e, &h)| e.dot(*axis).abs() * h)
440        .sum();
441    let dist = t.dot(*axis).abs();
442    proj_a + proj_b - dist
443}
444
445/// Returns `true` when `normal` is well-aligned with one of the box axes,
446/// indicating that a face (rather than an edge) is the contact feature.
447///
448/// `threshold` should be `1/√2 ≈ 0.707` to correctly handle 45° cases.
449#[inline]
450fn is_face_axis(normal: Vec3, axes: &[Vec3; 3], threshold: f32) -> bool {
451    axes.iter().any(|a| a.dot(normal).abs() > threshold)
452}
453
454// ============================================================================
455//  Geometry helpers
456// ============================================================================
457
458/// Compute all 8 corners of an oriented box.
459fn box_corners(pos: Vec3, rot: Quat, h: Vec3) -> [Vec3; 8] {
460    const SIGNS: [(f32, f32, f32); 8] = [
461        (1., 1., 1.),
462        (-1., 1., 1.),
463        (1., -1., 1.),
464        (-1., -1., 1.),
465        (1., 1., -1.),
466        (-1., 1., -1.),
467        (1., -1., -1.),
468        (-1., -1., -1.),
469    ];
470    SIGNS.map(|(sx, sy, sz)| pos + rot.mul_vec3(Vec3::new(sx * h.x, sy * h.y, sz * h.z)))
471}
472
473/// Build a `ContactPoint` with zeroed warm-start fields.
474#[inline]
475fn mk_contact(point: Vec3, normal: Vec3, penetration: f32) -> ContactPoint {
476    ContactPoint {
477        point,
478        normal,
479        penetration,
480        ..Default::default()
481    }
482}
483
484// ============================================================================
485//  Sutherland–Hodgman clipping — up to 4 contact points
486// ============================================================================
487
488/// Reduce `contacts` to the 4 points that best represent the contact patch:
489/// deepest point first, then 3 more selected for maximum area coverage.
490fn select_4_contacts(contacts: Vec<ContactPoint>) -> Vec<ContactPoint> {
491    if contacts.len() <= 4 {
492        return contacts;
493    }
494
495    let n = contacts.len();
496
497    // Step 1 — deepest.
498    let i0 = (0..n)
499        .max_by(|&a, &b| contacts[a].penetration.total_cmp(&contacts[b].penetration))
500        .unwrap();
501
502    let mut chosen = vec![i0];
503
504    // Steps 2-4 — greedily maximise minimum distance to already-chosen set.
505    for _ in 0..3 {
506        if chosen.len() == n {
507            break;
508        }
509        let next = (0..n).filter(|i| !chosen.contains(i)).max_by(|&a, &b| {
510            let da = chosen
511                .iter()
512                .map(|&c| (contacts[c].point - contacts[a].point).length_squared())
513                .fold(f32::INFINITY, f32::min);
514            let db = chosen
515                .iter()
516                .map(|&c| (contacts[c].point - contacts[b].point).length_squared())
517                .fold(f32::INFINITY, f32::min);
518            da.total_cmp(&db)
519        });
520        if let Some(idx) = next {
521            chosen.push(idx);
522        }
523    }
524
525    chosen.iter().map(|&i| contacts[i]).collect()
526}
527
528/// Sutherland–Hodgman box-vs-box clip.
529///
530/// Tests all 8 corners of the incident box against the reference box's face
531/// and its 4 side slabs.  Returns up to 4 contacts selected for maximum
532/// coverage.
533///
534/// `normal` must point **from reference toward incident** (A → B convention
535/// from the caller's perspective).
536fn clip_box_box(
537    normal: Vec3,
538    _min_pen: f32,
539    ref_pos: Vec3,
540    ref_rot: Quat,
541    ref_h: Vec3,
542    inc_pos: Vec3,
543    inc_rot: Quat,
544    inc_h: Vec3,
545) -> Vec<ContactPoint> {
546    let ref_axes = [
547        ref_rot.mul_vec3(Vec3::X),
548        ref_rot.mul_vec3(Vec3::Y),
549        ref_rot.mul_vec3(Vec3::Z),
550    ];
551    let ref_h_arr = [ref_h.x, ref_h.y, ref_h.z];
552
553    // Find the reference face — the axis most aligned with the contact normal.
554    let (face_idx, _) = ref_axes
555        .iter()
556        .enumerate()
557        .map(|(i, a)| (i, a.dot(normal).abs()))
558        .fold(
559            (0, 0.0f32),
560            |(bi, bv), (i, v)| if v > bv { (i, v) } else { (bi, bv) },
561        );
562
563    // Reference-box support (farthest extent) along the CONTACT NORMAL. Each contact is
564    // tagged with `normal` and the solver applies `penetration` along `normal`, so depth
565    // must be measured along `normal` too. When the reference face axis diverges from the
566    // normal (rotated boxes — is_face_axis admits up to 45°), the old `.dot(face_dir)` at the
567    // face centre over/under-reported depth and gave asymmetric depths across a flat face →
568    // spurious torque or unresolved overlap. The box's support along the normal (all three
569    // axes' projections, not just the face axis) makes each contact's depth the true MTV.
570    let ref_face_d = ref_pos.dot(normal)
571        + ref_axes[0].dot(normal).abs() * ref_h_arr[0]
572        + ref_axes[1].dot(normal).abs() * ref_h_arr[1]
573        + ref_axes[2].dot(normal).abs() * ref_h_arr[2];
574
575    // Tangent axes and their half-extents for the 4 side-slab clipping planes.
576    let t0 = ref_axes[(face_idx + 1) % 3];
577    let t1 = ref_axes[(face_idx + 2) % 3];
578    let e0 = ref_h_arr[(face_idx + 1) % 3];
579    let e1 = ref_h_arr[(face_idx + 2) % 3];
580
581    // Tolerance to avoid floating-point edge-case rejections.
582    const SLAB_TOLERANCE: f32 = 1e-3;
583
584    let contacts: Vec<ContactPoint> = box_corners(inc_pos, inc_rot, inc_h)
585        .iter()
586        .filter_map(|&corner| {
587            // 1. Corner must be on or behind the reference face (depth along `normal`).
588            let signed_depth = ref_face_d - corner.dot(normal);
589            if signed_depth <= 0.0 {
590                return None;
591            } // in front of reference face
592
593            // 2. Corner must lie within the side slabs of the reference face.
594            let local = corner - ref_pos;
595            if local.dot(t0).abs() > e0 + SLAB_TOLERANCE {
596                return None;
597            }
598            if local.dot(t1).abs() > e1 + SLAB_TOLERANCE {
599                return None;
600            }
601
602            // Clamp penetration to be physically meaningful.
603            // We allow slightly less than `min_pen` to avoid silently clamping
604            // valid shallow contacts to an arbitrary fraction.
605            let depth = signed_depth.max(0.0);
606
607            Some(mk_contact(corner, normal, depth))
608        })
609        .collect();
610
611    select_4_contacts(contacts)
612}
613
614// ============================================================================
615//  Tests
616// ============================================================================
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::components::BoxShape;
622
623    fn box_shape(half: f32) -> ColliderShape {
624        ColliderShape::Box(BoxShape {
625            half_extents: Vec3::splat(half),
626        })
627    }
628
629    // Regression: when box B is the SAT reference (its axis is more aligned with the
630    // contact normal than any of A's — e.g. a box tilted onto its corner resting on an
631    // axis-aligned box), the A→B normal must be flipped to reference→incident before
632    // clipping. The old primary path passed it unflipped, so `ref_face_d` sampled B's FAR
633    // face and reported a penetration inflated by ~2·hb, which made the solver blow the
634    // pair apart. A contact's penetration can never exceed the boxes' overlap along the
635    // contact normal (the SAT interval overlap) — assert exactly that.
636    #[test]
637    fn box_box_ref_b_penetration_not_inflated() {
638        // Rotate A so its local (1,1,1) body diagonal points along world +X; then all
639        // three of A's world axes sit 54.7° off +X (>45°), so `is_face_axis(normal, A)`
640        // is false and the axis-aligned B becomes the reference face.
641        let diag = Vec3::new(1.0, 1.0, 1.0).normalize();
642        let rot_a = Quat::from_axis_angle(
643            Vec3::new(0.0, 1.0, -1.0).normalize(),
644            diag.dot(Vec3::X).acos(),
645        );
646        let pos_a = Vec3::ZERO;
647        let ha = Vec3::splat(1.0);
648        // B offset along +X so the minimum-overlap (MTV) axis is +X.
649        let pos_b = Vec3::new(2.5, 0.0, 0.0);
650        let rot_b = Quat::IDENTITY;
651        let hb = Vec3::splat(1.0);
652
653        let contacts = NarrowPhase::box_box(pos_a, rot_a, ha, pos_b, rot_b, hb);
654        assert!(!contacts.is_empty(), "overlapping boxes must produce contacts");
655
656        let n = contacts[0].normal;
657        assert!(
658            n.x > 0.99,
659            "expected the +X contact normal that forces the ref=B path, got {n:?}"
660        );
661
662        // SAT interval overlap along the contact normal = A's max extent − B's min extent.
663        let extent = |rot: Quat, h: Vec3| {
664            let a = [rot.mul_vec3(Vec3::X), rot.mul_vec3(Vec3::Y), rot.mul_vec3(Vec3::Z)];
665            a[0].dot(n).abs() * h.x + a[1].dot(n).abs() * h.y + a[2].dot(n).abs() * h.z
666        };
667        let overlap = (pos_a.dot(n) + extent(rot_a, ha)) - (pos_b.dot(n) - extent(rot_b, hb));
668        assert!(overlap > 0.0, "boxes must actually overlap along the normal");
669
670        let max_pen = contacts
671            .iter()
672            .map(|c| c.penetration)
673            .fold(0.0_f32, f32::max);
674        assert!(
675            max_pen <= overlap + 1e-3,
676            "penetration {max_pen} exceeds the SAT overlap {overlap} along the normal \
677             → inflated depth (the ref=B unflipped-normal bug)"
678        );
679    }
680
681    // ── Sphere–Sphere ─────────────────────────────────────────────────────
682
683    #[test]
684    fn sphere_sphere_overlap_produces_contact() {
685        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(1.5, 0., 0.), 1.0);
686        assert!(c.is_some(), "overlapping spheres must collide");
687        let c = c.unwrap();
688        assert!(c.penetration > 0.0, "penetration must be positive");
689        assert!(
690            (c.normal.x - 1.0).abs() < 0.01,
691            "normal must point A→B (+X)"
692        );
693    }
694
695    #[test]
696    fn sphere_sphere_separated_returns_none() {
697        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(3.0, 0., 0.), 1.0);
698        assert!(c.is_none(), "separated spheres must not collide");
699    }
700
701    #[test]
702    fn sphere_sphere_touching_returns_none() {
703        // Exactly touching — penetration = 0, no constraint needed.
704        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(2.0, 0., 0.), 1.0);
705        assert!(
706            c.is_none(),
707            "just-touching spheres should not produce contact"
708        );
709    }
710
711    // ── Sphere–Plane ──────────────────────────────────────────────────────
712
713    #[test]
714    fn sphere_plane_below_produces_contact() {
715        // Plane: y = 0 (normal = +Y, d = 0). Sphere at y = 0.5 with r = 1.0
716        // → 0.5 units below the plane surface.
717        let c = NarrowPhase::sphere_plane(Vec3::new(0., 0.5, 0.), 1.0, Vec3::Y, 0.0);
718        assert!(c.is_some());
719        let c = c.unwrap();
720        assert!(c.penetration > 0.0);
721        // Normal should point from sphere into plane (i.e. -Y).
722        assert!((c.normal.y + 1.0).abs() < 0.01, "normal should be -Y");
723    }
724
725    #[test]
726    fn sphere_plane_above_returns_none() {
727        let c = NarrowPhase::sphere_plane(Vec3::new(0., 2.0, 0.), 1.0, Vec3::Y, 0.0);
728        assert!(c.is_none());
729    }
730
731    // ── Box–Plane ─────────────────────────────────────────────────────────
732
733    #[test]
734    fn box_plane_four_contacts_when_flat_on_ground() {
735        // Unit box sitting 0.5 units above y=0 plane → all 4 bottom corners
736        // penetrate by 0.5.
737        let contacts = NarrowPhase::box_plane(
738            Vec3::new(0., 0.5, 0.),
739            Quat::IDENTITY,
740            Vec3::splat(1.0),
741            Vec3::Y,
742            0.0,
743        );
744        assert_eq!(contacts.len(), 4, "flat box should have 4 contacts");
745        for c in &contacts {
746            assert!(c.penetration > 0.0, "each contact must penetrate");
747            assert!(
748                (c.normal.y + 1.0).abs() < 0.01,
749                "normal must be -Y (box→plane)"
750            );
751        }
752    }
753
754    #[test]
755    fn box_plane_no_contact_when_above() {
756        let contacts = NarrowPhase::box_plane(
757            Vec3::new(0., 2.0, 0.),
758            Quat::IDENTITY,
759            Vec3::splat(1.0),
760            Vec3::Y,
761            0.0,
762        );
763        assert!(contacts.is_empty());
764    }
765
766    // ── Box–Box SAT ───────────────────────────────────────────────────────
767
768    #[test]
769    fn box_box_overlap_produces_contacts() {
770        let contacts = NarrowPhase::box_box(
771            Vec3::ZERO,
772            Quat::IDENTITY,
773            Vec3::splat(1.0),
774            Vec3::new(1.5, 0., 0.),
775            Quat::IDENTITY,
776            Vec3::splat(1.0),
777        );
778        assert!(!contacts.is_empty(), "overlapping boxes must have contacts");
779        for c in &contacts {
780            assert!(c.penetration >= 0.0);
781        }
782    }
783
784    #[test]
785    fn box_box_separated_returns_empty() {
786        let contacts = NarrowPhase::box_box(
787            Vec3::ZERO,
788            Quat::IDENTITY,
789            Vec3::splat(1.0),
790            Vec3::new(5.0, 0., 0.),
791            Quat::IDENTITY,
792            Vec3::splat(1.0),
793        );
794        assert!(
795            contacts.is_empty(),
796            "separated boxes must not produce contacts"
797        );
798    }
799
800    #[test]
801    fn box_box_rotated_45_produces_contacts() {
802        let rot45 = Quat::from_rotation_y(std::f32::consts::FRAC_PI_4);
803        let contacts = NarrowPhase::box_box(
804            Vec3::ZERO,
805            Quat::IDENTITY,
806            Vec3::splat(0.8),
807            Vec3::new(1.0, 0., 0.),
808            rot45,
809            Vec3::splat(0.8),
810        );
811        assert!(
812            !contacts.is_empty(),
813            "rotated overlapping boxes must collide"
814        );
815    }
816
817    #[test]
818    fn box_box_face_contact_normal_is_axis_aligned() {
819        // Boxes overlapping along X — contact normal must be ±X.
820        let contacts = NarrowPhase::box_box(
821            Vec3::ZERO,
822            Quat::IDENTITY,
823            Vec3::splat(1.0),
824            Vec3::new(1.5, 0., 0.),
825            Quat::IDENTITY,
826            Vec3::splat(1.0),
827        );
828        assert!(!contacts.is_empty());
829        for c in &contacts {
830            assert!(
831                c.normal.x.abs() > 0.9,
832                "face contact normal should be X-aligned, got {:?}",
833                c.normal
834            );
835        }
836    }
837
838    #[test]
839    fn box_box_contact_count_at_most_4() {
840        let contacts = NarrowPhase::box_box(
841            Vec3::ZERO,
842            Quat::IDENTITY,
843            Vec3::splat(1.0),
844            Vec3::new(1.5, 0., 0.),
845            Quat::IDENTITY,
846            Vec3::splat(1.0),
847        );
848        assert!(
849            contacts.len() <= 4,
850            "manifold must not exceed 4 contact points"
851        );
852    }
853
854    #[test]
855    fn box_box_rotated_penetration_along_normal_equals_mtv() {
856        // Regression: contact depth must be measured along the CONTACT NORMAL, not the
857        // reference-face axis. Box A rotated 30° about Y (half 1,1,1) at origin; axis-aligned
858        // Box B (half 1,1,1) at (1.2,0,0). True MTV along X ≈ 1.16603. The old
859        // depth-along-face-axis code reported 1.327/1.327/0.327/0.327 — a +14% overshoot on
860        // two points and asymmetric depths across a symmetric flat contact (→ spurious torque).
861        let rot = Quat::from_rotation_y(std::f32::consts::FRAC_PI_6); // 30°
862        let contacts = NarrowPhase::box_box(
863            Vec3::ZERO,
864            rot,
865            Vec3::splat(1.0),
866            Vec3::new(1.2, 0.0, 0.0),
867            Quat::IDENTITY,
868            Vec3::splat(1.0),
869        );
870        assert!(!contacts.is_empty(), "rotated overlapping boxes must collide");
871        let expected_mtv = 1.166_f32;
872        let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
873        for c in &contacts {
874            lo = lo.min(c.penetration);
875            hi = hi.max(c.penetration);
876            assert!(
877                (c.penetration - expected_mtv).abs() < 0.02,
878                "penetration {} must equal the true MTV {} (measured along the contact normal)",
879                c.penetration,
880                expected_mtv,
881            );
882        }
883        // Depths across a coplanar face-face manifold must be uniform (no phantom torque).
884        assert!(
885            hi - lo < 0.02,
886            "manifold depths must be uniform across a flat contact, spread was {}",
887            hi - lo
888        );
889    }
890
891    // ── Dispatcher ────────────────────────────────────────────────────────
892
893    #[test]
894    fn dispatcher_box_box_finds_contact() {
895        let ba = box_shape(1.0);
896        let bb = box_shape(1.0);
897        let c = NarrowPhase::test_collision(
898            &ba,
899            Vec3::ZERO,
900            Quat::IDENTITY,
901            &bb,
902            Vec3::new(1.5, 0., 0.),
903            Quat::IDENTITY,
904        );
905        assert!(c.is_some(), "dispatcher must detect box-box overlap");
906    }
907
908    #[test]
909    fn dispatcher_manifold_populates_local_points() {
910        let ba = box_shape(1.0);
911        let bb = box_shape(1.0);
912        let contacts = NarrowPhase::test_collision_manifold(
913            &ba,
914            Vec3::ZERO,
915            Quat::IDENTITY,
916            &bb,
917            Vec3::new(1.5, 0., 0.),
918            Quat::IDENTITY,
919        );
920        assert!(!contacts.is_empty());
921        for c in &contacts {
922            // local_point_a and local_point_b should be non-default after dispatch.
923            // (They are 0 only if the contact point happens to be at the origin,
924            // which should never be the case for non-degenerate geometry.)
925            let _ = c.local_point_a; // just confirm they exist and compile
926            let _ = c.local_point_b;
927        }
928    }
929
930    #[test]
931    fn test_collision_returns_deepest_of_manifold() {
932        let ba = box_shape(1.0);
933        let bb = box_shape(1.0);
934
935        let manifold = NarrowPhase::test_collision_manifold(
936            &ba,
937            Vec3::ZERO,
938            Quat::IDENTITY,
939            &bb,
940            Vec3::new(1.5, 0., 0.),
941            Quat::IDENTITY,
942        );
943        let single = NarrowPhase::test_collision(
944            &ba,
945            Vec3::ZERO,
946            Quat::IDENTITY,
947            &bb,
948            Vec3::new(1.5, 0., 0.),
949            Quat::IDENTITY,
950        );
951
952        if let (Some(s), Some(deepest)) = (
953            single,
954            manifold
955                .iter()
956                .max_by(|a, b| a.penetration.total_cmp(&b.penetration)),
957        ) {
958            assert!(
959                (s.penetration - deepest.penetration).abs() < 1e-5,
960                "test_collision must return the deepest manifold contact"
961            );
962        }
963    }
964
965    // ── Normal convention consistency ─────────────────────────────────────
966
967    #[test]
968    fn sphere_sphere_normal_points_a_to_b() {
969        let c = NarrowPhase::sphere_sphere(Vec3::ZERO, 1.0, Vec3::new(1.5, 0., 0.), 1.0).unwrap();
970        // Dot of normal with (B_pos - A_pos) must be positive.
971        assert!(
972            c.normal.dot(Vec3::new(1.5, 0., 0.)) > 0.0,
973            "normal must point from A toward B"
974        );
975    }
976
977    #[test]
978    fn box_box_normal_points_a_to_b() {
979        let contacts = NarrowPhase::box_box(
980            Vec3::ZERO,
981            Quat::IDENTITY,
982            Vec3::splat(1.0),
983            Vec3::new(1.5, 0., 0.),
984            Quat::IDENTITY,
985            Vec3::splat(1.0),
986        );
987        let d = Vec3::new(1.5, 0., 0.); // B_pos - A_pos
988        for c in &contacts {
989            assert!(
990                c.normal.dot(d) > 0.0,
991                "box-box normal must point from A toward B, got {:?}",
992                c.normal
993            );
994        }
995    }
996}