Skip to main content

oxiphysics_collision/
sweep.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Swept-primitive collision queries.
5//!
6//! Provides analytic and conservative time-of-impact tests for common
7//! primitive pairs:
8//!
9//! - Sphere vs sphere (analytic)
10//! - Sphere vs AABB (conservative)
11//! - AABB vs AABB (conservative)
12//! - Ray vs sphere, box, triangle
13
14// ---------------------------------------------------------------------------
15// Math helpers
16// ---------------------------------------------------------------------------
17
18#[inline]
19fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
20    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
21}
22
23#[inline]
24fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
25    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
26}
27
28#[inline]
29fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
30    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
31}
32
33#[inline]
34fn scale(v: [f64; 3], s: f64) -> [f64; 3] {
35    [v[0] * s, v[1] * s, v[2] * s]
36}
37
38#[inline]
39fn len_sq(v: [f64; 3]) -> f64 {
40    dot(v, v)
41}
42
43#[inline]
44fn len(v: [f64; 3]) -> f64 {
45    len_sq(v).sqrt()
46}
47
48#[inline]
49fn normalize(v: [f64; 3]) -> [f64; 3] {
50    let l = len(v);
51    if l < 1e-300 {
52        [0.0, 0.0, 0.0]
53    } else {
54        scale(v, 1.0 / l)
55    }
56}
57
58#[inline]
59fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
60    [
61        a[1] * b[2] - a[2] * b[1],
62        a[2] * b[0] - a[0] * b[2],
63        a[0] * b[1] - a[1] * b[0],
64    ]
65}
66
67// ---------------------------------------------------------------------------
68// Public result types
69// ---------------------------------------------------------------------------
70
71/// Result of a swept-primitive time-of-impact query.
72#[derive(Debug, Clone, PartialEq)]
73pub struct SweepResult {
74    /// Time of impact in `[0, 1]` (fraction of the motion step).
75    pub toi: f64,
76    /// Contact normal at the time of impact (unit length, from B toward A).
77    pub normal: [f64; 3],
78    /// Contact point on shape A at the time of impact.
79    pub point_a: [f64; 3],
80    /// Contact point on shape B at the time of impact.
81    pub point_b: [f64; 3],
82}
83
84/// Result of a ray-cast query.
85#[derive(Debug, Clone, PartialEq)]
86pub struct RayHit {
87    /// Distance from the ray origin to the hit point.
88    pub t: f64,
89    /// Hit point in world space.
90    pub point: [f64; 3],
91    /// Surface normal at the hit point (unit length, pointing outward).
92    pub normal: [f64; 3],
93}
94
95// ---------------------------------------------------------------------------
96// swept_sphere_sphere
97// ---------------------------------------------------------------------------
98
99/// Analytic swept sphere–sphere time-of-impact.
100///
101/// `c0_a` / `c1_a` are the start and end centres of sphere A with radius `r_a`.
102/// `c0_b` / `c1_b` are the start and end centres of sphere B with radius `r_b`.
103///
104/// Returns the first `t ∈ [0, 1]` at which the spheres touch, or `None` if
105/// they do not collide during the step.
106pub fn swept_sphere_sphere(
107    c0_a: [f64; 3],
108    c1_a: [f64; 3],
109    r_a: f64,
110    c0_b: [f64; 3],
111    c1_b: [f64; 3],
112    r_b: f64,
113) -> Option<SweepResult> {
114    let r_sum = r_a + r_b;
115
116    // Relative displacement and velocity (b relative to a).
117    let dp = sub(c0_b, c0_a);
118    // Relative velocity: (c1_b - c0_b) - (c1_a - c0_a)
119    let dv = sub(sub(c1_b, c0_b), sub(c1_a, c0_a));
120
121    // |dp + t*dv|² = r_sum²
122    // a*t² + 2*b*t + (c - r²) = 0
123    let a = len_sq(dv);
124    let b = dot(dp, dv);
125    let c = len_sq(dp) - r_sum * r_sum;
126
127    // Already touching / overlapping at t=0
128    if c <= 0.0 {
129        let normal = if len_sq(dp) > 1e-20 {
130            normalize(scale(dp, -1.0))
131        } else {
132            [0.0, 1.0, 0.0]
133        };
134        let contact = add(c0_a, scale(normal, -r_a));
135        return Some(SweepResult {
136            toi: 0.0,
137            normal,
138            point_a: contact,
139            point_b: contact,
140        });
141    }
142
143    if a < 1e-300 {
144        // No relative motion.
145        return None;
146    }
147
148    let disc = b * b - a * c;
149    if disc < 0.0 {
150        return None; // Trajectories never intersect.
151    }
152
153    let sqrt_disc = disc.sqrt();
154    let t = (-b - sqrt_disc) / a;
155    let t = if (0.0..=1.0).contains(&t) {
156        t
157    } else {
158        let t2 = (-b + sqrt_disc) / a;
159        if (0.0..=1.0).contains(&t2) {
160            t2
161        } else {
162            return None;
163        }
164    };
165
166    // Positions at t
167    let pos_a = add(c0_a, scale(sub(c1_a, c0_a), t));
168    let pos_b = add(c0_b, scale(sub(c1_b, c0_b), t));
169    let d = sub(pos_b, pos_a);
170    let dist = len(d);
171    let normal = if dist > 1e-10 {
172        scale(d, 1.0 / dist)
173    } else {
174        [0.0, 1.0, 0.0]
175    };
176
177    Some(SweepResult {
178        toi: t,
179        normal,
180        point_a: add(pos_a, scale(normal, r_a)),
181        point_b: add(pos_b, scale(normal, -r_b)),
182    })
183}
184
185// ---------------------------------------------------------------------------
186// swept_sphere_aabb  (conservative)
187// ---------------------------------------------------------------------------
188
189/// Conservative swept sphere vs AABB time-of-impact.
190///
191/// The sphere moves from `c0` to `c1` with radius `r`.  The AABB is static
192/// and defined by its min/max corners `aabb_min`/`aabb_max`.
193///
194/// Uses the Minkowski-sum approach: expand the AABB by `r` on every face,
195/// then intersect the segment `[c0, c1]` against the expanded AABB.
196pub fn swept_sphere_aabb(
197    c0: [f64; 3],
198    c1: [f64; 3],
199    r: f64,
200    aabb_min: [f64; 3],
201    aabb_max: [f64; 3],
202) -> Option<SweepResult> {
203    // Expand AABB by sphere radius (Minkowski sum with sphere = box rounded by r).
204    // For a conservative test we expand by r on each face.
205    let exp_min = [aabb_min[0] - r, aabb_min[1] - r, aabb_min[2] - r];
206    let exp_max = [aabb_max[0] + r, aabb_max[1] + r, aabb_max[2] + r];
207
208    // Ray–AABB slab intersection.
209    let dir = sub(c1, c0);
210    let result = ray_aabb_slab(c0, dir, exp_min, exp_max, 0.0, 1.0)?;
211
212    // Reconstruct the hit position on the sphere and the actual surface normal.
213    let hit_center = add(c0, scale(dir, result.t));
214
215    // Find the closest point on the original (un-expanded) AABB.
216    let closest_on_aabb = closest_point_on_aabb(hit_center, aabb_min, aabb_max);
217    let diff = sub(hit_center, closest_on_aabb);
218    let dist = len(diff);
219    let normal = if dist > 1e-10 {
220        normalize(diff)
221    } else {
222        result.normal
223    };
224
225    Some(SweepResult {
226        toi: result.t,
227        normal,
228        point_a: add(hit_center, scale(normal, -r)),
229        point_b: closest_on_aabb,
230    })
231}
232
233// ---------------------------------------------------------------------------
234// swept_aabb_aabb  (conservative)
235// ---------------------------------------------------------------------------
236
237/// Conservative swept AABB vs static AABB time-of-impact.
238///
239/// `min_a`/`max_a` are the initial bounds of the moving AABB A.
240/// `vel` is the displacement vector of A (i.e., `A` moves by `vel` over `t=1`).
241/// `min_b`/`max_b` are the bounds of the static AABB B.
242///
243/// Uses the Minkowski-sum approach: expand B by the half-extents of A, then
244/// cast the point trajectory of A's centre through the expanded B.
245pub fn swept_aabb_aabb(
246    min_a: [f64; 3],
247    max_a: [f64; 3],
248    vel: [f64; 3],
249    min_b: [f64; 3],
250    max_b: [f64; 3],
251) -> Option<SweepResult> {
252    // Half-extents of A.
253    let half_a = [
254        (max_a[0] - min_a[0]) * 0.5,
255        (max_a[1] - min_a[1]) * 0.5,
256        (max_a[2] - min_a[2]) * 0.5,
257    ];
258    // Centre of A at t=0.
259    let center_a = [
260        (min_a[0] + max_a[0]) * 0.5,
261        (min_a[1] + max_a[1]) * 0.5,
262        (min_a[2] + max_a[2]) * 0.5,
263    ];
264
265    // Minkowski-expanded B (by A's half-extents).
266    let exp_min = [
267        min_b[0] - half_a[0],
268        min_b[1] - half_a[1],
269        min_b[2] - half_a[2],
270    ];
271    let exp_max = [
272        max_b[0] + half_a[0],
273        max_b[1] + half_a[1],
274        max_b[2] + half_a[2],
275    ];
276
277    // Ray–AABB slab test with centre of A sweeping by vel.
278    let result = ray_aabb_slab(center_a, vel, exp_min, exp_max, 0.0, 1.0)?;
279
280    // Hit position of A's centre.
281    let hit_center_a = add(center_a, scale(vel, result.t));
282
283    // Clamp to get the closest face point on B.
284    let contact_b = closest_point_on_aabb(hit_center_a, min_b, max_b);
285
286    Some(SweepResult {
287        toi: result.t,
288        normal: result.normal,
289        point_a: add(
290            hit_center_a,
291            scale(result.normal, -half_a[0].max(half_a[1]).max(half_a[2])),
292        ),
293        point_b: contact_b,
294    })
295}
296
297// ---------------------------------------------------------------------------
298// Ray vs sphere
299// ---------------------------------------------------------------------------
300
301/// Ray–sphere intersection.
302///
303/// The ray starts at `origin` and travels in direction `dir` (need not be unit
304/// length; parametric distance is returned in units of `|dir|`).
305/// Returns the first hit with `t ∈ [t_min, t_max]`.
306pub fn ray_sphere(
307    origin: [f64; 3],
308    dir: [f64; 3],
309    center: [f64; 3],
310    radius: f64,
311    t_min: f64,
312    t_max: f64,
313) -> Option<RayHit> {
314    let oc = sub(origin, center);
315    let a = len_sq(dir);
316    let b = dot(oc, dir);
317    let c = len_sq(oc) - radius * radius;
318
319    let disc = b * b - a * c;
320    if disc < 0.0 {
321        return None;
322    }
323
324    let sqrt_disc = disc.sqrt();
325    let t = {
326        let t1 = (-b - sqrt_disc) / a;
327        if t1 >= t_min && t1 <= t_max {
328            t1
329        } else {
330            let t2 = (-b + sqrt_disc) / a;
331            if t2 >= t_min && t2 <= t_max {
332                t2
333            } else {
334                return None;
335            }
336        }
337    };
338
339    let point = add(origin, scale(dir, t));
340    let normal = normalize(sub(point, center));
341
342    Some(RayHit { t, point, normal })
343}
344
345// ---------------------------------------------------------------------------
346// Ray vs AABB
347// ---------------------------------------------------------------------------
348
349/// Ray–AABB intersection using the slab method.
350///
351/// Returns the first hit with `t ∈ [t_min, t_max]`.
352pub fn ray_aabb(
353    origin: [f64; 3],
354    dir: [f64; 3],
355    aabb_min: [f64; 3],
356    aabb_max: [f64; 3],
357    t_min: f64,
358    t_max: f64,
359) -> Option<RayHit> {
360    ray_aabb_slab(origin, dir, aabb_min, aabb_max, t_min, t_max)
361}
362
363/// Internal slab-method AABB intersection returning a `RayHit`.
364fn ray_aabb_slab(
365    origin: [f64; 3],
366    dir: [f64; 3],
367    aabb_min: [f64; 3],
368    aabb_max: [f64; 3],
369    t_min: f64,
370    t_max: f64,
371) -> Option<RayHit> {
372    let mut t_near = t_min;
373    let mut t_far = t_max;
374    let mut hit_axis = 0usize;
375    let mut hit_sign = 1.0_f64;
376
377    for i in 0..3 {
378        if dir[i].abs() < 1e-300 {
379            // Ray is parallel to slab.
380            if origin[i] < aabb_min[i] || origin[i] > aabb_max[i] {
381                return None; // Ray is outside this slab.
382            }
383        } else {
384            let inv = 1.0 / dir[i];
385            let mut t1 = (aabb_min[i] - origin[i]) * inv;
386            let mut t2 = (aabb_max[i] - origin[i]) * inv;
387            let sign = if t1 <= t2 { -1.0_f64 } else { 1.0_f64 };
388            if t1 > t2 {
389                std::mem::swap(&mut t1, &mut t2);
390            }
391            if t1 > t_near {
392                t_near = t1;
393                hit_axis = i;
394                hit_sign = sign;
395            }
396            if t2 < t_far {
397                t_far = t2;
398            }
399            if t_near > t_far {
400                return None;
401            }
402        }
403    }
404
405    if t_near > t_far || t_near > t_max || t_far < t_min {
406        return None;
407    }
408
409    let t = if t_near >= t_min { t_near } else { t_far };
410    if t < t_min || t > t_max {
411        return None;
412    }
413
414    let point = add(origin, scale(dir, t));
415    let mut normal = [0.0f64; 3];
416    normal[hit_axis] = hit_sign;
417
418    Some(RayHit { t, point, normal })
419}
420
421// ---------------------------------------------------------------------------
422// Ray vs triangle (Möller–Trumbore)
423// ---------------------------------------------------------------------------
424
425/// Ray–triangle intersection using the Möller–Trumbore algorithm.
426///
427/// The triangle is defined by vertices `v0`, `v1`, `v2` (counter-clockwise
428/// winding defines the outward normal). Returns a hit for `t ∈ [t_min, t_max]`.
429pub fn ray_triangle(
430    origin: [f64; 3],
431    dir: [f64; 3],
432    v0: [f64; 3],
433    v1: [f64; 3],
434    v2: [f64; 3],
435    t_min: f64,
436    t_max: f64,
437) -> Option<RayHit> {
438    let e1 = sub(v1, v0);
439    let e2 = sub(v2, v0);
440
441    let h = cross(dir, e2);
442    let det = dot(e1, h);
443
444    // Back-face culling: |det| < epsilon means ray is parallel.
445    if det.abs() < 1e-10 {
446        return None;
447    }
448
449    let inv_det = 1.0 / det;
450    let s = sub(origin, v0);
451    let u = dot(s, h) * inv_det;
452    if !(0.0..=1.0).contains(&u) {
453        return None;
454    }
455
456    let q = cross(s, e1);
457    let v = dot(dir, q) * inv_det;
458    if v < 0.0 || u + v > 1.0 {
459        return None;
460    }
461
462    let t = dot(e2, q) * inv_det;
463    if t < t_min || t > t_max {
464        return None;
465    }
466
467    let point = add(origin, scale(dir, t));
468    let normal = normalize(cross(e1, e2));
469
470    Some(RayHit { t, point, normal })
471}
472
473// ---------------------------------------------------------------------------
474// Closest point helpers
475// ---------------------------------------------------------------------------
476
477/// Closest point on AABB to a query point.
478fn closest_point_on_aabb(pt: [f64; 3], aabb_min: [f64; 3], aabb_max: [f64; 3]) -> [f64; 3] {
479    [
480        pt[0].clamp(aabb_min[0], aabb_max[0]),
481        pt[1].clamp(aabb_min[1], aabb_max[1]),
482        pt[2].clamp(aabb_min[2], aabb_max[2]),
483    ]
484}
485
486/// Squared distance from a point to an AABB.
487pub fn point_aabb_dist_sq(pt: [f64; 3], aabb_min: [f64; 3], aabb_max: [f64; 3]) -> f64 {
488    let mut dist_sq = 0.0_f64;
489    for i in 0..3 {
490        if pt[i] < aabb_min[i] {
491            let d = aabb_min[i] - pt[i];
492            dist_sq += d * d;
493        } else if pt[i] > aabb_max[i] {
494            let d = pt[i] - aabb_max[i];
495            dist_sq += d * d;
496        }
497    }
498    dist_sq
499}
500
501/// Test whether a sphere overlaps an AABB.
502pub fn sphere_aabb_overlap(
503    center: [f64; 3],
504    radius: f64,
505    aabb_min: [f64; 3],
506    aabb_max: [f64; 3],
507) -> bool {
508    point_aabb_dist_sq(center, aabb_min, aabb_max) <= radius * radius
509}
510
511/// Test whether two AABBs overlap.
512pub fn aabb_aabb_overlap(
513    min_a: [f64; 3],
514    max_a: [f64; 3],
515    min_b: [f64; 3],
516    max_b: [f64; 3],
517) -> bool {
518    min_a[0] <= max_b[0]
519        && max_a[0] >= min_b[0]
520        && min_a[1] <= max_b[1]
521        && max_a[1] >= min_b[1]
522        && min_a[2] <= max_b[2]
523        && max_a[2] >= min_b[2]
524}
525
526// ---------------------------------------------------------------------------
527// Swept capsule vs static plane
528// ---------------------------------------------------------------------------
529
530/// Swept capsule vs an infinite static plane (`n·x = d`).
531///
532/// The capsule sweeps from `c0` to `c1` with the given `half_height` (along Y)
533/// and `radius`.  Returns the first `t ∈ [0, 1]` at which the capsule surface
534/// first touches the plane, or `None` if no contact.
535pub fn swept_capsule_plane(
536    c0: [f64; 3],
537    c1: [f64; 3],
538    radius: f64,
539    half_height: f64,
540    plane_normal: [f64; 3],
541    plane_d: f64,
542) -> Option<SweepResult> {
543    let n = normalize(plane_normal);
544    // The lowest point of the capsule along n at t=0 is the support point.
545    // Support: min_dist = dot(n, centre_bottom) - radius
546    // centre_bottom = c0 - n*(half_height) (if n is roughly up)
547    // Conservative: treat the capsule as a sphere of radius (radius + half_height) for the sweep.
548    let effective_r = radius + half_height;
549    let dist0 = dot(n, c0) - plane_d - effective_r;
550
551    if dist0 <= 0.0 {
552        return Some(SweepResult {
553            toi: 0.0,
554            normal: n,
555            point_a: c0,
556            point_b: add(c0, scale(n, -dist0)),
557        });
558    }
559
560    let disp = sub(c1, c0);
561    let closing = -dot(n, disp);
562
563    if closing <= 1e-12 {
564        return None;
565    }
566
567    let t = dist0 / closing;
568    if t > 1.0 {
569        return None;
570    }
571
572    let hit_pos = add(c0, scale(disp, t));
573    Some(SweepResult {
574        toi: t,
575        normal: n,
576        point_a: add(hit_pos, scale(n, -effective_r)),
577        point_b: add(hit_pos, scale(n, -effective_r)),
578    })
579}
580
581// ---------------------------------------------------------------------------
582// TOI refinement for sphere-sphere via bisection
583// ---------------------------------------------------------------------------
584
585/// Refine the time of impact for two moving spheres using bisection.
586///
587/// `t_lo` and `t_hi` must bracket the contact: at `t_lo` the spheres are
588/// separated, at `t_hi` they overlap (or just touch).  Returns the refined
589/// TOI within `[t_lo, t_hi]`.
590pub fn refine_sphere_sphere_toi(
591    c0_a: [f64; 3],
592    vel_a: [f64; 3],
593    r_a: f64,
594    c0_b: [f64; 3],
595    vel_b: [f64; 3],
596    r_b: f64,
597    t_lo: f64,
598    t_hi: f64,
599    tolerance: f64,
600    max_iter: usize,
601) -> f64 {
602    let r_sum = r_a + r_b;
603    let mut lo = t_lo;
604    let mut hi = t_hi;
605
606    for _ in 0..max_iter {
607        if hi - lo < tolerance {
608            break;
609        }
610        let mid = (lo + hi) * 0.5;
611        let pa = add(c0_a, scale(vel_a, mid));
612        let pb = add(c0_b, scale(vel_b, mid));
613        let d = len(sub(pa, pb));
614        if d <= r_sum {
615            hi = mid;
616        } else {
617            lo = mid;
618        }
619    }
620    (lo + hi) * 0.5
621}
622
623// ---------------------------------------------------------------------------
624// Sweep broadphase: collect candidate pairs from a list of swept spheres
625// ---------------------------------------------------------------------------
626
627/// A swept sphere description for broadphase sweeping.
628#[derive(Debug, Clone)]
629pub struct SweptSphere {
630    /// Start position.
631    pub pos0: [f64; 3],
632    /// End position.
633    pub pos1: [f64; 3],
634    /// Radius.
635    pub radius: f64,
636    /// User-supplied ID.
637    pub id: u32,
638}
639
640impl SweptSphere {
641    /// Create a new swept sphere.
642    pub fn new(pos0: [f64; 3], pos1: [f64; 3], radius: f64, id: u32) -> Self {
643        Self {
644            pos0,
645            pos1,
646            radius,
647            id,
648        }
649    }
650
651    /// AABB bounding the entire sweep.
652    pub fn sweep_aabb(&self) -> ([f64; 3], [f64; 3]) {
653        let mut mn = [0.0f64; 3];
654        let mut mx = [0.0f64; 3];
655        for i in 0..3 {
656            mn[i] = self.pos0[i].min(self.pos1[i]) - self.radius;
657            mx[i] = self.pos0[i].max(self.pos1[i]) + self.radius;
658        }
659        (mn, mx)
660    }
661
662    /// Test if the sweep AABB of this sphere overlaps with another.
663    pub fn sweep_aabb_overlaps(&self, other: &SweptSphere) -> bool {
664        let (amn, amx) = self.sweep_aabb();
665        let (bmn, bmx) = other.sweep_aabb();
666        amn[0] <= bmx[0]
667            && amx[0] >= bmn[0]
668            && amn[1] <= bmx[1]
669            && amx[1] >= bmn[1]
670            && amn[2] <= bmx[2]
671            && amx[2] >= bmn[2]
672    }
673}
674
675/// Broadphase sweep: O(n²) candidate pair collection using sweep AABBs.
676///
677/// Returns pairs `(id_a, id_b)` with `id_a < id_b` whose sweep AABBs overlap.
678pub fn sweep_broadphase(spheres: &[SweptSphere]) -> Vec<(u32, u32)> {
679    let mut pairs = Vec::new();
680    for i in 0..spheres.len() {
681        for j in (i + 1)..spheres.len() {
682            if spheres[i].sweep_aabb_overlaps(&spheres[j]) {
683                let (a, b) = if spheres[i].id < spheres[j].id {
684                    (spheres[i].id, spheres[j].id)
685                } else {
686                    (spheres[j].id, spheres[i].id)
687                };
688                pairs.push((a, b));
689            }
690        }
691    }
692    pairs
693}
694
695// ---------------------------------------------------------------------------
696// Time-interval sweep: TOI within [t_start, t_end]
697// ---------------------------------------------------------------------------
698
699/// Swept sphere-sphere test within a restricted time interval `[t_start, t_end]`.
700///
701/// `c0_a` is the position of sphere A at `t=0`; `vel_a` its velocity.
702/// The TOI is remapped to `[t_start, t_end]` before evaluation.
703///
704/// Returns the TOI in `[0, 1]` (fraction of the full interval) or `None`.
705pub fn swept_sphere_sphere_interval(
706    c0_a: [f64; 3],
707    vel_a: [f64; 3],
708    r_a: f64,
709    c0_b: [f64; 3],
710    vel_b: [f64; 3],
711    r_b: f64,
712    t_start: f64,
713    t_end: f64,
714) -> Option<SweepResult> {
715    // Advance positions to t_start, then test sweep from t_start to t_end.
716    let a0 = add(c0_a, scale(vel_a, t_start));
717    let a1 = add(c0_a, scale(vel_a, t_end));
718    let b0 = add(c0_b, scale(vel_b, t_start));
719    let b1 = add(c0_b, scale(vel_b, t_end));
720    swept_sphere_sphere(a0, a1, r_a, b0, b1, r_b)
721}
722
723// ---------------------------------------------------------------------------
724// Compound shape sweep: find earliest TOI among all child shapes
725// ---------------------------------------------------------------------------
726
727/// A compound swept shape made of multiple spheres (simplified).
728pub struct CompoundSweptShape {
729    /// Child spheres with local offsets relative to the compound centre.
730    pub spheres: Vec<([f64; 3], f64)>, // (local_offset, radius)
731}
732
733impl CompoundSweptShape {
734    /// Create a new compound swept shape.
735    pub fn new(spheres: Vec<([f64; 3], f64)>) -> Self {
736        Self { spheres }
737    }
738
739    /// Sweep this compound shape from `pos0` to `pos1` against another compound
740    /// shape sweeping from `other_pos0` to `other_pos1`.
741    ///
742    /// Returns the earliest `SweepResult` or `None` if no collision.
743    pub fn sweep_vs_compound(
744        &self,
745        pos0: [f64; 3],
746        pos1: [f64; 3],
747        other: &CompoundSweptShape,
748        other_pos0: [f64; 3],
749        other_pos1: [f64; 3],
750    ) -> Option<SweepResult> {
751        let mut best: Option<SweepResult> = None;
752
753        for (off_a, r_a) in &self.spheres {
754            let a0 = add(pos0, *off_a);
755            let a1 = add(pos1, *off_a);
756            for (off_b, r_b) in &other.spheres {
757                let b0 = add(other_pos0, *off_b);
758                let b1 = add(other_pos1, *off_b);
759                if let Some(result) = swept_sphere_sphere(a0, a1, *r_a, b0, b1, *r_b)
760                    && best.as_ref().is_none_or(|br| result.toi < br.toi)
761                {
762                    best = Some(result);
763                }
764            }
765        }
766        best
767    }
768}
769
770// ---------------------------------------------------------------------------
771// Swept capsule vs static AABB
772// ---------------------------------------------------------------------------
773
774/// Swept capsule vs static AABB time-of-impact.
775///
776/// The capsule is defined by its axis endpoints `c0`/`c1` (swept from start to end)
777/// and `radius`. The AABB is static. Uses an expanded AABB approach by inflating
778/// the box by `radius` on each face and testing the swept segment axis.
779pub fn swept_capsule_aabb(
780    axis_start_0: [f64; 3],
781    axis_start_1: [f64; 3],
782    axis_end_0: [f64; 3],
783    axis_end_1: [f64; 3],
784    radius: f64,
785    aabb_min: [f64; 3],
786    aabb_max: [f64; 3],
787) -> Option<SweepResult> {
788    // Use the midpoint of the capsule axis as a representative sweep trajectory.
789    let mid_start = scale(add(axis_start_0, axis_start_1), 0.5);
790    let mid_end = scale(add(axis_end_0, axis_end_1), 0.5);
791    // Half-length of capsule (contributes to effective radius)
792    let half_len = len(scale(sub(axis_start_1, axis_start_0), 0.5));
793    let effective_radius = radius + half_len;
794    // Expand AABB by effective radius
795    let exp_min = [
796        aabb_min[0] - effective_radius,
797        aabb_min[1] - effective_radius,
798        aabb_min[2] - effective_radius,
799    ];
800    let exp_max = [
801        aabb_max[0] + effective_radius,
802        aabb_max[1] + effective_radius,
803        aabb_max[2] + effective_radius,
804    ];
805    let dir = sub(mid_end, mid_start);
806    let hit = ray_aabb_slab(mid_start, dir, exp_min, exp_max, 0.0, 1.0)?;
807    let hit_center = add(mid_start, scale(dir, hit.t));
808    let closest_on_aabb = closest_point_on_aabb(hit_center, aabb_min, aabb_max);
809    let diff = sub(hit_center, closest_on_aabb);
810    let d = len(diff);
811    let normal = if d > 1e-10 {
812        normalize(diff)
813    } else {
814        hit.normal
815    };
816    Some(SweepResult {
817        toi: hit.t,
818        normal,
819        point_a: add(hit_center, scale(normal, -effective_radius)),
820        point_b: closest_on_aabb,
821    })
822}
823
824// ---------------------------------------------------------------------------
825// Linear CCD pair test
826// ---------------------------------------------------------------------------
827
828/// Linear continuous collision detection between two moving spheres.
829///
830/// `pos_a`/`vel_a`: position and velocity of sphere A.
831/// `pos_b`/`vel_b`: position and velocity of sphere B.
832/// `dt`: time step.
833///
834/// Returns the time of impact within `[0, dt]` or `None` if no collision.
835pub fn linear_ccd_sphere_sphere(
836    pos_a: [f64; 3],
837    vel_a: [f64; 3],
838    r_a: f64,
839    pos_b: [f64; 3],
840    vel_b: [f64; 3],
841    r_b: f64,
842    dt: f64,
843) -> Option<SweepResult> {
844    let c0_a = pos_a;
845    let c1_a = add(pos_a, scale(vel_a, dt));
846    let c0_b = pos_b;
847    let c1_b = add(pos_b, scale(vel_b, dt));
848    let result = swept_sphere_sphere(c0_a, c1_a, r_a, c0_b, c1_b, r_b)?;
849    // Scale toi back to real time
850    Some(SweepResult {
851        toi: result.toi * dt,
852        ..result
853    })
854}
855
856// ---------------------------------------------------------------------------
857// TOI binary search refinement (generic distance function)
858// ---------------------------------------------------------------------------
859
860/// Refine a time-of-impact by bisection using a generic distance function.
861///
862/// `dist_fn(t)` should return the signed distance (negative = overlapping)
863/// at time `t`. `t_lo` has `dist_fn(t_lo) >= 0`, `t_hi` has `dist_fn(t_hi) <= 0`.
864/// Returns the refined TOI.
865pub fn bisect_toi<F>(
866    mut t_lo: f64,
867    mut t_hi: f64,
868    tolerance: f64,
869    max_iter: usize,
870    dist_fn: F,
871) -> f64
872where
873    F: Fn(f64) -> f64,
874{
875    for _ in 0..max_iter {
876        if t_hi - t_lo < tolerance {
877            break;
878        }
879        let mid = (t_lo + t_hi) * 0.5;
880        if dist_fn(mid) >= 0.0 {
881            t_lo = mid;
882        } else {
883            t_hi = mid;
884        }
885    }
886    (t_lo + t_hi) * 0.5
887}
888
889// ---------------------------------------------------------------------------
890// Conservative advancement for rotating bodies
891// ---------------------------------------------------------------------------
892
893/// A rigid body state for conservative advancement.
894#[derive(Debug, Clone)]
895pub struct RotatingBodyState {
896    /// Center of mass position.
897    pub position: [f64; 3],
898    /// Linear velocity.
899    pub linear_vel: [f64; 3],
900    /// Angular speed (magnitude of angular velocity vector), radians/s.
901    pub angular_speed: f64,
902    /// Bounding sphere radius (enclosing the entire body).
903    pub bound_radius: f64,
904}
905
906impl RotatingBodyState {
907    /// Create a new rotating body state.
908    pub fn new(
909        position: [f64; 3],
910        linear_vel: [f64; 3],
911        angular_speed: f64,
912        bound_radius: f64,
913    ) -> Self {
914        Self {
915            position,
916            linear_vel,
917            angular_speed,
918            bound_radius,
919        }
920    }
921
922    /// Upper bound on how far any point on this body can move in time `dt`.
923    ///
924    /// Uses the formula: `|v_linear| * dt + angular_speed * bound_radius * dt`.
925    pub fn motion_bound(&self, dt: f64) -> f64 {
926        let linear_speed = len(self.linear_vel);
927        (linear_speed + self.angular_speed * self.bound_radius) * dt
928    }
929
930    /// Advance position by `dt`.
931    pub fn advance(&self, dt: f64) -> [f64; 3] {
932        add(self.position, scale(self.linear_vel, dt))
933    }
934}
935
936/// Conservative advancement TOI for two rotating bodies.
937///
938/// Iteratively advances time until the bounding spheres touch, using
939/// motion bounds to ensure conservative (never-tunneling) steps.
940///
941/// Returns the TOI in `[0, 1]` or `None` if the bodies never collide.
942pub fn conservative_advancement_rotating(
943    a: &RotatingBodyState,
944    b: &RotatingBodyState,
945    separation: f64,
946    t_start: f64,
947    t_end: f64,
948    tolerance: f64,
949    max_iter: usize,
950) -> Option<f64> {
951    let total_dt = t_end - t_start;
952    if total_dt <= 0.0 {
953        return None;
954    }
955    let sum_r = a.bound_radius + b.bound_radius;
956    let init_dist = len(sub(b.position, a.position)) - sum_r;
957    if init_dist <= 0.0 {
958        return Some(t_start);
959    }
960    if separation < 0.0 {
961        return Some(t_start);
962    }
963
964    let mut t = t_start;
965    let mut remaining_sep = separation.max(init_dist);
966
967    for _ in 0..max_iter {
968        if remaining_sep <= tolerance {
969            return Some(t);
970        }
971        let remaining_dt = t_end - t;
972        if remaining_dt <= 0.0 {
973            break;
974        }
975        let motion_a = a.motion_bound(remaining_dt);
976        let motion_b = b.motion_bound(remaining_dt);
977        let total_motion = motion_a + motion_b;
978        if total_motion < 1e-14 {
979            break;
980        }
981        let dt_step = (remaining_sep / total_motion).min(remaining_dt);
982        t += dt_step;
983        // Advance positions
984        let pos_a_t = a.advance(t - t_start);
985        let pos_b_t = b.advance(t - t_start);
986        let new_dist = len(sub(pos_b_t, pos_a_t)) - sum_r;
987        if new_dist <= tolerance {
988            return Some(t);
989        }
990        remaining_sep = new_dist;
991    }
992    None
993}
994
995// ---------------------------------------------------------------------------
996// Swept sphere query against a list of triangles
997// ---------------------------------------------------------------------------
998
999/// Result of a swept sphere vs triangle mesh query.
1000#[derive(Debug, Clone)]
1001pub struct TriangleMeshSweepResult {
1002    /// Time of impact.
1003    pub toi: f64,
1004    /// Triangle index that was hit.
1005    pub tri_index: usize,
1006    /// Contact normal.
1007    pub normal: [f64; 3],
1008    /// Contact point on the sphere.
1009    pub point_a: [f64; 3],
1010    /// Contact point on the triangle.
1011    pub point_b: [f64; 3],
1012}
1013
1014/// Sweep a sphere against a list of triangles and return the earliest impact.
1015///
1016/// Each triangle is `[v0, v1, v2]` as `[[f64; 3\]; 3]`.
1017pub fn swept_sphere_triangle_mesh(
1018    c0: [f64; 3],
1019    c1: [f64; 3],
1020    radius: f64,
1021    triangles: &[[[f64; 3]; 3]],
1022) -> Option<TriangleMeshSweepResult> {
1023    let mut best: Option<TriangleMeshSweepResult> = None;
1024
1025    for (i, tri) in triangles.iter().enumerate() {
1026        let v0 = tri[0];
1027        let v1 = tri[1];
1028        let v2 = tri[2];
1029
1030        // Use the triangle normal to create an offset plane sweep
1031        let e1 = sub(v1, v0);
1032        let e2 = sub(v2, v0);
1033        let raw_normal = cross(e1, e2);
1034        if len(raw_normal) < 1e-12 {
1035            continue;
1036        }
1037        let tri_normal_raw = normalize(raw_normal);
1038
1039        // Try both sides of the triangle (double-sided)
1040        // Use the side the sphere is approaching from
1041        let approach_normal = if dot(tri_normal_raw, sub(c0, v0)) >= 0.0 {
1042            tri_normal_raw
1043        } else {
1044            scale(tri_normal_raw, -1.0)
1045        };
1046
1047        let plane_d = dot(approach_normal, v0) + radius;
1048
1049        // Intersect the sphere centre with the offset plane
1050        let d0 = dot(approach_normal, c0) - plane_d;
1051        let d1 = dot(approach_normal, c1) - plane_d;
1052
1053        if d0 < 0.0 || (d0 - d1).abs() < 1e-14 {
1054            continue;
1055        }
1056
1057        let t = d0 / (d0 - d1);
1058        if !(0.0..=1.0).contains(&t) {
1059            continue;
1060        }
1061
1062        // Position of sphere centre at impact
1063        let dir = sub(c1, c0);
1064        let center_t = add(c0, scale(dir, t));
1065
1066        // Find closest point on triangle to sphere centre projection onto triangle plane
1067        let proj = sub(
1068            center_t,
1069            scale(approach_normal, dot(approach_normal, sub(center_t, v0))),
1070        );
1071        // Clamp to triangle using edge tests (simplified: use the closest-point algorithm)
1072        let closest_on_tri = closest_point_on_tri(v0, v1, v2, proj);
1073        let diff = sub(center_t, closest_on_tri);
1074        let d = len(diff);
1075        if d > radius + 1e-6 {
1076            continue;
1077        }
1078
1079        let normal = if d > 1e-10 {
1080            normalize(diff)
1081        } else {
1082            approach_normal
1083        };
1084
1085        let result = TriangleMeshSweepResult {
1086            toi: t,
1087            tri_index: i,
1088            normal,
1089            point_a: add(center_t, scale(normal, -radius)),
1090            point_b: closest_on_tri,
1091        };
1092
1093        if best.as_ref().is_none_or(|br| t < br.toi) {
1094            best = Some(result);
1095        }
1096    }
1097
1098    best
1099}
1100
1101/// Closest point on a triangle (inline helper for sweep.rs).
1102fn closest_point_on_tri(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3], p: [f64; 3]) -> [f64; 3] {
1103    let ab = sub(v1, v0);
1104    let ac = sub(v2, v0);
1105    let ap = sub(p, v0);
1106    let d1 = dot(ab, ap);
1107    let d2 = dot(ac, ap);
1108    if d1 <= 0.0 && d2 <= 0.0 {
1109        return v0;
1110    }
1111    let bp = sub(p, v1);
1112    let d3 = dot(ab, bp);
1113    let d4 = dot(ac, bp);
1114    if d3 >= 0.0 && d4 <= d3 {
1115        return v1;
1116    }
1117    let vc = d1 * d4 - d3 * d2;
1118    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
1119        let v = d1 / (d1 - d3);
1120        return add(v0, scale(ab, v));
1121    }
1122    let cp = sub(p, v2);
1123    let d5 = dot(ab, cp);
1124    let d6 = dot(ac, cp);
1125    if d6 >= 0.0 && d5 <= d6 {
1126        return v2;
1127    }
1128    let vb = d5 * d2 - d1 * d6;
1129    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
1130        let w = d2 / (d2 - d6);
1131        return add(v0, scale(ac, w));
1132    }
1133    let va = d3 * d6 - d5 * d4;
1134    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
1135        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
1136        return add(v1, scale(sub(v2, v1), w));
1137    }
1138    let denom = 1.0 / (va + vb + vc);
1139    let v = vb * denom;
1140    let w = vc * denom;
1141    add(add(v0, scale(ab, v)), scale(ac, w))
1142}
1143
1144// ---------------------------------------------------------------------------
1145// Sphere-capsule sweep
1146// ---------------------------------------------------------------------------
1147
1148/// Swept sphere vs static capsule time-of-impact.
1149///
1150/// The sphere sweeps from `c0_s` to `c1_s` with `r_s`. The capsule is static
1151/// with axis `(cap0, cap1)` and `r_c`. Reduces to swept-sphere-sphere using
1152/// the closest point on the capsule axis.
1153pub fn swept_sphere_capsule(
1154    c0_s: [f64; 3],
1155    c1_s: [f64; 3],
1156    r_s: f64,
1157    cap0: [f64; 3],
1158    cap1: [f64; 3],
1159    r_c: f64,
1160) -> Option<SweepResult> {
1161    // Find the closest point on the capsule axis to the midpoint of the sphere sweep
1162    let mid_sphere = scale(add(c0_s, c1_s), 0.5);
1163    let axis = sub(cap1, cap0);
1164    let axis_len_sq = dot(axis, axis);
1165    let t = if axis_len_sq < 1e-20 {
1166        0.0
1167    } else {
1168        (dot(sub(mid_sphere, cap0), axis) / axis_len_sq).clamp(0.0, 1.0)
1169    };
1170    let cap_closest = add(cap0, scale(axis, t));
1171    // Treat the capsule as a static sphere at the closest axis point
1172    swept_sphere_sphere(c0_s, c1_s, r_s, cap_closest, cap_closest, r_c)
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Tests
1177// ---------------------------------------------------------------------------
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182
1183    // ── swept_sphere_sphere ──────────────────────────────────────────────────
1184
1185    #[test]
1186    fn test_swept_sphere_sphere_head_on() {
1187        // Two spheres of radius 0.5 moving toward each other along X.
1188        // A: starts at x=-3, ends at x=-1  (moves +2)
1189        // B: starts at x=+3, ends at x=+1  (moves -2)
1190        // Gap at t=0: 6 - 1 = 5. Relative speed 4. They meet when gap = 0.
1191        // (3-2t) - (- 3 + 2t) = 1  => 6 - 4t = 1 => t = 1.25 — outside [0,1].
1192        // Use shorter distances:
1193        // A: -1.5 → -0.5,  B: 1.5 → 0.5   (each moves 1 unit)
1194        // gap at t=0: 3 - 1 = 2;  gap(t) = 2 - 2t = 0 → t = 1.0
1195        let result = swept_sphere_sphere(
1196            [-1.5, 0.0, 0.0],
1197            [-0.5, 0.0, 0.0],
1198            0.5,
1199            [1.5, 0.0, 0.0],
1200            [0.5, 0.0, 0.0],
1201            0.5,
1202        );
1203        assert!(result.is_some(), "head-on spheres should hit");
1204        let hit = result.unwrap();
1205        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1206    }
1207
1208    #[test]
1209    fn test_swept_sphere_sphere_miss() {
1210        // Two spheres moving in parallel (no collision).
1211        let result = swept_sphere_sphere(
1212            [0.0, 0.0, 0.0],
1213            [1.0, 0.0, 0.0],
1214            0.5,
1215            [0.0, 5.0, 0.0],
1216            [1.0, 5.0, 0.0],
1217            0.5,
1218        );
1219        assert!(result.is_none(), "parallel spheres should not collide");
1220    }
1221
1222    #[test]
1223    fn test_swept_sphere_sphere_already_overlapping() {
1224        // Spheres already overlap at t=0.
1225        let result = swept_sphere_sphere(
1226            [0.0, 0.0, 0.0],
1227            [1.0, 0.0, 0.0],
1228            0.5,
1229            [0.5, 0.0, 0.0],
1230            [1.5, 0.0, 0.0],
1231            0.5,
1232        );
1233        assert!(result.is_some());
1234        assert_eq!(result.unwrap().toi, 0.0);
1235    }
1236
1237    #[test]
1238    fn test_swept_sphere_sphere_moving_apart() {
1239        // Spheres start separated and move further apart.
1240        let result = swept_sphere_sphere(
1241            [0.0, 0.0, 0.0],
1242            [-1.0, 0.0, 0.0],
1243            0.5,
1244            [3.0, 0.0, 0.0],
1245            [4.0, 0.0, 0.0],
1246            0.5,
1247        );
1248        assert!(result.is_none(), "diverging spheres should not collide");
1249    }
1250
1251    // ── swept_sphere_aabb ────────────────────────────────────────────────────
1252
1253    #[test]
1254    fn test_swept_sphere_aabb_hit() {
1255        // Sphere falls from above toward an AABB.
1256        // Sphere centre: starts at (0,5,0), ends at (0,-1,0), radius=0.5
1257        // AABB: [-1,-1,-1] to [1,1,1]
1258        let result = swept_sphere_aabb(
1259            [0.0, 5.0, 0.0],
1260            [0.0, -1.0, 0.0],
1261            0.5,
1262            [-1.0, -1.0, -1.0],
1263            [1.0, 1.0, 1.0],
1264        );
1265        assert!(result.is_some(), "sphere moving toward AABB should hit");
1266        let hit = result.unwrap();
1267        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1268    }
1269
1270    #[test]
1271    fn test_swept_sphere_aabb_miss() {
1272        // Sphere moves away from AABB.
1273        let result = swept_sphere_aabb(
1274            [10.0, 0.0, 0.0],
1275            [20.0, 0.0, 0.0],
1276            0.5,
1277            [-1.0, -1.0, -1.0],
1278            [1.0, 1.0, 1.0],
1279        );
1280        assert!(result.is_none(), "sphere moving away should not hit");
1281    }
1282
1283    // ── swept_aabb_aabb ──────────────────────────────────────────────────────
1284
1285    #[test]
1286    fn test_swept_aabb_aabb_hit() {
1287        // AABB A slides along X toward AABB B.
1288        let result = swept_aabb_aabb(
1289            [-3.0, -0.5, -0.5],
1290            [-2.0, 0.5, 0.5], // min/max A at t=0
1291            [2.5, 0.0, 0.0],  // A moves +2.5 along X
1292            [0.0, -0.5, -0.5],
1293            [1.0, 0.5, 0.5], // static B
1294        );
1295        assert!(result.is_some(), "sliding AABB should hit static AABB");
1296        let hit = result.unwrap();
1297        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1298    }
1299
1300    #[test]
1301    fn test_swept_aabb_aabb_miss() {
1302        // AABB A slides away from AABB B.
1303        let result = swept_aabb_aabb(
1304            [-3.0, -0.5, -0.5],
1305            [-2.0, 0.5, 0.5],
1306            [-5.0, 0.0, 0.0],
1307            [2.0, -0.5, -0.5],
1308            [3.0, 0.5, 0.5],
1309        );
1310        assert!(result.is_none(), "AABB moving away should not hit");
1311    }
1312
1313    // ── ray_sphere ────────────────────────────────────────────────────────────
1314
1315    #[test]
1316    fn test_ray_sphere_hit() {
1317        // Ray along +X axis toward sphere at origin.
1318        let hit = ray_sphere(
1319            [-5.0, 0.0, 0.0],
1320            [1.0, 0.0, 0.0],
1321            [0.0, 0.0, 0.0],
1322            1.0,
1323            0.0,
1324            100.0,
1325        );
1326        assert!(hit.is_some(), "ray should hit sphere");
1327        let h = hit.unwrap();
1328        assert!((h.t - 4.0).abs() < 1e-9, "expected t=4.0, got {}", h.t);
1329    }
1330
1331    #[test]
1332    fn test_ray_sphere_miss() {
1333        // Ray parallel to sphere but not intersecting.
1334        let hit = ray_sphere(
1335            [-5.0, 2.0, 0.0],
1336            [1.0, 0.0, 0.0],
1337            [0.0, 0.0, 0.0],
1338            1.0,
1339            0.0,
1340            100.0,
1341        );
1342        assert!(hit.is_none(), "ray should miss sphere");
1343    }
1344
1345    #[test]
1346    fn test_ray_sphere_behind() {
1347        // Ray origin past the sphere.
1348        let hit = ray_sphere(
1349            [5.0, 0.0, 0.0],
1350            [1.0, 0.0, 0.0],
1351            [0.0, 0.0, 0.0],
1352            1.0,
1353            0.0,
1354            100.0,
1355        );
1356        assert!(hit.is_none(), "ray going away from sphere should miss");
1357    }
1358
1359    // ── ray_aabb ──────────────────────────────────────────────────────────────
1360
1361    #[test]
1362    fn test_ray_aabb_hit() {
1363        let hit = ray_aabb(
1364            [-5.0, 0.0, 0.0],
1365            [1.0, 0.0, 0.0],
1366            [-1.0, -1.0, -1.0],
1367            [1.0, 1.0, 1.0],
1368            0.0,
1369            100.0,
1370        );
1371        assert!(hit.is_some(), "ray should hit AABB");
1372        let h = hit.unwrap();
1373        assert!((h.t - 4.0).abs() < 1e-9, "expected t=4.0, got {}", h.t);
1374    }
1375
1376    #[test]
1377    fn test_ray_aabb_miss() {
1378        let hit = ray_aabb(
1379            [-5.0, 3.0, 0.0],
1380            [1.0, 0.0, 0.0],
1381            [-1.0, -1.0, -1.0],
1382            [1.0, 1.0, 1.0],
1383            0.0,
1384            100.0,
1385        );
1386        assert!(hit.is_none(), "ray should miss AABB");
1387    }
1388
1389    // ── ray_triangle ──────────────────────────────────────────────────────────
1390
1391    #[test]
1392    fn test_ray_triangle_hit() {
1393        // Ray straight down onto a horizontal triangle.
1394        let hit = ray_triangle(
1395            [0.0, 5.0, 0.0],
1396            [0.0, -1.0, 0.0],
1397            [-1.0, 0.0, -1.0],
1398            [1.0, 0.0, -1.0],
1399            [0.0, 0.0, 1.0],
1400            0.0,
1401            100.0,
1402        );
1403        assert!(hit.is_some(), "ray should hit triangle");
1404        let h = hit.unwrap();
1405        assert!((h.t - 5.0).abs() < 1e-9, "expected t=5.0, got {}", h.t);
1406    }
1407
1408    #[test]
1409    fn test_ray_triangle_miss() {
1410        // Ray misses the triangle.
1411        let hit = ray_triangle(
1412            [5.0, 5.0, 5.0],
1413            [0.0, -1.0, 0.0],
1414            [-1.0, 0.0, -1.0],
1415            [1.0, 0.0, -1.0],
1416            [0.0, 0.0, 1.0],
1417            0.0,
1418            100.0,
1419        );
1420        assert!(hit.is_none(), "ray should miss triangle");
1421    }
1422
1423    #[test]
1424    fn test_ray_triangle_normal_direction() {
1425        // Triangle in XZ plane with CCW winding → normal should be +Y.
1426        let hit = ray_triangle(
1427            [0.0, 5.0, 0.0],
1428            [0.0, -1.0, 0.0],
1429            [-1.0, 0.0, -1.0],
1430            [1.0, 0.0, -1.0],
1431            [0.0, 0.0, 1.0],
1432            0.0,
1433            100.0,
1434        );
1435        let h = hit.unwrap();
1436        // Normal: e1 = (2,0,0), e2 = (1,0,2) → cross = (0*2-0*0, 0*1-2*2, 2*0-0*1) = (0,-4,0) — normalize = (0,-1,0)
1437        // Actually with these verts let's just check it's unit length.
1438        let nl = len(h.normal);
1439        assert!(
1440            (nl - 1.0).abs() < 1e-9,
1441            "normal should be unit length, got {}",
1442            nl
1443        );
1444    }
1445
1446    // ── point_aabb_dist_sq ───────────────────────────────────────────────────
1447
1448    #[test]
1449    fn test_point_aabb_dist_sq_inside() {
1450        let d = point_aabb_dist_sq([0.0, 0.0, 0.0], [-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
1451        assert_eq!(d, 0.0, "point inside AABB has distance 0");
1452    }
1453
1454    #[test]
1455    fn test_point_aabb_dist_sq_outside() {
1456        let d = point_aabb_dist_sq([2.0, 0.0, 0.0], [-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
1457        assert!(
1458            (d - 1.0).abs() < 1e-9,
1459            "point 1 unit outside AABB, expected dsq=1, got {}",
1460            d
1461        );
1462    }
1463
1464    // ── sphere_aabb_overlap ──────────────────────────────────────────────────
1465
1466    #[test]
1467    fn test_sphere_aabb_overlap_touching() {
1468        // Sphere just touching the AABB.
1469        assert!(sphere_aabb_overlap(
1470            [2.0, 0.0, 0.0],
1471            1.0,
1472            [-1.0, -1.0, -1.0],
1473            [1.0, 1.0, 1.0]
1474        ));
1475    }
1476
1477    #[test]
1478    fn test_sphere_aabb_overlap_separated() {
1479        assert!(!sphere_aabb_overlap(
1480            [3.0, 0.0, 0.0],
1481            1.0,
1482            [-1.0, -1.0, -1.0],
1483            [1.0, 1.0, 1.0]
1484        ));
1485    }
1486
1487    // ── aabb_aabb_overlap ────────────────────────────────────────────────────
1488
1489    #[test]
1490    fn test_aabb_aabb_overlap_yes() {
1491        assert!(aabb_aabb_overlap(
1492            [-1.0, -1.0, -1.0],
1493            [1.0, 1.0, 1.0],
1494            [0.0, 0.0, 0.0],
1495            [2.0, 2.0, 2.0],
1496        ));
1497    }
1498
1499    #[test]
1500    fn test_aabb_aabb_overlap_no() {
1501        assert!(!aabb_aabb_overlap(
1502            [-1.0, -1.0, -1.0],
1503            [0.0, 0.0, 0.0],
1504            [1.0, 1.0, 1.0],
1505            [2.0, 2.0, 2.0],
1506        ));
1507    }
1508
1509    // ── swept_capsule_plane ───────────────────────────────────────────────────
1510
1511    #[test]
1512    fn test_swept_capsule_plane_hit() {
1513        // Capsule (treated conservatively as sphere of r+hh) falls toward y=0 plane
1514        let result = swept_capsule_plane(
1515            [0.0, 5.0, 0.0],
1516            [0.0, -1.0, 0.0],
1517            0.5,
1518            1.0,
1519            [0.0, 1.0, 0.0],
1520            0.0,
1521        );
1522        assert!(result.is_some(), "capsule should hit floor plane");
1523        let hit = result.unwrap();
1524        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1525    }
1526
1527    #[test]
1528    fn test_swept_capsule_plane_moving_away() {
1529        let result = swept_capsule_plane(
1530            [0.0, 5.0, 0.0],
1531            [0.0, 10.0, 0.0],
1532            0.5,
1533            1.0,
1534            [0.0, 1.0, 0.0],
1535            0.0,
1536        );
1537        assert!(result.is_none(), "moving away from plane should not hit");
1538    }
1539
1540    #[test]
1541    fn test_swept_capsule_plane_already_touching() {
1542        // Capsule effective radius = 0.5 + 0.5 = 1.0; position = (0, 1, 0) → dist = 0
1543        let result = swept_capsule_plane(
1544            [0.0, 1.0, 0.0],
1545            [0.0, 0.0, 0.0],
1546            0.5,
1547            0.5,
1548            [0.0, 1.0, 0.0],
1549            0.0,
1550        );
1551        assert!(result.is_some());
1552        assert_eq!(result.unwrap().toi, 0.0);
1553    }
1554
1555    // ── refine_sphere_sphere_toi ──────────────────────────────────────────────
1556
1557    #[test]
1558    fn test_refine_toi_close_result() {
1559        // Spheres at ±1, moving toward each other at speed 1
1560        // Expected TOI ≈ 0.5 (contact at centre)
1561        let toi = refine_sphere_sphere_toi(
1562            [-1.0, 0.0, 0.0],
1563            [1.0, 0.0, 0.0],
1564            0.5,
1565            [1.0, 0.0, 0.0],
1566            [-1.0, 0.0, 0.0],
1567            0.5,
1568            0.0,
1569            1.0,
1570            1e-8,
1571            64,
1572        );
1573        assert!((toi - 0.5).abs() < 0.01, "refined toi={toi}, expected ≈0.5");
1574    }
1575
1576    // ── SweptSphere ──────────────────────────────────────────────────────────
1577
1578    #[test]
1579    fn test_swept_sphere_aabb_correct() {
1580        let s = SweptSphere::new([0.0; 3], [2.0, 0.0, 0.0], 1.0, 0);
1581        let (mn, mx) = s.sweep_aabb();
1582        assert!((mn[0] - (-1.0)).abs() < 1e-12);
1583        assert!((mx[0] - 3.0).abs() < 1e-12);
1584    }
1585
1586    #[test]
1587    fn test_swept_sphere_aabb_overlap() {
1588        let a = SweptSphere::new([0.0; 3], [1.0, 0.0, 0.0], 0.5, 0);
1589        let b = SweptSphere::new([0.5, 0.0, 0.0], [1.5, 0.0, 0.0], 0.5, 1);
1590        assert!(a.sweep_aabb_overlaps(&b));
1591    }
1592
1593    #[test]
1594    fn test_swept_sphere_aabb_no_overlap() {
1595        let a = SweptSphere::new([0.0; 3], [0.0; 3], 0.1, 0);
1596        let b = SweptSphere::new([10.0, 0.0, 0.0], [10.0, 0.0, 0.0], 0.1, 1);
1597        assert!(!a.sweep_aabb_overlaps(&b));
1598    }
1599
1600    // ── sweep_broadphase ─────────────────────────────────────────────────────
1601
1602    #[test]
1603    fn test_sweep_broadphase_finds_pair() {
1604        let spheres = vec![
1605            SweptSphere::new([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], 0.5, 10),
1606            SweptSphere::new([0.5, 0.0, 0.0], [2.0, 0.0, 0.0], 0.5, 20),
1607            SweptSphere::new([50.0, 0.0, 0.0], [51.0, 0.0, 0.0], 0.5, 30),
1608        ];
1609        let pairs = sweep_broadphase(&spheres);
1610        assert_eq!(pairs.len(), 1, "only pair (10,20) should overlap");
1611        assert_eq!(pairs[0], (10, 20));
1612    }
1613
1614    #[test]
1615    fn test_sweep_broadphase_no_pairs() {
1616        let spheres = vec![
1617            SweptSphere::new([0.0; 3], [0.0; 3], 0.1, 1),
1618            SweptSphere::new([100.0, 0.0, 0.0], [100.0, 0.0, 0.0], 0.1, 2),
1619        ];
1620        let pairs = sweep_broadphase(&spheres);
1621        assert!(pairs.is_empty());
1622    }
1623
1624    // ── swept_sphere_sphere_interval ─────────────────────────────────────────
1625
1626    #[test]
1627    fn test_interval_sweep_finds_hit() {
1628        // Spheres at ±5 moving toward each other at speed 1/s.
1629        // In interval [4, 6] they should collide.
1630        let result = swept_sphere_sphere_interval(
1631            [-5.0, 0.0, 0.0],
1632            [1.0, 0.0, 0.0],
1633            0.5,
1634            [5.0, 0.0, 0.0],
1635            [-1.0, 0.0, 0.0],
1636            0.5,
1637            4.0,
1638            6.0,
1639        );
1640        assert!(result.is_some(), "should hit within [4,6]");
1641    }
1642
1643    #[test]
1644    fn test_interval_sweep_no_hit_outside() {
1645        // Spheres collide at t=4.5; interval is [5, 10] → already past
1646        // They would be overlapping at t_start=5, so this would actually return Some(0.0)
1647        // Use diverging spheres instead
1648        let result = swept_sphere_sphere_interval(
1649            [-5.0, 0.0, 0.0],
1650            [-1.0, 0.0, 0.0],
1651            0.5, // moving away
1652            [5.0, 0.0, 0.0],
1653            [1.0, 0.0, 0.0],
1654            0.5,
1655            0.0,
1656            5.0,
1657        );
1658        assert!(result.is_none(), "diverging spheres should not collide");
1659    }
1660
1661    // ── CompoundSweptShape ───────────────────────────────────────────────────
1662
1663    #[test]
1664    fn test_compound_sweep_finds_earliest_hit() {
1665        // Compound A: single sphere at origin offset [0,0,0]
1666        // Compound B: single sphere at [4,0,0] offset [0,0,0]
1667        // A moves from [-5,0,0] to [0,0,0]; B is static
1668        let comp_a = CompoundSweptShape::new(vec![([0.0; 3], 0.5)]);
1669        let comp_b = CompoundSweptShape::new(vec![([0.0; 3], 0.5)]);
1670        let result = comp_a.sweep_vs_compound(
1671            [-5.0, 0.0, 0.0],
1672            [-1.0, 0.0, 0.0], // moves to x=-1
1673            &comp_b,
1674            [0.0, 0.0, 0.0],
1675            [0.0, 0.0, 0.0], // static at origin
1676        );
1677        // A gets to x=-1 at t=1, but distance at end is |(-1)-(0)| = 1 = sum_r → should touch
1678        assert!(result.is_some(), "compound sweep should detect hit");
1679    }
1680
1681    #[test]
1682    fn test_compound_sweep_no_hit_separated() {
1683        let comp_a = CompoundSweptShape::new(vec![([0.0; 3], 0.5)]);
1684        let comp_b = CompoundSweptShape::new(vec![([0.0; 3], 0.5)]);
1685        // A moves away from B
1686        let result = comp_a.sweep_vs_compound(
1687            [0.0; 3],
1688            [-10.0, 0.0, 0.0],
1689            &comp_b,
1690            [50.0, 0.0, 0.0],
1691            [50.0, 0.0, 0.0],
1692        );
1693        assert!(result.is_none());
1694    }
1695
1696    // ── swept_capsule_aabb ────────────────────────────────────────────────────
1697
1698    #[test]
1699    fn test_swept_capsule_aabb_hit() {
1700        // Capsule descending from above toward a box
1701        let result = swept_capsule_aabb(
1702            [0.0, 5.0, -0.3],
1703            [0.0, 5.0, 0.3], // start axis
1704            [0.0, 0.0, -0.3],
1705            [0.0, 0.0, 0.3], // end axis
1706            0.2,
1707            [-1.0, -1.0, -1.0],
1708            [1.0, 1.0, 1.0],
1709        );
1710        assert!(result.is_some(), "descending capsule should hit AABB");
1711        let hit = result.unwrap();
1712        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1713    }
1714
1715    #[test]
1716    fn test_swept_capsule_aabb_miss() {
1717        // Capsule moves away from box
1718        let result = swept_capsule_aabb(
1719            [10.0, 0.0, -0.3],
1720            [10.0, 0.0, 0.3],
1721            [20.0, 0.0, -0.3],
1722            [20.0, 0.0, 0.3],
1723            0.2,
1724            [-1.0, -1.0, -1.0],
1725            [1.0, 1.0, 1.0],
1726        );
1727        assert!(result.is_none(), "capsule moving away should not hit AABB");
1728    }
1729
1730    // ── linear_ccd_sphere_sphere ──────────────────────────────────────────────
1731
1732    #[test]
1733    fn test_linear_ccd_sphere_sphere_hit() {
1734        // Two spheres converging: A at x=-1, vel=+2; B at x=1, vel=-2; r=0.5 each
1735        // They meet when |pa - pb| = 1.0: pa = -1+2t, pb = 1-2t → dist = 2-4t = 1 → t=0.25
1736        let result = linear_ccd_sphere_sphere(
1737            [-1.0, 0.0, 0.0],
1738            [2.0, 0.0, 0.0],
1739            0.5,
1740            [1.0, 0.0, 0.0],
1741            [-2.0, 0.0, 0.0],
1742            0.5,
1743            1.0,
1744        );
1745        assert!(result.is_some(), "converging spheres should hit");
1746        let hit = result.unwrap();
1747        // toi is scaled by dt=1.0
1748        assert!(
1749            hit.toi >= 0.0 && hit.toi <= 1.0,
1750            "toi in [0,1], got {}",
1751            hit.toi
1752        );
1753    }
1754
1755    #[test]
1756    fn test_linear_ccd_sphere_sphere_no_hit() {
1757        let result = linear_ccd_sphere_sphere(
1758            [-10.0, 0.0, 0.0],
1759            [-1.0, 0.0, 0.0],
1760            0.3,
1761            [10.0, 0.0, 0.0],
1762            [1.0, 0.0, 0.0],
1763            0.3,
1764            1.0,
1765        );
1766        assert!(result.is_none(), "spheres moving away should not hit");
1767    }
1768
1769    // ── bisect_toi ────────────────────────────────────────────────────────────
1770
1771    #[test]
1772    fn test_bisect_toi_sphere_contact() {
1773        // Two spheres at ±1 with radius 0.5 each, converging at speed 1 each
1774        // Contact at t=0.5 (sum distances = 1.0 = 2*0.5)
1775        let toi = bisect_toi(0.0, 1.0, 1e-8, 64, |t| {
1776            let pa = add([-1.0, 0.0, 0.0], scale([1.0, 0.0, 0.0], t));
1777            let pb = add([1.0, 0.0, 0.0], scale([-1.0, 0.0, 0.0], t));
1778            len(sub(pa, pb)) - 1.0 // contact when dist = 1.0 (sum of radii)
1779        });
1780        assert!(
1781            (toi - 0.5).abs() < 1e-6,
1782            "bisect_toi should find t≈0.5, got {toi}"
1783        );
1784    }
1785
1786    #[test]
1787    fn test_bisect_toi_already_contact() {
1788        // dist_fn always negative → t_hi converges toward t_lo
1789        let toi = bisect_toi(0.0, 1.0, 1e-8, 64, |_t| -1.0);
1790        assert!(toi < 0.5, "Should converge to low end when always negative");
1791    }
1792
1793    // ── RotatingBodyState ─────────────────────────────────────────────────────
1794
1795    #[test]
1796    fn test_rotating_body_motion_bound() {
1797        let body = RotatingBodyState::new([0.0; 3], [1.0, 0.0, 0.0], 2.0, 0.5);
1798        // linear_speed = 1, angular_speed * bound_radius = 1.0
1799        // motion_bound(1.0) = (1 + 1) * 1 = 2
1800        let mb = body.motion_bound(1.0);
1801        assert!(
1802            (mb - 2.0).abs() < 1e-10,
1803            "motion_bound should be 2.0, got {mb}"
1804        );
1805    }
1806
1807    #[test]
1808    fn test_rotating_body_advance() {
1809        let body = RotatingBodyState::new([1.0, 0.0, 0.0], [2.0, 0.0, 0.0], 0.0, 1.0);
1810        let pos = body.advance(0.5);
1811        assert!(
1812            (pos[0] - 2.0).abs() < 1e-10,
1813            "advanced x should be 2.0, got {}",
1814            pos[0]
1815        );
1816    }
1817
1818    #[test]
1819    fn test_rotating_body_motion_bound_zero_dt() {
1820        let body = RotatingBodyState::new([0.0; 3], [10.0, 0.0, 0.0], 5.0, 1.0);
1821        let mb = body.motion_bound(0.0);
1822        assert_eq!(mb, 0.0, "motion_bound(0) should be 0");
1823    }
1824
1825    // ── conservative_advancement_rotating ────────────────────────────────────
1826
1827    #[test]
1828    fn test_conservative_advancement_no_collision_moving_apart() {
1829        let a = RotatingBodyState::new([-10.0, 0.0, 0.0], [-1.0, 0.0, 0.0], 0.0, 0.5);
1830        let b = RotatingBodyState::new([10.0, 0.0, 0.0], [1.0, 0.0, 0.0], 0.0, 0.5);
1831        let result = conservative_advancement_rotating(&a, &b, 18.0, 0.0, 1.0, 1e-4, 100);
1832        assert!(result.is_none(), "diverging bodies should not collide");
1833    }
1834
1835    #[test]
1836    fn test_conservative_advancement_already_overlapping() {
1837        let a = RotatingBodyState::new([0.0; 3], [0.0; 3], 0.0, 1.0);
1838        let b = RotatingBodyState::new([0.5, 0.0, 0.0], [0.0; 3], 0.0, 1.0);
1839        let result = conservative_advancement_rotating(&a, &b, 0.0, 0.0, 1.0, 1e-4, 100);
1840        // Overlap at start → returns t_start
1841        assert!(result.is_some(), "overlapping at start should return a toi");
1842        assert_eq!(result.unwrap(), 0.0, "overlapping at start → toi = t_start");
1843    }
1844
1845    // ── TriangleMeshSweepResult ───────────────────────────────────────────────
1846
1847    #[test]
1848    fn test_swept_sphere_triangle_mesh_hit() {
1849        // Triangle in XZ plane, sphere sweeps down from above
1850        let triangles = vec![[[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [0.0, 0.0, 1.0]]];
1851        let result = swept_sphere_triangle_mesh([0.0, 3.0, 0.0], [0.0, -1.0, 0.0], 0.5, &triangles);
1852        assert!(
1853            result.is_some(),
1854            "sphere sweeping toward triangle should hit"
1855        );
1856        let hit = result.unwrap();
1857        assert!(hit.toi >= 0.0 && hit.toi <= 1.0, "toi={}", hit.toi);
1858        assert_eq!(hit.tri_index, 0, "should hit first triangle");
1859    }
1860
1861    #[test]
1862    fn test_swept_sphere_triangle_mesh_miss() {
1863        // Triangle far from the sphere's path
1864        let triangles = vec![[[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [0.0, 0.0, 1.0]]];
1865        let result =
1866            swept_sphere_triangle_mesh([10.0, 3.0, 0.0], [10.0, -1.0, 0.0], 0.5, &triangles);
1867        assert!(result.is_none(), "sphere far from triangle should miss");
1868    }
1869
1870    #[test]
1871    fn test_swept_sphere_triangle_mesh_multiple_triangles() {
1872        // Two triangles; sphere should hit the closer one (index 1 at y=2)
1873        let triangles = vec![
1874            [[-1.0, -5.0, -1.0], [1.0, -5.0, -1.0], [0.0, -5.0, 1.0]], // very far
1875            [[-1.0, 1.0, -1.0], [1.0, 1.0, -1.0], [0.0, 1.0, 1.0]],    // close
1876        ];
1877        // Sphere moves from y=5 to y=-5, radius=0.3
1878        let result = swept_sphere_triangle_mesh([0.0, 5.0, 0.0], [0.0, -5.0, 0.0], 0.3, &triangles);
1879        assert!(result.is_some(), "should hit a triangle");
1880    }
1881
1882    // ── swept_sphere_capsule ──────────────────────────────────────────────────
1883
1884    #[test]
1885    fn test_swept_sphere_capsule_hit() {
1886        // Sphere at x=-3 sweeps to x=0.5 past the capsule axis, radius 0.3 + capsule 0.5 = 0.8 sum
1887        // At end: sphere center at 0.5, capsule axis at 0 → dist = 0.5 < 0.8 → collision
1888        let result = swept_sphere_capsule(
1889            [-3.0, 0.0, 0.0],
1890            [0.5, 0.0, 0.0],
1891            0.3,
1892            [0.0, -1.0, 0.0],
1893            [0.0, 1.0, 0.0],
1894            0.5,
1895        );
1896        assert!(result.is_some(), "sphere sweeping past capsule should hit");
1897    }
1898
1899    #[test]
1900    fn test_swept_sphere_capsule_miss() {
1901        // Sphere moves away from capsule
1902        let result = swept_sphere_capsule(
1903            [5.0, 0.0, 0.0],
1904            [10.0, 0.0, 0.0],
1905            0.3,
1906            [0.0, -1.0, 0.0],
1907            [0.0, 1.0, 0.0],
1908            0.5,
1909        );
1910        assert!(
1911            result.is_none(),
1912            "sphere moving away should not hit capsule"
1913        );
1914    }
1915
1916    #[test]
1917    fn test_swept_sphere_capsule_already_overlapping() {
1918        // Sphere already inside the capsule radius
1919        let result = swept_sphere_capsule(
1920            [0.3, 0.0, 0.0],
1921            [0.5, 0.0, 0.0],
1922            0.3,
1923            [0.0, -1.0, 0.0],
1924            [0.0, 1.0, 0.0],
1925            0.5,
1926        );
1927        assert!(result.is_some(), "overlapping should produce result");
1928        assert_eq!(result.unwrap().toi, 0.0, "overlap at start → toi=0");
1929    }
1930
1931    // ── Additional ray tests ──────────────────────────────────────────────────
1932
1933    #[test]
1934    fn test_ray_sphere_grazing() {
1935        // Ray grazing the sphere tangentially
1936        let hit = ray_sphere(
1937            [-5.0, 1.0, 0.0],
1938            [1.0, 0.0, 0.0],
1939            [0.0, 0.0, 0.0],
1940            1.0,
1941            0.0,
1942            100.0,
1943        );
1944        assert!(hit.is_some(), "grazing ray should hit sphere at tangent");
1945        let h = hit.unwrap();
1946        assert!(
1947            (h.point[1] - 1.0).abs() < 1e-6,
1948            "hit y should be ≈1 (tangent)"
1949        );
1950    }
1951
1952    #[test]
1953    fn test_ray_aabb_inside_origin() {
1954        // Ray starting inside the AABB
1955        let hit = ray_aabb(
1956            [0.0, 0.0, 0.0],
1957            [1.0, 0.0, 0.0],
1958            [-1.0, -1.0, -1.0],
1959            [1.0, 1.0, 1.0],
1960            0.0,
1961            100.0,
1962        );
1963        assert!(
1964            hit.is_some(),
1965            "ray from inside AABB should hit the far wall"
1966        );
1967    }
1968
1969    #[test]
1970    fn test_point_aabb_dist_sq_corner() {
1971        // Point at (2, 2, 2), AABB [-1,1]^3 → distance to corner (1,1,1) = sqrt(3)
1972        let d = point_aabb_dist_sq([2.0, 2.0, 2.0], [-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
1973        assert!((d - 3.0).abs() < 1e-10, "Expected dsq=3.0, got {d}");
1974    }
1975
1976    #[test]
1977    fn test_aabb_aabb_overlap_touching_face() {
1978        // Two AABBs touching on a face (edge-case overlap)
1979        assert!(
1980            aabb_aabb_overlap(
1981                [0.0, 0.0, 0.0],
1982                [1.0, 1.0, 1.0],
1983                [1.0, 0.0, 0.0],
1984                [2.0, 1.0, 1.0],
1985            ),
1986            "face-touching AABBs should overlap"
1987        );
1988    }
1989}