Skip to main content

oxiphysics_collision/narrowphase/
simple.rs

1//! Self-contained narrow-phase collision detection using plain f64 arrays.
2//!
3//! This module provides standalone functions that operate on raw geometric
4//! data without any external dependencies on nalgebra or shape abstractions.
5
6// Copyright 2026 COOLJAPAN OU (Team KitaSan)
7// SPDX-License-Identifier: Apache-2.0
8
9// ── Vector helpers ────────────────────────────────────────────────────────────
10
11fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
12    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
13}
14
15fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
16    [
17        a[1] * b[2] - a[2] * b[1],
18        a[2] * b[0] - a[0] * b[2],
19        a[0] * b[1] - a[1] * b[0],
20    ]
21}
22
23fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
24    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
25}
26
27fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
28    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
29}
30
31fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
32    [a[0] * s, a[1] * s, a[2] * s]
33}
34
35fn len(a: [f64; 3]) -> f64 {
36    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
37}
38
39fn len_sq(a: [f64; 3]) -> f64 {
40    a[0] * a[0] + a[1] * a[1] + a[2] * a[2]
41}
42
43fn normalize(a: [f64; 3]) -> [f64; 3] {
44    let l = len(a);
45    if l > 1e-10 {
46        scale(a, 1.0 / l)
47    } else {
48        [0.0, 1.0, 0.0]
49    }
50}
51
52fn neg(a: [f64; 3]) -> [f64; 3] {
53    [-a[0], -a[1], -a[2]]
54}
55
56// ── Contact types ─────────────────────────────────────────────────────────────
57
58/// A single contact point between two shapes.
59#[derive(Debug, Clone, PartialEq)]
60pub struct Contact {
61    /// Witness point on shape A in world space.
62    pub point_a: [f64; 3],
63    /// Witness point on shape B in world space.
64    pub point_b: [f64; 3],
65    /// Contact normal pointing from B toward A (unit length).
66    pub normal: [f64; 3],
67    /// Penetration depth (positive means overlapping).
68    pub depth: f64,
69}
70
71/// Result of a narrow-phase collision query.
72#[derive(Debug, Clone)]
73pub enum NarrowPhaseResult {
74    /// The shapes are separated; no contact.
75    NoContact,
76    /// A single contact point was found.
77    SingleContact(Contact),
78    /// Multiple contact points were found (contact manifold).
79    Manifold(Vec<Contact>),
80}
81
82impl NarrowPhaseResult {
83    /// Returns `true` if there is at least one contact.
84    pub fn has_contact(&self) -> bool {
85        !matches!(self, NarrowPhaseResult::NoContact)
86    }
87
88    /// Returns the first contact if any.
89    pub fn first_contact(&self) -> Option<&Contact> {
90        match self {
91            NarrowPhaseResult::NoContact => None,
92            NarrowPhaseResult::SingleContact(c) => Some(c),
93            NarrowPhaseResult::Manifold(v) => v.first(),
94        }
95    }
96}
97
98// ── Sphere vs Sphere ──────────────────────────────────────────────────────────
99
100/// Sphere–sphere narrow-phase test.
101///
102/// Returns `NoContact` when the spheres are separated, `SingleContact`
103/// when they overlap.
104pub fn sphere_sphere(ca: [f64; 3], ra: f64, cb: [f64; 3], rb: f64) -> NarrowPhaseResult {
105    let d = sub(cb, ca);
106    let dist = len(d);
107    let depth = ra + rb - dist;
108    if depth < 0.0 {
109        return NarrowPhaseResult::NoContact;
110    }
111    let normal = if dist > 1e-10 {
112        scale(d, 1.0 / dist)
113    } else {
114        [0.0, 1.0, 0.0]
115    };
116    let point_a = add(ca, scale(normal, ra));
117    let point_b = sub(cb, scale(normal, rb));
118    NarrowPhaseResult::SingleContact(Contact {
119        point_a,
120        point_b,
121        normal,
122        depth,
123    })
124}
125
126// ── Sphere vs Box ─────────────────────────────────────────────────────────────
127
128/// Sphere–axis-aligned box narrow-phase test.
129///
130/// The box is defined by its `box_center` and `half_extents`.
131/// Returns `NoContact` when the sphere does not reach the box.
132pub fn sphere_box(
133    sphere_center: [f64; 3],
134    radius: f64,
135    box_center: [f64; 3],
136    half_extents: [f64; 3],
137) -> NarrowPhaseResult {
138    // Closest point on box to sphere center (work in box-local space).
139    let local = sub(sphere_center, box_center);
140    let clamped = [
141        local[0].clamp(-half_extents[0], half_extents[0]),
142        local[1].clamp(-half_extents[1], half_extents[1]),
143        local[2].clamp(-half_extents[2], half_extents[2]),
144    ];
145    let diff = sub(local, clamped);
146    let dist_sq = len_sq(diff);
147
148    if dist_sq >= radius * radius {
149        return NarrowPhaseResult::NoContact;
150    }
151
152    let dist = dist_sq.sqrt();
153    let (normal, depth) = if dist > 1e-10 {
154        // Sphere centre is outside the box.
155        (normalize(diff), radius - dist)
156    } else {
157        // Sphere centre is inside the box; push out through nearest face.
158        let dx = half_extents[0] - local[0].abs();
159        let dy = half_extents[1] - local[1].abs();
160        let dz = half_extents[2] - local[2].abs();
161        let n = if dx <= dy && dx <= dz {
162            [local[0].signum(), 0.0, 0.0]
163        } else if dy <= dz {
164            [0.0, local[1].signum(), 0.0]
165        } else {
166            [0.0, 0.0, local[2].signum()]
167        };
168        let pen = dx.min(dy).min(dz) + radius;
169        (n, pen)
170    };
171
172    // Convert closest point back to world space.
173    let closest_world = add(box_center, clamped);
174    let point_a = sub(sphere_center, scale(normal, radius));
175    let point_b = closest_world;
176
177    NarrowPhaseResult::SingleContact(Contact {
178        point_a,
179        point_b,
180        normal,
181        depth,
182    })
183}
184
185// ── Box vs Box (SAT) ──────────────────────────────────────────────────────────
186
187/// OBB–OBB SAT collision test using 15 separating axes.
188///
189/// `center_a`/`center_b` are world-space box centres.
190/// `half_a`/`half_b` are half-extents.
191/// `rot_a`/`rot_b` are rotation matrices whose columns are the box's local axes.
192///
193/// Returns `NoContact` if any axis separates the boxes, otherwise the contact
194/// with minimum penetration depth.
195pub fn box_box_sat(
196    center_a: [f64; 3],
197    half_a: [f64; 3],
198    rot_a: [[f64; 3]; 3],
199    center_b: [f64; 3],
200    half_b: [f64; 3],
201    rot_b: [[f64; 3]; 3],
202) -> NarrowPhaseResult {
203    // Translation in world space.
204    let t_world = sub(center_b, center_a);
205
206    // Express t in A's frame.
207    let t = [
208        dot(rot_a[0], t_world),
209        dot(rot_a[1], t_world),
210        dot(rot_a[2], t_world),
211    ];
212
213    // R[i][j] = dot(rot_a[i], rot_b[j])
214    let mut r = [[0.0f64; 3]; 3];
215    let mut abs_r = [[0.0f64; 3]; 3];
216    for i in 0..3 {
217        for j in 0..3 {
218            r[i][j] = dot(rot_a[i], rot_b[j]);
219            abs_r[i][j] = r[i][j].abs() + 1e-6;
220        }
221    }
222
223    let mut min_depth = f64::INFINITY;
224    let mut best_axis = [0.0f64, 1.0, 0.0];
225
226    // ── A's face normals (axes 0..2) ─────────────────────────────────────────
227    for i in 0..3 {
228        let ra = half_a[i];
229        let rb = half_b[0] * abs_r[i][0] + half_b[1] * abs_r[i][1] + half_b[2] * abs_r[i][2];
230        let depth = (ra + rb) - t[i].abs();
231        if depth < 0.0 {
232            return NarrowPhaseResult::NoContact;
233        }
234        if depth < min_depth {
235            min_depth = depth;
236            let sign = if t[i] >= 0.0 { 1.0 } else { -1.0 };
237            best_axis = rot_a[i];
238            best_axis[0] *= sign;
239            best_axis[1] *= sign;
240            best_axis[2] *= sign;
241        }
242    }
243
244    // ── B's face normals (axes 3..5) ─────────────────────────────────────────
245    for j in 0..3 {
246        let ra = half_a[0] * abs_r[0][j] + half_a[1] * abs_r[1][j] + half_a[2] * abs_r[2][j];
247        let rb = half_b[j];
248        let sep = t[0] * r[0][j] + t[1] * r[1][j] + t[2] * r[2][j];
249        let depth = (ra + rb) - sep.abs();
250        if depth < 0.0 {
251            return NarrowPhaseResult::NoContact;
252        }
253        if depth < min_depth {
254            min_depth = depth;
255            let sign = if sep >= 0.0 { 1.0 } else { -1.0 };
256            best_axis = rot_b[j];
257            best_axis[0] *= sign;
258            best_axis[1] *= sign;
259            best_axis[2] *= sign;
260        }
261    }
262
263    // ── Edge-edge cross products (axes 6..14) ─────────────────────────────────
264    let edge_pairs: [(usize, usize); 9] = [
265        (0, 0),
266        (0, 1),
267        (0, 2),
268        (1, 0),
269        (1, 1),
270        (1, 2),
271        (2, 0),
272        (2, 1),
273        (2, 2),
274    ];
275
276    for (i, j) in edge_pairs {
277        let i1 = (i + 1) % 3;
278        let i2 = (i + 2) % 3;
279        let j1 = (j + 1) % 3;
280        let j2 = (j + 2) % 3;
281
282        let ra = half_a[i1] * abs_r[i2][j] + half_a[i2] * abs_r[i1][j];
283        let rb = half_b[j1] * abs_r[i][j2] + half_b[j2] * abs_r[i][j1];
284        let sep = t[i2] * r[i1][j] - t[i1] * r[i2][j];
285        let depth = (ra + rb) - sep.abs();
286        if depth < 0.0 {
287            return NarrowPhaseResult::NoContact;
288        }
289
290        // Build the cross-product axis in world space.
291        let axis = cross(rot_a[i], rot_b[j]);
292        let axis_len = len(axis);
293        if axis_len > 1e-6 && depth < min_depth {
294            min_depth = depth;
295            let axis_n = scale(axis, 1.0 / axis_len);
296            // Orient toward B.
297            let sign = if dot(axis_n, t_world) >= 0.0 {
298                1.0
299            } else {
300                -1.0
301            };
302            best_axis = scale(axis_n, sign);
303        }
304    }
305
306    let point_a = add(center_a, scale(best_axis, min_depth * 0.5));
307    let point_b = sub(center_b, scale(best_axis, min_depth * 0.5));
308
309    NarrowPhaseResult::SingleContact(Contact {
310        point_a,
311        point_b,
312        normal: best_axis,
313        depth: min_depth,
314    })
315}
316
317// ── Capsule vs Capsule ────────────────────────────────────────────────────────
318
319/// Capsule–capsule narrow-phase test.
320///
321/// Each capsule is defined by two segment endpoints `(a0, a1)` and radii.
322/// Finds the closest points between the two segments, then performs a
323/// sphere–sphere test at those points.
324pub fn capsule_capsule(
325    a0: [f64; 3],
326    a1: [f64; 3],
327    ra: f64,
328    b0: [f64; 3],
329    b1: [f64; 3],
330    rb: f64,
331) -> NarrowPhaseResult {
332    let (_, _, ca, cb) = segment_segment_closest(a0, a1, b0, b1);
333    sphere_sphere(ca, ra, cb, rb)
334}
335
336// ── Closest point utilities ───────────────────────────────────────────────────
337
338/// Closest point on segment `(a, b)` to point `p`.
339///
340/// Returns the closest point.
341pub fn closest_point_on_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
342    let ab = sub(b, a);
343    let len_sq_ab = len_sq(ab);
344    if len_sq_ab < 1e-20 {
345        return a;
346    }
347    let t = (dot(sub(p, a), ab) / len_sq_ab).clamp(0.0, 1.0);
348    add(a, scale(ab, t))
349}
350
351/// Closest point on triangle `(a, b, c)` to point `p`.
352///
353/// Uses the Ericson barycentric projection method.
354pub fn closest_point_on_triangle(p: [f64; 3], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [f64; 3] {
355    let ab = sub(b, a);
356    let ac = sub(c, a);
357    let ap = sub(p, a);
358
359    let d1 = dot(ab, ap);
360    let d2 = dot(ac, ap);
361    if d1 <= 0.0 && d2 <= 0.0 {
362        return a;
363    }
364
365    let bp = sub(p, b);
366    let d3 = dot(ab, bp);
367    let d4 = dot(ac, bp);
368    if d3 >= 0.0 && d4 <= d3 {
369        return b;
370    }
371
372    let vc = d1 * d4 - d3 * d2;
373    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
374        let v = d1 / (d1 - d3);
375        return add(a, scale(ab, v));
376    }
377
378    let cp = sub(p, c);
379    let d5 = dot(ab, cp);
380    let d6 = dot(ac, cp);
381    if d6 >= 0.0 && d5 <= d6 {
382        return c;
383    }
384
385    let vb = d5 * d2 - d1 * d6;
386    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
387        let w = d2 / (d2 - d6);
388        return add(a, scale(ac, w));
389    }
390
391    let va = d3 * d6 - d5 * d4;
392    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
393        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
394        return add(b, scale(sub(c, b), w));
395    }
396
397    let denom = 1.0 / (va + vb + vc);
398    let v = vb * denom;
399    let w = vc * denom;
400    add(add(a, scale(ab, v)), scale(ac, w))
401}
402
403/// Closest points between two segments `(a0, a1)` and `(b0, b1)`.
404///
405/// Returns `(t_a, t_b, point_on_a, point_on_b)`.
406pub fn segment_segment_closest(
407    a0: [f64; 3],
408    a1: [f64; 3],
409    b0: [f64; 3],
410    b1: [f64; 3],
411) -> (f64, f64, [f64; 3], [f64; 3]) {
412    let d1 = sub(a1, a0);
413    let d2 = sub(b1, b0);
414    let r = sub(a0, b0);
415
416    let a = dot(d1, d1);
417    let e = dot(d2, d2);
418    let f = dot(d2, r);
419
420    let (s, t) = if a <= 1e-10 && e <= 1e-10 {
421        (0.0_f64, 0.0_f64)
422    } else if a <= 1e-10 {
423        (0.0, (f / e).clamp(0.0, 1.0))
424    } else {
425        let c = dot(d1, r);
426        if e <= 1e-10 {
427            ((-c / a).clamp(0.0, 1.0), 0.0)
428        } else {
429            let b = dot(d1, d2);
430            let denom = a * e - b * b;
431            let s0 = if denom.abs() > 1e-10 {
432                ((b * f - c * e) / denom).clamp(0.0, 1.0)
433            } else {
434                0.0
435            };
436            let t_raw = (b * s0 + f) / e;
437            if t_raw < 0.0 {
438                ((-c / a).clamp(0.0, 1.0), 0.0)
439            } else if t_raw > 1.0 {
440                (((b - c) / a).clamp(0.0, 1.0), 1.0)
441            } else {
442                (s0, t_raw)
443            }
444        }
445    };
446
447    let pa = add(a0, scale(d1, s));
448    let pb = add(b0, scale(d2, t));
449    (s, t, pa, pb)
450}
451
452// ── GJK ───────────────────────────────────────────────────────────────────────
453
454/// A GJK simplex (up to 4 points).
455#[derive(Debug, Clone)]
456pub struct GjkSimplex {
457    pts: [[f64; 3]; 4],
458    count: usize,
459}
460
461impl GjkSimplex {
462    /// Create an empty simplex.
463    pub fn new() -> Self {
464        Self {
465            pts: [[0.0; 3]; 4],
466            count: 0,
467        }
468    }
469
470    /// Add a point to the simplex.
471    pub fn push(&mut self, p: [f64; 3]) {
472        if self.count < 4 {
473            self.pts[self.count] = p;
474            self.count += 1;
475        }
476    }
477
478    /// Number of points in the simplex.
479    pub fn len(&self) -> usize {
480        self.count
481    }
482
483    /// Returns `true` if the simplex has no points.
484    pub fn is_empty(&self) -> bool {
485        self.count == 0
486    }
487}
488
489impl Default for GjkSimplex {
490    fn default() -> Self {
491        Self::new()
492    }
493}
494
495/// Support function for the Minkowski difference of two convex shapes.
496///
497/// `support_a(d)` returns the support point of shape A in direction `d`.
498/// `support_b(d)` returns the support point of shape B in direction `d`.
499/// The Minkowski difference support is `support_a(d) - support_b(-d)`.
500fn minkowski_diff_support<A, B>(dir: [f64; 3], support_a: &A, support_b: &B) -> [f64; 3]
501where
502    A: Fn([f64; 3]) -> [f64; 3],
503    B: Fn([f64; 3]) -> [f64; 3],
504{
505    let pa = support_a(dir);
506    let pb = support_b(neg(dir));
507    sub(pa, pb)
508}
509
510/// GJK intersection test for two convex shapes.
511///
512/// `support_a` and `support_b` are support-function closures.
513/// Returns `true` if the shapes intersect (origin inside Minkowski difference).
514pub fn gjk_intersect<A, B>(support_a: A, support_b: B) -> bool
515where
516    A: Fn([f64; 3]) -> [f64; 3],
517    B: Fn([f64; 3]) -> [f64; 3],
518{
519    let mut simplex = GjkSimplex::new();
520    let mut dir = [1.0f64, 0.0, 0.0];
521
522    let first = minkowski_diff_support(dir, &support_a, &support_b);
523    simplex.push(first);
524    dir = neg(first);
525
526    if len_sq(dir) < 1e-20 {
527        // Origin is on the first support point — shapes touch.
528        return true;
529    }
530
531    for _ in 0..64 {
532        let a = minkowski_diff_support(dir, &support_a, &support_b);
533        if dot(a, dir) < 0.0 {
534            // New point doesn't pass origin in direction `dir`.
535            return false;
536        }
537        simplex.push(a);
538
539        match do_simplex_gjk(&mut simplex, &mut dir) {
540            GjkStep::ContainsOrigin => return true,
541            GjkStep::Continue => {}
542        }
543    }
544    // Conservative: assume intersecting after max iterations.
545    true
546}
547
548enum GjkStep {
549    ContainsOrigin,
550    Continue,
551}
552
553/// Process simplex and update direction for GJK.
554fn do_simplex_gjk(simplex: &mut GjkSimplex, dir: &mut [f64; 3]) -> GjkStep {
555    match simplex.count {
556        2 => {
557            // Line case.
558            let b = simplex.pts[0];
559            let a = simplex.pts[1];
560            let ab = sub(b, a);
561            let ao = neg(a);
562            if dot(ab, ao) > 0.0 {
563                *dir = cross(cross(ab, ao), ab);
564            } else {
565                simplex.pts[0] = a;
566                simplex.count = 1;
567                *dir = ao;
568            }
569            GjkStep::Continue
570        }
571        3 => {
572            // Triangle case.
573            let c = simplex.pts[0];
574            let b = simplex.pts[1];
575            let a = simplex.pts[2];
576            let ab = sub(b, a);
577            let ac = sub(c, a);
578            let ao = neg(a);
579            let abc = cross(ab, ac);
580
581            if dot(cross(abc, ac), ao) > 0.0 {
582                if dot(ac, ao) > 0.0 {
583                    simplex.pts[0] = c;
584                    simplex.pts[1] = a;
585                    simplex.count = 2;
586                    *dir = cross(cross(ac, ao), ac);
587                } else {
588                    simplex.pts[0] = b;
589                    simplex.pts[1] = a;
590                    simplex.count = 2;
591                    return do_simplex_gjk(simplex, dir);
592                }
593            } else if dot(cross(ab, abc), ao) > 0.0 {
594                simplex.pts[0] = b;
595                simplex.pts[1] = a;
596                simplex.count = 2;
597                return do_simplex_gjk(simplex, dir);
598            } else {
599                // Origin is above/below triangle plane.
600                if dot(abc, ao) > 0.0 {
601                    *dir = abc;
602                } else {
603                    // Flip winding.
604                    simplex.pts[0] = b;
605                    simplex.pts[1] = c;
606                    // pts[2] stays as a
607                    *dir = neg(abc);
608                }
609            }
610            GjkStep::Continue
611        }
612        4 => {
613            // Tetrahedron case — check if origin is inside.
614            let d = simplex.pts[0];
615            let c = simplex.pts[1];
616            let b = simplex.pts[2];
617            let a = simplex.pts[3];
618            let ab = sub(b, a);
619            let ac = sub(c, a);
620            let ad = sub(d, a);
621            let ao = neg(a);
622
623            let abc = cross(ab, ac);
624            let acd = cross(ac, ad);
625            let adb = cross(ad, ab);
626
627            if dot(abc, ao) > 0.0 {
628                // Remove d, keep triangle abc.
629                simplex.pts[0] = c;
630                simplex.pts[1] = b;
631                simplex.pts[2] = a;
632                simplex.count = 3;
633                return do_simplex_gjk(simplex, dir);
634            }
635            if dot(acd, ao) > 0.0 {
636                simplex.pts[0] = d;
637                simplex.pts[1] = c;
638                simplex.pts[2] = a;
639                simplex.count = 3;
640                return do_simplex_gjk(simplex, dir);
641            }
642            if dot(adb, ao) > 0.0 {
643                simplex.pts[0] = b;
644                simplex.pts[1] = d;
645                simplex.pts[2] = a;
646                simplex.count = 3;
647                return do_simplex_gjk(simplex, dir);
648            }
649            GjkStep::ContainsOrigin
650        }
651        _ => GjkStep::Continue,
652    }
653}
654
655// ── Contact manifold clip (box-box reference face) ────────────────────────────
656
657/// Clip a polygon against one side-plane of a reference face.
658///
659/// `poly` is the current polygon vertices.
660/// `plane_normal` and `plane_offset` define the clip plane (`n·x = offset`).
661/// Points with `n·x > offset` are kept (or interpolated at the boundary).
662fn clip_polygon_by_plane(
663    poly: &[[f64; 3]],
664    plane_normal: [f64; 3],
665    plane_offset: f64,
666) -> Vec<[f64; 3]> {
667    let mut out = Vec::new();
668    if poly.is_empty() {
669        return out;
670    }
671    let n = poly.len();
672    for i in 0..n {
673        let curr = poly[i];
674        let next = poly[(i + 1) % n];
675        let dc = dot(plane_normal, curr) - plane_offset;
676        let dn = dot(plane_normal, next) - plane_offset;
677
678        if dc >= 0.0 {
679            out.push(curr);
680        }
681        // Edge crosses boundary.
682        if (dc < 0.0) != (dn < 0.0) {
683            let t = dc / (dc - dn);
684            let interp = add(curr, scale(sub(next, curr), t));
685            out.push(interp);
686        }
687    }
688    out
689}
690
691/// Build a box-box contact manifold by clipping the incident face polygon
692/// against the reference face's four side planes.
693///
694/// `ref_center` is the world-space centre of the reference face.
695/// `ref_u` and `ref_v` are the two tangent axes of the reference face.
696/// `ref_half_u` and `ref_half_v` are the face's half-extents along those axes.
697/// `ref_normal` is the face normal (pointing outward from reference box).
698/// `incident_verts` are the four corners of the incident face.
699/// Returns a vector of contact points (those below the reference face plane).
700pub fn clip_incident_face(
701    ref_center: [f64; 3],
702    ref_u: [f64; 3],
703    ref_v: [f64; 3],
704    ref_half_u: f64,
705    ref_half_v: f64,
706    ref_normal: [f64; 3],
707    incident_verts: &[[f64; 3]; 4],
708) -> Vec<Contact> {
709    // Start with incident face polygon.
710    let mut poly: Vec<[f64; 3]> = incident_verts.to_vec();
711
712    // Clip by the four edge planes of the reference face.
713    poly = clip_polygon_by_plane(&poly, ref_u, dot(ref_u, ref_center) + ref_half_u);
714    poly = clip_polygon_by_plane(&poly, neg(ref_u), -dot(ref_u, ref_center) + ref_half_u);
715    poly = clip_polygon_by_plane(&poly, ref_v, dot(ref_v, ref_center) + ref_half_v);
716    poly = clip_polygon_by_plane(&poly, neg(ref_v), -dot(ref_v, ref_center) + ref_half_v);
717
718    // Keep points below (or on) the reference face plane.
719    let ref_plane_offset = dot(ref_normal, ref_center);
720    poly.retain(|&p| dot(ref_normal, p) <= ref_plane_offset + 1e-6);
721
722    poly.into_iter()
723        .map(|pt| {
724            let proj = add(
725                ref_center,
726                add(
727                    scale(ref_u, dot(ref_u, sub(pt, ref_center))),
728                    scale(ref_v, dot(ref_v, sub(pt, ref_center))),
729                ),
730            );
731            let depth = ref_plane_offset - dot(ref_normal, pt);
732            Contact {
733                point_a: proj,
734                point_b: pt,
735                normal: ref_normal,
736                depth: depth.max(0.0),
737            }
738        })
739        .collect()
740}
741
742// ── Triangle-AABB intersection ─────────────────────────────────────────────────
743
744/// Triangle-AABB intersection test using the Separating Axis Theorem.
745///
746/// `tri_verts` – the three triangle vertices in world space.
747/// `box_center` – centre of the AABB.
748/// `half_extents` – half-extents of the AABB along x, y, z.
749///
750/// Returns `true` if the triangle overlaps the box.
751pub fn triangle_aabb_intersect(
752    tri_verts: &[[f64; 3]; 3],
753    box_center: [f64; 3],
754    half_extents: [f64; 3],
755) -> bool {
756    // Translate triangle to box-centred space.
757    let v: [[f64; 3]; 3] = [
758        sub(tri_verts[0], box_center),
759        sub(tri_verts[1], box_center),
760        sub(tri_verts[2], box_center),
761    ];
762
763    // Test 3 face-normal axes of the AABB.
764    for a in 0..3 {
765        let mn = v[0][a].min(v[1][a]).min(v[2][a]);
766        let mx = v[0][a].max(v[1][a]).max(v[2][a]);
767        if mn > half_extents[a] || mx < -half_extents[a] {
768            return false;
769        }
770    }
771
772    // Test triangle normal axis.
773    let e0 = sub(v[1], v[0]);
774    let e1 = sub(v[2], v[1]);
775    let n_tri = cross(e0, e1);
776    let d = dot(n_tri, v[0]);
777    // Radius of AABB projected onto triangle normal.
778    let r = half_extents[0] * n_tri[0].abs()
779        + half_extents[1] * n_tri[1].abs()
780        + half_extents[2] * n_tri[2].abs();
781    if d.abs() > r {
782        return false;
783    }
784
785    // Test 9 cross-product axes (edge × box-axis).
786    let edges = [e0, e1, sub(v[0], v[2])];
787    let box_axes: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
788
789    for &edge in &edges {
790        for &axis in &box_axes {
791            let sep_axis = cross(edge, axis);
792            if len_sq(sep_axis) < 1e-20 {
793                continue;
794            }
795            let p: [f64; 3] = [
796                dot(sep_axis, v[0]),
797                dot(sep_axis, v[1]),
798                dot(sep_axis, v[2]),
799            ];
800            let p_min = p[0].min(p[1]).min(p[2]);
801            let p_max = p[0].max(p[1]).max(p[2]);
802            let r_box = half_extents[0] * sep_axis[0].abs()
803                + half_extents[1] * sep_axis[1].abs()
804                + half_extents[2] * sep_axis[2].abs();
805            if p_min > r_box || p_max < -r_box {
806                return false;
807            }
808        }
809    }
810
811    true
812}
813
814// ── OBB-Capsule intersection ───────────────────────────────────────────────────
815
816/// OBB–capsule narrow-phase test.
817///
818/// The capsule is defined by its segment `(c0, c1)` and radius `cr`.
819/// The OBB is defined by `center`, `half_extents`, and rotation matrix `rot`.
820/// Returns `NoContact` or a `SingleContact`.
821pub fn obb_capsule(
822    obb_center: [f64; 3],
823    obb_half: [f64; 3],
824    obb_rot: [[f64; 3]; 3],
825    cap_a: [f64; 3],
826    cap_b: [f64; 3],
827    cap_r: f64,
828) -> NarrowPhaseResult {
829    // Transform capsule segment endpoints into OBB-local space.
830    let local_a = world_to_obb_local(sub(cap_a, obb_center), obb_rot);
831    let local_b = world_to_obb_local(sub(cap_b, obb_center), obb_rot);
832
833    // Find closest point on the segment to the AABB (OBB in local = AABB).
834    let cp = closest_point_segment_aabb(local_a, local_b, obb_half);
835
836    // Sphere test at closest point.
837    let diff = sub(local_a, cp);
838    // The actual closest point on the segment to the AABB surface needs finding.
839    // We use: closest segment point to the AABB closest point.
840    let cp_seg = closest_point_on_segment(cp, local_a, local_b);
841    let d = sub(cp_seg, cp);
842    let dist = len(d);
843    let depth = cap_r - dist;
844    if depth < 0.0 {
845        return NarrowPhaseResult::NoContact;
846    }
847
848    let _ = diff; // silence unused warning
849    let normal_local = if dist > 1e-10 {
850        normalize(d)
851    } else {
852        [0.0, 1.0, 0.0]
853    };
854    // Transform normal back to world space.
855    let normal_world = obb_local_to_world(normal_local, obb_rot);
856
857    let pa = add(
858        obb_center,
859        obb_local_to_world(add(cp, scale(normal_local, depth * 0.5)), obb_rot),
860    );
861    let pb = add(
862        obb_center,
863        obb_local_to_world(
864            sub(cp_seg, scale(normal_local, cap_r - depth * 0.5)),
865            obb_rot,
866        ),
867    );
868
869    NarrowPhaseResult::SingleContact(Contact {
870        point_a: pa,
871        point_b: pb,
872        normal: normal_world,
873        depth,
874    })
875}
876
877/// Closest point on segment `(a, b)` to a box `[-half, +half]` in 3D.
878fn closest_point_segment_aabb(a: [f64; 3], b: [f64; 3], half: [f64; 3]) -> [f64; 3] {
879    // Clamp parametric t to find closest segment point to AABB.
880    // We find the closest point on the box to the segment endpoints and midpoint.
881    let candidates = [a, b, add(scale(a, 0.5), scale(b, 0.5))];
882    let mut best = [0.0f64; 3];
883    let mut best_dist = f64::INFINITY;
884    for &p in &candidates {
885        let clamped = [
886            p[0].clamp(-half[0], half[0]),
887            p[1].clamp(-half[1], half[1]),
888            p[2].clamp(-half[2], half[2]),
889        ];
890        let d = len_sq(sub(p, clamped));
891        if d < best_dist {
892            best_dist = d;
893            best = clamped;
894        }
895    }
896    best
897}
898
899fn world_to_obb_local(v: [f64; 3], rot: [[f64; 3]; 3]) -> [f64; 3] {
900    [dot(rot[0], v), dot(rot[1], v), dot(rot[2], v)]
901}
902
903fn obb_local_to_world(v: [f64; 3], rot: [[f64; 3]; 3]) -> [f64; 3] {
904    [
905        rot[0][0] * v[0] + rot[1][0] * v[1] + rot[2][0] * v[2],
906        rot[0][1] * v[0] + rot[1][1] * v[1] + rot[2][1] * v[2],
907        rot[0][2] * v[0] + rot[1][2] * v[1] + rot[2][2] * v[2],
908    ]
909}
910
911// ── AABB-Capsule intersection ──────────────────────────────────────────────────
912
913/// AABB–capsule narrow-phase test.
914///
915/// Finds the closest point on the capsule segment to the AABB, then performs
916/// a sphere–box test at that point.
917pub fn aabb_capsule(
918    box_center: [f64; 3],
919    half_extents: [f64; 3],
920    cap_a: [f64; 3],
921    cap_b: [f64; 3],
922    cap_r: f64,
923) -> NarrowPhaseResult {
924    // Closest point on segment to box centre.
925    let closest_on_seg = closest_point_on_segment(box_center, cap_a, cap_b);
926    // Now test the sphere at that point against the AABB.
927    sphere_box(closest_on_seg, cap_r, box_center, half_extents)
928}
929
930// ── Heightfield-Sphere ────────────────────────────────────────────────────────
931
932/// A simple heightfield defined on a uniform 2D grid.
933///
934/// The heightfield stores heights at grid points (x, z) with spacing `cell_size`.
935/// Origin is at `(origin_x, origin_z)`.
936#[derive(Debug, Clone)]
937pub struct Heightfield {
938    /// Heights at grid points (row-major: row = z index, col = x index).
939    pub heights: Vec<f64>,
940    /// Number of columns (x direction).
941    pub n_x: usize,
942    /// Number of rows (z direction).
943    pub n_z: usize,
944    /// Cell size (same in x and z).
945    pub cell_size: f64,
946    /// World-space x origin.
947    pub origin_x: f64,
948    /// World-space z origin.
949    pub origin_z: f64,
950}
951
952impl Heightfield {
953    /// Create a flat heightfield at `y = 0`.
954    pub fn flat(n_x: usize, n_z: usize, cell_size: f64) -> Self {
955        Self {
956            heights: vec![0.0; n_x * n_z],
957            n_x,
958            n_z,
959            cell_size,
960            origin_x: 0.0,
961            origin_z: 0.0,
962        }
963    }
964
965    /// Get the height at grid cell (ix, iz).
966    pub fn height_at(&self, ix: usize, iz: usize) -> f64 {
967        self.heights[iz * self.n_x + ix]
968    }
969
970    /// Bilinearly interpolated height at world-space (x, z).
971    pub fn height_world(&self, x: f64, z: f64) -> f64 {
972        let lx = (x - self.origin_x) / self.cell_size;
973        let lz = (z - self.origin_z) / self.cell_size;
974        let ix = (lx as usize).min(self.n_x - 2);
975        let iz = (lz as usize).min(self.n_z - 2);
976        let fx = (lx - ix as f64).clamp(0.0, 1.0);
977        let fz = (lz - iz as f64).clamp(0.0, 1.0);
978
979        let h00 = self.height_at(ix, iz);
980        let h10 = self.height_at(ix + 1, iz);
981        let h01 = self.height_at(ix, iz + 1);
982        let h11 = self.height_at(ix + 1, iz + 1);
983
984        h00 * (1.0 - fx) * (1.0 - fz)
985            + h10 * fx * (1.0 - fz)
986            + h01 * (1.0 - fx) * fz
987            + h11 * fx * fz
988    }
989
990    /// Surface normal at world-space (x, z) via central differences.
991    pub fn normal_world(&self, x: f64, z: f64) -> [f64; 3] {
992        let h = self.cell_size * 0.5;
993        let dydx = (self.height_world(x + h, z) - self.height_world(x - h, z)) / (2.0 * h);
994        let dydz = (self.height_world(x, z + h) - self.height_world(x, z - h)) / (2.0 * h);
995        normalize([-dydx, 1.0, -dydz])
996    }
997}
998
999/// Heightfield–sphere narrow-phase test.
1000///
1001/// Finds the heightfield height directly beneath the sphere centre and checks
1002/// for penetration.
1003pub fn heightfield_sphere(
1004    hf: &Heightfield,
1005    sphere_center: [f64; 3],
1006    radius: f64,
1007) -> NarrowPhaseResult {
1008    let x = sphere_center[0];
1009    let z = sphere_center[2];
1010    let y_surf = hf.height_world(x, z);
1011    let depth = radius - (sphere_center[1] - y_surf);
1012
1013    if depth < 0.0 {
1014        return NarrowPhaseResult::NoContact;
1015    }
1016
1017    let normal = hf.normal_world(x, z);
1018    let point_b = [x, y_surf, z];
1019    let point_a = sub(sphere_center, scale(normal, radius));
1020
1021    NarrowPhaseResult::SingleContact(Contact {
1022        point_a,
1023        point_b,
1024        normal,
1025        depth,
1026    })
1027}
1028
1029// ── Heightfield-Box ───────────────────────────────────────────────────────────
1030
1031/// Heightfield–AABB narrow-phase test.
1032///
1033/// Iterates over the grid cells beneath the box AABB footprint and tests each
1034/// cell's representative height against the box bottom face.
1035pub fn heightfield_box(
1036    hf: &Heightfield,
1037    box_center: [f64; 3],
1038    half_extents: [f64; 3],
1039) -> NarrowPhaseResult {
1040    let x_min = box_center[0] - half_extents[0];
1041    let x_max = box_center[0] + half_extents[0];
1042    let z_min = box_center[2] - half_extents[2];
1043    let z_max = box_center[2] + half_extents[2];
1044
1045    let mut deepest: Option<Contact> = None;
1046    let mut max_depth = 0.0f64;
1047
1048    // Sample at 4 corners and centre of the box footprint.
1049    let samples = [
1050        [x_min, z_min],
1051        [x_max, z_min],
1052        [x_min, z_max],
1053        [x_max, z_max],
1054        [box_center[0], box_center[2]],
1055    ];
1056
1057    for &[sx, sz] in &samples {
1058        let y_surf = hf.height_world(sx, sz);
1059        let box_bottom = box_center[1] - half_extents[1];
1060        let depth = y_surf - box_bottom;
1061        if depth > max_depth {
1062            max_depth = depth;
1063            let normal = hf.normal_world(sx, sz);
1064            deepest = Some(Contact {
1065                point_a: [sx, box_bottom, sz],
1066                point_b: [sx, y_surf, sz],
1067                normal,
1068                depth,
1069            });
1070        }
1071    }
1072
1073    match deepest {
1074        Some(c) => NarrowPhaseResult::SingleContact(c),
1075        None => NarrowPhaseResult::NoContact,
1076    }
1077}
1078
1079// ── Triangle–sphere narrow phase ──────────────────────────────────────────────
1080
1081/// Triangle–sphere narrow-phase test.
1082///
1083/// Finds the closest point on the triangle to the sphere centre and performs
1084/// a point-sphere overlap check.
1085pub fn triangle_sphere(
1086    tri_a: [f64; 3],
1087    tri_b: [f64; 3],
1088    tri_c: [f64; 3],
1089    sphere_center: [f64; 3],
1090    radius: f64,
1091) -> NarrowPhaseResult {
1092    let cp = closest_point_on_triangle(sphere_center, tri_a, tri_b, tri_c);
1093    let d = sub(sphere_center, cp);
1094    let dist = len(d);
1095    let depth = radius - dist;
1096
1097    if depth < 0.0 {
1098        return NarrowPhaseResult::NoContact;
1099    }
1100
1101    let normal = if dist > 1e-10 {
1102        scale(d, 1.0 / dist)
1103    } else {
1104        // Degenerate: use triangle normal.
1105        let ab = sub(tri_b, tri_a);
1106        let ac = sub(tri_c, tri_a);
1107        normalize(cross(ab, ac))
1108    };
1109
1110    let point_a = sub(sphere_center, scale(normal, radius));
1111    NarrowPhaseResult::SingleContact(Contact {
1112        point_a,
1113        point_b: cp,
1114        normal,
1115        depth,
1116    })
1117}
1118
1119// ── Tests ─────────────────────────────────────────────────────────────────────
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
1126        (a - b).abs() < tol
1127    }
1128
1129    fn vec_approx_eq(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
1130        approx_eq(a[0], b[0], tol) && approx_eq(a[1], b[1], tol) && approx_eq(a[2], b[2], tol)
1131    }
1132
1133    // ── sphere_sphere ────────────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_sphere_sphere_touching() {
1137        let ca = [0.0f64, 0.0, 0.0];
1138        let cb = [2.0f64, 0.0, 0.0];
1139        match sphere_sphere(ca, 1.0, cb, 1.0) {
1140            NarrowPhaseResult::SingleContact(c) => {
1141                assert!(approx_eq(c.depth, 0.0, 1e-10), "depth={}", c.depth);
1142            }
1143            _ => panic!("expected SingleContact for touching spheres"),
1144        }
1145    }
1146
1147    #[test]
1148    fn test_sphere_sphere_overlapping() {
1149        let ca = [0.0f64, 0.0, 0.0];
1150        let cb = [1.5f64, 0.0, 0.0];
1151        match sphere_sphere(ca, 1.0, cb, 1.0) {
1152            NarrowPhaseResult::SingleContact(c) => {
1153                assert!(approx_eq(c.depth, 0.5, 1e-10), "depth={}", c.depth);
1154                let nlen = len(c.normal);
1155                assert!(approx_eq(nlen, 1.0, 1e-10), "normal length={}", nlen);
1156            }
1157            _ => panic!("expected SingleContact for overlapping spheres"),
1158        }
1159    }
1160
1161    #[test]
1162    fn test_sphere_sphere_separated() {
1163        let ca = [0.0f64, 0.0, 0.0];
1164        let cb = [5.0f64, 0.0, 0.0];
1165        assert!(
1166            matches!(
1167                sphere_sphere(ca, 1.0, cb, 1.0),
1168                NarrowPhaseResult::NoContact
1169            ),
1170            "separated spheres must return NoContact"
1171        );
1172    }
1173
1174    // ── sphere_box ───────────────────────────────────────────────────────────
1175
1176    #[test]
1177    fn test_sphere_box_hit() {
1178        // Sphere center at [1.3, 0, 0], radius 0.5; box center at [0,0,0] half_extents [1,1,1].
1179        // Closest point on box surface = [1, 0, 0]; dist = 0.3 < radius → contact.
1180        let sc = [1.3f64, 0.0, 0.0];
1181        let bc = [0.0f64, 0.0, 0.0];
1182        let he = [1.0f64, 1.0, 1.0];
1183        match sphere_box(sc, 0.5, bc, he) {
1184            NarrowPhaseResult::SingleContact(c) => {
1185                assert!(c.depth > 0.0, "depth={}", c.depth);
1186                assert!(approx_eq(len(c.normal), 1.0, 1e-10));
1187            }
1188            _ => panic!("expected contact"),
1189        }
1190    }
1191
1192    #[test]
1193    fn test_sphere_box_miss() {
1194        let sc = [5.0f64, 0.0, 0.0];
1195        let bc = [0.0f64, 0.0, 0.0];
1196        let he = [1.0f64, 1.0, 1.0];
1197        assert!(
1198            matches!(sphere_box(sc, 0.5, bc, he), NarrowPhaseResult::NoContact),
1199            "far sphere must return NoContact"
1200        );
1201    }
1202
1203    // ── box_box_sat ──────────────────────────────────────────────────────────
1204
1205    #[test]
1206    fn test_box_box_axis_aligned_overlapping() {
1207        let identity = [[1.0f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1208        let ca = [0.0f64, 0.0, 0.0];
1209        let cb = [1.5f64, 0.0, 0.0];
1210        let half = [1.0f64, 1.0, 1.0];
1211        match box_box_sat(ca, half, identity, cb, half, identity) {
1212            NarrowPhaseResult::SingleContact(c) => {
1213                assert!(c.depth > 0.0, "depth={}", c.depth);
1214                assert!(
1215                    approx_eq(c.depth, 0.5, 1e-5),
1216                    "expected depth≈0.5, got {}",
1217                    c.depth
1218                );
1219            }
1220            _ => panic!("expected contact for overlapping boxes"),
1221        }
1222    }
1223
1224    #[test]
1225    fn test_box_box_axis_aligned_separated() {
1226        let identity = [[1.0f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1227        let ca = [0.0f64, 0.0, 0.0];
1228        let cb = [5.0f64, 0.0, 0.0];
1229        let half = [1.0f64, 1.0, 1.0];
1230        assert!(
1231            matches!(
1232                box_box_sat(ca, half, identity, cb, half, identity),
1233                NarrowPhaseResult::NoContact
1234            ),
1235            "separated boxes must return NoContact"
1236        );
1237    }
1238
1239    // ── capsule_capsule ──────────────────────────────────────────────────────
1240
1241    #[test]
1242    fn test_capsule_capsule_overlap() {
1243        let a0 = [0.0f64, -1.0, 0.0];
1244        let a1 = [0.0f64, 1.0, 0.0];
1245        let b0 = [0.8f64, -1.0, 0.0];
1246        let b1 = [0.8f64, 1.0, 0.0];
1247        match capsule_capsule(a0, a1, 0.5, b0, b1, 0.5) {
1248            NarrowPhaseResult::SingleContact(c) => {
1249                assert!(c.depth > 0.0, "depth={}", c.depth);
1250                assert!(approx_eq(len(c.normal), 1.0, 1e-10));
1251            }
1252            _ => panic!("expected contact for overlapping capsules"),
1253        }
1254    }
1255
1256    #[test]
1257    fn test_capsule_capsule_separated() {
1258        let a0 = [0.0f64, 0.0, 0.0];
1259        let a1 = [1.0f64, 0.0, 0.0];
1260        let b0 = [10.0f64, 0.0, 0.0];
1261        let b1 = [11.0f64, 0.0, 0.0];
1262        assert!(
1263            matches!(
1264                capsule_capsule(a0, a1, 0.3, b0, b1, 0.3),
1265                NarrowPhaseResult::NoContact
1266            ),
1267            "far capsules must return NoContact"
1268        );
1269    }
1270
1271    // ── closest_point_on_segment ─────────────────────────────────────────────
1272
1273    #[test]
1274    fn test_closest_point_on_segment_midpoint() {
1275        let p = [1.0f64, 1.0, 0.0];
1276        let a = [0.0f64, 0.0, 0.0];
1277        let b = [2.0f64, 0.0, 0.0];
1278        let cp = closest_point_on_segment(p, a, b);
1279        assert!(vec_approx_eq(cp, [1.0, 0.0, 0.0], 1e-10), "cp={:?}", cp);
1280    }
1281
1282    #[test]
1283    fn test_closest_point_on_segment_endpoint() {
1284        let p = [-1.0f64, 0.0, 0.0];
1285        let a = [0.0f64, 0.0, 0.0];
1286        let b = [2.0f64, 0.0, 0.0];
1287        let cp = closest_point_on_segment(p, a, b);
1288        assert!(vec_approx_eq(cp, [0.0, 0.0, 0.0], 1e-10), "cp={:?}", cp);
1289    }
1290
1291    #[test]
1292    fn test_closest_point_on_triangle_vertex() {
1293        // Point behind vertex a — should clamp to a.
1294        let p = [-1.0f64, -1.0, 0.0];
1295        let a = [0.0f64, 0.0, 0.0];
1296        let b = [2.0f64, 0.0, 0.0];
1297        let c = [1.0f64, 2.0, 0.0];
1298        let cp = closest_point_on_triangle(p, a, b, c);
1299        assert!(vec_approx_eq(cp, a, 1e-10), "cp={:?}", cp);
1300    }
1301
1302    // ── gjk_intersect ────────────────────────────────────────────────────────
1303
1304    #[test]
1305    fn test_gjk_sphere_pair_overlap() {
1306        // Two unit spheres at [0,0,0] and [1,0,0]: they overlap.
1307        let support_a = |d: [f64; 3]| -> [f64; 3] {
1308            let n = normalize(d);
1309            scale(n, 1.0) // sphere A at origin, radius 1
1310        };
1311        let support_b = |d: [f64; 3]| -> [f64; 3] {
1312            let n = normalize(d);
1313            add([1.0, 0.0, 0.0], scale(n, 1.0)) // sphere B at [1,0,0], radius 1
1314        };
1315        assert!(
1316            gjk_intersect(support_a, support_b),
1317            "overlapping spheres must intersect"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_gjk_sphere_pair_separated() {
1323        // Two unit spheres at [0,0,0] and [5,0,0]: separated.
1324        let support_a = |d: [f64; 3]| -> [f64; 3] { scale(normalize(d), 1.0) };
1325        let support_b =
1326            |d: [f64; 3]| -> [f64; 3] { add([5.0, 0.0, 0.0], scale(normalize(d), 1.0)) };
1327        assert!(
1328            !gjk_intersect(support_a, support_b),
1329            "separated spheres must not intersect"
1330        );
1331    }
1332
1333    // ── NarrowPhaseResult helpers ────────────────────────────────────────────
1334
1335    #[test]
1336    fn test_narrow_phase_result_has_contact() {
1337        assert!(!NarrowPhaseResult::NoContact.has_contact());
1338        let c = Contact {
1339            point_a: [0.0; 3],
1340            point_b: [0.0; 3],
1341            normal: [0.0, 1.0, 0.0],
1342            depth: 0.1,
1343        };
1344        assert!(NarrowPhaseResult::SingleContact(c.clone()).has_contact());
1345        assert!(NarrowPhaseResult::Manifold(vec![c]).has_contact());
1346    }
1347
1348    // ── triangle_aabb_intersect ─────────────────────────────────────────────
1349
1350    #[test]
1351    fn test_triangle_aabb_intersect_overlap() {
1352        // Triangle in xy-plane centred at origin, box also centred at origin.
1353        let tri: [[f64; 3]; 3] = [[-0.5, 0.0, -0.5], [0.5, 0.0, -0.5], [0.0, 0.0, 0.5]];
1354        let box_c = [0.0f64, 0.0, 0.0];
1355        let half = [1.0f64, 1.0, 1.0];
1356        assert!(
1357            triangle_aabb_intersect(&tri, box_c, half),
1358            "Triangle inside box must intersect"
1359        );
1360    }
1361
1362    #[test]
1363    fn test_triangle_aabb_intersect_separated() {
1364        // Triangle far away from the box.
1365        let tri: [[f64; 3]; 3] = [[10.0, 0.0, 0.0], [11.0, 0.0, 0.0], [10.5, 1.0, 0.0]];
1366        let box_c = [0.0f64, 0.0, 0.0];
1367        let half = [1.0f64, 1.0, 1.0];
1368        assert!(
1369            !triangle_aabb_intersect(&tri, box_c, half),
1370            "Separated triangle must not intersect box"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_triangle_aabb_intersect_edge_touch() {
1376        // Triangle with one vertex just touching the box face.
1377        let tri: [[f64; 3]; 3] = [
1378            [1.0, 0.0, 0.0], // on +x face
1379            [2.0, 0.5, 0.0],
1380            [2.0, -0.5, 0.0],
1381        ];
1382        let box_c = [0.0f64, 0.0, 0.0];
1383        let half = [1.0f64, 1.0, 1.0];
1384        // The vertex at x=1 is exactly on the face; should intersect.
1385        assert!(
1386            triangle_aabb_intersect(&tri, box_c, half),
1387            "Triangle touching box face must intersect"
1388        );
1389    }
1390
1391    // ── aabb_capsule ────────────────────────────────────────────────────────
1392
1393    #[test]
1394    fn test_aabb_capsule_overlap() {
1395        // Capsule along y-axis from (0,-2,0) to (0,2,0) with radius 0.5.
1396        // Box at origin with half-extents [1,1,1] → capsule passes through → contact.
1397        let box_c = [0.0f64, 0.0, 0.0];
1398        let half = [1.0f64, 1.0, 1.0];
1399        let cap_a = [0.0f64, -2.0, 0.0];
1400        let cap_b = [0.0f64, 2.0, 0.0];
1401        match aabb_capsule(box_c, half, cap_a, cap_b, 0.5) {
1402            NarrowPhaseResult::SingleContact(c) => {
1403                assert!(c.depth >= 0.0, "depth={}", c.depth);
1404            }
1405            _ => panic!("expected contact for overlapping AABB-capsule"),
1406        }
1407    }
1408
1409    #[test]
1410    fn test_aabb_capsule_separated() {
1411        let box_c = [0.0f64, 0.0, 0.0];
1412        let half = [0.5f64, 0.5, 0.5];
1413        let cap_a = [5.0f64, 0.0, 0.0];
1414        let cap_b = [6.0f64, 0.0, 0.0];
1415        assert!(
1416            matches!(
1417                aabb_capsule(box_c, half, cap_a, cap_b, 0.3),
1418                NarrowPhaseResult::NoContact
1419            ),
1420            "Far capsule must return NoContact"
1421        );
1422    }
1423
1424    // ── obb_capsule ─────────────────────────────────────────────────────────
1425
1426    #[test]
1427    fn test_obb_capsule_axis_aligned_overlap() {
1428        let identity = [[1.0f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1429        let obb_c = [0.0f64, 0.0, 0.0];
1430        let obb_h = [1.0f64, 1.0, 1.0];
1431        let cap_a = [0.0f64, -2.0, 0.0];
1432        let cap_b = [0.0f64, 2.0, 0.0];
1433        let result = obb_capsule(obb_c, obb_h, identity, cap_a, cap_b, 0.5);
1434        assert!(result.has_contact(), "OBB-capsule should have contact");
1435    }
1436
1437    // ── heightfield_sphere ──────────────────────────────────────────────────
1438
1439    #[test]
1440    fn test_heightfield_sphere_above() {
1441        let hf = Heightfield::flat(5, 5, 1.0);
1442        // Sphere far above the flat heightfield → no contact.
1443        let sphere_c = [2.0f64, 5.0, 2.0];
1444        assert!(
1445            matches!(
1446                heightfield_sphere(&hf, sphere_c, 0.5),
1447                NarrowPhaseResult::NoContact
1448            ),
1449            "Sphere high above heightfield must return NoContact"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_heightfield_sphere_below() {
1455        let hf = Heightfield::flat(5, 5, 1.0);
1456        // Sphere centre at y=0.3 with radius=0.5: bottom is at y=-0.2 → penetration = 0.5-0.3 = 0.2.
1457        let sphere_c = [2.0f64, 0.3, 2.0];
1458        match heightfield_sphere(&hf, sphere_c, 0.5) {
1459            NarrowPhaseResult::SingleContact(c) => {
1460                assert!(
1461                    approx_eq(c.depth, 0.2, 1e-6),
1462                    "Expected depth 0.2, got {}",
1463                    c.depth
1464                );
1465            }
1466            _ => panic!("expected contact for sphere below heightfield"),
1467        }
1468    }
1469
1470    #[test]
1471    fn test_heightfield_sphere_contact_normal() {
1472        let hf = Heightfield::flat(5, 5, 1.0);
1473        let sphere_c = [2.0f64, 0.3, 2.0];
1474        match heightfield_sphere(&hf, sphere_c, 0.5) {
1475            NarrowPhaseResult::SingleContact(c) => {
1476                // Normal should point up for a flat surface.
1477                assert!(
1478                    approx_eq(c.normal[1], 1.0, 1e-6),
1479                    "Normal should be (0,1,0) for flat surface: {:?}",
1480                    c.normal
1481                );
1482            }
1483            _ => panic!("expected contact"),
1484        }
1485    }
1486
1487    // ── heightfield_box ─────────────────────────────────────────────────────
1488
1489    #[test]
1490    fn test_heightfield_box_above() {
1491        let hf = Heightfield::flat(5, 5, 1.0);
1492        let box_c = [2.0f64, 5.0, 2.0];
1493        let half = [0.5f64, 0.5, 0.5];
1494        assert!(
1495            matches!(
1496                heightfield_box(&hf, box_c, half),
1497                NarrowPhaseResult::NoContact
1498            ),
1499            "Box far above heightfield must return NoContact"
1500        );
1501    }
1502
1503    #[test]
1504    fn test_heightfield_box_penetrating() {
1505        let hf = Heightfield::flat(5, 5, 1.0);
1506        // Box bottom at y = 0.0 - 0.5 = -0.5 → penetrates flat surface at y=0.
1507        let box_c = [2.0f64, 0.0, 2.0];
1508        let half = [0.5f64, 0.5, 0.5];
1509        match heightfield_box(&hf, box_c, half) {
1510            NarrowPhaseResult::SingleContact(c) => {
1511                assert!(
1512                    c.depth > 0.0,
1513                    "Penetrating box should have positive depth: {}",
1514                    c.depth
1515                );
1516            }
1517            _ => panic!("expected contact for penetrating box"),
1518        }
1519    }
1520
1521    // ── triangle_sphere ──────────────────────────────────────────────────────
1522
1523    #[test]
1524    fn test_triangle_sphere_contact() {
1525        // Sphere centred 0.3 above the triangle → contact with depth = radius - 0.3.
1526        let tri_a = [-1.0f64, 0.0, -1.0];
1527        let tri_b = [1.0f64, 0.0, -1.0];
1528        let tri_c = [0.0f64, 0.0, 1.0];
1529        let sphere_c = [0.0f64, 0.3, 0.0];
1530        let radius = 0.5;
1531        match triangle_sphere(tri_a, tri_b, tri_c, sphere_c, radius) {
1532            NarrowPhaseResult::SingleContact(c) => {
1533                assert!(approx_eq(c.depth, 0.2, 1e-6), "depth={}", c.depth);
1534                assert!(
1535                    approx_eq(len(c.normal), 1.0, 1e-10),
1536                    "normal not unit: {:?}",
1537                    c.normal
1538                );
1539            }
1540            _ => panic!("expected contact"),
1541        }
1542    }
1543
1544    #[test]
1545    fn test_triangle_sphere_no_contact() {
1546        let tri_a = [-1.0f64, 0.0, -1.0];
1547        let tri_b = [1.0f64, 0.0, -1.0];
1548        let tri_c = [0.0f64, 0.0, 1.0];
1549        let sphere_c = [0.0f64, 5.0, 0.0];
1550        assert!(
1551            matches!(
1552                triangle_sphere(tri_a, tri_b, tri_c, sphere_c, 0.5),
1553                NarrowPhaseResult::NoContact
1554            ),
1555            "Sphere far above triangle must return NoContact"
1556        );
1557    }
1558
1559    #[test]
1560    fn test_heightfield_bilinear_flat() {
1561        let hf = Heightfield::flat(4, 4, 1.0);
1562        // For a flat heightfield, height at any point should be 0.
1563        assert!(approx_eq(hf.height_world(1.5, 1.5), 0.0, 1e-12));
1564        assert!(approx_eq(hf.height_world(0.1, 2.9), 0.0, 1e-12));
1565    }
1566
1567    #[test]
1568    fn test_heightfield_normal_flat_is_up() {
1569        let hf = Heightfield::flat(4, 4, 1.0);
1570        let n = hf.normal_world(1.5, 1.5);
1571        assert!(
1572            vec_approx_eq(n, [0.0, 1.0, 0.0], 1e-6),
1573            "Flat HF normal: {:?}",
1574            n
1575        );
1576    }
1577}