Skip to main content

parry3d/query/sweep_toi/
sweep_toi.rs

1//! Conservative-advancement time of impact on endpoint-interpolated sweeps.
2//!
3//! A GJK distance query (with a warm-started simplex cache) provides a separating axis, and a
4//! push-back loop with a hybrid bisection/false-position root finder advances the earliest
5//! time at which the shapes reach the target separation.
6
7use super::proxy_distance::{proxy_distance, SimplexCache};
8use super::separation::SeparationFunction;
9use super::sweep::{rotate_vec, Sweep};
10use super::toi_proxy::ToiProxy;
11use crate::math::{Real, Vector};
12
13/// The outcome of a [`sweep_time_of_impact`] computation.
14#[derive(Copy, Clone, Debug, PartialEq, Eq)]
15pub enum SweepToiStatus {
16    /// The shapes were overlapped at the start time; continuous collision gave up
17    /// (`fraction == 0`).
18    Overlapped,
19    /// The shapes reach the target separation at `fraction`.
20    Hit,
21    /// The shapes never come within the target separation over the sweep
22    /// (`fraction == max_fraction`).
23    Separated,
24    /// The root finder failed to converge; `fraction` holds the last safe time.
25    Failed,
26}
27
28/// The result of a [`sweep_time_of_impact`] computation.
29#[derive(Copy, Clone, Debug)]
30pub struct SweepToiOutput {
31    /// How the computation terminated.
32    pub status: SweepToiStatus,
33    /// The normalized time of impact in `[0, max_fraction]`.
34    pub fraction: Real,
35    /// The averaged world-space hit point (only meaningful on `Hit`/`Failed`).
36    pub point: Vector,
37    /// The world-space separating axis at the hit time, pointing from the first shape to the
38    /// second (only meaningful on `Hit`/`Failed`).
39    pub normal: Vector,
40}
41
42/// Computes the time of impact between two proxies following endpoint-interpolated sweeps.
43///
44/// The shapes are advanced to the earliest time in `[0, max_fraction]` at which their
45/// core-shape separation drops to `max(linear_slop, radius_a + radius_b - linear_slop)`.
46/// `linear_slop` is typically 0.005 length units.
47pub fn sweep_time_of_impact(
48    proxy_a: &ToiProxy,
49    sweep_a: &Sweep,
50    proxy_b: &ToiProxy,
51    sweep_b: &Sweep,
52    max_fraction: Real,
53    linear_slop: Real,
54) -> SweepToiOutput {
55    let mut output = SweepToiOutput {
56        status: SweepToiStatus::Separated,
57        fraction: max_fraction,
58        point: Vector::ZERO,
59        normal: Vector::ZERO,
60    };
61
62    // Shift to the first sweep’s start center for better floating-point accuracy.
63    let origin = sweep_a.c1;
64    let sweep_a = sweep_a.shifted(origin);
65    let sweep_b = sweep_b.shifted(origin);
66
67    #[cfg(feature = "dim2")]
68    let max_push_back_iterations = 8; // Maximum polygon vertex count.
69    #[cfg(feature = "dim3")]
70    let max_push_back_iterations = proxy_a.points().len() + proxy_b.points().len();
71
72    #[cfg(feature = "dim2")]
73    const MAX_DISTANCE_ITERATIONS: u32 = 20;
74    #[cfg(feature = "dim3")]
75    const MAX_DISTANCE_ITERATIONS: u32 = 25;
76
77    let t_max = max_fraction;
78
79    // Set up target distance and tolerance.
80    let total_radius = proxy_a.radius + proxy_b.radius;
81    let target = linear_slop.max(total_radius - linear_slop);
82    let tolerance = 0.25 * linear_slop;
83    debug_assert!(target > tolerance);
84
85    let mut t1 = 0.0;
86    let mut distance_iterations = 0u32;
87    let mut cache = SimplexCache::default();
88
89    // The outer loop progressively attempts to compute new separating axes.
90    // This loop terminates when an axis is repeated (no progress is made).
91    loop {
92        // Get the distance between shapes. We can also use the results to get a separating
93        // axis.
94        let xf_a = sweep_a.transform_at(t1);
95        let xf_b = sweep_b.transform_at(t1);
96        let pos12 = xf_a.inv_mul(&xf_b);
97        let distance_output = proxy_distance(&pos12, proxy_a, proxy_b, false, &mut cache);
98
99        // The distance query runs in frame A, project the witness data back to the (shifted)
100        // world.
101        let world_normal = rotate_vec(&xf_a.rotation, distance_output.normal);
102        let world_point_a = xf_a.transform_point(distance_output.point_a);
103        let world_point_b = xf_a.transform_point(distance_output.point_b);
104
105        distance_iterations += 1;
106
107        let averaged_hit_point = || {
108            let pa = world_point_a + proxy_a.radius * world_normal;
109            let pb = world_point_b - proxy_b.radius * world_normal;
110            0.5 * (pa + pb) + origin
111        };
112
113        // If the shapes are overlapped, we give up on continuous collision.
114        if distance_output.distance <= 0.0 {
115            output.status = SweepToiStatus::Overlapped;
116            output.fraction = 0.0;
117            break;
118        }
119
120        if distance_output.distance <= target + tolerance {
121            // Success!
122            output.status = SweepToiStatus::Hit;
123            output.point = averaged_hit_point();
124            output.normal = world_normal;
125            output.fraction = t1;
126            break;
127        }
128
129        // In 3D, check for slow progress before running the push-back loop…
130        #[cfg(feature = "dim3")]
131        if distance_iterations == MAX_DISTANCE_ITERATIONS {
132            // Progress too slow. This can happen when a capsule rotates around a triangle
133            // vertex.
134            output.status = SweepToiStatus::Failed;
135            output.fraction = t1;
136            output.point = averaged_hit_point();
137            output.normal = world_normal;
138            break;
139        }
140
141        // Initialize the separating axis.
142        let mut fcn = SeparationFunction::new(
143            &cache,
144            proxy_a,
145            &sweep_a,
146            proxy_b,
147            &sweep_b,
148            world_normal,
149            t1,
150        );
151
152        // Compute the TOI on the separating axis. We do this by successively resolving the
153        // deepest point. This loop is bounded by the number of vertices.
154        let mut done = false;
155        let mut t2 = t_max;
156        let mut push_back_iterations = 0;
157        loop {
158            // Find the deepest point at t2. Store the witness point indices.
159            let (mut s2, index_a, index_b) = fcn.find_min_separation(t2);
160
161            // Is the final configuration separated?
162            if s2 - target > tolerance {
163                // Victory!
164                output.status = SweepToiStatus::Separated;
165                output.fraction = t_max;
166                done = true;
167                break;
168            }
169
170            // Has the separation reached tolerance?
171            if s2 >= target - tolerance {
172                // Advance the sweeps.
173                t1 = t2;
174                break;
175            }
176
177            // Compute the initial separation of the witness points.
178            let mut s1 = fcn.evaluate(index_a, index_b, t1);
179
180            // Check for initial overlap. This might happen if the root finder runs out of
181            // iterations.
182            if s1 < target - tolerance {
183                output.status = SweepToiStatus::Failed;
184                output.fraction = t1;
185                done = true;
186                break;
187            }
188
189            // Check for touching.
190            if s1 <= target + tolerance {
191                // Success! t1 should hold the TOI (could be 0.0).
192                output.status = SweepToiStatus::Hit;
193                output.point = averaged_hit_point();
194                output.normal = world_normal;
195                output.fraction = t1;
196                done = true;
197                break;
198            }
199
200            // Compute 1D root of: f(t) - target = 0.
201            let mut root_iteration_count = 0;
202            const MAX_ROOT_ITERATIONS: u32 = 50;
203            let mut a1 = t1;
204            let mut a2 = t2;
205            loop {
206                // Use a mix of false position and bisection.
207                let t = if root_iteration_count & 1 == 1 {
208                    // False position to improve convergence.
209                    a1 + (target - s1) * (a2 - a1) / (s2 - s1)
210                } else {
211                    // Bisection to guarantee progress.
212                    0.5 * (a1 + a2)
213                };
214
215                root_iteration_count += 1;
216
217                let s = fcn.evaluate(index_a, index_b, t);
218
219                // Has the separation reached tolerance?
220                if (s - target).abs() <= tolerance {
221                    // t2 holds a tentative value for t1.
222                    t2 = t;
223                    break;
224                }
225
226                // Ensure we continue to bracket the root.
227                if s > target {
228                    a1 = t;
229                    s1 = s;
230                } else {
231                    a2 = t;
232                    s2 = s;
233                }
234
235                if root_iteration_count == MAX_ROOT_ITERATIONS {
236                    break;
237                }
238            }
239
240            // Restart the inner loop if we have a failing edge case (3D edge/edge axis only).
241            if root_iteration_count == MAX_ROOT_ITERATIONS - 1 && fcn.uses_edge_axis() {
242                t2 = t_max;
243                fcn.force_fixed_axis(t1);
244            }
245
246            push_back_iterations += 1;
247            if push_back_iterations == max_push_back_iterations {
248                break;
249            }
250        }
251
252        if done {
253            break;
254        }
255
256        // …while in 2D it is checked after the push-back loop, letting the last distance
257        // iteration still resolve an impact.
258        #[cfg(feature = "dim2")]
259        if distance_iterations == MAX_DISTANCE_ITERATIONS {
260            // Root finder got stuck. Semi-victory.
261            output.status = SweepToiStatus::Failed;
262            output.point = averaged_hit_point();
263            output.normal = world_normal;
264            output.fraction = t1;
265            break;
266        }
267    }
268
269    output
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::math::{Pose, Rotation};
276
277    fn slop() -> Real {
278        0.005
279    }
280
281    #[cfg(feature = "dim2")]
282    fn pose(x: Real, y: Real) -> Pose {
283        Pose::from_parts(Vector::new(x, y), Rotation::identity())
284    }
285    #[cfg(feature = "dim3")]
286    fn pose(x: Real, y: Real) -> Pose {
287        Pose::from_parts(Vector::new(x, y, 0.0), Rotation::IDENTITY)
288    }
289
290    fn ball_proxy(radius: Real) -> ToiProxy<'static> {
291        ToiProxy::point(Vector::ZERO, radius)
292    }
293
294    fn cuboid_proxy(half_extent: Real) -> ToiProxy<'static> {
295        #[cfg(feature = "dim2")]
296        {
297            ToiProxy::from_array(
298                [
299                    Vector::new(-half_extent, -half_extent),
300                    Vector::new(half_extent, -half_extent),
301                    Vector::new(half_extent, half_extent),
302                    Vector::new(-half_extent, half_extent),
303                ],
304                0.0,
305            )
306        }
307        #[cfg(feature = "dim3")]
308        {
309            let h = half_extent;
310            ToiProxy::from_array(
311                [
312                    Vector::new(-h, -h, -h),
313                    Vector::new(h, -h, -h),
314                    Vector::new(h, h, -h),
315                    Vector::new(-h, h, -h),
316                    Vector::new(-h, -h, h),
317                    Vector::new(h, -h, h),
318                    Vector::new(h, h, h),
319                    Vector::new(-h, h, h),
320                ],
321                0.0,
322            )
323        }
324    }
325
326    #[test]
327    fn ball_hits_static_box() {
328        // Static unit box at the origin; ball of radius 0.1 sweeping from x = -3 to x = +3.
329        let wall = cuboid_proxy(0.5);
330        let wall_sweep = Sweep::constant(&pose(0.0, 0.0), Vector::ZERO);
331        let ball = ball_proxy(0.1);
332        let ball_sweep = Sweep::from_poses(&pose(-3.0, 0.0), &pose(3.0, 0.0), Vector::ZERO);
333
334        let result = sweep_time_of_impact(&wall, &wall_sweep, &ball, &ball_sweep, 1.0, slop());
335        assert_eq!(result.status, SweepToiStatus::Hit);
336
337        // The ball is stopped when its core (center) is `target = radius - slop` away from
338        // the box surface: center_x = -0.5 - (0.1 - 0.005), fraction = (start - x) / travel.
339        let expected = (3.0 - 0.5 - (0.1 - slop())) / 6.0;
340        assert!(
341            (result.fraction - expected).abs() < 0.001,
342            "fraction {} vs expected {expected}",
343            result.fraction
344        );
345    }
346
347    #[test]
348    fn ball_misses_box() {
349        // Ball sweeping parallel to the box, far away.
350        let wall = cuboid_proxy(0.5);
351        let wall_sweep = Sweep::constant(&pose(0.0, 0.0), Vector::ZERO);
352        let ball = ball_proxy(0.1);
353        let ball_sweep = Sweep::from_poses(&pose(-3.0, 5.0), &pose(3.0, 5.0), Vector::ZERO);
354
355        let result = sweep_time_of_impact(&wall, &wall_sweep, &ball, &ball_sweep, 1.0, slop());
356        assert_eq!(result.status, SweepToiStatus::Separated);
357        assert_eq!(result.fraction, 1.0);
358    }
359
360    #[test]
361    fn overlapped_at_start_returns_fraction_zero() {
362        let wall = cuboid_proxy(0.5);
363        let wall_sweep = Sweep::constant(&pose(0.0, 0.0), Vector::ZERO);
364        let ball = ball_proxy(0.1);
365        let ball_sweep = Sweep::from_poses(&pose(0.0, 0.0), &pose(3.0, 0.0), Vector::ZERO);
366
367        let result = sweep_time_of_impact(&wall, &wall_sweep, &ball, &ball_sweep, 1.0, slop());
368        assert_eq!(result.status, SweepToiStatus::Overlapped);
369        assert_eq!(result.fraction, 0.0);
370    }
371
372    #[test]
373    fn box_vs_box_face_impact() {
374        // Two unit boxes; one sweeps into the other along x.
375        let a = cuboid_proxy(0.5);
376        let a_sweep = Sweep::constant(&pose(0.0, 0.0), Vector::ZERO);
377        let b = cuboid_proxy(0.5);
378        let b_sweep = Sweep::from_poses(&pose(-4.0, 0.0), &pose(4.0, 0.0), Vector::ZERO);
379
380        let result = sweep_time_of_impact(&a, &a_sweep, &b, &b_sweep, 1.0, slop());
381        assert_eq!(result.status, SweepToiStatus::Hit);
382
383        // Faces meet when the gap reaches target = slop: center_x = -(0.5 + 0.5 + slop).
384        let expected = (4.0 - 1.0 - slop()) / 8.0;
385        assert!(
386            (result.fraction - expected).abs() < 0.001,
387            "fraction {} vs expected {expected}",
388            result.fraction
389        );
390    }
391
392    #[test]
393    fn rotating_bar_hits_ball() {
394        // A long thin bar rotating 90° about the origin must catch a ball placed along its
395        // swept arc even though the bar’s endpoint poses don’t overlap the ball.
396        let half_length = 2.0;
397        #[cfg(feature = "dim2")]
398        let (bar, start, end) = (
399            ToiProxy::from_array(
400                [
401                    Vector::new(-half_length, 0.0),
402                    Vector::new(half_length, 0.0),
403                ],
404                0.05,
405            ),
406            Pose::from_parts(Vector::ZERO, Rotation::identity()),
407            Pose::from_parts(
408                Vector::ZERO,
409                Rotation::from_angle(core::f32::consts::FRAC_PI_2 as Real),
410            ),
411        );
412        #[cfg(feature = "dim3")]
413        let (bar, start, end) = (
414            ToiProxy::from_array(
415                [
416                    Vector::new(-half_length, 0.0, 0.0),
417                    Vector::new(half_length, 0.0, 0.0),
418                ],
419                0.05,
420            ),
421            Pose::from_parts(Vector::ZERO, Rotation::IDENTITY),
422            Pose::from_parts(
423                Vector::ZERO,
424                Rotation::from_rotation_z(core::f32::consts::FRAC_PI_2 as Real),
425            ),
426        );
427
428        let bar_sweep = Sweep::from_poses(&start, &end, Vector::ZERO);
429        let ball = ball_proxy(0.1);
430        // Place the ball at 45° on the arc of radius 1.5.
431        let d = 1.5 * (0.5_f64.sqrt() as Real);
432        let ball_sweep = Sweep::constant(&pose(d, d), Vector::ZERO);
433
434        let result = sweep_time_of_impact(&bar, &bar_sweep, &ball, &ball_sweep, 1.0, slop());
435        assert_eq!(result.status, SweepToiStatus::Hit);
436        // The bar reaches 45° at t = 0.5; it should hit slightly before.
437        assert!(
438            result.fraction > 0.3 && result.fraction < 0.5,
439            "fraction {}",
440            result.fraction
441        );
442    }
443}