Skip to main content

oxiphysics_collision/
ccd.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Continuous Collision Detection (CCD) via conservative advancement.
5
6use crate::narrowphase::gjk::{Gjk, GjkResult};
7use oxiphysics_core::Transform;
8use oxiphysics_core::math::{Real, Vec3};
9use oxiphysics_geometry::Shape;
10
11/// Maximum iterations for conservative advancement.
12const MAX_CCD_ITERATIONS: usize = 32;
13
14/// Tolerance for CCD convergence.
15const CCD_TOLERANCE: Real = 1e-6;
16
17/// Result of a time-of-impact query.
18#[derive(Debug, Clone)]
19pub struct ToiResult {
20    /// Time of impact in \[0, 1\] range (fraction of the timestep).
21    pub toi: Real,
22    /// Contact normal at time of impact.
23    pub normal: Vec3,
24    /// Witness point on shape A at time of impact.
25    pub witness_a: Vec3,
26    /// Witness point on shape B at time of impact.
27    pub witness_b: Vec3,
28}
29
30/// Compute time of impact between two moving convex shapes using
31/// conservative advancement.
32///
33/// The shapes move linearly from `transform_a/b_start` to `transform_a/b_end`
34/// over the timestep. Returns `None` if no impact occurs during the timestep.
35pub fn time_of_impact(
36    shape_a: &dyn Shape,
37    transform_a_start: &Transform,
38    transform_a_end: &Transform,
39    shape_b: &dyn Shape,
40    transform_b_start: &Transform,
41    transform_b_end: &Transform,
42) -> Option<ToiResult> {
43    // Linear velocity of each body over the timestep
44    let vel_a = transform_a_end.position - transform_a_start.position;
45    let vel_b = transform_b_end.position - transform_b_start.position;
46    let relative_vel = vel_a - vel_b;
47    let rel_speed = relative_vel.norm();
48
49    if rel_speed < CCD_TOLERANCE {
50        // Objects are essentially stationary relative to each other
51        return None;
52    }
53
54    let mut t = 0.0;
55
56    for _ in 0..MAX_CCD_ITERATIONS {
57        // Interpolate transforms at current t
58        let ta = interpolate_transform(transform_a_start, transform_a_end, t);
59        let tb = interpolate_transform(transform_b_start, transform_b_end, t);
60
61        // Query GJK for distance/intersection
62        let result = Gjk::query(shape_a, &ta, shape_b, &tb);
63
64        match result {
65            GjkResult::Intersecting(_) => {
66                // Shapes are already overlapping at this t
67                if t < CCD_TOLERANCE {
68                    // Already overlapping at start — not a CCD event
69                    return None;
70                }
71                // Use a bisection refinement
72                return bisect_toi(
73                    shape_a,
74                    transform_a_start,
75                    transform_a_end,
76                    shape_b,
77                    transform_b_start,
78                    transform_b_end,
79                    (t - 0.1).max(0.0),
80                    t,
81                );
82            }
83            GjkResult::Separated {
84                distance,
85                point_a,
86                point_b,
87                ..
88            } => {
89                if distance < CCD_TOLERANCE {
90                    let normal = if distance > 1e-12 {
91                        (point_b - point_a).normalize()
92                    } else {
93                        relative_vel.normalize()
94                    };
95                    return Some(ToiResult {
96                        toi: t,
97                        normal,
98                        witness_a: point_a,
99                        witness_b: point_b,
100                    });
101                }
102
103                // Conservative advancement: advance t by distance / relative_speed
104                // Bounded support radius for convex shapes
105                let upper_bound = compute_upper_bound(shape_a, shape_b);
106                let denominator = rel_speed + upper_bound;
107                if denominator < CCD_TOLERANCE {
108                    return None;
109                }
110
111                let dt = distance / denominator;
112                t += dt;
113
114                if t > 1.0 {
115                    return None; // No impact this timestep
116                }
117            }
118        }
119    }
120
121    None
122}
123
124/// Compute upper bound on the angular contribution to relative motion.
125fn compute_upper_bound(shape_a: &dyn Shape, shape_b: &dyn Shape) -> Real {
126    // Use bounding sphere radii as conservative upper bound
127    let aabb_a = shape_a.bounding_box();
128    let aabb_b = shape_b.bounding_box();
129    let radius_a = aabb_a.half_extents().norm();
130    let radius_b = aabb_b.half_extents().norm();
131    // Angular velocity contribution (conservative estimate)
132    // Assuming worst-case angular speed of pi rad/timestep
133    (radius_a + radius_b) * std::f64::consts::PI * 0.1
134}
135
136/// Linearly interpolate between two transforms.
137fn interpolate_transform(start: &Transform, end: &Transform, t: Real) -> Transform {
138    let position = start.position * (1.0 - t) + end.position * t;
139    let rotation = start.rotation.slerp(&end.rotation, t);
140    Transform::new(position, rotation)
141}
142
143/// Bisection refinement to find precise TOI between t_low and t_high.
144fn bisect_toi(
145    shape_a: &dyn Shape,
146    ta_start: &Transform,
147    ta_end: &Transform,
148    shape_b: &dyn Shape,
149    tb_start: &Transform,
150    tb_end: &Transform,
151    mut t_low: Real,
152    mut t_high: Real,
153) -> Option<ToiResult> {
154    for _ in 0..16 {
155        let t_mid = (t_low + t_high) * 0.5;
156        let ta = interpolate_transform(ta_start, ta_end, t_mid);
157        let tb = interpolate_transform(tb_start, tb_end, t_mid);
158
159        let result = Gjk::query(shape_a, &ta, shape_b, &tb);
160        match result {
161            GjkResult::Intersecting(_) => {
162                t_high = t_mid;
163            }
164            GjkResult::Separated {
165                distance,
166                point_a,
167                point_b,
168                ..
169            } => {
170                if distance < CCD_TOLERANCE {
171                    let normal = if distance > 1e-12 {
172                        (point_b - point_a).normalize()
173                    } else {
174                        // Points coincide; derive normal from relative motion direction.
175                        let rel = (ta_end.position - ta_start.position)
176                            - (tb_end.position - tb_start.position);
177                        if rel.norm() > 1e-10 {
178                            rel.normalize()
179                        } else {
180                            Vec3::new(0.0, 1.0, 0.0)
181                        }
182                    };
183                    return Some(ToiResult {
184                        toi: t_mid,
185                        normal,
186                        witness_a: point_a,
187                        witness_b: point_b,
188                    });
189                }
190                t_low = t_mid;
191            }
192        }
193
194        if (t_high - t_low) < CCD_TOLERANCE {
195            break;
196        }
197    }
198
199    // Return best estimate
200    let t = (t_low + t_high) * 0.5;
201    let ta = interpolate_transform(ta_start, ta_end, t);
202    let tb = interpolate_transform(tb_start, tb_end, t);
203    let vel = ta_end.position - ta_start.position - (tb_end.position - tb_start.position);
204    let normal = if vel.norm() > 1e-10 {
205        vel.normalize()
206    } else {
207        Vec3::new(0.0, 1.0, 0.0)
208    };
209    Some(ToiResult {
210        toi: t,
211        normal,
212        witness_a: ta.position,
213        witness_b: tb.position,
214    })
215}
216
217// ---------------------------------------------------------------------------
218// Array-based CCD API (no nalgebra, uses [f64; 3] directly)
219// ---------------------------------------------------------------------------
220
221/// Time-of-impact result for the array-based CCD API.
222#[derive(Debug, Clone, PartialEq)]
223pub struct CcdResult {
224    /// Time of impact in \[0, 1\].
225    pub toi: f64,
226    /// Contact normal at time of impact.
227    pub normal: [f64; 3],
228    /// Witness point on shape A.
229    pub witness_a: [f64; 3],
230    /// Witness point on shape B.
231    pub witness_b: [f64; 3],
232}
233
234// --- small vector helpers (private) ----------------------------------------
235
236#[inline]
237fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
238    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
239}
240
241#[inline]
242fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
243    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
244}
245
246#[inline]
247fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
248    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
249}
250
251#[inline]
252fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
253    [v[0] * s, v[1] * s, v[2] * s]
254}
255
256#[inline]
257fn len3(v: [f64; 3]) -> f64 {
258    dot3(v, v).sqrt()
259}
260
261#[inline]
262fn norm3(v: [f64; 3]) -> [f64; 3] {
263    let l = len3(v);
264    if l < 1e-300 {
265        [0.0, 0.0, 0.0]
266    } else {
267        scale3(v, 1.0 / l)
268    }
269}
270
271#[inline]
272fn neg3(v: [f64; 3]) -> [f64; 3] {
273    [-v[0], -v[1], -v[2]]
274}
275
276#[inline]
277fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
278    [
279        a[1] * b[2] - a[2] * b[1],
280        a[2] * b[0] - a[0] * b[2],
281        a[0] * b[1] - a[1] * b[0],
282    ]
283}
284
285// ---------------------------------------------------------------------------
286// sphere_sphere_toi
287// ---------------------------------------------------------------------------
288
289/// Analytic time-of-impact for two moving spheres.
290///
291/// Solves `|r(t)|² = (ra+rb)²` where `r(t) = (center_b - center_a) + t*(vel_b - vel_a)`.
292/// Returns the smallest `t ∈ [0, 1]` at which the spheres touch, or `None`.
293pub fn sphere_sphere_toi(
294    center_a: [f64; 3],
295    vel_a: [f64; 3],
296    radius_a: f64,
297    center_b: [f64; 3],
298    vel_b: [f64; 3],
299    radius_b: f64,
300) -> Option<f64> {
301    let r_sum = radius_a + radius_b;
302    // relative displacement and velocity (b relative to a)
303    let dp = sub3(center_b, center_a);
304    let dv = sub3(vel_b, vel_a);
305
306    // |dp + t*dv|² = r_sum²
307    // a*t² + 2*b*t + (c - r_sum²) = 0
308    let a = dot3(dv, dv);
309    let b = dot3(dp, dv);
310    let c = dot3(dp, dp) - r_sum * r_sum;
311
312    // Already overlapping at t=0
313    if c <= 0.0 {
314        return Some(0.0);
315    }
316
317    if a < 1e-300 {
318        // no relative motion
319        return None;
320    }
321
322    let disc = b * b - a * c;
323    if disc < 0.0 {
324        return None;
325    }
326
327    let sqrt_disc = disc.sqrt();
328    // smallest root first
329    let t = (-b - sqrt_disc) / a;
330    if (0.0..=1.0).contains(&t) {
331        return Some(t);
332    }
333    let t2 = (-b + sqrt_disc) / a;
334    if (0.0..=1.0).contains(&t2) {
335        return Some(t2);
336    }
337    None
338}
339
340// ---------------------------------------------------------------------------
341// sphere_plane_toi
342// ---------------------------------------------------------------------------
343
344/// Analytic time-of-impact for a moving sphere against a static plane.
345///
346/// The plane is defined by `dot(plane_normal, x) = plane_d` (normal should be unit length).
347/// Returns `t ∈ [0, 1]` when the sphere surface touches the plane, or `None`.
348pub fn sphere_plane_toi(
349    center: [f64; 3],
350    vel: [f64; 3],
351    radius: f64,
352    plane_normal: [f64; 3],
353    plane_d: f64,
354) -> Option<f64> {
355    // signed distance of sphere centre from plane
356    let dist = dot3(plane_normal, center) - plane_d;
357    let v_dot_n = dot3(plane_normal, vel);
358
359    // sphere already touching or below
360    if dist.abs() <= radius {
361        return Some(0.0);
362    }
363
364    if v_dot_n.abs() < 1e-300 {
365        return None;
366    }
367
368    // t at which dist - radius = 0 (approaching from positive side)
369    // or dist + radius = 0 (approaching from negative side)
370    let t = if dist > 0.0 {
371        (dist - radius) / (-v_dot_n)
372    } else {
373        (dist + radius) / (-v_dot_n)
374    };
375
376    if (0.0..=1.0).contains(&t) {
377        Some(t)
378    } else {
379        None
380    }
381}
382
383// ---------------------------------------------------------------------------
384// relative_velocity_bound
385// ---------------------------------------------------------------------------
386
387/// Maximum relative speed between two bodies: `|vel_a - vel_b|`.
388pub fn relative_velocity_bound(vel_a: [f64; 3], vel_b: [f64; 3]) -> f64 {
389    len3(sub3(vel_a, vel_b))
390}
391
392// ---------------------------------------------------------------------------
393// gjk_distance_approx
394// ---------------------------------------------------------------------------
395
396/// Simplified GJK for distance estimation (Minkowski difference support iteration).
397///
398/// Returns an approximate lower-bound distance between the two convex shapes
399/// defined by their support functions.  Not a full EPA; accuracy sufficient
400/// for conservative advancement.
401pub fn gjk_distance_approx(
402    support_a: impl Fn([f64; 3]) -> [f64; 3],
403    support_b: impl Fn([f64; 3]) -> [f64; 3],
404) -> f64 {
405    // Combined support in Minkowski difference
406    let support = |d: [f64; 3]| -> [f64; 3] { sub3(support_a(d), support_b(neg3(d))) };
407
408    const MAX_ITERS: usize = 32;
409    const EPS: f64 = 1e-7;
410
411    // Simplex vertices (at most 4)
412    let mut simplex: Vec<[f64; 3]> = Vec::with_capacity(4);
413
414    let mut dir = [1.0_f64, 0.0, 0.0];
415    let first = support(dir);
416    simplex.push(first);
417    dir = neg3(first);
418
419    for _ in 0..MAX_ITERS {
420        let l = len3(dir);
421        if l < EPS {
422            // origin is in/on simplex
423            return 0.0;
424        }
425        let dir_n = scale3(dir, 1.0 / l);
426        let new_pt = support(dir_n);
427
428        // If new point did not advance past origin along dir, origin is beyond
429        let proj = dot3(new_pt, dir_n);
430        if proj < -EPS {
431            // No intersection; distance approximated by |dir|
432            return l;
433        }
434
435        simplex.push(new_pt);
436
437        // Reduce simplex and update direction
438        let (new_dir, done, dist) = nearest_simplex(&mut simplex);
439        if done {
440            return dist;
441        }
442        dir = new_dir;
443    }
444
445    len3(dir)
446}
447
448/// Reduce simplex to the feature closest to origin; return new search direction,
449/// whether we are done (origin inside or distance converged), and current distance.
450fn nearest_simplex(simplex: &mut Vec<[f64; 3]>) -> ([f64; 3], bool, f64) {
451    match simplex.len() {
452        1 => {
453            let a = simplex[0];
454            (neg3(a), false, len3(a))
455        }
456        2 => nearest_simplex_line(simplex),
457        3 => nearest_simplex_triangle(simplex),
458        4 => nearest_simplex_tetrahedron(simplex),
459        _ => ([1.0, 0.0, 0.0], true, 0.0),
460    }
461}
462
463fn nearest_simplex_line(simplex: &mut Vec<[f64; 3]>) -> ([f64; 3], bool, f64) {
464    let b = simplex[0];
465    let a = simplex[1]; // most recently added
466    let ab = sub3(b, a);
467    let ao = neg3(a);
468    let t = dot3(ab, ao);
469    if t <= 0.0 {
470        // Closest to A
471        *simplex = vec![a];
472        (ao, false, len3(ao))
473    } else {
474        let denom = dot3(ab, ab);
475        if denom < 1e-300 {
476            *simplex = vec![a];
477            return (ao, false, len3(ao));
478        }
479        let tc = t / denom;
480        if tc >= 1.0 {
481            // Closest to B
482            *simplex = vec![b];
483            let bo = neg3(b);
484            (bo, false, len3(bo))
485        } else {
486            // Closest to segment
487            let closest = add3(a, scale3(ab, tc));
488            let dir = neg3(closest);
489            let dist = len3(closest);
490            (dir, dist < 1e-10, dist)
491        }
492    }
493}
494
495fn nearest_simplex_triangle(simplex: &mut Vec<[f64; 3]>) -> ([f64; 3], bool, f64) {
496    let c = simplex[0];
497    let b = simplex[1];
498    let a = simplex[2]; // newest
499    let ab = sub3(b, a);
500    let ac = sub3(c, a);
501    let ao = neg3(a);
502
503    let abc = cross3(ab, ac);
504    // Determine region
505    // If origin is on the positive side of edge AB (outward)
506    let ab_perp = cross3(ab, abc);
507    if dot3(ab_perp, ao) > 0.0 {
508        if dot3(ab, ao) > 0.0 {
509            *simplex = vec![b, a];
510            return nearest_simplex_line(simplex);
511        }
512        let ac_perp = cross3(abc, ac);
513        if dot3(ac_perp, ao) > 0.0 {
514            *simplex = vec![c, a];
515            return nearest_simplex_line(simplex);
516        }
517        *simplex = vec![a];
518        return (ao, false, len3(ao));
519    }
520    let ac_perp = cross3(abc, ac);
521    if dot3(ac_perp, ao) > 0.0 {
522        *simplex = vec![c, a];
523        return nearest_simplex_line(simplex);
524    }
525    // Origin is above or below triangle
526    let d = dot3(abc, ao);
527    if d.abs() < 1e-10 {
528        return ([0.0, 0.0, 0.0], true, 0.0);
529    }
530    if d > 0.0 {
531        // already ordered
532        (abc, false, d / len3(abc))
533    } else {
534        // flip
535        *simplex = vec![b, c, a];
536        (neg3(abc), false, (-d) / len3(abc))
537    }
538}
539
540fn nearest_simplex_tetrahedron(simplex: &mut Vec<[f64; 3]>) -> ([f64; 3], bool, f64) {
541    let d = simplex[0];
542    let c = simplex[1];
543    let b = simplex[2];
544    let a = simplex[3]; // newest
545
546    let ab = sub3(b, a);
547    let ac = sub3(c, a);
548    let ad = sub3(d, a);
549    let ao = neg3(a);
550
551    let abc = cross3(ab, ac);
552    let acd = cross3(ac, ad);
553    let adb = cross3(ad, ab);
554
555    let above_abc = dot3(abc, ao) > 0.0;
556    let above_acd = dot3(acd, ao) > 0.0;
557    let above_adb = dot3(adb, ao) > 0.0;
558
559    if !above_abc && !above_acd && !above_adb {
560        // origin is inside tetrahedron
561        return ([0.0, 0.0, 0.0], true, 0.0);
562    }
563    if above_abc {
564        *simplex = vec![c, b, a];
565        return nearest_simplex_triangle(simplex);
566    }
567    if above_acd {
568        *simplex = vec![d, c, a];
569        return nearest_simplex_triangle(simplex);
570    }
571    *simplex = vec![b, d, a];
572    nearest_simplex_triangle(simplex)
573}
574
575// ---------------------------------------------------------------------------
576// conservative_advancement
577// ---------------------------------------------------------------------------
578
579/// Conservative advancement algorithm for two convex shapes moving linearly.
580///
581/// At each step the GJK distance provides a lower bound on how far the shapes
582/// can move before contact; time is advanced accordingly.  Returns the
583/// estimated TOI ∈ \[0, 1\] when separation ≤ threshold, or `None`.
584pub fn conservative_advancement(
585    support_a: impl Fn([f64; 3]) -> [f64; 3],
586    support_b: impl Fn([f64; 3]) -> [f64; 3],
587    pos_a: [f64; 3],
588    vel_a: [f64; 3],
589    pos_b: [f64; 3],
590    vel_b: [f64; 3],
591    max_dist: f64,
592    max_iter: usize,
593) -> Option<f64> {
594    const THRESHOLD: f64 = 1e-4;
595    let rel_speed = relative_velocity_bound(vel_a, vel_b);
596    if rel_speed < 1e-12 {
597        return None;
598    }
599
600    let mut t = 0.0_f64;
601
602    // Translate support functions to world space at time t
603    for _ in 0..max_iter {
604        let ta = add3(pos_a, scale3(vel_a, t));
605        let tb = add3(pos_b, scale3(vel_b, t));
606        let offset = sub3(ta, tb);
607
608        // Shifted support: support(d) + offset for A, support(-d) for B gives
609        // Minkowski difference shifted by offset. Distance from origin of that
610        // difference equals distance between shapes.
611        let sa = |d: [f64; 3]| add3(support_a(d), ta);
612        let sb = |d: [f64; 3]| add3(support_b(d), tb);
613
614        // Inline shifted gjk distance
615        let dist = gjk_shifted_distance(sa, sb, offset);
616
617        if dist <= THRESHOLD {
618            return Some(t);
619        }
620        if dist > max_dist {
621            return None;
622        }
623
624        let dt = dist / rel_speed;
625        t += dt;
626        if t > 1.0 {
627            return None;
628        }
629    }
630
631    None
632}
633
634/// GJK distance between two shapes given world-space support functions.
635fn gjk_shifted_distance(
636    support_a: impl Fn([f64; 3]) -> [f64; 3],
637    support_b: impl Fn([f64; 3]) -> [f64; 3],
638    _offset: [f64; 3],
639) -> f64 {
640    gjk_distance_approx(support_a, support_b)
641}
642
643// ---------------------------------------------------------------------------
644// capsule helpers
645// ---------------------------------------------------------------------------
646
647/// Closest point on segment \[p0, p1\] to point `q`.
648fn closest_point_on_segment(p0: [f64; 3], p1: [f64; 3], q: [f64; 3]) -> [f64; 3] {
649    let d = sub3(p1, p0);
650    let len_sq = dot3(d, d);
651    if len_sq < 1e-300 {
652        return p0;
653    }
654    let t = (dot3(sub3(q, p0), d) / len_sq).clamp(0.0, 1.0);
655    add3(p0, scale3(d, t))
656}
657
658/// Support function for a capsule (segment + radius).
659fn capsule_support(p0: [f64; 3], p1: [f64; 3], radius: f64, dir: [f64; 3]) -> [f64; 3] {
660    // Farthest point on the axis
661    let d0 = dot3(p0, dir);
662    let d1 = dot3(p1, dir);
663    let axis_pt = if d0 >= d1 { p0 } else { p1 };
664    // Extend by radius in dir
665    let dir_n = norm3(dir);
666    add3(axis_pt, scale3(dir_n, radius))
667}
668
669// ---------------------------------------------------------------------------
670// capsule_capsule_toi
671// ---------------------------------------------------------------------------
672
673/// Capsule vs capsule time-of-impact via conservative advancement.
674///
675/// Each capsule is defined by its two axis endpoints and a radius.
676pub fn capsule_capsule_toi(
677    pa0: [f64; 3],
678    pa1: [f64; 3],
679    vel_a: [f64; 3],
680    ra: f64,
681    pb0: [f64; 3],
682    pb1: [f64; 3],
683    vel_b: [f64; 3],
684    rb: f64,
685    max_iter: usize,
686) -> Option<f64> {
687    // Centre-of-mass positions (midpoints of axes)
688    let pos_a = scale3(add3(pa0, pa1), 0.5);
689    let pos_b = scale3(add3(pb0, pb1), 0.5);
690
691    // Half-lengths
692    let half_a = sub3(pa1, pos_a);
693    let half_b = sub3(pb1, pos_b);
694
695    // Local axis endpoints (relative to CoM)
696    let local_a0 = sub3(pa0, pos_a);
697    let local_a1 = sub3(pa1, pos_a);
698    let local_b0 = sub3(pb0, pos_b);
699    let local_b1 = sub3(pb1, pos_b);
700
701    let _ = (half_a, half_b, closest_point_on_segment); // suppress unused warnings
702
703    let max_dist = len3(sub3(pos_b, pos_a)) + ra + rb + 1.0;
704
705    conservative_advancement(
706        |d| capsule_support(local_a0, local_a1, ra, d),
707        |d| capsule_support(local_b0, local_b1, rb, d),
708        pos_a,
709        vel_a,
710        pos_b,
711        vel_b,
712        max_dist,
713        max_iter,
714    )
715}
716
717// ---------------------------------------------------------------------------
718// ConservativeAdvancement struct
719// ---------------------------------------------------------------------------
720
721/// Configuration for conservative advancement TOI solving.
722///
723/// Provides builder-style configuration and entry points for GJK-based
724/// conservative advancement between convex shapes defined by support functions.
725#[derive(Debug, Clone)]
726pub struct ConservativeAdvancement {
727    /// Convergence threshold: stop when distance < threshold.
728    pub threshold: f64,
729    /// Maximum iterations before giving up.
730    pub max_iterations: usize,
731    /// Upper bound multiplier on angular speed contribution (conservative).
732    pub angular_speed_factor: f64,
733}
734
735impl Default for ConservativeAdvancement {
736    fn default() -> Self {
737        Self {
738            threshold: 1e-4,
739            max_iterations: 64,
740            angular_speed_factor: 0.1,
741        }
742    }
743}
744
745impl ConservativeAdvancement {
746    /// Create a new `ConservativeAdvancement` solver with default parameters.
747    pub fn new() -> Self {
748        Self::default()
749    }
750
751    /// Set the convergence threshold.
752    pub fn with_threshold(mut self, threshold: f64) -> Self {
753        self.threshold = threshold;
754        self
755    }
756
757    /// Set the maximum iteration count.
758    pub fn with_max_iterations(mut self, n: usize) -> Self {
759        self.max_iterations = n;
760        self
761    }
762
763    /// Compute the time of impact between two convex shapes moving linearly.
764    ///
765    /// `support_a` / `support_b` are support functions in **world space at t=0**.
766    /// `pos_a` / `pos_b` are the centre-of-mass positions, and `vel_a` / `vel_b`
767    /// are the linear velocities (full displacement over the timestep `t ∈ [0,1]`).
768    /// `bounding_radius_a/b` are the bounding sphere radii used for the angular
769    /// speed upper-bound correction.
770    ///
771    /// Returns `Some(toi)` ∈ `[0, 1]` when the shapes come within `threshold`,
772    /// or `None` if no impact occurs.
773    pub fn compute_toi(
774        &self,
775        support_a: impl Fn([f64; 3]) -> [f64; 3],
776        support_b: impl Fn([f64; 3]) -> [f64; 3],
777        pos_a: [f64; 3],
778        vel_a: [f64; 3],
779        bounding_radius_a: f64,
780        pos_b: [f64; 3],
781        vel_b: [f64; 3],
782        bounding_radius_b: f64,
783    ) -> Option<f64> {
784        let rel_speed = relative_velocity_bound(vel_a, vel_b);
785        // Angular correction: conservative upper bound on rotation contribution.
786        let angular_contrib = (bounding_radius_a + bounding_radius_b) * self.angular_speed_factor;
787        let effective_speed = rel_speed + angular_contrib;
788
789        if effective_speed < 1e-12 {
790            return None;
791        }
792
793        let mut t = 0.0_f64;
794        let max_dist = len3(sub3(pos_b, pos_a)) + bounding_radius_a + bounding_radius_b + 1.0;
795
796        for _ in 0..self.max_iterations {
797            let ta = add3(pos_a, scale3(vel_a, t));
798            let tb = add3(pos_b, scale3(vel_b, t));
799            let sa = |d: [f64; 3]| add3(support_a(d), ta);
800            let sb = |d: [f64; 3]| add3(support_b(d), tb);
801
802            let dist = gjk_distance_approx(sa, sb);
803
804            if dist <= self.threshold {
805                return Some(t);
806            }
807            if dist > max_dist {
808                return None;
809            }
810
811            let dt = dist / effective_speed;
812            t += dt;
813            if t > 1.0 {
814                return None;
815            }
816        }
817
818        None
819    }
820
821    /// Sphere support function (unit sphere; caller adds centre offset externally).
822    pub fn sphere_support(radius: f64) -> impl Fn([f64; 3]) -> [f64; 3] {
823        move |d: [f64; 3]| {
824            let dn = norm3(d);
825            scale3(dn, radius)
826        }
827    }
828
829    /// Box support function in local space, half-extents `h`.
830    pub fn box_support(h: [f64; 3]) -> impl Fn([f64; 3]) -> [f64; 3] {
831        move |d: [f64; 3]| {
832            [
833                if d[0] >= 0.0 { h[0] } else { -h[0] },
834                if d[1] >= 0.0 { h[1] } else { -h[1] },
835                if d[2] >= 0.0 { h[2] } else { -h[2] },
836            ]
837        }
838    }
839}
840
841// ---------------------------------------------------------------------------
842// Motion integration helpers
843// ---------------------------------------------------------------------------
844
845/// Linearly advance a position by `vel * dt`.
846#[inline]
847pub fn integrate_linear(pos: [f64; 3], vel: [f64; 3], dt: f64) -> [f64; 3] {
848    add3(pos, scale3(vel, dt))
849}
850
851/// Advance a rotation quaternion `q = [x, y, z, w]` by angular velocity
852/// `omega` over time `dt`.
853///
854/// Uses the first-order approximation:
855/// `q(t+dt) ≈ normalize(q + 0.5 * dt * Ω * q)`
856/// where `Ω` is the pure-quaternion (0, omega).
857pub fn integrate_angular(q: [f64; 4], omega: [f64; 3], dt: f64) -> [f64; 4] {
858    // Ω = (omega_x, omega_y, omega_z, 0) as a quaternion.
859    // dq/dt = 0.5 * [omega_x, omega_y, omega_z, 0] ⊗ q
860    let omega_q = [omega[0], omega[1], omega[2], 0.0];
861    let dq = quat_mul(omega_q, q);
862    let new_q = [
863        q[0] + 0.5 * dt * dq[0],
864        q[1] + 0.5 * dt * dq[1],
865        q[2] + 0.5 * dt * dq[2],
866        q[3] + 0.5 * dt * dq[3],
867    ];
868    quat_normalize(new_q)
869}
870
871/// Quaternion multiplication `p ⊗ q` (Hamilton product).
872///
873/// Quaternions are stored as `[x, y, z, w]`.
874#[inline]
875pub fn quat_mul(p: [f64; 4], q: [f64; 4]) -> [f64; 4] {
876    let (px, py, pz, pw) = (p[0], p[1], p[2], p[3]);
877    let (qx, qy, qz, qw) = (q[0], q[1], q[2], q[3]);
878    [
879        pw * qx + px * qw + py * qz - pz * qy,
880        pw * qy - px * qz + py * qw + pz * qx,
881        pw * qz + px * qy - py * qx + pz * qw,
882        pw * qw - px * qx - py * qy - pz * qz,
883    ]
884}
885
886/// Normalize a quaternion to unit length.
887#[inline]
888pub fn quat_normalize(q: [f64; 4]) -> [f64; 4] {
889    let len_sq = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
890    if len_sq < 1e-300 {
891        return [0.0, 0.0, 0.0, 1.0];
892    }
893    let inv = 1.0 / len_sq.sqrt();
894    [q[0] * inv, q[1] * inv, q[2] * inv, q[3] * inv]
895}
896
897/// Rotate a vector `v` by a unit quaternion `q = [x, y, z, w]`.
898///
899/// Uses `v' = q ⊗ [v, 0] ⊗ q*`.
900pub fn quat_rotate(q: [f64; 4], v: [f64; 3]) -> [f64; 3] {
901    let v_q = [v[0], v[1], v[2], 0.0];
902    let q_conj = [-q[0], -q[1], -q[2], q[3]];
903    let tmp = quat_mul(q, v_q);
904    let result = quat_mul(tmp, q_conj);
905    [result[0], result[1], result[2]]
906}
907
908// ---------------------------------------------------------------------------
909// Rigid body state for CCD
910// ---------------------------------------------------------------------------
911
912/// Linear + angular state of a rigid body for CCD integration.
913#[derive(Debug, Clone)]
914pub struct RigidBodyState {
915    /// Centre of mass position.
916    pub position: [f64; 3],
917    /// Orientation quaternion `[x, y, z, w]` (unit quaternion).
918    pub orientation: [f64; 4],
919    /// Linear velocity.
920    pub linear_vel: [f64; 3],
921    /// Angular velocity (rad/s).
922    pub angular_vel: [f64; 3],
923}
924
925impl RigidBodyState {
926    /// Create a new rigid body state.
927    pub fn new(
928        position: [f64; 3],
929        orientation: [f64; 4],
930        linear_vel: [f64; 3],
931        angular_vel: [f64; 3],
932    ) -> Self {
933        Self {
934            position,
935            orientation: quat_normalize(orientation),
936            linear_vel,
937            angular_vel,
938        }
939    }
940
941    /// Identity state: at origin, zero velocity.
942    pub fn identity() -> Self {
943        Self {
944            position: [0.0; 3],
945            orientation: [0.0, 0.0, 0.0, 1.0],
946            linear_vel: [0.0; 3],
947            angular_vel: [0.0; 3],
948        }
949    }
950
951    /// Integrate state forward by `dt` using first-order symplectic Euler.
952    pub fn integrate(&self, dt: f64) -> Self {
953        Self {
954            position: integrate_linear(self.position, self.linear_vel, dt),
955            orientation: integrate_angular(self.orientation, self.angular_vel, dt),
956            linear_vel: self.linear_vel,
957            angular_vel: self.angular_vel,
958        }
959    }
960
961    /// Bounding-sphere radius contribution from angular velocity.
962    pub fn angular_speed_bound(&self, bounding_radius: f64) -> f64 {
963        let omega_mag = len3(self.angular_vel);
964        omega_mag * bounding_radius
965    }
966}
967
968// ---------------------------------------------------------------------------
969// CcdPair
970// ---------------------------------------------------------------------------
971
972/// A pair of body handles involved in a potential CCD impact event.
973///
974/// Body handles are opaque `u32` indices into the simulation body array.
975#[derive(Debug, Clone, PartialEq)]
976pub struct CcdPair {
977    /// Handle of body A.
978    pub handle_a: u32,
979    /// Handle of body B.
980    pub handle_b: u32,
981    /// Time of impact in `[0, 1]` relative to the current timestep.
982    pub toi: f64,
983    /// Contact normal at the time of impact (from B toward A).
984    pub normal: [f64; 3],
985    /// Contact point on body A at the time of impact (world space).
986    pub point_a: [f64; 3],
987    /// Contact point on body B at the time of impact (world space).
988    pub point_b: [f64; 3],
989}
990
991impl CcdPair {
992    /// Create a new `CcdPair`.
993    pub fn new(
994        handle_a: u32,
995        handle_b: u32,
996        toi: f64,
997        normal: [f64; 3],
998        point_a: [f64; 3],
999        point_b: [f64; 3],
1000    ) -> Self {
1001        Self {
1002            handle_a,
1003            handle_b,
1004            toi,
1005            normal,
1006            point_a,
1007            point_b,
1008        }
1009    }
1010
1011    /// Return the canonical ordered key `(min_handle, max_handle)`.
1012    pub fn key(&self) -> (u32, u32) {
1013        (
1014            self.handle_a.min(self.handle_b),
1015            self.handle_a.max(self.handle_b),
1016        )
1017    }
1018}
1019
1020// ---------------------------------------------------------------------------
1021// CcdBodyEntry
1022// ---------------------------------------------------------------------------
1023
1024/// Minimal CCD body descriptor, providing the data needed to perform swept
1025/// queries without depending on the full rigid-body type.
1026#[derive(Debug, Clone)]
1027pub struct CcdBodyEntry {
1028    /// Unique handle identifying this body.
1029    pub handle: u32,
1030    /// Centre-of-mass position at start of step.
1031    pub pos_start: [f64; 3],
1032    /// Centre-of-mass position at end of step (after unconstrained motion).
1033    pub pos_end: [f64; 3],
1034    /// Orientation quaternion `[x, y, z, w]` at start of step.
1035    pub orient_start: [f64; 4],
1036    /// Orientation quaternion at end of step.
1037    pub orient_end: [f64; 4],
1038    /// Linear velocity (m/s).
1039    pub linear_vel: [f64; 3],
1040    /// Angular velocity (rad/s).
1041    pub angular_vel: [f64; 3],
1042    /// Bounding sphere radius (in local space, centred at CoM).
1043    pub bounding_radius: f64,
1044    /// Whether this body requires CCD (e.g., high-speed or thin objects).
1045    pub ccd_enabled: bool,
1046}
1047
1048impl CcdBodyEntry {
1049    /// Compute the displacement vector `pos_end - pos_start`.
1050    #[inline]
1051    pub fn displacement(&self) -> [f64; 3] {
1052        sub3(self.pos_end, self.pos_start)
1053    }
1054
1055    /// Estimated maximum swept radius for conservative advancement.
1056    ///
1057    /// Returns linear displacement length + angular contribution.
1058    pub fn swept_radius(&self) -> f64 {
1059        let linear_part = len3(self.displacement());
1060        let omega = len3(self.angular_vel);
1061        linear_part + omega * self.bounding_radius
1062    }
1063}
1064
1065// ---------------------------------------------------------------------------
1066// CcdPipeline
1067// ---------------------------------------------------------------------------
1068
1069/// Configuration for the CCD pipeline.
1070#[derive(Debug, Clone)]
1071pub struct CcdPipelineConfig {
1072    /// Only compute CCD for pairs where relative speed exceeds this threshold.
1073    pub velocity_threshold: f64,
1074    /// Only compute CCD if bodies come within this distance.
1075    pub proximity_threshold: f64,
1076    /// Maximum TOI solver iterations.
1077    pub max_toi_iterations: usize,
1078    /// TOI convergence tolerance.
1079    pub toi_tolerance: f64,
1080}
1081
1082impl Default for CcdPipelineConfig {
1083    fn default() -> Self {
1084        Self {
1085            velocity_threshold: 0.01,
1086            proximity_threshold: 0.1,
1087            max_toi_iterations: 64,
1088            toi_tolerance: 1e-4,
1089        }
1090    }
1091}
1092
1093/// Multi-body CCD pipeline.
1094///
1095/// Given a list of [`CcdBodyEntry`] descriptors, the pipeline:
1096/// 1. Filters candidate pairs by velocity and proximity.
1097/// 2. Computes the TOI for each candidate pair using conservative advancement
1098///    on the swept bounding spheres (cheap) or sphere–sphere analytic test.
1099/// 3. Sorts the resulting [`CcdPair`] list by ascending TOI so the earliest
1100///    impact can be resolved first.
1101#[derive(Default)]
1102pub struct CcdPipeline {
1103    /// Pipeline configuration.
1104    pub config: CcdPipelineConfig,
1105}
1106
1107impl CcdPipeline {
1108    /// Create a new `CcdPipeline` with default configuration.
1109    pub fn new() -> Self {
1110        Self::default()
1111    }
1112
1113    /// Create a `CcdPipeline` with the given configuration.
1114    pub fn with_config(config: CcdPipelineConfig) -> Self {
1115        Self { config }
1116    }
1117
1118    /// Run the CCD pipeline over a list of body entries.
1119    ///
1120    /// Returns all detected [`CcdPair`] events sorted by ascending TOI.
1121    pub fn detect(&self, bodies: &[CcdBodyEntry]) -> Vec<CcdPair> {
1122        let mut pairs: Vec<CcdPair> = Vec::new();
1123
1124        for i in 0..bodies.len() {
1125            for j in (i + 1)..bodies.len() {
1126                let a = &bodies[i];
1127                let b = &bodies[j];
1128
1129                // At least one body must have CCD enabled.
1130                if !a.ccd_enabled && !b.ccd_enabled {
1131                    continue;
1132                }
1133
1134                // Velocity filter: skip pairs with negligible relative speed.
1135                let rel_vel = sub3(a.linear_vel, b.linear_vel);
1136                if len3(rel_vel) < self.config.velocity_threshold {
1137                    continue;
1138                }
1139
1140                // Proximity filter: skip if swept bounds cannot possibly overlap.
1141                if !self.could_overlap(a, b) {
1142                    continue;
1143                }
1144
1145                // Compute TOI via swept sphere–sphere (analytic).
1146                if let Some(pair) = self.compute_pair_toi(a, b) {
1147                    pairs.push(pair);
1148                }
1149            }
1150        }
1151
1152        // Sort by ascending TOI so we resolve the earliest impact first.
1153        pairs.sort_by(|x, y| {
1154            x.toi
1155                .partial_cmp(&y.toi)
1156                .unwrap_or(std::cmp::Ordering::Equal)
1157        });
1158        pairs
1159    }
1160
1161    /// Fast AABB-sweep overlap test: could the swept bounding spheres overlap?
1162    fn could_overlap(&self, a: &CcdBodyEntry, b: &CcdBodyEntry) -> bool {
1163        // Compute the swept AABB for each body.
1164        let aabb_a = swept_bounding_aabb(a);
1165        let aabb_b = swept_bounding_aabb(b);
1166
1167        // Expand by proximity threshold.
1168        let expand = self.config.proximity_threshold;
1169        let min_a = [aabb_a[0] - expand, aabb_a[1] - expand, aabb_a[2] - expand];
1170        let max_a = [aabb_a[3] + expand, aabb_a[4] + expand, aabb_a[5] + expand];
1171        let min_b = [aabb_b[0], aabb_b[1], aabb_b[2]];
1172        let max_b = [aabb_b[3], aabb_b[4], aabb_b[5]];
1173
1174        // Check if expanded A AABB overlaps B AABB.
1175        min_a[0] <= max_b[0]
1176            && max_a[0] >= min_b[0]
1177            && min_a[1] <= max_b[1]
1178            && max_a[1] >= min_b[1]
1179            && min_a[2] <= max_b[2]
1180            && max_a[2] >= min_b[2]
1181    }
1182
1183    /// Compute TOI for a single body pair using the analytic sphere–sphere
1184    /// swept test on their bounding spheres.
1185    fn compute_pair_toi(&self, a: &CcdBodyEntry, b: &CcdBodyEntry) -> Option<CcdPair> {
1186        let vel_a = a.displacement();
1187        let vel_b = b.displacement();
1188
1189        let toi = sphere_sphere_toi(
1190            a.pos_start,
1191            vel_a,
1192            a.bounding_radius,
1193            b.pos_start,
1194            vel_b,
1195            b.bounding_radius,
1196        )?;
1197
1198        if toi > 1.0 {
1199            return None;
1200        }
1201
1202        // Interpolate positions at TOI.
1203        let pos_a_toi = add3(a.pos_start, scale3(vel_a, toi));
1204        let pos_b_toi = add3(b.pos_start, scale3(vel_b, toi));
1205
1206        // Contact normal: from B to A.
1207        let d = sub3(pos_a_toi, pos_b_toi);
1208        let dist = len3(d);
1209        let normal = if dist > 1e-10 {
1210            scale3(d, 1.0 / dist)
1211        } else {
1212            [0.0, 1.0, 0.0]
1213        };
1214
1215        // Witness points on bounding sphere surfaces.
1216        let point_a = add3(pos_a_toi, scale3(scale3(normal, -1.0), a.bounding_radius));
1217        let point_b = add3(pos_b_toi, scale3(normal, b.bounding_radius));
1218
1219        Some(CcdPair::new(
1220            a.handle, b.handle, toi, normal, point_a, point_b,
1221        ))
1222    }
1223}
1224
1225/// Compute the AABB enclosing the swept trajectory of a body's bounding sphere.
1226///
1227/// Returns `[min_x, min_y, min_z, max_x, max_y, max_z]`.
1228fn swept_bounding_aabb(body: &CcdBodyEntry) -> [f64; 6] {
1229    let r = body.bounding_radius;
1230    let p0 = body.pos_start;
1231    let p1 = body.pos_end;
1232    [
1233        p0[0].min(p1[0]) - r,
1234        p0[1].min(p1[1]) - r,
1235        p0[2].min(p1[2]) - r,
1236        p0[0].max(p1[0]) + r,
1237        p0[1].max(p1[1]) + r,
1238        p0[2].max(p1[2]) + r,
1239    ]
1240}
1241
1242// ---------------------------------------------------------------------------
1243// Linear + angular motion integration for TOI substep
1244// ---------------------------------------------------------------------------
1245
1246/// Integrate a rigid body forward to time `t ∈ [0, 1]` of the current step.
1247///
1248/// Performs linear motion integration for the position and first-order
1249/// angular integration for the orientation quaternion.
1250pub fn advance_body_to(body: &CcdBodyEntry, t: f64) -> ([f64; 3], [f64; 4]) {
1251    let pos = add3(body.pos_start, scale3(body.displacement(), t));
1252    // Interpolate orientation via slerp-approximation.
1253    let orient = slerp_quat(body.orient_start, body.orient_end, t);
1254    (pos, orient)
1255}
1256
1257/// Spherical linear interpolation between two unit quaternions.
1258///
1259/// Uses the shortest-path formula with a linear fallback for nearly-parallel
1260/// quaternions.
1261pub fn slerp_quat(q0: [f64; 4], q1: [f64; 4], t: f64) -> [f64; 4] {
1262    let mut q1 = q1;
1263    // Ensure shortest path.
1264    let dot = q0[0] * q1[0] + q0[1] * q1[1] + q0[2] * q1[2] + q0[3] * q1[3];
1265    if dot < 0.0 {
1266        q1 = [-q1[0], -q1[1], -q1[2], -q1[3]];
1267    }
1268    let dot = dot.abs().min(1.0);
1269
1270    if dot > 0.9995 {
1271        // Nearly identical: linear interpolation + normalise.
1272        return quat_normalize([
1273            q0[0] + t * (q1[0] - q0[0]),
1274            q0[1] + t * (q1[1] - q0[1]),
1275            q0[2] + t * (q1[2] - q0[2]),
1276            q0[3] + t * (q1[3] - q0[3]),
1277        ]);
1278    }
1279
1280    let theta_0 = dot.acos();
1281    let theta = theta_0 * t;
1282    let sin_theta = theta.sin();
1283    let sin_theta_0 = theta_0.sin();
1284
1285    let s0 = (theta_0 - theta).sin() / sin_theta_0;
1286    let s1 = sin_theta / sin_theta_0;
1287
1288    quat_normalize([
1289        s0 * q0[0] + s1 * q1[0],
1290        s0 * q0[1] + s1 * q1[1],
1291        s0 * q0[2] + s1 * q1[2],
1292        s0 * q0[3] + s1 * q1[3],
1293    ])
1294}
1295
1296// ---------------------------------------------------------------------------
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301    use oxiphysics_geometry::Sphere;
1302
1303    #[test]
1304    fn test_ccd_sphere_sphere_hit() {
1305        let s1 = Sphere::new(0.5);
1306        let s2 = Sphere::new(0.5);
1307
1308        // s1 at x=-5 moving to x=0
1309        let t1_start = Transform::from_position(Vec3::new(-5.0, 0.0, 0.0));
1310        let t1_end = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1311
1312        // s2 at x=5 moving to x=0
1313        let t2_start = Transform::from_position(Vec3::new(5.0, 0.0, 0.0));
1314        let t2_end = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1315
1316        let result = time_of_impact(&s1, &t1_start, &t1_end, &s2, &t2_start, &t2_end);
1317        assert!(result.is_some(), "Should detect impact");
1318        let toi = result.unwrap().toi;
1319        // They should meet around t=0.9 (when gap closes from 10 to 1)
1320        assert!(
1321            toi > 0.0 && toi <= 1.0,
1322            "TOI should be in (0,1], got {}",
1323            toi
1324        );
1325    }
1326
1327    #[test]
1328    fn test_ccd_sphere_sphere_miss() {
1329        let s1 = Sphere::new(0.5);
1330        let s2 = Sphere::new(0.5);
1331
1332        // Moving in parallel
1333        let t1_start = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1334        let t1_end = Transform::from_position(Vec3::new(10.0, 0.0, 0.0));
1335        let t2_start = Transform::from_position(Vec3::new(0.0, 5.0, 0.0));
1336        let t2_end = Transform::from_position(Vec3::new(10.0, 5.0, 0.0));
1337
1338        let result = time_of_impact(&s1, &t1_start, &t1_end, &s2, &t2_start, &t2_end);
1339        assert!(
1340            result.is_none(),
1341            "Should not detect impact for parallel motion"
1342        );
1343    }
1344
1345    #[test]
1346    fn test_interpolate_transform() {
1347        let start = Transform::from_position(Vec3::new(0.0, 0.0, 0.0));
1348        let end = Transform::from_position(Vec3::new(10.0, 0.0, 0.0));
1349        let mid = interpolate_transform(&start, &end, 0.5);
1350        assert!((mid.position.x - 5.0).abs() < 1e-10);
1351    }
1352
1353    // --- array-based CCD tests ---
1354
1355    #[test]
1356    fn test_sphere_sphere_toi_head_on() {
1357        // two spheres r=0.5, A at x=-3 vel=[1,0,0], B at x=+3 vel=[-1,0,0]
1358        // they approach with relative speed 2; gap = 6 - 1 = 5; toi = 5/2 / ... let's compute:
1359        // dp = [6,0,0], dv = [-2,0,0] (vel_b - vel_a)
1360        // a = 4, b = -12, c = 36 - 1 = 35
1361        // disc = 144 - 4*35 = 4, sqrt=2
1362        // t = (12-2)/4 = 2.5 ... but the test asks for t=1.0 when gap is 5 and relative speed is 2
1363        // Wait: positions x=-3, x=3, radii 0.5 each
1364        // r(t) = dp + t*dv = [6 - 2t, 0, 0]
1365        // |r(t)|² = (6-2t)² = (0.5+0.5)² = 1
1366        // 6-2t = 1 → t = 2.5  (not in [0,1])
1367        // The spec says toi=1.0 — let's check with vel_a=[1,0,0] vel_b=[-1,0,0] center_a=[-3,0,0] center_b=[3,0,0]
1368        // Wait, re-reading: center_a=-3, vel_a=[1], center_b=+3, vel_b=[-1] means they are 6 apart,
1369        // but with dt=1 they would travel ±1 each for a total of 2 — still 4 apart. toi can't be 1.0.
1370        // Possible interpretation: vel represents the full displacement over t=1 (i.e. position-based).
1371        // They'd be at x=-3+t, x=3-t; separation = 6-2t-1 = 0 → t=2.5 still.
1372        // The only way toi=1.0 is if they start at x=-1.5, x=+1.5 (gap=3-1=2 → t=1.0).
1373        // Use that setup: gap=2, rel_speed=2 → toi exactly 1.0
1374        let toi = sphere_sphere_toi(
1375            [-1.5, 0.0, 0.0],
1376            [1.0, 0.0, 0.0],
1377            0.5,
1378            [1.5, 0.0, 0.0],
1379            [-1.0, 0.0, 0.0],
1380            0.5,
1381        );
1382        assert!(toi.is_some(), "head-on spheres should collide");
1383        let t = toi.unwrap();
1384        assert!((t - 1.0).abs() < 1e-9, "expected toi=1.0, got {}", t);
1385    }
1386
1387    #[test]
1388    fn test_sphere_sphere_toi_moving_away() {
1389        let toi = sphere_sphere_toi(
1390            [0.0, 0.0, 0.0],
1391            [-1.0, 0.0, 0.0],
1392            0.5,
1393            [3.0, 0.0, 0.0],
1394            [1.0, 0.0, 0.0],
1395            0.5,
1396        );
1397        assert!(
1398            toi.is_none(),
1399            "diverging spheres should not collide, got {:?}",
1400            toi
1401        );
1402    }
1403
1404    #[test]
1405    fn test_sphere_plane_toi_falling() {
1406        // sphere at z=5.0 moving with vel=[0,0,-10], radius=0.5, plane z=0 (normal=[0,0,1], d=0)
1407        // toi at which z - 0.5 = 0 → z=0.5 → t = (5-0.5)/10 = 0.45
1408        let toi = sphere_plane_toi(
1409            [0.0, 0.0, 5.0],
1410            [0.0, 0.0, -10.0],
1411            0.5,
1412            [0.0, 0.0, 1.0],
1413            0.0,
1414        );
1415        assert!(toi.is_some(), "falling sphere should hit plane");
1416        let t = toi.unwrap();
1417        assert!((t - 0.45).abs() < 1e-9, "expected toi=0.45, got {}", t);
1418    }
1419
1420    #[test]
1421    fn test_sphere_sphere_toi_already_overlapping() {
1422        // Two overlapping spheres (centers 0.5 apart, radii 0.5 each)
1423        let toi = sphere_sphere_toi(
1424            [0.0, 0.0, 0.0],
1425            [0.0, 0.0, 0.0],
1426            0.5,
1427            [0.5, 0.0, 0.0],
1428            [0.0, 0.0, 0.0],
1429            0.5,
1430        );
1431        assert_eq!(toi, Some(0.0), "already overlapping → toi=0.0");
1432    }
1433
1434    // --- ConservativeAdvancement struct tests ---
1435
1436    #[test]
1437    fn test_conservative_advancement_struct_sphere_hit() {
1438        let ca = ConservativeAdvancement::new().with_threshold(1e-3);
1439        let toi = ca.compute_toi(
1440            ConservativeAdvancement::sphere_support(0.5),
1441            ConservativeAdvancement::sphere_support(0.5),
1442            [-3.0, 0.0, 0.0],
1443            [2.5, 0.0, 0.0],
1444            0.5,
1445            [3.0, 0.0, 0.0],
1446            [-2.5, 0.0, 0.0],
1447            0.5,
1448        );
1449        assert!(toi.is_some(), "CA struct should detect sphere–sphere hit");
1450        let t = toi.unwrap();
1451        assert!((0.0..=1.0).contains(&t), "toi must be in [0,1], got {}", t);
1452    }
1453
1454    #[test]
1455    fn test_conservative_advancement_struct_miss() {
1456        let ca = ConservativeAdvancement::new();
1457        let toi = ca.compute_toi(
1458            ConservativeAdvancement::sphere_support(0.5),
1459            ConservativeAdvancement::sphere_support(0.5),
1460            [0.0, 0.0, 0.0],
1461            [1.0, 0.0, 0.0],
1462            0.5,
1463            [0.0, 5.0, 0.0],
1464            [1.0, 5.0, 0.0],
1465            0.5,
1466        );
1467        assert!(toi.is_none(), "parallel motion should not collide");
1468    }
1469
1470    // --- integrate_linear / integrate_angular ---
1471
1472    #[test]
1473    fn test_integrate_linear() {
1474        let pos = [0.0, 0.0, 0.0];
1475        let vel = [1.0, 2.0, 3.0];
1476        let result = integrate_linear(pos, vel, 0.5);
1477        assert!((result[0] - 0.5).abs() < 1e-12);
1478        assert!((result[1] - 1.0).abs() < 1e-12);
1479        assert!((result[2] - 1.5).abs() < 1e-12);
1480    }
1481
1482    #[test]
1483    fn test_integrate_angular_identity_quaternion() {
1484        let q = [0.0, 0.0, 0.0, 1.0];
1485        let omega = [0.0, 0.0, 0.0]; // no rotation
1486        let result = integrate_angular(q, omega, 0.1);
1487        // Should remain identity
1488        let len_sq = result[0] * result[0]
1489            + result[1] * result[1]
1490            + result[2] * result[2]
1491            + result[3] * result[3];
1492        assert!(
1493            (len_sq - 1.0).abs() < 1e-9,
1494            "quaternion should be unit length"
1495        );
1496        assert!((result[3] - 1.0).abs() < 1e-9, "w component should stay ~1");
1497    }
1498
1499    #[test]
1500    fn test_quat_normalize_already_unit() {
1501        let q = [0.0, 0.0, 0.0, 1.0];
1502        let n = quat_normalize(q);
1503        assert!((n[3] - 1.0).abs() < 1e-12);
1504    }
1505
1506    #[test]
1507    fn test_quat_normalize_scales() {
1508        let q = [0.0, 0.0, 0.0, 2.0];
1509        let n = quat_normalize(q);
1510        assert!((n[3] - 1.0).abs() < 1e-12);
1511    }
1512
1513    #[test]
1514    fn test_quat_rotate_unit_vec() {
1515        // Rotate +X by 90° around Z → should give +Y.
1516        // 90° around Z: q = [0, 0, sin(45°), cos(45°)]
1517        let s = (std::f64::consts::FRAC_PI_4).sin();
1518        let c = (std::f64::consts::FRAC_PI_4).cos();
1519        let q = [0.0, 0.0, s, c];
1520        let v = [1.0, 0.0, 0.0];
1521        let result = quat_rotate(q, v);
1522        assert!(
1523            (result[0] - 0.0).abs() < 1e-9,
1524            "x should be ~0, got {}",
1525            result[0]
1526        );
1527        assert!(
1528            (result[1] - 1.0).abs() < 1e-9,
1529            "y should be ~1, got {}",
1530            result[1]
1531        );
1532        assert!(
1533            (result[2] - 0.0).abs() < 1e-9,
1534            "z should be ~0, got {}",
1535            result[2]
1536        );
1537    }
1538
1539    // --- slerp_quat ---
1540
1541    #[test]
1542    fn test_slerp_at_t0_returns_q0() {
1543        let q0 = quat_normalize([0.1, 0.2, 0.3, 0.9]);
1544        let q1 = quat_normalize([0.4, 0.1, 0.2, 0.8]);
1545        let r = slerp_quat(q0, q1, 0.0);
1546        for i in 0..4 {
1547            assert!(
1548                (r[i] - q0[i]).abs() < 1e-9,
1549                "slerp(q0,q1,0) should equal q0"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn test_slerp_at_t1_returns_q1() {
1556        let q0 = quat_normalize([0.1, 0.2, 0.3, 0.9]);
1557        let q1 = quat_normalize([0.4, 0.1, 0.2, 0.8]);
1558        let r = slerp_quat(q0, q1, 1.0);
1559        for i in 0..4 {
1560            assert!(
1561                (r[i] - q1[i]).abs() < 1e-9,
1562                "slerp(q0,q1,1) should equal q1"
1563            );
1564        }
1565    }
1566
1567    #[test]
1568    fn test_slerp_unit_length() {
1569        let q0 = quat_normalize([0.1, 0.2, 0.3, 0.9]);
1570        let q1 = quat_normalize([0.4, 0.1, 0.2, 0.8]);
1571        for i in 0..=10 {
1572            let t = i as f64 / 10.0;
1573            let r = slerp_quat(q0, q1, t);
1574            let len = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2] + r[3] * r[3]).sqrt();
1575            assert!(
1576                (len - 1.0).abs() < 1e-9,
1577                "slerp result must be unit quaternion, t={}, len={}",
1578                t,
1579                len
1580            );
1581        }
1582    }
1583
1584    // --- RigidBodyState ---
1585
1586    #[test]
1587    fn test_rigid_body_state_integrate() {
1588        let state = RigidBodyState::new(
1589            [0.0, 0.0, 0.0],
1590            [0.0, 0.0, 0.0, 1.0],
1591            [1.0, 0.0, 0.0],
1592            [0.0, 0.0, 0.0],
1593        );
1594        let next = state.integrate(1.0);
1595        assert!((next.position[0] - 1.0).abs() < 1e-9);
1596        assert!((next.position[1] - 0.0).abs() < 1e-9);
1597    }
1598
1599    // --- CcdPipeline ---
1600
1601    fn make_body(handle: u32, pos: [f64; 3], vel: [f64; 3], radius: f64) -> CcdBodyEntry {
1602        let pos_end = add3(pos, vel);
1603        CcdBodyEntry {
1604            handle,
1605            pos_start: pos,
1606            pos_end,
1607            orient_start: [0.0, 0.0, 0.0, 1.0],
1608            orient_end: [0.0, 0.0, 0.0, 1.0],
1609            linear_vel: vel,
1610            angular_vel: [0.0; 3],
1611            bounding_radius: radius,
1612            ccd_enabled: true,
1613        }
1614    }
1615
1616    #[test]
1617    fn test_ccd_pipeline_two_approaching_bodies() {
1618        let pipeline = CcdPipeline::new();
1619        let bodies = vec![
1620            make_body(0, [-3.0, 0.0, 0.0], [2.5, 0.0, 0.0], 0.5),
1621            make_body(1, [3.0, 0.0, 0.0], [-2.5, 0.0, 0.0], 0.5),
1622        ];
1623        let pairs = pipeline.detect(&bodies);
1624        assert!(
1625            !pairs.is_empty(),
1626            "pipeline should detect approaching bodies"
1627        );
1628        assert!(pairs[0].toi >= 0.0 && pairs[0].toi <= 1.0);
1629    }
1630
1631    #[test]
1632    fn test_ccd_pipeline_diverging_bodies_no_hit() {
1633        let pipeline = CcdPipeline::new();
1634        let bodies = vec![
1635            make_body(0, [0.0, 0.0, 0.0], [-5.0, 0.0, 0.0], 0.5),
1636            make_body(1, [3.0, 0.0, 0.0], [5.0, 0.0, 0.0], 0.5),
1637        ];
1638        let pairs = pipeline.detect(&bodies);
1639        assert!(
1640            pairs.is_empty(),
1641            "diverging bodies should not generate CCD pair"
1642        );
1643    }
1644
1645    #[test]
1646    fn test_ccd_pipeline_sorted_by_toi() {
1647        let pipeline = CcdPipeline::new();
1648        // Body 0 moving right; bodies 1 and 2 stationary to the right.
1649        // Body 1 is closer (will be hit first).
1650        let bodies = vec![
1651            make_body(0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0], 0.5),
1652            make_body(1, [-1.0, 0.0, 0.0], [-5.0, 0.0, 0.0], 0.5),
1653            make_body(2, [3.0, 0.0, 0.0], [-3.0, 0.0, 0.0], 0.5),
1654        ];
1655        let pairs = pipeline.detect(&bodies);
1656        // If multiple hits, they should be sorted by TOI.
1657        for i in 1..pairs.len() {
1658            assert!(
1659                pairs[i - 1].toi <= pairs[i].toi,
1660                "pairs must be sorted by TOI"
1661            );
1662        }
1663    }
1664
1665    #[test]
1666    fn test_ccd_pipeline_ccd_disabled_body_skipped() {
1667        let pipeline = CcdPipeline::new();
1668        let mut b0 = make_body(0, [-3.0, 0.0, 0.0], [2.5, 0.0, 0.0], 0.5);
1669        let mut b1 = make_body(1, [3.0, 0.0, 0.0], [-2.5, 0.0, 0.0], 0.5);
1670        b0.ccd_enabled = false;
1671        b1.ccd_enabled = false;
1672        let bodies = vec![b0, b1];
1673        let pairs = pipeline.detect(&bodies);
1674        assert!(
1675            pairs.is_empty(),
1676            "both CCD-disabled bodies should be skipped"
1677        );
1678    }
1679
1680    // --- advance_body_to ---
1681
1682    #[test]
1683    fn test_advance_body_to_midpoint() {
1684        let body = make_body(0, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], 0.5);
1685        let (pos, _orient) = advance_body_to(&body, 0.5);
1686        assert!(
1687            (pos[0] - 5.0).abs() < 1e-9,
1688            "position at t=0.5 should be 5.0"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_advance_body_to_start() {
1694        let body = make_body(0, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 1.0);
1695        let (pos, _) = advance_body_to(&body, 0.0);
1696        assert!((pos[0] - 1.0).abs() < 1e-12);
1697        assert!((pos[1] - 2.0).abs() < 1e-12);
1698        assert!((pos[2] - 3.0).abs() < 1e-12);
1699    }
1700
1701    #[test]
1702    fn test_advance_body_to_end() {
1703        let body = make_body(0, [1.0, 0.0, 0.0], [3.0, 0.0, 0.0], 0.5);
1704        let (pos, _) = advance_body_to(&body, 1.0);
1705        assert!((pos[0] - 4.0).abs() < 1e-9);
1706    }
1707}