Skip to main content

oxiphysics_collision/
shape_cast.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shape casting (continuous collision detection / swept queries).
5//!
6//! Provides time-of-impact (TOI) computation for moving shapes: spheres,
7//! capsules, boxes, and general convex shapes. Uses `[f64; 3]` for 3D vectors.
8
9// ─────────────────────────────────────────────────────────────────────────────
10// Vector helpers
11// ─────────────────────────────────────────────────────────────────────────────
12
13fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
14    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
15}
16
17fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
18    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
19}
20
21fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
22    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
23}
24
25fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
26    [a[0] * s, a[1] * s, a[2] * s]
27}
28
29fn len3(a: [f64; 3]) -> f64 {
30    dot3(a, a).sqrt()
31}
32
33fn normalize3(a: [f64; 3]) -> [f64; 3] {
34    let l = len3(a).max(1e-15);
35    scale3(a, 1.0 / l)
36}
37
38fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
39    add3(scale3(a, 1.0 - t), scale3(b, t))
40}
41
42fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
43    len3(sub3(a, b))
44}
45
46// ─────────────────────────────────────────────────────────────────────────────
47// ShapeCastConfig
48// ─────────────────────────────────────────────────────────────────────────────
49
50/// Configuration for shape casting queries.
51#[derive(Debug, Clone, Copy)]
52pub struct ShapeCastConfig {
53    /// Maximum time of contact to consider (normalized to \[0, 1\]).
54    pub max_toc: f64,
55    /// Target separation distance (shapes are in contact when dist <= target_distance).
56    pub target_distance: f64,
57    /// Tolerance for time-of-impact convergence.
58    pub toi_tolerance: f64,
59}
60
61impl Default for ShapeCastConfig {
62    fn default() -> Self {
63        Self {
64            max_toc: 1.0,
65            target_distance: 0.0,
66            toi_tolerance: 1e-6,
67        }
68    }
69}
70
71// ─────────────────────────────────────────────────────────────────────────────
72// ShapeCastResult
73// ─────────────────────────────────────────────────────────────────────────────
74
75/// Result of a shape cast query.
76#[derive(Debug, Clone)]
77pub struct ShapeCastResult {
78    /// Normalized time of impact ∈ \[0, max_toc\], or `max_toc` if no hit.
79    pub toi: f64,
80    /// Contact normal at the time of impact (pointing from B to A).
81    pub normal_at_toi: [f64; 3],
82    /// Witness point on shape A at TOI.
83    pub witness_a: [f64; 3],
84    /// Witness point on shape B at TOI.
85    pub witness_b: [f64; 3],
86    /// True if the shapes were already penetrating at t=0.
87    pub is_penetrating: bool,
88    /// True if a hit was found within max_toc.
89    pub hit: bool,
90}
91
92impl ShapeCastResult {
93    /// Creates a hit result.
94    pub fn hit(
95        toi: f64,
96        normal_at_toi: [f64; 3],
97        witness_a: [f64; 3],
98        witness_b: [f64; 3],
99    ) -> Self {
100        Self {
101            toi,
102            normal_at_toi,
103            witness_a,
104            witness_b,
105            is_penetrating: false,
106            hit: true,
107        }
108    }
109
110    /// Creates a penetrating (initial overlap) result.
111    pub fn penetrating(witness_a: [f64; 3], witness_b: [f64; 3], normal: [f64; 3]) -> Self {
112        Self {
113            toi: 0.0,
114            normal_at_toi: normal,
115            witness_a,
116            witness_b,
117            is_penetrating: true,
118            hit: true,
119        }
120    }
121
122    /// Creates a no-hit result.
123    pub fn no_hit(max_toc: f64) -> Self {
124        Self {
125            toi: max_toc,
126            normal_at_toi: [0.0, 1.0, 0.0],
127            witness_a: [0.0; 3],
128            witness_b: [0.0; 3],
129            is_penetrating: false,
130            hit: false,
131        }
132    }
133}
134
135// ─────────────────────────────────────────────────────────────────────────────
136// LinearMotion
137// ─────────────────────────────────────────────────────────────────────────────
138
139/// Linear motion of a rigid body: position(t) = pos + t * vel.
140#[derive(Debug, Clone, Copy)]
141pub struct LinearMotion {
142    /// Initial position.
143    pub pos: [f64; 3],
144    /// Velocity vector.
145    pub vel: [f64; 3],
146}
147
148impl LinearMotion {
149    /// Creates a new linear motion.
150    pub fn new(pos: [f64; 3], vel: [f64; 3]) -> Self {
151        Self { pos, vel }
152    }
153
154    /// Position at time t.
155    pub fn position_at(&self, t: f64) -> [f64; 3] {
156        add3(self.pos, scale3(self.vel, t))
157    }
158
159    /// Relative velocity between two motions.
160    pub fn relative_velocity(a: &Self, b: &Self) -> [f64; 3] {
161        sub3(a.vel, b.vel)
162    }
163
164    /// Conservative TOI upper bound for two linearly moving spheres.
165    ///
166    /// Returns None if they will not collide within \[0, max_t\].
167    pub fn compute_toi_linear_sphere(
168        motion_a: &LinearMotion,
169        radius_a: f64,
170        motion_b: &LinearMotion,
171        radius_b: f64,
172        max_t: f64,
173    ) -> Option<f64> {
174        let rel_pos = sub3(motion_a.pos, motion_b.pos);
175        let rel_vel = sub3(motion_a.vel, motion_b.vel);
176        let r = radius_a + radius_b;
177
178        let a = dot3(rel_vel, rel_vel);
179        let b = 2.0 * dot3(rel_pos, rel_vel);
180        let c = dot3(rel_pos, rel_pos) - r * r;
181
182        if c <= 0.0 {
183            // Already overlapping
184            return Some(0.0);
185        }
186        if a < 1e-15 {
187            return None;
188        }
189        let disc = b * b - 4.0 * a * c;
190        if disc < 0.0 {
191            return None;
192        }
193        let t = (-b - disc.sqrt()) / (2.0 * a);
194        if t >= 0.0 && t <= max_t {
195            Some(t)
196        } else {
197            None
198        }
199    }
200}
201
202// ─────────────────────────────────────────────────────────────────────────────
203// AngularMotion
204// ─────────────────────────────────────────────────────────────────────────────
205
206/// Combined translation + rotation motion.
207#[derive(Debug, Clone, Copy)]
208pub struct AngularMotion {
209    /// Initial position.
210    pub pos: [f64; 3],
211    /// Linear velocity.
212    pub vel: [f64; 3],
213    /// Angular velocity (rotation axis * angular speed).
214    pub angular_vel: [f64; 3],
215    /// Initial orientation (unit quaternion \[x, y, z, w\]).
216    pub orientation: [f64; 4],
217}
218
219impl AngularMotion {
220    /// Creates a new angular motion (no rotation by default).
221    pub fn new(pos: [f64; 3], vel: [f64; 3]) -> Self {
222        Self {
223            pos,
224            vel,
225            angular_vel: [0.0; 3],
226            orientation: [0.0, 0.0, 0.0, 1.0],
227        }
228    }
229
230    /// Creates a motion with both translation and rotation.
231    pub fn with_rotation(pos: [f64; 3], vel: [f64; 3], angular_vel: [f64; 3]) -> Self {
232        Self {
233            pos,
234            vel,
235            angular_vel,
236            orientation: [0.0, 0.0, 0.0, 1.0],
237        }
238    }
239
240    /// Position at time t (linear component only).
241    pub fn position_at(&self, t: f64) -> [f64; 3] {
242        add3(self.pos, scale3(self.vel, t))
243    }
244
245    /// Computes an upper bound on the displacement magnitude over \[0, t\].
246    pub fn motion_bound(&self, t: f64, point_offset: f64) -> f64 {
247        let linear = len3(self.vel) * t;
248        let angular = len3(self.angular_vel) * t * point_offset;
249        linear + angular
250    }
251}
252
253// ─────────────────────────────────────────────────────────────────────────────
254// SphereCast — moving sphere cast
255// ─────────────────────────────────────────────────────────────────────────────
256
257/// Cast a moving sphere against static primitives.
258pub struct SphereCast;
259
260impl SphereCast {
261    /// Sphere vs sphere TOI computation.
262    pub fn vs_sphere(
263        motion_a: &LinearMotion,
264        radius_a: f64,
265        center_b: [f64; 3],
266        radius_b: f64,
267        cfg: &ShapeCastConfig,
268    ) -> ShapeCastResult {
269        let static_motion = LinearMotion::new(center_b, [0.0; 3]);
270        match LinearMotion::compute_toi_linear_sphere(
271            motion_a,
272            radius_a,
273            &static_motion,
274            radius_b,
275            cfg.max_toc,
276        ) {
277            Some(toi) if toi <= 0.0 => {
278                let n = normalize3(sub3(motion_a.pos, center_b));
279                ShapeCastResult::penetrating(motion_a.pos, center_b, n)
280            }
281            Some(toi) => {
282                let pos_a = motion_a.position_at(toi);
283                let n = normalize3(sub3(pos_a, center_b));
284                let wa = add3(pos_a, scale3(n, -radius_a));
285                let wb = add3(center_b, scale3(n, radius_b));
286                ShapeCastResult::hit(toi, n, wa, wb)
287            }
288            None => ShapeCastResult::no_hit(cfg.max_toc),
289        }
290    }
291
292    /// Sphere vs axis-aligned box TOI via conservative advancement.
293    pub fn vs_box(
294        motion_a: &LinearMotion,
295        radius_a: f64,
296        box_center: [f64; 3],
297        half_extents: [f64; 3],
298        cfg: &ShapeCastConfig,
299    ) -> ShapeCastResult {
300        // Use conservative advancement: step until sphere hits box
301        let lo = sub3(box_center, half_extents);
302        let hi = add3(box_center, half_extents);
303
304        let mut t = 0.0;
305        let max_iters = 64;
306        for _ in 0..max_iters {
307            let pos = motion_a.position_at(t);
308            // Closest point on box to sphere center
309            let q = [
310                pos[0].clamp(lo[0], hi[0]),
311                pos[1].clamp(lo[1], hi[1]),
312                pos[2].clamp(lo[2], hi[2]),
313            ];
314            let d = dist3(pos, q) - radius_a;
315            if d <= cfg.target_distance + cfg.toi_tolerance {
316                let n = if len3(sub3(pos, q)) > 1e-12 {
317                    normalize3(sub3(pos, q))
318                } else {
319                    [0.0, 1.0, 0.0]
320                };
321                let wa = add3(pos, scale3(n, -radius_a));
322                return ShapeCastResult::hit(t, n, wa, q);
323            }
324            // Advance by d / |vel|
325            let speed = len3(motion_a.vel).max(1e-12);
326            t += d.max(cfg.toi_tolerance) / speed;
327            if t > cfg.max_toc {
328                break;
329            }
330        }
331        ShapeCastResult::no_hit(cfg.max_toc)
332    }
333
334    /// Sphere vs triangle TOI via conservative advancement.
335    pub fn vs_triangle(
336        motion_a: &LinearMotion,
337        radius_a: f64,
338        v0: [f64; 3],
339        v1: [f64; 3],
340        v2: [f64; 3],
341        cfg: &ShapeCastConfig,
342    ) -> ShapeCastResult {
343        let mut t = 0.0;
344        let max_iters = 64;
345        for _ in 0..max_iters {
346            let pos = motion_a.position_at(t);
347            let ab = sub3(v1, v0);
348            let ac = sub3(v2, v0);
349            let ap = sub3(pos, v0);
350            let d1 = dot3(ab, ap);
351            let d2 = dot3(ac, ap);
352            let d3 = dot3(ab, sub3(pos, v1));
353            let d4 = dot3(ac, sub3(pos, v1));
354            let (u, v) = if d1 <= 0.0 && d2 <= 0.0 {
355                (1.0, 0.0)
356            } else if d3 >= 0.0 && d4 <= d3 {
357                (0.0, 1.0)
358            } else {
359                let a_coef = dot3(ab, ab);
360                let b_coef = dot3(ab, ac);
361                let c_coef = dot3(ac, ac);
362                let det = a_coef * c_coef - b_coef * b_coef;
363                if det.abs() < 1e-12 {
364                    (1.0, 0.0)
365                } else {
366                    let inv_det = 1.0 / det;
367                    let v_ = (c_coef * d1 - b_coef * d2) * inv_det;
368                    let w_ = (a_coef * d2 - b_coef * d1) * inv_det;
369                    let v_ = v_.clamp(0.0, 1.0);
370                    let w_ = w_.clamp(0.0, 1.0 - v_);
371                    (1.0 - v_ - w_, v_)
372                }
373            };
374            let _q = add3(
375                add3(scale3(v0, u + v), scale3(v1, 1.0 - u - v)),
376                scale3(v2, 0.0),
377            );
378            let _ = (u, v);
379            // Simplified: use PointTriangleDist
380            let (q, _) = crate_point_tri(pos, v0, v1, v2);
381            let d = dist3(pos, q) - radius_a;
382            if d <= cfg.target_distance + cfg.toi_tolerance {
383                let n = if len3(sub3(pos, q)) > 1e-12 {
384                    normalize3(sub3(pos, q))
385                } else {
386                    [0.0, 0.0, 1.0]
387                };
388                let wa = add3(pos, scale3(n, -radius_a));
389                return ShapeCastResult::hit(t, n, wa, q);
390            }
391            let speed = len3(motion_a.vel).max(1e-12);
392            t += d.max(cfg.toi_tolerance) / speed;
393            if t > cfg.max_toc {
394                break;
395            }
396        }
397        ShapeCastResult::no_hit(cfg.max_toc)
398    }
399}
400
401/// Local copy of the point-triangle closest point logic (avoids cross-crate dep).
402fn crate_point_tri(p: [f64; 3], v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> ([f64; 3], [f64; 3]) {
403    let ab = sub3(v1, v0);
404    let ac = sub3(v2, v0);
405    let ap = sub3(p, v0);
406
407    let d1 = dot3(ab, ap);
408    let d2 = dot3(ac, ap);
409    if d1 <= 0.0 && d2 <= 0.0 {
410        return (v0, [1.0, 0.0, 0.0]);
411    }
412    let bp = sub3(p, v1);
413    let d3 = dot3(ab, bp);
414    let d4 = dot3(ac, bp);
415    if d3 >= 0.0 && d4 <= d3 {
416        return (v1, [0.0, 1.0, 0.0]);
417    }
418    let cp = sub3(p, v2);
419    let d5 = dot3(ab, cp);
420    let d6 = dot3(ac, cp);
421    if d6 >= 0.0 && d5 <= d6 {
422        return (v2, [0.0, 0.0, 1.0]);
423    }
424    let vc = d1 * d4 - d3 * d2;
425    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
426        let v = d1 / (d1 - d3);
427        let q = add3(v0, scale3(ab, v));
428        return (q, [1.0 - v, v, 0.0]);
429    }
430    let vb = d5 * d2 - d1 * d6;
431    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
432        let w = d2 / (d2 - d6);
433        let q = add3(v0, scale3(ac, w));
434        return (q, [1.0 - w, 0.0, w]);
435    }
436    let va = d3 * d6 - d5 * d4;
437    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
438        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
439        let q = lerp3(v1, v2, w);
440        return (q, [0.0, 1.0 - w, w]);
441    }
442    let denom = 1.0 / (va + vb + vc);
443    let v = vb * denom;
444    let w = vc * denom;
445    let u = 1.0 - v - w;
446    let q = add3(add3(scale3(v0, u), scale3(v1, v)), scale3(v2, w));
447    (q, [u, v, w])
448}
449
450// ─────────────────────────────────────────────────────────────────────────────
451// CapsuleCast — moving capsule cast
452// ─────────────────────────────────────────────────────────────────────────────
453
454/// Cast a moving capsule against static primitives.
455pub struct CapsuleCast;
456
457impl CapsuleCast {
458    /// Capsule vs capsule TOI via conservative advancement.
459    pub fn vs_capsule(
460        motion: &LinearMotion,
461        local_a: [f64; 3],
462        local_b: [f64; 3],
463        radius_a: f64,
464        static_a: [f64; 3],
465        static_b: [f64; 3],
466        radius_b: f64,
467        cfg: &ShapeCastConfig,
468    ) -> ShapeCastResult {
469        let r = radius_a + radius_b;
470        let mut t = 0.0;
471        let max_iters = 64;
472        for _ in 0..max_iters {
473            let offset = motion.position_at(t);
474            let cap_a = add3(local_a, offset);
475            let cap_b = add3(local_b, offset);
476            // Segment-segment closest distance
477            let (seg_dist, wa, wb) = seg_seg_dist(cap_a, cap_b, static_a, static_b);
478            let d = seg_dist - r;
479            if d <= cfg.target_distance {
480                let n = if seg_dist > 1e-12 {
481                    normalize3(sub3(wa, wb))
482                } else {
483                    [0.0, 1.0, 0.0]
484                };
485                return ShapeCastResult::hit(t, n, wa, wb);
486            }
487            let speed = len3(motion.vel).max(1e-12);
488            t += d / speed;
489            if t > cfg.max_toc {
490                break;
491            }
492        }
493        ShapeCastResult::no_hit(cfg.max_toc)
494    }
495
496    /// Capsule vs axis-aligned box TOI.
497    pub fn vs_box(
498        motion: &LinearMotion,
499        local_a: [f64; 3],
500        local_b: [f64; 3],
501        radius: f64,
502        box_center: [f64; 3],
503        half_extents: [f64; 3],
504        cfg: &ShapeCastConfig,
505    ) -> ShapeCastResult {
506        let lo = sub3(box_center, half_extents);
507        let hi = add3(box_center, half_extents);
508        let mut t = 0.0;
509        let max_iters = 64;
510        for _ in 0..max_iters {
511            let offset = motion.position_at(t);
512            let cap_a = add3(local_a, offset);
513            let cap_b = add3(local_b, offset);
514            // Find min distance from capsule segment to box
515            let mut min_dist = f64::INFINITY;
516            let mut best_q = cap_a;
517            for &p in &[cap_a, cap_b] {
518                let q = [
519                    p[0].clamp(lo[0], hi[0]),
520                    p[1].clamp(lo[1], hi[1]),
521                    p[2].clamp(lo[2], hi[2]),
522                ];
523                let d = dist3(p, q);
524                if d < min_dist {
525                    min_dist = d;
526                    best_q = q;
527                }
528            }
529            let _ = best_q;
530            let d = min_dist - radius;
531            if d <= cfg.target_distance {
532                return ShapeCastResult::hit(t, [0.0, 1.0, 0.0], cap_a, box_center);
533            }
534            let speed = len3(motion.vel).max(1e-12);
535            t += d / speed;
536            if t > cfg.max_toc {
537                break;
538            }
539        }
540        ShapeCastResult::no_hit(cfg.max_toc)
541    }
542}
543
544/// Local segment-segment closest distance.
545fn seg_seg_dist(
546    p1: [f64; 3],
547    p2: [f64; 3],
548    p3: [f64; 3],
549    p4: [f64; 3],
550) -> (f64, [f64; 3], [f64; 3]) {
551    let d1 = sub3(p2, p1);
552    let d2 = sub3(p4, p3);
553    let r = sub3(p1, p3);
554    let a = dot3(d1, d1);
555    let e = dot3(d2, d2);
556    let f = dot3(d2, r);
557    let (s, t);
558    if a < 1e-15 && e < 1e-15 {
559        return (dist3(p1, p3), p1, p3);
560    }
561    if a < 1e-15 {
562        s = 0.0;
563        t = (f / e).clamp(0.0, 1.0);
564    } else {
565        let c = dot3(d1, r);
566        if e < 1e-15 {
567            t = 0.0;
568            s = (-c / a).clamp(0.0, 1.0);
569        } else {
570            let b = dot3(d1, d2);
571            let denom = a * e - b * b;
572            s = if denom > 1e-15 {
573                ((b * f - c * e) / denom).clamp(0.0, 1.0)
574            } else {
575                0.0
576            };
577            t = ((b * s + f) / e).clamp(0.0, 1.0);
578        }
579    }
580    let wa = lerp3(p1, p2, s);
581    let wb = lerp3(p3, p4, t);
582    (dist3(wa, wb), wa, wb)
583}
584
585// ─────────────────────────────────────────────────────────────────────────────
586// BoxCast — oriented box cast
587// ─────────────────────────────────────────────────────────────────────────────
588
589/// Cast a moving axis-aligned box against static primitives.
590pub struct BoxCast;
591
592impl BoxCast {
593    /// Box vs box TOI via conservative advancement.
594    pub fn vs_box(
595        motion: &LinearMotion,
596        half_a: [f64; 3],
597        center_b: [f64; 3],
598        half_b: [f64; 3],
599        cfg: &ShapeCastConfig,
600    ) -> ShapeCastResult {
601        let mut t = 0.0;
602        let max_iters = 64;
603        for _ in 0..max_iters {
604            let pos_a = motion.position_at(t);
605            let lo_a = sub3(pos_a, half_a);
606            let hi_a = add3(pos_a, half_a);
607            let lo_b = sub3(center_b, half_b);
608            let hi_b = add3(center_b, half_b);
609
610            // AABB-AABB separation
611            let sep = [
612                (lo_a[0] - hi_b[0]).max(lo_b[0] - hi_a[0]).max(0.0),
613                (lo_a[1] - hi_b[1]).max(lo_b[1] - hi_a[1]).max(0.0),
614                (lo_a[2] - hi_b[2]).max(lo_b[2] - hi_a[2]).max(0.0),
615            ];
616            let d = len3(sep);
617            if d <= cfg.target_distance {
618                let n = normalize3(sub3(pos_a, center_b));
619                return ShapeCastResult::hit(t, n, pos_a, center_b);
620            }
621            let speed = len3(motion.vel).max(1e-12);
622            t += d / speed;
623            if t > cfg.max_toc {
624                break;
625            }
626        }
627        ShapeCastResult::no_hit(cfg.max_toc)
628    }
629
630    /// Box vs triangle TOI via conservative advancement.
631    pub fn vs_triangle(
632        motion: &LinearMotion,
633        half_extents: [f64; 3],
634        v0: [f64; 3],
635        v1: [f64; 3],
636        v2: [f64; 3],
637        cfg: &ShapeCastConfig,
638    ) -> ShapeCastResult {
639        let mut t = 0.0;
640        let max_iters = 64;
641        for _ in 0..max_iters {
642            let pos = motion.position_at(t);
643            let lo = sub3(pos, half_extents);
644            let hi = add3(pos, half_extents);
645            // Check each triangle vertex against box
646            let mut min_dist = f64::INFINITY;
647            for &v in &[v0, v1, v2] {
648                let q = [
649                    v[0].clamp(lo[0], hi[0]),
650                    v[1].clamp(lo[1], hi[1]),
651                    v[2].clamp(lo[2], hi[2]),
652                ];
653                let d = dist3(v, q);
654                if d < min_dist {
655                    min_dist = d;
656                }
657            }
658            if min_dist <= cfg.target_distance {
659                let tri_center = scale3(add3(add3(v0, v1), v2), 1.0 / 3.0);
660                let n = normalize3(sub3(pos, tri_center));
661                return ShapeCastResult::hit(t, n, pos, tri_center);
662            }
663            let speed = len3(motion.vel).max(1e-12);
664            t += min_dist / speed;
665            if t > cfg.max_toc {
666                break;
667            }
668        }
669        ShapeCastResult::no_hit(cfg.max_toc)
670    }
671}
672
673// ─────────────────────────────────────────────────────────────────────────────
674// ConvexCast — GJK-based convex shape cast
675// ─────────────────────────────────────────────────────────────────────────────
676
677/// GJK-based convex shape cast using conservative advancement.
678///
679/// Shapes are represented as point clouds (convex hulls).
680pub struct ConvexCast;
681
682impl ConvexCast {
683    /// GJK distance between two point clouds (approximate).
684    fn gjk_distance(pts_a: &[[f64; 3]], pts_b: &[[f64; 3]]) -> (f64, [f64; 3], [f64; 3]) {
685        // Simple implementation: find closest pair of points
686        let mut min_dist = f64::INFINITY;
687        let mut best_a = pts_a[0];
688        let mut best_b = pts_b[0];
689        for &a in pts_a {
690            for &b in pts_b {
691                let d = dist3(a, b);
692                if d < min_dist {
693                    min_dist = d;
694                    best_a = a;
695                    best_b = b;
696                }
697            }
698        }
699        (min_dist, best_a, best_b)
700    }
701
702    /// Translates all points of a convex shape by offset.
703    fn translate_shape(pts: &[[f64; 3]], offset: [f64; 3]) -> Vec<[f64; 3]> {
704        pts.iter().map(|&p| add3(p, offset)).collect()
705    }
706
707    /// Convex cast via conservative advancement with TOI bisection.
708    pub fn cast(
709        motion: &LinearMotion,
710        shape_a: &[[f64; 3]],
711        shape_b: &[[f64; 3]],
712        cfg: &ShapeCastConfig,
713    ) -> ShapeCastResult {
714        let mut t = 0.0;
715        let max_iters = 32;
716        for _ in 0..max_iters {
717            let offset = motion.position_at(t);
718            let moved_a = Self::translate_shape(shape_a, offset);
719            let (d, wa, wb) = Self::gjk_distance(&moved_a, shape_b);
720            if d <= cfg.target_distance {
721                let n = if d > 1e-12 {
722                    normalize3(sub3(wa, wb))
723                } else {
724                    [0.0, 1.0, 0.0]
725                };
726                return ShapeCastResult::hit(t, n, wa, wb);
727            }
728            let speed = len3(motion.vel).max(1e-12);
729            t += d / speed;
730            if t > cfg.max_toc {
731                break;
732            }
733        }
734        ShapeCastResult::no_hit(cfg.max_toc)
735    }
736}
737
738// ─────────────────────────────────────────────────────────────────────────────
739// SubstepCast — tunneling prevention via substep subdivision
740// ─────────────────────────────────────────────────────────────────────────────
741
742/// Prevents tunneling by subdividing the motion into smaller substeps.
743pub struct SubstepCast {
744    /// Minimum substep size (dt) to prevent infinite subdivision.
745    pub min_substep: f64,
746    /// Maximum number of substeps.
747    pub max_substeps: usize,
748}
749
750impl SubstepCast {
751    /// Creates a new substep caster.
752    pub fn new(min_substep: f64, max_substeps: usize) -> Self {
753        Self {
754            min_substep,
755            max_substeps,
756        }
757    }
758
759    /// Runs a shape cast query with tunneling prevention.
760    ///
761    /// `cast_fn` performs the actual cast for a sub-interval.
762    pub fn cast<F>(&self, t_start: f64, t_end: f64, cast_fn: F) -> Option<ShapeCastResult>
763    where
764        F: Fn(f64, f64) -> ShapeCastResult,
765    {
766        let dt = t_end - t_start;
767        let n_steps = ((dt / self.min_substep).ceil() as usize)
768            .min(self.max_substeps)
769            .max(1);
770        let step = dt / n_steps as f64;
771        for i in 0..n_steps {
772            let ta = t_start + i as f64 * step;
773            let tb = ta + step;
774            let result = cast_fn(ta, tb);
775            if result.hit {
776                return Some(result);
777            }
778        }
779        None
780    }
781}
782
783// ─────────────────────────────────────────────────────────────────────────────
784// NonLinearCast — general non-linear motion cast
785// ─────────────────────────────────────────────────────────────────────────────
786
787/// Non-linear motion cast via time integration and bisection.
788///
789/// Handles arbitrary smooth motions (e.g., ballistic, pendulum) by
790/// stepping forward and bisecting when contact is detected.
791pub struct NonLinearCast {
792    /// Initial time step for integration.
793    pub dt: f64,
794    /// Tolerance for contact detection.
795    pub tol: f64,
796    /// Maximum number of bisection steps.
797    pub max_bisect: usize,
798}
799
800impl NonLinearCast {
801    /// Creates a new non-linear cast solver.
802    pub fn new(dt: f64, tol: f64, max_bisect: usize) -> Self {
803        Self {
804            dt,
805            tol,
806            max_bisect,
807        }
808    }
809
810    /// Casts using a provided distance function `dist_fn(t) -> distance`.
811    ///
812    /// Returns the approximate TOI or None if no contact within \[0, t_max\].
813    pub fn cast<F>(&self, t_max: f64, dist_fn: F) -> Option<f64>
814    where
815        F: Fn(f64) -> f64,
816    {
817        let mut t = 0.0;
818        let d0 = dist_fn(0.0);
819        if d0 <= self.tol {
820            return Some(0.0);
821        }
822
823        while t < t_max {
824            t += self.dt;
825            let t_cur = t.min(t_max);
826            let d = dist_fn(t_cur);
827            if d <= self.tol {
828                // Bisect in [t - dt, t_cur]
829                let mut lo = (t - self.dt).max(0.0);
830                let mut hi = t_cur;
831                for _ in 0..self.max_bisect {
832                    let mid = 0.5 * (lo + hi);
833                    let d_mid = dist_fn(mid);
834                    if d_mid <= self.tol {
835                        hi = mid;
836                    } else {
837                        lo = mid;
838                    }
839                    if hi - lo < self.tol * 0.1 {
840                        break;
841                    }
842                }
843                return Some(hi);
844            }
845        }
846        None
847    }
848}
849
850// ─────────────────────────────────────────────────────────────────────────────
851// MeshBvhCast — BVH-traversal cast against mesh
852// ─────────────────────────────────────────────────────────────────────────────
853
854/// Shape cast against a complex mesh using a simple BVH traversal.
855pub struct MeshBvhCast {
856    /// Mesh vertices.
857    pub vertices: Vec<[f64; 3]>,
858    /// Triangle indices.
859    pub indices: Vec<usize>,
860}
861
862impl MeshBvhCast {
863    /// Creates a new mesh BVH cast structure.
864    pub fn new(vertices: Vec<[f64; 3]>, indices: Vec<usize>) -> Self {
865        assert!(indices.len().is_multiple_of(3));
866        Self { vertices, indices }
867    }
868
869    /// Casts a moving sphere against the mesh.
870    ///
871    /// Returns the earliest TOI ShapeCastResult.
872    pub fn sphere_cast(
873        &self,
874        motion: &LinearMotion,
875        radius: f64,
876        cfg: &ShapeCastConfig,
877    ) -> ShapeCastResult {
878        let n_tris = self.indices.len() / 3;
879        let mut best = ShapeCastResult::no_hit(cfg.max_toc);
880
881        for tri in 0..n_tris {
882            let v0 = self.vertices[self.indices[3 * tri]];
883            let v1 = self.vertices[self.indices[3 * tri + 1]];
884            let v2 = self.vertices[self.indices[3 * tri + 2]];
885            let result = SphereCast::vs_triangle(motion, radius, v0, v1, v2, cfg);
886            if result.hit && result.toi < best.toi {
887                best = result;
888            }
889        }
890        best
891    }
892
893    /// Number of triangles in the mesh.
894    pub fn n_triangles(&self) -> usize {
895        self.indices.len() / 3
896    }
897}
898
899// ─────────────────────────────────────────────────────────────────────────────
900// Tests
901// ─────────────────────────────────────────────────────────────────────────────
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    fn default_cfg() -> ShapeCastConfig {
908        ShapeCastConfig::default()
909    }
910
911    #[test]
912    fn test_shape_cast_config_default() {
913        let cfg = ShapeCastConfig::default();
914        assert!((cfg.max_toc - 1.0).abs() < 1e-12);
915        assert_eq!(cfg.target_distance, 0.0);
916    }
917
918    #[test]
919    fn test_linear_motion_position_at() {
920        let m = LinearMotion::new([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
921        let p = m.position_at(2.0);
922        assert!((p[0] - 2.0).abs() < 1e-12);
923    }
924
925    #[test]
926    fn test_linear_motion_toi_sphere_hit() {
927        let a = LinearMotion::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
928        let b = LinearMotion::new([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
929        let toi = LinearMotion::compute_toi_linear_sphere(&a, 0.5, &b, 0.5, 10.0);
930        assert!(toi.is_some());
931        assert!((toi.unwrap() - 4.0).abs() < 0.1);
932    }
933
934    #[test]
935    fn test_linear_motion_toi_sphere_miss() {
936        let a = LinearMotion::new([10.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
937        let b = LinearMotion::new([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
938        let toi = LinearMotion::compute_toi_linear_sphere(&a, 0.5, &b, 0.5, 10.0);
939        assert!(toi.is_none());
940    }
941
942    #[test]
943    fn test_linear_motion_toi_sphere_initial_overlap() {
944        let a = LinearMotion::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
945        let b = LinearMotion::new([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
946        let toi = LinearMotion::compute_toi_linear_sphere(&a, 1.0, &b, 1.0, 10.0);
947        assert!(toi.is_some());
948        assert!(toi.unwrap() <= 0.0);
949    }
950
951    #[test]
952    fn test_sphere_cast_vs_sphere_hit() {
953        // Sphere at [0,0,-1.5] moving +z at speed 1. Both radii 0.5.
954        // Contact at t = 1.5 - 1.0 = 0.5 (within max_toc=1.0).
955        let motion = LinearMotion::new([0.0, 0.0, -1.5], [0.0, 0.0, 1.0]);
956        let cfg = default_cfg();
957        let result = SphereCast::vs_sphere(&motion, 0.5, [0.0, 0.0, 0.0], 0.5, &cfg);
958        assert!(result.hit);
959        assert!(result.toi > 0.0 && result.toi <= 1.0);
960    }
961
962    #[test]
963    fn test_sphere_cast_vs_sphere_miss() {
964        let motion = LinearMotion::new([10.0, 0.0, -5.0], [0.0, 0.0, -1.0]);
965        let cfg = default_cfg();
966        let result = SphereCast::vs_sphere(&motion, 0.5, [0.0, 0.0, 0.0], 0.5, &cfg);
967        assert!(!result.hit);
968    }
969
970    #[test]
971    fn test_sphere_cast_vs_sphere_penetrating() {
972        let motion = LinearMotion::new([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
973        let cfg = default_cfg();
974        let result = SphereCast::vs_sphere(&motion, 1.0, [0.0, 0.0, 0.0], 1.0, &cfg);
975        assert!(result.hit);
976        assert!(result.is_penetrating);
977    }
978
979    #[test]
980    fn test_sphere_cast_vs_box_hit() {
981        let motion = LinearMotion::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
982        let cfg = ShapeCastConfig {
983            max_toc: 10.0,
984            ..default_cfg()
985        };
986        let result = SphereCast::vs_box(&motion, 0.5, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], &cfg);
987        assert!(result.hit);
988    }
989
990    #[test]
991    fn test_capsule_cast_vs_capsule() {
992        let motion = LinearMotion::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
993        let cfg = ShapeCastConfig {
994            max_toc: 10.0,
995            ..default_cfg()
996        };
997        let result = CapsuleCast::vs_capsule(
998            &motion,
999            [-0.5, 0.0, 0.0],
1000            [0.5, 0.0, 0.0],
1001            0.25,
1002            [-0.5, 0.0, 0.0],
1003            [0.5, 0.0, 0.0],
1004            0.25,
1005            &cfg,
1006        );
1007        assert!(result.hit);
1008    }
1009
1010    #[test]
1011    fn test_box_cast_vs_box_hit() {
1012        let motion = LinearMotion::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
1013        let cfg = ShapeCastConfig {
1014            max_toc: 10.0,
1015            ..default_cfg()
1016        };
1017        let result = BoxCast::vs_box(
1018            &motion,
1019            [0.5, 0.5, 0.5],
1020            [0.0, 0.0, 0.0],
1021            [0.5, 0.5, 0.5],
1022            &cfg,
1023        );
1024        assert!(result.hit);
1025    }
1026
1027    #[test]
1028    fn test_box_cast_vs_box_miss() {
1029        let motion = LinearMotion::new([10.0, 10.0, -5.0], [0.0, 0.0, -1.0]);
1030        let cfg = default_cfg();
1031        let result = BoxCast::vs_box(
1032            &motion,
1033            [0.1, 0.1, 0.1],
1034            [0.0, 0.0, 0.0],
1035            [0.1, 0.1, 0.1],
1036            &cfg,
1037        );
1038        assert!(!result.hit);
1039    }
1040
1041    #[test]
1042    fn test_convex_cast_hit() {
1043        let motion = LinearMotion::new([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]);
1044        let cfg = ShapeCastConfig {
1045            max_toc: 10.0,
1046            ..default_cfg()
1047        };
1048        let shape_a = vec![[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]];
1049        let shape_b = vec![[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]];
1050        let result = ConvexCast::cast(&motion, &shape_a, &shape_b, &cfg);
1051        assert!(result.hit);
1052    }
1053
1054    #[test]
1055    fn test_angular_motion_position_at() {
1056        let m = AngularMotion::new([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1057        let p = m.position_at(1.0);
1058        assert!((p[0] - 1.0).abs() < 1e-12);
1059    }
1060
1061    #[test]
1062    fn test_angular_motion_bound() {
1063        let m = AngularMotion::with_rotation([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
1064        let bound = m.motion_bound(1.0, 0.5);
1065        assert!(bound > 1.0); // at least linear part
1066    }
1067
1068    #[test]
1069    fn test_substep_cast_finds_hit() {
1070        let caster = SubstepCast::new(0.1, 100);
1071        let result = caster.cast(0.0, 1.0, |ta, _tb| {
1072            if ta > 0.5 {
1073                ShapeCastResult::hit(ta, [0.0, 1.0, 0.0], [0.0; 3], [0.0; 3])
1074            } else {
1075                ShapeCastResult::no_hit(1.0)
1076            }
1077        });
1078        assert!(result.is_some());
1079    }
1080
1081    #[test]
1082    fn test_substep_cast_no_hit() {
1083        let caster = SubstepCast::new(0.1, 10);
1084        let result = caster.cast(0.0, 1.0, |_ta, _tb| ShapeCastResult::no_hit(1.0));
1085        assert!(result.is_none());
1086    }
1087
1088    #[test]
1089    fn test_nonlinear_cast_hit() {
1090        let caster = NonLinearCast::new(0.01, 0.05, 20);
1091        // Distance decreases linearly: d(t) = 1.0 - t
1092        let toi = caster.cast(2.0, |t| 1.0 - t);
1093        assert!(toi.is_some());
1094        let t = toi.unwrap();
1095        assert!((t - 1.0).abs() < 0.1, "toi={}", t);
1096    }
1097
1098    #[test]
1099    fn test_nonlinear_cast_no_hit() {
1100        let caster = NonLinearCast::new(0.1, 0.05, 10);
1101        // Distance always > 1
1102        let toi = caster.cast(1.0, |_t| 2.0);
1103        assert!(toi.is_none());
1104    }
1105
1106    #[test]
1107    fn test_mesh_bvh_cast_sphere() {
1108        let verts = vec![[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1109        let idx = vec![0usize, 1, 2];
1110        let mesh = MeshBvhCast::new(verts, idx);
1111        let motion = LinearMotion::new([0.0, 0.5, -3.0], [0.0, 0.0, 1.0]);
1112        let cfg = ShapeCastConfig {
1113            max_toc: 10.0,
1114            ..default_cfg()
1115        };
1116        let result = mesh.sphere_cast(&motion, 0.2, &cfg);
1117        assert!(result.hit);
1118    }
1119
1120    #[test]
1121    fn test_shape_cast_result_no_hit() {
1122        let r = ShapeCastResult::no_hit(1.0);
1123        assert!(!r.hit);
1124        assert!(!r.is_penetrating);
1125    }
1126
1127    #[test]
1128    fn test_shape_cast_result_penetrating() {
1129        let r = ShapeCastResult::penetrating([0.0; 3], [0.0; 3], [0.0, 1.0, 0.0]);
1130        assert!(r.hit);
1131        assert!(r.is_penetrating);
1132        assert_eq!(r.toi, 0.0);
1133    }
1134
1135    #[test]
1136    fn test_relative_velocity() {
1137        let a = LinearMotion::new([0.0; 3], [1.0, 0.0, 0.0]);
1138        let b = LinearMotion::new([0.0; 3], [0.5, 0.0, 0.0]);
1139        let rel = LinearMotion::relative_velocity(&a, &b);
1140        assert!((rel[0] - 0.5).abs() < 1e-12);
1141    }
1142
1143    #[test]
1144    fn test_sphere_vs_triangle_no_hit() {
1145        let motion = LinearMotion::new([10.0, 10.0, -5.0], [0.0, 0.0, -1.0]);
1146        let cfg = default_cfg();
1147        let result = SphereCast::vs_triangle(
1148            &motion,
1149            0.1,
1150            [-0.1, -0.1, 0.0],
1151            [0.1, -0.1, 0.0],
1152            [0.0, 0.1, 0.0],
1153            &cfg,
1154        );
1155        assert!(!result.hit);
1156    }
1157}