Skip to main content

oxiphysics_collision/
gjk_epa.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GJK (Gilbert-Johnson-Keerthi) and EPA (Expanding Polytope Algorithm)
5//! for convex shape collision detection using plain `[f64; 3]` arrays.
6
7// ─── Helper math ────────────────────────────────────────────────────────────
8
9/// Dot product of two 3-vectors stored as `[f64; 3]`.
10#[inline]
11pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
12    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
13}
14
15#[inline]
16pub fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
17    [
18        a[1] * b[2] - a[2] * b[1],
19        a[2] * b[0] - a[0] * b[2],
20        a[0] * b[1] - a[1] * b[0],
21    ]
22}
23
24#[inline]
25pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
26    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
27}
28
29#[inline]
30pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
31    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
32}
33
34#[inline]
35pub fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
36    [a[0] * s, a[1] * s, a[2] * s]
37}
38
39#[inline]
40pub fn len3(a: [f64; 3]) -> f64 {
41    dot3(a, a).sqrt()
42}
43
44#[inline]
45pub fn normalize3(a: [f64; 3]) -> [f64; 3] {
46    let l = len3(a);
47    if l < 1e-12 {
48        [0.0, 0.0, 1.0]
49    } else {
50        scale3(a, 1.0 / l)
51    }
52}
53
54#[inline]
55fn neg3(a: [f64; 3]) -> [f64; 3] {
56    [-a[0], -a[1], -a[2]]
57}
58
59// ─── ConvexShape trait ───────────────────────────────────────────────────────
60
61/// A convex shape that exposes a support function used by GJK/EPA.
62pub trait ConvexShape {
63    /// Returns the furthest point on the shape in the given direction.
64    fn support(&self, direction: [f64; 3]) -> [f64; 3];
65}
66
67// ─── GjkSphere ───────────────────────────────────────────────────────────────
68
69/// A sphere represented by center and radius, used with GJK/EPA.
70pub struct GjkSphere {
71    /// Center of the sphere.
72    pub center: [f64; 3],
73    /// Radius of the sphere.
74    pub radius: f64,
75}
76
77impl ConvexShape for GjkSphere {
78    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
79        let n = normalize3(direction);
80        add3(self.center, scale3(n, self.radius))
81    }
82}
83
84// ─── GjkBox ──────────────────────────────────────────────────────────────────
85
86/// An axis-aligned box represented by center and half-extents, used with GJK/EPA.
87pub struct GjkBox {
88    /// Center of the box.
89    pub center: [f64; 3],
90    /// Half-extents along each axis.
91    pub half_extents: [f64; 3],
92}
93
94impl ConvexShape for GjkBox {
95    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
96        [
97            self.center[0] + self.half_extents[0] * direction[0].signum(),
98            self.center[1] + self.half_extents[1] * direction[1].signum(),
99            self.center[2] + self.half_extents[2] * direction[2].signum(),
100        ]
101    }
102}
103
104// ─── GjkCapsule ──────────────────────────────────────────────────────────────
105
106/// A capsule (cylinder with hemispherical caps) used with GJK/EPA.
107pub struct GjkCapsule {
108    /// One end of the capsule axis.
109    pub p1: [f64; 3],
110    /// Other end of the capsule axis.
111    pub p2: [f64; 3],
112    /// Radius of the capsule.
113    pub radius: f64,
114}
115
116impl ConvexShape for GjkCapsule {
117    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
118        let n = normalize3(direction);
119        let d1 = dot3(self.p1, n);
120        let d2 = dot3(self.p2, n);
121        let base = if d1 > d2 { self.p1 } else { self.p2 };
122        add3(base, scale3(n, self.radius))
123    }
124}
125
126// ─── Minkowski difference support ────────────────────────────────────────────
127
128/// Support function for the Minkowski difference of two convex shapes.
129pub fn minkowski_support(
130    shape_a: &dyn ConvexShape,
131    shape_b: &dyn ConvexShape,
132    dir: [f64; 3],
133) -> [f64; 3] {
134    sub3(shape_a.support(dir), shape_b.support(neg3(dir)))
135}
136
137// ─── Simplex ─────────────────────────────────────────────────────────────────
138
139/// A simplex of up to 4 points in the Minkowski difference space.
140#[derive(Clone)]
141pub struct Simplex {
142    /// Points of the simplex (at most 4).
143    pub points: Vec<[f64; 3]>,
144}
145
146impl Simplex {
147    fn new() -> Self {
148        Self {
149            points: Vec::with_capacity(4),
150        }
151    }
152
153    fn push(&mut self, p: [f64; 3]) {
154        self.points.push(p);
155    }
156
157    fn len(&self) -> usize {
158        self.points.len()
159    }
160}
161
162// ─── do_simplex ──────────────────────────────────────────────────────────────
163
164/// Process the simplex; returns true if origin is contained.
165/// Also updates `direction` with the next search direction.
166pub fn do_simplex(simplex: &mut Simplex, direction: &mut [f64; 3]) -> bool {
167    match simplex.len() {
168        2 => handle_line(simplex, direction),
169        3 => handle_triangle(simplex, direction),
170        4 => handle_tetrahedron(simplex, direction),
171        _ => false,
172    }
173}
174
175fn handle_line(simplex: &mut Simplex, direction: &mut [f64; 3]) -> bool {
176    let b = simplex.points[0];
177    let a = simplex.points[1];
178    let ab = sub3(b, a);
179    let ao = neg3(a);
180    if dot3(ab, ao) > 0.0 {
181        *direction = sub3(scale3(ab, dot3(ab, ao) / dot3(ab, ab)), ao);
182        // direction = AB × (AO × AB) (triple product)
183        let t = cross3(cross3(ab, ao), ab);
184        if len3(t) < 1e-12 {
185            // AO parallel to AB — pick perpendicular
186            *direction = [1.0, 0.0, 0.0];
187        } else {
188            *direction = t;
189        }
190    } else {
191        simplex.points = vec![a];
192        *direction = ao;
193    }
194    false
195}
196
197fn handle_triangle(simplex: &mut Simplex, direction: &mut [f64; 3]) -> bool {
198    let c = simplex.points[0];
199    let b = simplex.points[1];
200    let a = simplex.points[2];
201
202    let ab = sub3(b, a);
203    let ac = sub3(c, a);
204    let ao = neg3(a);
205    let abc = cross3(ab, ac);
206
207    // Is origin outside edge AB (away from C)?
208    if dot3(cross3(abc, ac), ao) > 0.0 {
209        if dot3(ac, ao) > 0.0 {
210            simplex.points = vec![c, a];
211            *direction = cross3(cross3(ac, ao), ac);
212        } else {
213            simplex.points = vec![b, a];
214            return handle_line(simplex, direction);
215        }
216    } else if dot3(cross3(ab, abc), ao) > 0.0 {
217        simplex.points = vec![b, a];
218        return handle_line(simplex, direction);
219    } else {
220        // Inside triangle; origin above or below?
221        if dot3(abc, ao) > 0.0 {
222            *direction = abc;
223        } else {
224            simplex.points = vec![b, c, a];
225            *direction = neg3(abc);
226        }
227    }
228    false
229}
230
231fn handle_tetrahedron(simplex: &mut Simplex, direction: &mut [f64; 3]) -> bool {
232    let d = simplex.points[0];
233    let c = simplex.points[1];
234    let b = simplex.points[2];
235    let a = simplex.points[3];
236
237    let ab = sub3(b, a);
238    let ac = sub3(c, a);
239    let ad = sub3(d, a);
240    let ao = neg3(a);
241
242    let abc = cross3(ab, ac);
243    let acd = cross3(ac, ad);
244    let adb = cross3(ad, ab);
245
246    if dot3(abc, ao) > 0.0 {
247        simplex.points = vec![c, b, a];
248        return handle_triangle(simplex, direction);
249    }
250    if dot3(acd, ao) > 0.0 {
251        simplex.points = vec![d, c, a];
252        return handle_triangle(simplex, direction);
253    }
254    if dot3(adb, ao) > 0.0 {
255        simplex.points = vec![b, d, a];
256        return handle_triangle(simplex, direction);
257    }
258    true
259}
260
261// ─── GJK intersection test ───────────────────────────────────────────────────
262
263/// Returns true if the two convex shapes intersect using GJK.
264pub fn gjk_intersect(shape_a: &dyn ConvexShape, shape_b: &dyn ConvexShape) -> bool {
265    let mut direction = [1.0_f64, 0.0, 0.0];
266    let mut simplex = Simplex::new();
267
268    let support = minkowski_support(shape_a, shape_b, direction);
269    simplex.push(support);
270    direction = neg3(support);
271
272    for _ in 0..64 {
273        if len3(direction) < 1e-12 {
274            return true;
275        }
276        let a = minkowski_support(shape_a, shape_b, direction);
277        if dot3(a, direction) < 0.0 {
278            return false;
279        }
280        simplex.push(a);
281        if do_simplex(&mut simplex, &mut direction) {
282            return true;
283        }
284    }
285    false
286}
287
288/// Returns `Some(distance)` if not intersecting, `None` if intersecting.
289pub fn gjk_distance(shape_a: &dyn ConvexShape, shape_b: &dyn ConvexShape) -> Option<f64> {
290    if gjk_intersect(shape_a, shape_b) {
291        None
292    } else {
293        // Simple distance estimate: iterate and track closest point
294        let mut direction = [1.0_f64, 0.0, 0.0];
295        let mut min_dist = f64::MAX;
296
297        for _ in 0..64 {
298            let s = minkowski_support(shape_a, shape_b, direction);
299            let d = len3(s);
300            if d < min_dist {
301                min_dist = d;
302            }
303            let new_dir = neg3(s);
304            if len3(new_dir) < 1e-12 {
305                break;
306            }
307            let next_s = minkowski_support(shape_a, shape_b, new_dir);
308            if dot3(next_s, new_dir) < dot3(s, direction) + 1e-10 {
309                break;
310            }
311            direction = new_dir;
312        }
313
314        // Approximate: distance between closest support points
315        let sa = shape_a.support(direction);
316        let sb = shape_b.support(neg3(direction));
317        let dist = len3(sub3(sa, sb));
318        Some(dist.max(0.0))
319    }
320}
321
322// ─── EPA ─────────────────────────────────────────────────────────────────────
323
324/// A face of the polytope used in EPA.
325#[derive(Clone)]
326pub struct EpaFace {
327    /// First vertex.
328    pub a: [f64; 3],
329    /// Second vertex.
330    pub b: [f64; 3],
331    /// Third vertex.
332    pub c: [f64; 3],
333    /// Outward-pointing unit normal.
334    pub normal: [f64; 3],
335    /// Distance from origin to the face plane.
336    pub dist: f64,
337}
338
339impl EpaFace {
340    fn new(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Self {
341        let ab = sub3(b, a);
342        let ac = sub3(c, a);
343        let n = cross3(ab, ac);
344        let mut normal = normalize3(n);
345        let mut dist = dot3(normal, a);
346        // Ensure normal points away from origin (positive dist)
347        if dist < 0.0 {
348            normal = neg3(normal);
349            dist = -dist;
350        }
351        Self {
352            a,
353            b,
354            c,
355            normal,
356            dist,
357        }
358    }
359}
360
361/// Result of the EPA penetration depth query.
362pub struct EpaResult {
363    /// Penetration depth (positive means overlap).
364    pub depth: f64,
365    /// Collision normal pointing from shape B to shape A.
366    pub normal: [f64; 3],
367    /// Contact point on shape A.
368    pub contact_a: [f64; 3],
369    /// Contact point on shape B.
370    pub contact_b: [f64; 3],
371}
372
373fn barycentric_contact(
374    shape_a: &dyn ConvexShape,
375    shape_b: &dyn ConvexShape,
376    face: &EpaFace,
377) -> ([f64; 3], [f64; 3]) {
378    // Use barycentric coords of origin projection onto face
379    let n = face.normal;
380    let proj = scale3(n, face.dist);
381
382    let v0 = sub3(face.b, face.a);
383    let v1 = sub3(face.c, face.a);
384    let v2 = sub3(proj, face.a);
385
386    let d00 = dot3(v0, v0);
387    let d01 = dot3(v0, v1);
388    let d11 = dot3(v1, v1);
389    let d20 = dot3(v2, v0);
390    let d21 = dot3(v2, v1);
391    let denom = d00 * d11 - d01 * d01;
392
393    let (u, v, w) = if denom.abs() < 1e-12 {
394        (1.0_f64 / 3.0, 1.0_f64 / 3.0, 1.0_f64 / 3.0)
395    } else {
396        let v_coord = (d11 * d20 - d01 * d21) / denom;
397        let w_coord = (d00 * d21 - d01 * d20) / denom;
398        let u_coord = 1.0 - v_coord - w_coord;
399        (u_coord, v_coord, w_coord)
400    };
401
402    // Reconstruct support points from Minkowski difference vertices
403    // face.a = sa_a - sb_a, face.b = sa_b - sb_b, face.c = sa_c - sb_c
404    // We need to recover original support points; store them as (sa, sb) pairs
405    // Since we only have the Minkowski difference points, approximate with
406    // closest support point in face normal direction
407    let ca = add3(
408        add3(
409            scale3(shape_a.support(face.a), u),
410            scale3(shape_a.support(face.b), v),
411        ),
412        scale3(shape_a.support(face.c), w),
413    );
414    let cb = add3(
415        add3(
416            scale3(shape_b.support(neg3(face.a)), u),
417            scale3(shape_b.support(neg3(face.b)), v),
418        ),
419        scale3(shape_b.support(neg3(face.c)), w),
420    );
421    (ca, cb)
422}
423
424/// Expand polytope algorithm: finds penetration depth given initial simplex from GJK.
425pub fn epa(
426    shape_a: &dyn ConvexShape,
427    shape_b: &dyn ConvexShape,
428    simplex: Simplex,
429) -> Option<EpaResult> {
430    if simplex.points.len() < 4 {
431        return None;
432    }
433
434    let mut faces: Vec<EpaFace> = vec![
435        EpaFace::new(simplex.points[0], simplex.points[1], simplex.points[2]),
436        EpaFace::new(simplex.points[0], simplex.points[1], simplex.points[3]),
437        EpaFace::new(simplex.points[0], simplex.points[2], simplex.points[3]),
438        EpaFace::new(simplex.points[1], simplex.points[2], simplex.points[3]),
439    ];
440
441    for _ in 0..64 {
442        // Find face closest to origin
443        let (min_idx, min_face) = {
444            let mut best_idx = 0;
445            let mut best_dist = f64::MAX;
446            for (i, face) in faces.iter().enumerate() {
447                if face.dist < best_dist {
448                    best_dist = face.dist;
449                    best_idx = i;
450                }
451            }
452            (best_idx, faces[best_idx].clone())
453        };
454
455        let support = minkowski_support(shape_a, shape_b, min_face.normal);
456        let d = dot3(support, min_face.normal);
457
458        if (d - min_face.dist).abs() < 1e-6 {
459            // Converged
460            let (contact_a, contact_b) = barycentric_contact(shape_a, shape_b, &min_face);
461            return Some(EpaResult {
462                depth: min_face.dist,
463                normal: min_face.normal,
464                contact_a,
465                contact_b,
466            });
467        }
468
469        // Remove faces that can "see" the new support point and collect silhouette edges
470        let mut silhouette: Vec<([f64; 3], [f64; 3])> = Vec::new();
471        let new_point = support;
472
473        let mut i = 0;
474        while i < faces.len() {
475            if dot3(faces[i].normal, sub3(new_point, faces[i].a)) > 0.0 {
476                let f = faces.remove(i);
477                // Collect edges (in reverse winding to keep silhouette consistent)
478                for edge in [(f.a, f.b), (f.b, f.c), (f.c, f.a)] {
479                    // Remove shared edges (horizon silhouette)
480                    let shared = silhouette
481                        .iter()
482                        .position(|&(x, y)| x == edge.1 && y == edge.0);
483                    if let Some(pos) = shared {
484                        silhouette.remove(pos);
485                    } else {
486                        silhouette.push(edge);
487                    }
488                }
489            } else {
490                i += 1;
491            }
492        }
493
494        // Re-expand with new faces from silhouette
495        for (ea, eb) in silhouette {
496            faces.push(EpaFace::new(ea, eb, new_point));
497        }
498
499        // Safety: remove the face we started with if it's still there (re-inserted version covers it)
500        let _ = min_idx; // already removed or updated above
501    }
502
503    // Return best face found so far
504    let best = faces.iter().min_by(|a, b| {
505        a.dist
506            .partial_cmp(&b.dist)
507            .unwrap_or(std::cmp::Ordering::Equal)
508    })?;
509    let (contact_a, contact_b) = barycentric_contact(shape_a, shape_b, best);
510    Some(EpaResult {
511        depth: best.dist,
512        normal: best.normal,
513        contact_a,
514        contact_b,
515    })
516}
517
518// ─── Full GJK + EPA pipeline ─────────────────────────────────────────────────
519
520/// Run GJK to detect intersection, then EPA to find penetration depth and contact.
521/// Returns `None` if the shapes are not intersecting.
522pub fn gjk_epa_contact(shape_a: &dyn ConvexShape, shape_b: &dyn ConvexShape) -> Option<EpaResult> {
523    // Run GJK and collect the final simplex
524    let mut direction = [1.0_f64, 0.0, 0.0];
525    let mut simplex = Simplex::new();
526
527    let first_support = minkowski_support(shape_a, shape_b, direction);
528    simplex.push(first_support);
529    direction = neg3(first_support);
530
531    let mut intersects = false;
532    for _ in 0..64 {
533        if len3(direction) < 1e-12 {
534            intersects = true;
535            break;
536        }
537        let a = minkowski_support(shape_a, shape_b, direction);
538        if dot3(a, direction) < 0.0 {
539            return None;
540        }
541        simplex.push(a);
542        if do_simplex(&mut simplex, &mut direction) {
543            intersects = true;
544            break;
545        }
546    }
547
548    if !intersects {
549        return None;
550    }
551
552    // Ensure we have a tetrahedron for EPA
553    let simplex = ensure_tetrahedron(shape_a, shape_b, simplex);
554    epa(shape_a, shape_b, simplex)
555}
556
557fn ensure_tetrahedron(
558    shape_a: &dyn ConvexShape,
559    shape_b: &dyn ConvexShape,
560    mut simplex: Simplex,
561) -> Simplex {
562    let dirs = [
563        [1.0_f64, 0.0, 0.0],
564        [-1.0, 0.0, 0.0],
565        [0.0, 1.0, 0.0],
566        [0.0, -1.0, 0.0],
567        [0.0, 0.0, 1.0],
568        [0.0, 0.0, -1.0],
569    ];
570
571    // Fill up to 3 unique points
572    for dir in dirs {
573        if simplex.len() >= 3 {
574            break;
575        }
576        let p = minkowski_support(shape_a, shape_b, dir);
577        let is_new = simplex.points.iter().all(|&q| len3(sub3(p, q)) > 1e-10);
578        if is_new {
579            simplex.push(p);
580        }
581    }
582
583    if simplex.len() < 3 {
584        return simplex;
585    }
586
587    // Check if we already have a valid (non-degenerate) tetrahedron
588    if simplex.len() >= 4 {
589        let a = simplex.points[0];
590        let b = simplex.points[1];
591        let c = simplex.points[2];
592        let d = simplex.points[3];
593        let ab = sub3(b, a);
594        let ac = sub3(c, a);
595        let normal = cross3(ab, ac);
596        let vol = dot3(normal, sub3(d, a));
597        if vol.abs() > 1e-10 {
598            return simplex; // already a valid tetrahedron
599        }
600        // Degenerate: drop the 4th point and find a better one
601        simplex.points.truncate(3);
602    }
603
604    // For the 4th point, we need it to be non-coplanar with the first 3
605    let a = simplex.points[0];
606    let b = simplex.points[1];
607    let c = simplex.points[2];
608    let ab = sub3(b, a);
609    let ac = sub3(c, a);
610    let normal = cross3(ab, ac);
611
612    for dir in dirs {
613        let p = minkowski_support(shape_a, shape_b, dir);
614        let is_new = simplex.points.iter().all(|&q| len3(sub3(p, q)) > 1e-10);
615        if !is_new {
616            continue;
617        }
618        // Check non-coplanar: dot(normal, p-a) != 0
619        let vol = dot3(normal, sub3(p, a));
620        if vol.abs() > 1e-10 {
621            simplex.push(p);
622            break;
623        }
624    }
625
626    simplex
627}
628
629// ─── GJK Distance query (improved) ───────────────────────────────────────────
630
631/// Closest point on a line segment AB to the origin.
632pub fn closest_point_segment_to_origin(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
633    let ab = sub3(b, a);
634    let t = dot3(neg3(a), ab) / dot3(ab, ab).max(1e-30);
635    let t = t.clamp(0.0, 1.0);
636    add3(a, scale3(ab, t))
637}
638
639/// Closest point on triangle ABC to the origin (point projection).
640pub fn closest_point_triangle_to_origin(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [f64; 3] {
641    let ab = sub3(b, a);
642    let ac = sub3(c, a);
643    let ao = neg3(a);
644
645    let d1 = dot3(ab, ao);
646    let d2 = dot3(ac, ao);
647    if d1 <= 0.0 && d2 <= 0.0 {
648        return a;
649    }
650
651    let bo = neg3(b);
652    let d3 = dot3(ab, bo);
653    let d4 = dot3(ac, bo);
654    if d3 >= 0.0 && d4 <= d3 {
655        return b;
656    }
657
658    let co = neg3(c);
659    let d5 = dot3(ab, co);
660    let d6 = dot3(ac, co);
661    if d6 >= 0.0 && d5 <= d6 {
662        return c;
663    }
664
665    let vc = d1 * d4 - d3 * d2;
666    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
667        let v = d1 / (d1 - d3);
668        return add3(a, scale3(ab, v));
669    }
670
671    let vb = d5 * d2 - d1 * d6;
672    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
673        let w = d2 / (d2 - d6);
674        return add3(a, scale3(ac, w));
675    }
676
677    let va = d3 * d6 - d5 * d4;
678    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
679        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
680        return add3(b, scale3(sub3(c, b), w));
681    }
682
683    let denom = 1.0 / (va + vb + vc);
684    let v = vb * denom;
685    let w = vc * denom;
686    add3(a, add3(scale3(ab, v), scale3(ac, w)))
687}
688
689/// Improved GJK distance query that tracks the closest simplex points.
690///
691/// Returns `Some((dist, witness_a, witness_b))` — the minimum distance and
692/// witness points on each shape — when the shapes do **not** intersect.
693/// Returns `None` when they intersect.
694pub fn gjk_distance_with_witnesses(
695    shape_a: &dyn ConvexShape,
696    shape_b: &dyn ConvexShape,
697) -> Option<(f64, [f64; 3], [f64; 3])> {
698    if gjk_intersect(shape_a, shape_b) {
699        return None;
700    }
701    // Walk the Minkowski difference to find the closest point.
702    let mut dir = [1.0_f64, 0.0, 0.0];
703    let mut best_a = shape_a.support(dir);
704    let mut best_b = shape_b.support(neg3(dir));
705    let mut best_dist = f64::MAX;
706
707    for _ in 0..64 {
708        let sa = shape_a.support(dir);
709        let sb = shape_b.support(neg3(dir));
710        let diff = sub3(sa, sb); // point on Minkowski difference boundary
711        let d = len3(diff);
712        if d < best_dist {
713            best_dist = d;
714            best_a = sa;
715            best_b = sb;
716        }
717        let new_dir = neg3(diff);
718        if len3(new_dir) < 1e-12 {
719            break;
720        }
721        dir = normalize3(new_dir);
722    }
723    Some((best_dist.max(0.0), best_a, best_b))
724}
725
726// ─── GJK with rotation ───────────────────────────────────────────────────────
727
728/// Apply a 3×3 rotation matrix (row-major) to a 3D vector.
729pub fn rotate_vec(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
730    [
731        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
732        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
733        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
734    ]
735}
736
737/// Rotate a convex shape by `rot` (row-major 3×3 rotation matrix) and
738/// translate it by `translation`.  Returns a new support function wrapper.
739pub struct TransformedShape<'a> {
740    /// Underlying shape.
741    pub shape: &'a dyn ConvexShape,
742    /// Row-major 3×3 rotation matrix.
743    pub rotation: [[f64; 3]; 3],
744    /// Translation vector.
745    pub translation: [f64; 3],
746}
747
748impl<'a> ConvexShape for TransformedShape<'a> {
749    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
750        // Transform direction into local frame: R^T * dir
751        let local_dir = [
752            self.rotation[0][0] * direction[0]
753                + self.rotation[1][0] * direction[1]
754                + self.rotation[2][0] * direction[2],
755            self.rotation[0][1] * direction[0]
756                + self.rotation[1][1] * direction[1]
757                + self.rotation[2][1] * direction[2],
758            self.rotation[0][2] * direction[0]
759                + self.rotation[1][2] * direction[1]
760                + self.rotation[2][2] * direction[2],
761        ];
762        let local_pt = self.shape.support(local_dir);
763        // Transform back to world frame and add translation
764        add3(rotate_vec(self.rotation, local_pt), self.translation)
765    }
766}
767
768/// Test intersection between two shapes given explicit poses.
769pub fn gjk_intersect_transformed(
770    shape_a: &dyn ConvexShape,
771    rot_a: [[f64; 3]; 3],
772    pos_a: [f64; 3],
773    shape_b: &dyn ConvexShape,
774    rot_b: [[f64; 3]; 3],
775    pos_b: [f64; 3],
776) -> bool {
777    let ta = TransformedShape {
778        shape: shape_a,
779        rotation: rot_a,
780        translation: pos_a,
781    };
782    let tb = TransformedShape {
783        shape: shape_b,
784        rotation: rot_b,
785        translation: pos_b,
786    };
787    gjk_intersect(&ta, &tb)
788}
789
790// ─── EPA polytope expansion helpers ──────────────────────────────────────────
791
792/// Count the number of faces in the EPA polytope after `n` expansion steps.
793///
794/// The initial tetrahedron has 4 faces.  Each expansion step removes at least
795/// 1 face visible from the new support point and adds at least 3 new faces
796/// along the silhouette edges.  This is a conservative *lower-bound* estimate.
797pub fn epa_face_count_lower_bound(expansion_steps: usize) -> usize {
798    4 + 2 * expansion_steps
799}
800
801/// A pair of closest points on two shapes — the result of a closest-point query.
802#[derive(Debug, Clone)]
803pub struct ClosestPoints {
804    /// Closest point on shape A.
805    pub point_a: [f64; 3],
806    /// Closest point on shape B.
807    pub point_b: [f64; 3],
808    /// Distance between the two points.
809    pub distance: f64,
810}
811
812/// Compute the closest points between two non-intersecting convex shapes.
813///
814/// Returns `None` if the shapes are intersecting.
815pub fn closest_points_between_shapes(
816    shape_a: &dyn ConvexShape,
817    shape_b: &dyn ConvexShape,
818) -> Option<ClosestPoints> {
819    gjk_distance_with_witnesses(shape_a, shape_b).map(|(dist, pa, pb)| ClosestPoints {
820        point_a: pa,
821        point_b: pb,
822        distance: dist,
823    })
824}
825
826// ─── GjkConvexHull ────────────────────────────────────────────────────────────
827
828/// A convex hull defined by an explicit list of vertices.
829pub struct GjkConvexHull {
830    /// Vertices of the convex hull.
831    pub vertices: Vec<[f64; 3]>,
832}
833
834impl GjkConvexHull {
835    /// Construct from a list of points.
836    pub fn new(vertices: Vec<[f64; 3]>) -> Self {
837        Self { vertices }
838    }
839}
840
841impl ConvexShape for GjkConvexHull {
842    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
843        let mut best = self.vertices[0];
844        let mut best_dot = dot3(best, direction);
845        for &v in &self.vertices[1..] {
846            let d = dot3(v, direction);
847            if d > best_dot {
848                best_dot = d;
849                best = v;
850            }
851        }
852        best
853    }
854}
855
856/// A cylinder (finite) approximated as a convex hull of 16 sample points.
857pub struct GjkCylinder {
858    /// Center of the bottom cap.
859    pub base: [f64; 3],
860    /// Axis direction (unit vector).
861    pub axis: [f64; 3],
862    /// Height of the cylinder.
863    pub height: f64,
864    /// Radius of the cylinder.
865    pub radius: f64,
866}
867
868impl ConvexShape for GjkCylinder {
869    fn support(&self, direction: [f64; 3]) -> [f64; 3] {
870        // Project direction onto the axis and radial plane.
871        let axis = normalize3(self.axis);
872        let d_axial = dot3(direction, axis);
873        // Radial component of direction
874        let radial = sub3(direction, scale3(axis, d_axial));
875        let radial_len = len3(radial);
876        let radial_pt = if radial_len > 1e-12 {
877            scale3(radial, self.radius / radial_len)
878        } else {
879            [self.radius, 0.0, 0.0]
880        };
881        // Choose top or bottom cap
882        let cap_offset = if d_axial >= 0.0 {
883            scale3(axis, self.height)
884        } else {
885            [0.0; 3]
886        };
887        add3(add3(self.base, radial_pt), cap_offset)
888    }
889}
890
891// ─── Tests ────────────────────────────────────────────────────────────────────
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    fn sphere(cx: f64, cy: f64, cz: f64, r: f64) -> GjkSphere {
898        GjkSphere {
899            center: [cx, cy, cz],
900            radius: r,
901        }
902    }
903
904    fn aabb(cx: f64, cy: f64, cz: f64, hx: f64, hy: f64, hz: f64) -> GjkBox {
905        GjkBox {
906            center: [cx, cy, cz],
907            half_extents: [hx, hy, hz],
908        }
909    }
910
911    fn capsule(p1: [f64; 3], p2: [f64; 3], r: f64) -> GjkCapsule {
912        GjkCapsule { p1, p2, radius: r }
913    }
914
915    // ── math helpers ────────────────────────────────────────────────────────
916
917    #[test]
918    fn test_dot3() {
919        assert!((dot3([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]) - 32.0).abs() < 1e-12);
920    }
921
922    #[test]
923    fn test_cross3() {
924        let c = cross3([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
925        assert!((c[0]).abs() < 1e-12);
926        assert!((c[1]).abs() < 1e-12);
927        assert!((c[2] - 1.0).abs() < 1e-12);
928    }
929
930    #[test]
931    fn test_normalize3() {
932        let n = normalize3([3.0, 0.0, 0.0]);
933        assert!((n[0] - 1.0).abs() < 1e-12);
934    }
935
936    #[test]
937    fn test_normalize3_zero() {
938        let n = normalize3([0.0, 0.0, 0.0]);
939        assert!((n[2] - 1.0).abs() < 1e-12);
940    }
941
942    #[test]
943    fn test_len3() {
944        assert!((len3([3.0, 4.0, 0.0]) - 5.0).abs() < 1e-12);
945    }
946
947    // ── support functions ────────────────────────────────────────────────────
948
949    #[test]
950    fn test_sphere_support() {
951        let s = sphere(0.0, 0.0, 0.0, 2.0);
952        let p = s.support([1.0, 0.0, 0.0]);
953        assert!((p[0] - 2.0).abs() < 1e-10);
954    }
955
956    #[test]
957    fn test_box_support() {
958        let b = aabb(0.0, 0.0, 0.0, 1.0, 2.0, 3.0);
959        let p = b.support([1.0, 1.0, 1.0]);
960        assert_eq!(p, [1.0, 2.0, 3.0]);
961    }
962
963    #[test]
964    fn test_capsule_support() {
965        let c = capsule([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 0.5);
966        let p = c.support([0.0, 1.0, 0.0]);
967        assert!((p[1] - 1.5).abs() < 1e-10);
968    }
969
970    // ── sphere-sphere ────────────────────────────────────────────────────────
971
972    #[test]
973    fn test_sphere_sphere_overlap() {
974        let a = sphere(0.0, 0.0, 0.0, 1.0);
975        let b = sphere(1.5, 0.0, 0.0, 1.0);
976        assert!(gjk_intersect(&a, &b));
977    }
978
979    #[test]
980    fn test_sphere_sphere_touching() {
981        let a = sphere(0.0, 0.0, 0.0, 1.0);
982        let b = sphere(2.0, 0.0, 0.0, 1.0);
983        // Touching spheres: borderline; GJK may report either
984        let _ = gjk_intersect(&a, &b);
985    }
986
987    #[test]
988    fn test_sphere_sphere_separated() {
989        let a = sphere(0.0, 0.0, 0.0, 1.0);
990        let b = sphere(3.0, 0.0, 0.0, 1.0);
991        assert!(!gjk_intersect(&a, &b));
992    }
993
994    #[test]
995    fn test_sphere_sphere_distance_separated() {
996        let a = sphere(0.0, 0.0, 0.0, 1.0);
997        let b = sphere(4.0, 0.0, 0.0, 1.0);
998        let d = gjk_distance(&a, &b);
999        assert!(d.is_some());
1000        // Distance between surfaces should be ~2.0
1001        let d = d.unwrap();
1002        assert!(d > 0.0 && d < 5.0, "distance={}", d);
1003    }
1004
1005    #[test]
1006    fn test_sphere_sphere_distance_intersecting() {
1007        let a = sphere(0.0, 0.0, 0.0, 1.0);
1008        let b = sphere(1.0, 0.0, 0.0, 1.0);
1009        assert!(gjk_distance(&a, &b).is_none());
1010    }
1011
1012    // ── box-box ──────────────────────────────────────────────────────────────
1013
1014    #[test]
1015    fn test_box_box_overlap() {
1016        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1017        let b = aabb(1.5, 0.0, 0.0, 1.0, 1.0, 1.0);
1018        assert!(gjk_intersect(&a, &b));
1019    }
1020
1021    #[test]
1022    fn test_box_box_separated() {
1023        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1024        let b = aabb(3.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1025        assert!(!gjk_intersect(&a, &b));
1026    }
1027
1028    #[test]
1029    fn test_box_box_same_position() {
1030        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1031        let b = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1032        assert!(gjk_intersect(&a, &b));
1033    }
1034
1035    #[test]
1036    fn test_box_box_distance() {
1037        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1038        let b = aabb(4.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1039        let d = gjk_distance(&a, &b);
1040        assert!(d.is_some());
1041        assert!(d.unwrap() > 0.0);
1042    }
1043
1044    // ── sphere-box ───────────────────────────────────────────────────────────
1045
1046    #[test]
1047    fn test_sphere_box_overlap() {
1048        let a = sphere(0.0, 0.0, 0.0, 1.0);
1049        let b = aabb(1.5, 0.0, 0.0, 1.0, 1.0, 1.0);
1050        assert!(gjk_intersect(&a, &b));
1051    }
1052
1053    #[test]
1054    fn test_sphere_box_separated() {
1055        let a = sphere(0.0, 0.0, 0.0, 0.5);
1056        let b = aabb(3.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1057        assert!(!gjk_intersect(&a, &b));
1058    }
1059
1060    #[test]
1061    fn test_sphere_box_distance() {
1062        let a = sphere(0.0, 0.0, 0.0, 0.5);
1063        let b = aabb(4.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1064        let d = gjk_distance(&a, &b);
1065        assert!(d.is_some());
1066        assert!(d.unwrap() > 0.0);
1067    }
1068
1069    // ── capsule tests ─────────────────────────────────────────────────────────
1070
1071    #[test]
1072    fn test_capsule_sphere_overlap() {
1073        let a = capsule([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 1.0);
1074        // capsule extends to x=1.0 (radius), sphere center at x=1.2 with r=0.5: overlap
1075        let b = sphere(1.2, 0.0, 0.0, 0.5);
1076        assert!(gjk_intersect(&a, &b));
1077    }
1078
1079    #[test]
1080    fn test_capsule_sphere_separated() {
1081        let a = capsule([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 0.5);
1082        let b = sphere(5.0, 0.0, 0.0, 0.5);
1083        assert!(!gjk_intersect(&a, &b));
1084    }
1085
1086    #[test]
1087    fn test_capsule_box_overlap() {
1088        let a = capsule([0.0, -1.0, 0.0], [0.0, 1.0, 0.0], 1.0);
1089        let b = aabb(1.5, 0.0, 0.0, 1.0, 1.0, 1.0);
1090        assert!(gjk_intersect(&a, &b));
1091    }
1092
1093    // ── EPA contact ───────────────────────────────────────────────────────────
1094
1095    #[test]
1096    fn test_epa_sphere_sphere_depth() {
1097        let a = sphere(0.0, 0.0, 0.0, 1.0);
1098        let b = sphere(1.0, 0.0, 0.0, 1.0);
1099        assert!(gjk_intersect(&a, &b), "spheres must intersect");
1100        if let Some(r) = gjk_epa_contact(&a, &b) {
1101            assert!(r.depth >= 0.0, "depth must be non-negative");
1102        }
1103    }
1104
1105    #[test]
1106    fn test_epa_sphere_sphere_no_contact_when_separated() {
1107        let a = sphere(0.0, 0.0, 0.0, 1.0);
1108        let b = sphere(3.0, 0.0, 0.0, 1.0);
1109        let result = gjk_epa_contact(&a, &b);
1110        assert!(result.is_none());
1111    }
1112
1113    #[test]
1114    fn test_epa_box_box_depth() {
1115        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1116        let b = aabb(1.5, 0.0, 0.0, 1.0, 1.0, 1.0);
1117        let result = gjk_epa_contact(&a, &b);
1118        assert!(result.is_some());
1119        let r = result.unwrap();
1120        assert!(r.depth > 0.0);
1121    }
1122
1123    #[test]
1124    fn test_epa_normal_unit_length() {
1125        let a = sphere(0.0, 0.0, 0.0, 1.0);
1126        let b = sphere(1.5, 0.0, 0.0, 1.0);
1127        if let Some(r) = gjk_epa_contact(&a, &b) {
1128            let nl = len3(r.normal);
1129            assert!((nl - 1.0).abs() < 1e-6, "normal length={}", nl);
1130        }
1131    }
1132
1133    // ── minkowski_support ────────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_minkowski_support_spheres() {
1137        let a = sphere(0.0, 0.0, 0.0, 1.0);
1138        let b = sphere(3.0, 0.0, 0.0, 1.0);
1139        // In direction [1,0,0]: sa = [1,0,0], sb_neg = sb.support([-1,0,0]) = [2,0,0]
1140        // minkowski = [1,0,0] - [2,0,0] = [-1,0,0]
1141        let m = minkowski_support(&a, &b, [1.0, 0.0, 0.0]);
1142        assert!((m[0] - (-1.0)).abs() < 1e-10, "m[0]={}", m[0]);
1143    }
1144
1145    // ── closest_point_segment_to_origin ──────────────────────────────────────
1146
1147    #[test]
1148    fn test_closest_point_segment_origin_inside_segment() {
1149        let p = closest_point_segment_to_origin([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1150        assert!(
1151            p[0].abs() < 1e-12,
1152            "closest point on segment to origin: {:?}",
1153            p
1154        );
1155    }
1156
1157    #[test]
1158    fn test_closest_point_segment_origin_past_end() {
1159        // Origin is past B end → closest is B
1160        let p = closest_point_segment_to_origin([2.0, 0.0, 0.0], [3.0, 0.0, 0.0]);
1161        assert!((p[0] - 2.0).abs() < 1e-12, "expected [2,0,0], got {:?}", p);
1162    }
1163
1164    // ── closest_point_triangle_to_origin ─────────────────────────────────────
1165
1166    #[test]
1167    fn test_closest_point_triangle_origin_inside() {
1168        // Triangle in xz-plane containing origin: origin projects to itself
1169        let a = [-1.0, 0.0, -1.0];
1170        let b = [1.0, 0.0, -1.0];
1171        let c = [0.0, 0.0, 1.0];
1172        let p = closest_point_triangle_to_origin(a, b, c);
1173        // origin is inside the triangle → closest point is origin itself
1174        assert!(
1175            len3(p) < 0.5,
1176            "closest point should be near origin, got {:?}",
1177            p
1178        );
1179    }
1180
1181    #[test]
1182    fn test_closest_point_triangle_origin_outside_near_vertex() {
1183        // Triangle far from origin: closest should be vertex A
1184        let a = [10.0, 0.0, 0.0];
1185        let b = [11.0, 0.0, 0.0];
1186        let c = [10.0, 1.0, 0.0];
1187        let p = closest_point_triangle_to_origin(a, b, c);
1188        assert!((p[0] - 10.0).abs() < 1e-10, "expected x~10, got {:?}", p);
1189    }
1190
1191    // ── gjk_distance_with_witnesses ───────────────────────────────────────────
1192
1193    #[test]
1194    fn test_gjk_distance_witnesses_separated_spheres() {
1195        let a = sphere(0.0, 0.0, 0.0, 1.0);
1196        let b = sphere(5.0, 0.0, 0.0, 1.0);
1197        let result = gjk_distance_with_witnesses(&a, &b);
1198        assert!(result.is_some(), "separated spheres should have a distance");
1199        let (dist, pa, pb) = result.unwrap();
1200        assert!(dist > 0.0, "distance should be positive");
1201        assert!(pa.iter().all(|x| x.is_finite()));
1202        assert!(pb.iter().all(|x| x.is_finite()));
1203    }
1204
1205    #[test]
1206    fn test_gjk_distance_witnesses_intersecting_returns_none() {
1207        let a = sphere(0.0, 0.0, 0.0, 1.0);
1208        let b = sphere(0.5, 0.0, 0.0, 1.0);
1209        assert!(gjk_distance_with_witnesses(&a, &b).is_none());
1210    }
1211
1212    // ── TransformedShape ─────────────────────────────────────────────────────
1213
1214    fn identity_rot() -> [[f64; 3]; 3] {
1215        [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
1216    }
1217
1218    #[test]
1219    fn test_transformed_shape_identity_matches_direct() {
1220        let s = sphere(0.0, 0.0, 0.0, 1.0);
1221        let t = TransformedShape {
1222            shape: &s,
1223            rotation: identity_rot(),
1224            translation: [0.0; 3],
1225        };
1226        let p_direct = s.support([1.0, 0.0, 0.0]);
1227        let p_trans = t.support([1.0, 0.0, 0.0]);
1228        assert!((p_direct[0] - p_trans[0]).abs() < 1e-10);
1229    }
1230
1231    #[test]
1232    fn test_transformed_shape_translation() {
1233        let s = sphere(0.0, 0.0, 0.0, 1.0);
1234        let t = TransformedShape {
1235            shape: &s,
1236            rotation: identity_rot(),
1237            translation: [3.0, 0.0, 0.0],
1238        };
1239        let p = t.support([1.0, 0.0, 0.0]);
1240        // sphere center at (3,0,0) with r=1 → support in +x is (4,0,0)
1241        assert!((p[0] - 4.0).abs() < 1e-10, "p={:?}", p);
1242    }
1243
1244    #[test]
1245    fn test_gjk_intersect_transformed_separated() {
1246        let s = sphere(0.0, 0.0, 0.0, 1.0);
1247        let intersects = gjk_intersect_transformed(
1248            &s,
1249            identity_rot(),
1250            [0.0; 3],
1251            &s,
1252            identity_rot(),
1253            [5.0, 0.0, 0.0],
1254        );
1255        assert!(!intersects);
1256    }
1257
1258    #[test]
1259    fn test_gjk_intersect_transformed_overlapping() {
1260        let s = sphere(0.0, 0.0, 0.0, 1.0);
1261        let intersects = gjk_intersect_transformed(
1262            &s,
1263            identity_rot(),
1264            [0.0; 3],
1265            &s,
1266            identity_rot(),
1267            [1.5, 0.0, 0.0],
1268        );
1269        assert!(intersects);
1270    }
1271
1272    // ── closest_points_between_shapes ────────────────────────────────────────
1273
1274    #[test]
1275    fn test_closest_points_separated_boxes() {
1276        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1277        let b = aabb(5.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1278        let cp = closest_points_between_shapes(&a, &b);
1279        assert!(cp.is_some(), "separated boxes should have closest points");
1280        let cp = cp.unwrap();
1281        assert!(cp.distance > 0.0);
1282        assert!(cp.point_a.iter().all(|x| x.is_finite()));
1283        assert!(cp.point_b.iter().all(|x| x.is_finite()));
1284    }
1285
1286    #[test]
1287    fn test_closest_points_intersecting_returns_none() {
1288        let a = aabb(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
1289        let b = aabb(0.5, 0.0, 0.0, 1.0, 1.0, 1.0);
1290        assert!(closest_points_between_shapes(&a, &b).is_none());
1291    }
1292
1293    // ── GjkConvexHull ─────────────────────────────────────────────────────────
1294
1295    #[test]
1296    fn test_convex_hull_support_in_x() {
1297        let hull = GjkConvexHull::new(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]);
1298        let p = hull.support([1.0, 0.0, 0.0]);
1299        assert!((p[0] - 2.0).abs() < 1e-12, "p={:?}", p);
1300    }
1301
1302    #[test]
1303    fn test_convex_hull_intersects_sphere() {
1304        // Tetrahedron hull centered near origin
1305        let hull = GjkConvexHull::new(vec![
1306            [1.0, 0.0, 0.0],
1307            [-1.0, 0.0, 0.0],
1308            [0.0, 1.0, 0.0],
1309            [0.0, 0.0, 1.0],
1310        ]);
1311        let s = sphere(0.0, 0.0, 0.0, 0.5);
1312        assert!(gjk_intersect(&hull, &s));
1313    }
1314
1315    // ── GjkCylinder ──────────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn test_cylinder_support_top() {
1319        let cyl = GjkCylinder {
1320            base: [0.0, 0.0, 0.0],
1321            axis: [0.0, 1.0, 0.0],
1322            height: 2.0,
1323            radius: 0.5,
1324        };
1325        // Direction straight up: support should be near (0, 2, 0) + radial
1326        let p = cyl.support([0.0, 1.0, 0.0]);
1327        assert!(p[1] >= 1.9, "top support y should be near 2, got {}", p[1]);
1328    }
1329
1330    #[test]
1331    fn test_cylinder_intersects_sphere_at_side() {
1332        let cyl = GjkCylinder {
1333            base: [0.0, 0.0, 0.0],
1334            axis: [0.0, 1.0, 0.0],
1335            height: 2.0,
1336            radius: 1.0,
1337        };
1338        let s = sphere(1.5, 1.0, 0.0, 1.0);
1339        assert!(gjk_intersect(&cyl, &s));
1340    }
1341
1342    #[test]
1343    fn test_cylinder_separated_from_sphere() {
1344        let cyl = GjkCylinder {
1345            base: [0.0, 0.0, 0.0],
1346            axis: [0.0, 1.0, 0.0],
1347            height: 1.0,
1348            radius: 0.5,
1349        };
1350        let s = sphere(10.0, 0.5, 0.0, 0.5);
1351        assert!(!gjk_intersect(&cyl, &s));
1352    }
1353
1354    // ── epa_face_count_lower_bound ────────────────────────────────────────────
1355
1356    #[test]
1357    fn test_epa_face_count_lower_bound() {
1358        assert_eq!(epa_face_count_lower_bound(0), 4);
1359        assert_eq!(epa_face_count_lower_bound(3), 10);
1360    }
1361
1362    // ── rotate_vec ────────────────────────────────────────────────────────────
1363
1364    #[test]
1365    fn test_rotate_vec_identity() {
1366        let v = [1.0, 2.0, 3.0];
1367        let r = rotate_vec([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], v);
1368        assert!((r[0] - v[0]).abs() < 1e-12);
1369        assert!((r[1] - v[1]).abs() < 1e-12);
1370        assert!((r[2] - v[2]).abs() < 1e-12);
1371    }
1372
1373    #[test]
1374    fn test_rotate_vec_90_around_z() {
1375        // Rotation 90° around Z: (1,0,0) → (0,1,0)
1376        let rot = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1377        let r = rotate_vec(rot, [1.0, 0.0, 0.0]);
1378        assert!((r[0]).abs() < 1e-12, "r[0]={}", r[0]);
1379        assert!((r[1] - 1.0).abs() < 1e-12, "r[1]={}", r[1]);
1380    }
1381}