Skip to main content

alice_physics/solver/
world_ccd.rs

1//! Continuous collision in the world step: sweeping fast bodies so that they
2//! cannot pass through thin geometry between two substeps.
3//!
4//! Off by default ([`WorldCcdConfig::new`]); [`PhysicsWorld::set_continuous_collision`]
5//! turns it on. While it is off nothing in this module runs and the step is
6//! the step without it, operation for operation.
7//!
8//! # Method
9//!
10//! In every XPBD substep ([`PhysicsWorld::step`] and `step_parallel`), right
11//! after the positions are predicted and before the discrete contacts are
12//! detected:
13//!
14//! 1. **Which bodies.** A dynamic awake or a kinematic, non-sensor body with
15//!    a collision radius `r` is swept when its displacement in the substep `d` is longer
16//!    than `motion_threshold · r` (strictly). With the default threshold `1`,
17//!    a body that moves at most its radius per substep is left to the discrete
18//!    detection, which then always sees it on the near side of a surface of
19//!    zero thickness before its centre crosses it.
20//! 2. **Sweep.** Every body is put back at its pose at the start of the
21//!    substep, and each swept body's sphere of radius `r` is cast along `d`
22//!    with [`PhysicsWorld::cast_sphere`] against the other bodies (their exact
23//!    shapes) and the static colliders. SDF colliders are not swept (they keep
24//!    their discrete push-out). The bodies are then put back at their predicted
25//!    poses. The cast sees what its filter sees: bodies on the layers of the
26//!    swept body's mask, no sensors. Before the cast, every target the swept
27//!    body overlaps at the start of the substep is collected
28//!    ([`PhysicsWorld::overlap_sphere`]) and every one it does not move
29//!    further into is hidden at once, however many there are (a sphere
30//!    rolling on a floor, or wedged among many bodies, still sees the wall
31//!    ahead). Moving further into a target it overlaps is a hit at `t = 0`
32//!    along the separating normal of the discrete contact. The cast returns
33//!    only the nearest target, so a target that cannot answer the sweep (a
34//!    body the pair filter does not let it collide with) is hidden and the
35//!    cast repeated until it answers or finds nothing; each repetition hides
36//!    one more target, so there is no count limit to run out of.
37//! 3. **Moving obstacles.** A hit on a body that moved in the substep is
38//!    re-timed by the relative motion of the two bounding spheres
39//!    ([`crate::ccd::sphere_sphere_toi`]), exact for two plain spheres and
40//!    early (never late) for a shaped body. When the bounding spheres do not
41//!    meet under the relative motion, the target is hidden as above.
42//! 4. **Response.** For the first hit at fraction `t` of the substep with
43//!    normal `n` (from the obstacle toward the swept body), both bodies are
44//!    placed at their poses at `t`, after which the pair moves as one along
45//!    `n` (their mass-weighted displacement along `n`) and each keeps its own
46//!    tangential displacement for the rest of the substep. A hit on a body
47//!    also adds a contact of depth `0` between the two, so the velocity pass
48//!    of the substep applies the pair's restitution to the approach velocity
49//!    the bodies had before the substep's solve. A static or kinematic
50//!    obstacle is not moved. A hit on a static collider has no body to carry
51//!    a contact: the body stops at the surface along `n` and slides along it.
52//!
53//! A **kinematic** body is swept for the dynamic bodies it would pass
54//! through and only those (static geometry and other kinematic bodies are
55//! hidden). It is not placed: it follows its target, and step 4 with its
56//! infinite mass carries the dynamic body along `n` to touch it and gives it
57//! `(1 + e)` times the approach speed, as a wall moving into a free body.
58//!
59//! Step 4 makes a single head-on impact of two spheres come out as the
60//! textbook collision: the momentum along `n` is kept and the separation
61//! speed is `e` times the approach speed (`tests/analytic_world_step_ccd.rs`).
62//!
63//! # Why this method
64//!
65//! Stopping the body at the time of impact and handing the contact to the
66//! existing velocity pass reuses the restitution law the discrete contacts
67//! already use, so a body is not treated differently because it was fast.
68//! Moving the pair as one along `n` after the impact keeps the momentum of
69//! the substep: stopping both bodies at `t` would scale their velocities by
70//! `t` and lose momentum when the masses or speeds differ. A speculative
71//! contact alone (a contact with a negative depth solved by the position pass)
72//! was not used, because the position pass only resolves positive depths and
73//! changing it would change the step while the setting is off.
74//!
75//! # Determinism
76//!
77//! The sweep runs on one thread in body-index order, reads no clock and no
78//! thread count, and computes every hit before it moves any body, so the
79//! result depends only on the world. It is the same with `--features parallel`.
80//!
81//! # Not covered
82//!
83//! - Rotation during the substep (`COV-RIGID-075`): the sweep is a translating
84//!   sphere, the bounding sphere for a shaped body.
85//! - The TGS backend ([`crate::solver::SolverBackend::Tgs`]) does not sweep.
86//! - A target the swept body overlaps at the start and moves out of or along
87//!   is left to the discrete contacts.
88//! - The displacement is measured with
89//!   [`Vec3Fix::checked_length_scaled`](crate::math::Vec3Fix::checked_length_scaled);
90//!   a body whose displacement in one substep is `2⁶³` or more is not swept.
91//! - Only the first hit of a substep is answered; a second obstacle reached
92//!   by the slide after it is left to the discrete detection of later
93//!   substeps.
94//! - When several swept bodies hit the same movable body in one substep, that
95//!   body is placed for each hit in body-index order and the last placement
96//!   stands.
97
98use super::{BodyType, PhysicsWorld};
99use crate::collider::Contact;
100use crate::math::{Fix128, Vec3Fix};
101#[cfg(not(feature = "std"))]
102use alloc::vec::Vec;
103
104/// The setting for continuous collision in the world step (see the module
105/// documentation of `world_ccd` for the method).
106///
107/// Built with [`Self::new`] (off) or [`Self::on`] and adjusted with the
108/// `with_*` methods; the fields cannot be written from outside the crate.
109///
110/// # Examples
111///
112/// ```
113/// use alice_physics::{Fix128, PhysicsConfig, PhysicsWorld, WorldCcdConfig};
114///
115/// let mut world = PhysicsWorld::new(PhysicsConfig::default());
116/// assert!(!world.continuous_collision().is_enabled());
117///
118/// world.set_continuous_collision(WorldCcdConfig::on().with_motion_threshold(Fix128::from_ratio(1, 2)));
119/// assert!(world.continuous_collision().is_enabled());
120/// assert_eq!(world.continuous_collision().motion_threshold(), Fix128::from_ratio(1, 2));
121/// ```
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct WorldCcdConfig {
125    enabled: bool,
126    motion_threshold: Fix128,
127}
128
129impl WorldCcdConfig {
130    /// Off, with the default threshold `1` (a body is swept when it moves more
131    /// than its collision radius in one substep).
132    #[must_use]
133    pub const fn new() -> Self {
134        Self {
135            enabled: false,
136            motion_threshold: Fix128::ONE,
137        }
138    }
139
140    /// On, with the default threshold `1`.
141    #[must_use]
142    pub const fn on() -> Self {
143        Self::new().with_enabled(true)
144    }
145
146    /// The same setting, switched on or off.
147    #[must_use]
148    pub const fn with_enabled(mut self, enabled: bool) -> Self {
149        self.enabled = enabled;
150        self
151    }
152
153    /// The same setting with another threshold, in collision radii: a body is
154    /// swept when its displacement in one substep is longer than
155    /// `threshold × radius`. A threshold `≤ 0` sweeps every moving body.
156    #[must_use]
157    pub const fn with_motion_threshold(mut self, threshold: Fix128) -> Self {
158        self.motion_threshold = threshold;
159        self
160    }
161
162    /// Whether the step sweeps fast bodies.
163    #[must_use]
164    pub const fn is_enabled(&self) -> bool {
165        self.enabled
166    }
167
168    /// The threshold, in collision radii.
169    #[must_use]
170    pub const fn motion_threshold(&self) -> Fix128 {
171        self.motion_threshold
172    }
173}
174
175impl Default for WorldCcdConfig {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181/// The first hit of one swept body in one substep.
182#[derive(Clone, Copy, Debug)]
183pub(super) struct CcdHit {
184    /// The swept body.
185    body: usize,
186    /// The body hit, `None` for a static collider.
187    other: Option<usize>,
188    /// Fraction of the substep at the hit, in `[0, 1]`.
189    t: Fix128,
190    /// Unit normal at the hit, from the obstacle toward the swept body.
191    normal: Vec3Fix,
192    /// Contact point on the obstacle.
193    point: Vec3Fix,
194}
195
196impl PhysicsWorld {
197    /// Set continuous collision for the world step. Off by default.
198    pub fn set_continuous_collision(&mut self, config: WorldCcdConfig) {
199        self.ccd = config;
200    }
201
202    /// The continuous collision setting of the world step.
203    #[must_use]
204    pub fn continuous_collision(&self) -> WorldCcdConfig {
205        self.ccd
206    }
207
208    /// Whether the body is moved by the substep and may be placed by a hit.
209    fn ccd_movable(&self, i: usize) -> bool {
210        self.bodies[i].body_type == BodyType::Dynamic && !self.park.is_parked(i)
211    }
212
213    /// The displacement of body `i` in this substep (zero for a body the
214    /// substep does not move).
215    fn ccd_displacement(&self, i: usize) -> Vec3Fix {
216        let b = &self.bodies[i];
217        match b.body_type {
218            BodyType::Static => Vec3Fix::ZERO,
219            BodyType::Kinematic | BodyType::Dynamic if self.park.is_parked(i) => Vec3Fix::ZERO,
220            _ => b.position - b.prev_position,
221        }
222    }
223
224    /// Exchange the predicted and the start-of-substep pose of every body the
225    /// substep moved (twice is the identity).
226    fn ccd_swap_poses(&mut self) {
227        for i in 0..self.bodies.len() {
228            let b = &self.bodies[i];
229            if b.body_type == BodyType::Static || self.park.is_parked(i) {
230                continue;
231            }
232            let b = &mut self.bodies[i];
233            core::mem::swap(&mut b.position, &mut b.prev_position);
234            core::mem::swap(&mut b.rotation, &mut b.prev_rotation);
235        }
236    }
237
238    /// Steps 1–4 of the module documentation up to the placement: find the
239    /// first hit of every swept body and place the bodies. Returns the hits,
240    /// whose contacts [`Self::ccd_add_contacts`] adds after the discrete
241    /// detection. Does nothing while the setting is off.
242    pub(super) fn ccd_sweep(&mut self) -> Vec<CcdHit> {
243        let mut hits = Vec::new();
244        if !self.ccd.enabled {
245            return hits;
246        }
247        let n = self.bodies.len();
248        let mut swept = Vec::new();
249        for i in 0..n {
250            let b = &self.bodies[i];
251            let mover = match b.body_type {
252                BodyType::Dynamic => !self.islands.is_sleeping(i),
253                BodyType::Kinematic => true,
254                BodyType::Static => false,
255            };
256            if !mover || b.is_sensor || self.park.is_parked(i) {
257                continue;
258            }
259            let Some(radius) = self.body_collision_radii.get(i).and_then(|r| *r) else {
260                continue;
261            };
262            let d = b.position - b.prev_position;
263            let Some(travel) = d.checked_length_scaled() else {
264                continue;
265            };
266            if travel.is_zero() {
267                continue;
268            }
269            let Some(limit) = self.ccd.motion_threshold.checked_mul(radius) else {
270                continue;
271            };
272            if travel > limit {
273                swept.push((i, radius, d, travel));
274            }
275        }
276        if swept.is_empty() {
277            return hits;
278        }
279
280        self.ccd_swap_poses();
281        for &(i, radius, d, travel) in &swept {
282            let cast = self.ccd_first_hit(i, radius, d, travel);
283            let pair = self.ccd_first_moving_hit(i, radius, d);
284            let first = match (cast, pair) {
285                (Some(c), Some(p)) => Some(if p.t < c.t { p } else { c }),
286                (c, p) => c.or(p),
287            };
288            if let Some(hit) = first {
289                hits.push(hit);
290            }
291        }
292        self.ccd_swap_poses();
293
294        // Every hit is known, and every displacement read, before any body
295        // moves.
296        let displacement: Vec<Vec3Fix> = (0..n).map(|i| self.ccd_displacement(i)).collect();
297        for hit in &hits {
298            self.ccd_place(hit, &displacement);
299        }
300        hits
301    }
302
303    /// The first hit of body `i` (at its start pose, the world at the start of
304    /// the substep), moving by `d` of length `travel`.
305    ///
306    /// First every target the body overlaps at the start is collected
307    /// ([`PhysicsWorld::overlap_sphere`] with the cast's filter) and every one
308    /// that cannot answer the sweep is hidden at once: a target it does not
309    /// move further into (`d·n ≥ 0` along the separating normal) and one the
310    /// pair filter rejects. A target it does move into is left visible, so
311    /// the cast reports it at `t = 0`. The cast then returns only the nearest
312    /// target, so a target found unable to answer is hidden and the cast
313    /// repeated: a body the pair filter rejects, a moving body whose bounding
314    /// sphere the relative motion misses, and, for a kinematic body, everything
315    /// but the dynamic bodies it can push. Every repetition hides one more
316    /// target and a hidden target is not returned again, so the loop ends after
317    /// at most as many casts as there are targets; there is no count limit
318    /// that could end it without an answer. Hidden targets are put back, in
319    /// reverse order, before returning.
320    fn ccd_first_hit(
321        &mut self,
322        i: usize,
323        radius: Fix128,
324        d: Vec3Fix,
325        travel: Fix128,
326    ) -> Option<CcdHit> {
327        let own = self.body_filter(i);
328        let kinematic = self.bodies[i].body_type == BodyType::Kinematic;
329        let filter = crate::shape_raycast::RayFilter::new()
330            .with_layer_mask(own.mask)
331            .with_sdf(false)
332            .with_static(!kinematic)
333            .excluding_body(i);
334        let start = self.bodies[i].position;
335        let mut hidden = Hidden::default();
336        for target in self.overlap_sphere(start, radius, &filter) {
337            let other = match target {
338                crate::shape_raycast::RayTarget::Body(j) => Some(j),
339                _ => None,
340            };
341            // A target without a separating normal is left to the cast (it
342            // either sweeps it or reports a start overlap, judged below).
343            let Some(n) = self.ccd_start_normal(i, radius, target) else {
344                continue;
345            };
346            if d.dot(n) >= Fix128::ZERO || !self.ccd_accepts(i, other, kinematic) {
347                self.ccd_hide(target, &mut hidden);
348            }
349        }
350        let mut found = None;
351        while let Some(hit) = self.cast_sphere(start, radius, d, travel, &filter) {
352            match self.ccd_judge(i, radius, d, travel, kinematic, &hit) {
353                Judged::Hit(h) => {
354                    found = Some(h);
355                    break;
356                }
357                Judged::Hide => {
358                    if !self.ccd_hide(hit.target, &mut hidden) {
359                        break;
360                    }
361                }
362            }
363        }
364        for (j, layer) in hidden.bodies.into_iter().rev() {
365            self.body_filters[j].layer = layer;
366        }
367        for (k, c) in hidden.statics.into_iter().rev() {
368            self.static_colliders[k] = c;
369        }
370        found
371    }
372
373    /// Hide `target` from the casts of [`Self::ccd_first_hit`] (a body by
374    /// clearing its layer, a static collider by an empty mesh), recording what
375    /// to put back. `false` for a target that cannot be hidden (an SDF
376    /// collider, which the cast's filter excludes anyway).
377    fn ccd_hide(&mut self, target: crate::shape_raycast::RayTarget, hidden: &mut Hidden) -> bool {
378        match target {
379            crate::shape_raycast::RayTarget::Body(j) => {
380                hidden.bodies.push((j, self.body_filters[j].layer));
381                self.body_filters[j].layer = 0;
382                true
383            }
384            crate::shape_raycast::RayTarget::StaticCollider(k) => {
385                let empty = crate::static_collider::StaticCollider::TriMesh(
386                    crate::trimesh::TriMesh::from_indexed(&[], &[]),
387                );
388                hidden
389                    .statics
390                    .push((k, core::mem::replace(&mut self.static_colliders[k], empty)));
391                true
392            }
393            crate::shape_raycast::RayTarget::Sdf(_) => false,
394        }
395    }
396
397    /// What one cast hit means for the sweep of body `i` (see
398    /// [`Self::ccd_first_hit`]).
399    fn ccd_judge(
400        &self,
401        i: usize,
402        radius: Fix128,
403        d: Vec3Fix,
404        travel: Fix128,
405        kinematic: bool,
406        hit: &crate::world_shape_query::WorldShapeHit,
407    ) -> Judged {
408        let start = self.bodies[i].position;
409        // Overlapping at the start: the query reports the body's own centre.
410        // Moving further into the target is a hit at `t = 0` along the
411        // separating normal; anything else is left to the discrete contacts.
412        if hit.t.is_zero() && hit.point == start {
413            return match self.ccd_start_normal(i, radius, hit.target) {
414                Some(n) if d.dot(n) < Fix128::ZERO && self.ccd_accepts(i, hit.body, kinematic) => {
415                    Judged::Hit(CcdHit {
416                        body: i,
417                        other: hit.body,
418                        t: Fix128::ZERO,
419                        normal: n,
420                        point: start - n * radius,
421                    })
422                }
423                _ => Judged::Hide,
424            };
425        }
426        if !self.ccd_accepts(i, hit.body, kinematic) {
427            return Judged::Hide;
428        }
429        let Some(j) = hit.body else {
430            return Judged::Hit(CcdHit {
431                body: i,
432                other: None,
433                t: fraction(hit.t, travel),
434                normal: hit.normal,
435                point: hit.point,
436            });
437        };
438        // The swapped body holds its predicted position in `prev_position`.
439        let dj = match self.bodies[j].body_type {
440            BodyType::Static => Vec3Fix::ZERO,
441            _ if self.park.is_parked(j) => Vec3Fix::ZERO,
442            _ => self.bodies[j].prev_position - self.bodies[j].position,
443        };
444        if dj == Vec3Fix::ZERO {
445            return Judged::Hit(CcdHit {
446                body: i,
447                other: Some(j),
448                t: fraction(hit.t, travel),
449                normal: hit.normal,
450                point: hit.point,
451            });
452        }
453        let Some(rj) = self.body_collision_radii.get(j).and_then(|r| *r) else {
454            return Judged::Hide;
455        };
456        // Obstacle as sphere A, the swept body as sphere B: the normal points
457        // from the obstacle toward the swept body.
458        match crate::ccd::sphere_sphere_toi(self.bodies[j].position, rj, dj, start, radius, d) {
459            Some(toi) => Judged::Hit(CcdHit {
460                body: i,
461                other: Some(j),
462                t: toi.t,
463                normal: toi.normal,
464                point: toi.point,
465            }),
466            None => Judged::Hide,
467        }
468    }
469
470    /// Whether a hit of body `i` on `other` (`None`: a static collider) is
471    /// answered: the pair filter lets them collide, and a kinematic body only
472    /// answers dynamic bodies it can move.
473    fn ccd_accepts(&self, i: usize, other: Option<usize>, kinematic: bool) -> bool {
474        match other {
475            None => !kinematic,
476            Some(j) => {
477                crate::filter::CollisionFilter::can_collide(
478                    &self.body_filter(i),
479                    &self.body_filter(j),
480                ) && (!kinematic || self.ccd_movable(j))
481            }
482        }
483    }
484
485    /// The separating normal (toward body `i`'s sphere at its start) of a
486    /// target `i` overlaps at the start of the substep, as the discrete
487    /// contacts compute it; `None` when there is none to take.
488    fn ccd_start_normal(
489        &self,
490        i: usize,
491        radius: Fix128,
492        target: crate::shape_raycast::RayTarget,
493    ) -> Option<Vec3Fix> {
494        let start = self.bodies[i].position;
495        match target {
496            crate::shape_raycast::RayTarget::Body(j) => {
497                let pose = (self.bodies[j].position, self.bodies[j].rotation);
498                match self.body_colliders.get(j).and_then(Option::as_ref) {
499                    Some(c) => crate::body_collider::contact_with_sphere(
500                        c,
501                        pose,
502                        crate::collider::Sphere::new(start, radius),
503                        false,
504                    )
505                    .map(|c| c.normal),
506                    None => (start - pose.0).try_normalize_scaled(),
507                }
508            }
509            crate::shape_raycast::RayTarget::StaticCollider(k) => self
510                .static_colliders
511                .get(k)
512                .and_then(|c| c.collide_sphere(start, radius))
513                .map(|c| c.normal),
514            crate::shape_raycast::RayTarget::Sdf(_) => None,
515        }
516    }
517
518    /// The first hit of body `i` (at its start pose, radius `radius`, moving by
519    /// `d`) on another body that moves in this substep, by the relative motion
520    /// of the bounding spheres; ties go to the lower body index. The cast of
521    /// [`Self::ccd_first_hit`] sees such a body only where it starts, which
522    /// misses two bodies that close on each other within the substep.
523    fn ccd_first_moving_hit(&self, i: usize, radius: Fix128, d: Vec3Fix) -> Option<CcdHit> {
524        let own = self.body_filter(i);
525        let start = self.bodies[i].position;
526        let mut best: Option<CcdHit> = None;
527        for j in 0..self.bodies.len() {
528            if j == i || self.bodies[j].is_sensor {
529                continue;
530            }
531            if self.bodies[i].body_type == BodyType::Kinematic && !self.ccd_movable(j) {
532                continue;
533            }
534            let Some(rj) = self.body_collision_radii.get(j).and_then(|r| *r) else {
535                continue;
536            };
537            // The swapped body holds its predicted position in `prev_position`.
538            let dj = match self.bodies[j].body_type {
539                BodyType::Static => continue,
540                _ if self.park.is_parked(j) => continue,
541                _ => self.bodies[j].prev_position - self.bodies[j].position,
542            };
543            if dj == Vec3Fix::ZERO {
544                continue;
545            }
546            let other = self.body_filter(j);
547            if !crate::filter::CollisionFilter::can_collide(&own, &other) {
548                continue;
549            }
550            let pj = self.bodies[j].position;
551            // Overlapping at the start: left to the discrete contacts.
552            let gap = start - pj;
553            let reach = radius + rj;
554            if gap
555                .checked_length_squared()
556                .zip(reach.checked_mul(reach))
557                .is_some_and(|(g2, r2)| g2 <= r2)
558            {
559                continue;
560            }
561            let Some(toi) = crate::ccd::sphere_sphere_toi(pj, rj, dj, start, radius, d) else {
562                continue;
563            };
564            if best.is_none_or(|b| toi.t < b.t) {
565                best = Some(CcdHit {
566                    body: i,
567                    other: Some(j),
568                    t: toi.t,
569                    normal: toi.normal,
570                    point: toi.point,
571                });
572            }
573        }
574        best
575    }
576
577    /// Place the bodies of one hit (step 4 of the module documentation).
578    fn ccd_place(&mut self, hit: &CcdHit, displacement: &[Vec3Fix]) {
579        let n = hit.normal;
580        let rest = Fix128::ONE - hit.t;
581        let di = displacement[hit.body];
582        let (dj, wj) = match hit.other {
583            Some(j) => (
584                displacement[j],
585                if self.ccd_movable(j) {
586                    self.bodies[j].inv_mass
587                } else {
588                    Fix128::ZERO
589                },
590            ),
591            None => (Vec3Fix::ZERO, Fix128::ZERO),
592        };
593        let wi = self.bodies[hit.body].inv_mass;
594        let w = wi + wj;
595        // Displacement of the pair along `n` after the hit: the mass-weighted
596        // mean (a static or kinematic obstacle carries the pair with it).
597        let along = if w.is_zero() {
598            Fix128::ZERO
599        } else {
600            (di.dot(n) * wj + dj.dot(n) * wi) / w
601        };
602        let place = |p0: Vec3Fix, d: Vec3Fix| -> Vec3Fix {
603            let tangent = d - n * d.dot(n);
604            p0 + d * hit.t + (tangent + n * along) * rest
605        };
606        // A kinematic body follows its target: it is not placed.
607        if self.ccd_movable(hit.body) {
608            let pi = place(self.bodies[hit.body].prev_position, di);
609            self.bodies[hit.body].position = pi;
610        }
611        if let Some(j) = hit.other {
612            if self.ccd_movable(j) {
613                let pj = place(self.bodies[j].prev_position, dj);
614                self.bodies[j].position = pj;
615            }
616        }
617    }
618
619    /// Add the depth-0 contacts of the hits on bodies, after the substep's
620    /// discrete detection (which clears the contacts), once per pair.
621    pub(super) fn ccd_add_contacts(&mut self, hits: &[CcdHit]) {
622        for (k, hit) in hits.iter().enumerate() {
623            let Some(j) = hit.other else {
624                continue;
625            };
626            let i = hit.body;
627            // The other body of a pair hit from both sides adds it once.
628            if hits[..k].iter().any(|h| h.body == j && h.other == Some(i)) {
629                continue;
630            }
631            let radius = self
632                .body_collision_radii
633                .get(i)
634                .and_then(|r| *r)
635                .unwrap_or(Fix128::ZERO);
636            let contact = Contact {
637                depth: Fix128::ZERO,
638                normal: hit.normal,
639                point_a: self.bodies[i].position - hit.normal * radius,
640                point_b: hit.point,
641            };
642            let rel_vel = (self.bodies[i].velocity - self.bodies[j].velocity).dot(hit.normal);
643            self.events
644                .report_contact(i, j, hit.normal, hit.point, Fix128::ZERO, rel_vel);
645            for x in [i, j] {
646                if !self.islands.is_sleeping(x) {
647                    continue;
648                }
649                if self.park.is_parked(x) {
650                    self.islands.wake_body(x);
651                    self.park.unpark(x, &mut self.stage_work);
652                } else {
653                    self.islands.wake_island(x);
654                }
655            }
656            self.add_contact_with_material(i, j, contact);
657        }
658    }
659}
660
661/// The targets one sweep has hidden, in the order they were hidden.
662#[derive(Default)]
663struct Hidden {
664    /// Body index and its collision layer before it was hidden.
665    bodies: Vec<(usize, u32)>,
666    /// Static collider index and the collider itself.
667    statics: Vec<(usize, crate::static_collider::StaticCollider)>,
668}
669
670/// The verdict on one cast hit.
671enum Judged {
672    /// The sweep's answer.
673    Hit(CcdHit),
674    /// Hide the target and cast again.
675    Hide,
676}
677
678/// `hit / travel` as a fraction of the substep, `1` at or past the end.
679fn fraction(hit: Fix128, travel: Fix128) -> Fix128 {
680    if hit >= travel {
681        Fix128::ONE
682    } else {
683        hit / travel
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    //! The sweep through the library's own entry points.
690    //!
691    //! oracle: closed-form kinematics of translating spheres, as in
692    //! `tests/analytic_world_step_ccd.rs`, on smaller scenes. One substep of
693    //! `h = 1/64` s, no gravity, no damping, material `μ = 0`, `e = 1/2`.
694    //! Displacements are chosen so that every time of impact is a dyadic
695    //! fraction, and the sweep's hits ([`PhysicsWorld::ccd_sweep`]) are
696    //! compared bit for bit; the stepped world (`PhysicsWorld::step`, which
697    //! calls the sweep) is compared bit for bit where the step is exact and to
698    //! `1e-9` m (`64e-9` m/s) otherwise.
699
700    use super::*;
701    use crate::filter::CollisionFilter;
702    use crate::material::PhysicsMaterial;
703    use crate::plane_collider::PlaneCollider;
704    use crate::shape::Shape;
705    use crate::solver::{PhysicsConfig, RigidBody};
706    use crate::static_collider::StaticCollider;
707    use crate::trimesh::TriMesh;
708
709    fn f(x: f64) -> Fix128 {
710        Fix128::from_f64(x)
711    }
712
713    fn v3(x: f64, y: f64, z: f64) -> Vec3Fix {
714        Vec3Fix::new(f(x), f(y), f(z))
715    }
716
717    fn h() -> Fix128 {
718        Fix128::from_ratio(1, 64)
719    }
720
721    fn close(got: Vec3Fix, want: Vec3Fix, tol: f64, what: &str) {
722        let d = [got.x - want.x, got.y - want.y, got.z - want.z];
723        assert!(
724            d.iter().all(|e| e.to_f64().abs() <= tol),
725            "{what}: got ({}, {}, {}), want ({}, {}, {})",
726            got.x.to_f64(),
727            got.y.to_f64(),
728            got.z.to_f64(),
729            want.x.to_f64(),
730            want.y.to_f64(),
731            want.z.to_f64()
732        );
733    }
734
735    const POS_TOL: f64 = 1e-9;
736    const VEL_TOL: f64 = 64e-9;
737
738    fn bare(config: WorldCcdConfig) -> PhysicsWorld {
739        let mut w = PhysicsWorld::new(PhysicsConfig {
740            substeps: 1,
741            gravity: Vec3Fix::ZERO,
742            damping: Fix128::ONE,
743            ..PhysicsConfig::default()
744        });
745        w.set_continuous_collision(config);
746        w
747    }
748
749    fn half_bouncy(w: &mut PhysicsWorld) {
750        let id = w.material_table.register(PhysicsMaterial::new(
751            1,
752            Fix128::ZERO,
753            Fix128::from_ratio(1, 2),
754        ));
755        for i in 0..w.bodies.len() {
756            w.set_body_material(i, id);
757        }
758    }
759
760    fn sphere(
761        w: &mut PhysicsWorld,
762        at: Vec3Fix,
763        mass: i64,
764        radius: f64,
765        velocity: Vec3Fix,
766    ) -> usize {
767        let mut b = RigidBody::new_dynamic(at, Fix128::from_int(mass));
768        b.velocity = velocity;
769        w.add_body_with_radius(b, f(radius))
770    }
771
772    /// A still box plate `1/32 × 6 × 6` centred at `x = 5` (near face
773    /// `x = 5 − 1/64`).
774    fn plate(w: &mut PhysicsWorld) -> usize {
775        let p = w.add_body(RigidBody::new_static(v3(5.0, 0.0, 0.0)));
776        w.set_body_shape(
777            p,
778            &Shape::Box {
779                half_extents: v3(1.0 / 64.0, 3.0, 3.0),
780            },
781        );
782        p
783    }
784
785    /// Where a sphere of radius `1/4` thrown along `+x` stops on [`plate`]:
786    /// `5 − 1/64 − 1/4 = 4.734375`, exact in binary.
787    fn stop_x() -> Fix128 {
788        Fix128::from_int(5) - Fix128::from_ratio(1, 64) - Fix128::from_ratio(1, 4)
789    }
790
791    /// The state the sweep sees inside a substep: every body at its start
792    /// pose in `prev_position`, and body `i` predicted at `start + d`.
793    fn predict(w: &mut PhysicsWorld, moves: &[(usize, Vec3Fix)]) {
794        for b in &mut w.bodies {
795            b.prev_position = b.position;
796            b.prev_rotation = b.rotation;
797        }
798        for &(i, d) in moves {
799            w.bodies[i].position = w.bodies[i].prev_position + d;
800        }
801    }
802
803    fn x(at: Fix128) -> Vec3Fix {
804        Vec3Fix::new(at, Fix128::ZERO, Fix128::ZERO)
805    }
806
807    // ── Off ─────────────────────────────────────────────────────────────────
808
809    /// Off: the sweep returns no hit and moves nothing, and the step lets a
810    /// sphere moving 8 m pass through the plate to exactly `x = 8`.
811    #[test]
812    fn off_sweeps_nothing() {
813        let mut w = bare(WorldCcdConfig::new());
814        assert_eq!(w.continuous_collision(), WorldCcdConfig::new());
815        assert!(!w.continuous_collision().is_enabled());
816        plate(&mut w);
817        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, v3(512.0, 0.0, 0.0));
818        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
819        assert!(w.ccd_sweep().is_empty());
820        assert_eq!(w.bodies[s].position, v3(8.0, 0.0, 0.0));
821        assert_eq!(w.bodies[s].prev_position, Vec3Fix::ZERO);
822
823        let mut w = bare(WorldCcdConfig::default());
824        plate(&mut w);
825        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, v3(512.0, 0.0, 0.0));
826        w.step(h());
827        assert_eq!(w.bodies[s].position, v3(8.0, 0.0, 0.0));
828        assert_eq!(w.bodies[s].velocity, v3(512.0, 0.0, 0.0));
829        assert!(w.contact_events().is_empty());
830    }
831
832    // ── Which bodies ────────────────────────────────────────────────────────
833
834    /// The threshold is strict: with `40` radii (`10` m for `r = 1/4`) a
835    /// displacement of exactly `10` m is not swept and one of `10 + 2⁻²⁰` m is,
836    /// with the hit at `t = 4.734375 / (10 + 2⁻²⁰)` on the plate.
837    #[test]
838    fn the_threshold_selects_displacements_strictly_longer_than_threshold_times_radius() {
839        let config = WorldCcdConfig::on().with_motion_threshold(Fix128::from_int(40));
840        assert_eq!(config.motion_threshold(), Fix128::from_int(40));
841        assert!(config.is_enabled());
842
843        let mut w = bare(config);
844        plate(&mut w);
845        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
846        predict(&mut w, &[(s, v3(10.0, 0.0, 0.0))]);
847        assert!(w.ccd_sweep().is_empty(), "swept at the threshold");
848        assert_eq!(w.bodies[s].position, v3(10.0, 0.0, 0.0));
849
850        let past = Fix128::from_int(10) + Fix128::from_ratio(1, 1 << 20);
851        let mut w = bare(config);
852        let p = plate(&mut w);
853        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
854        predict(&mut w, &[(s, x(past))]);
855        let hits = w.ccd_sweep();
856        assert_eq!(hits.len(), 1);
857        assert_eq!(hits[0].body, s);
858        assert_eq!(hits[0].other, Some(p));
859        assert_eq!(hits[0].t, stop_x() / past);
860        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
861        close(w.bodies[s].position, x(stop_x()), POS_TOL, "placed");
862    }
863
864    /// Bodies the sweep leaves alone however fast they move: a sensor, a body
865    /// without a collision radius, a body that does not move, and a threshold
866    /// whose product with the radius overflows.
867    #[test]
868    fn sensors_bodies_without_radius_still_bodies_and_overflowing_limits_are_not_swept() {
869        let run = |w: &mut PhysicsWorld, i: usize, d: Vec3Fix| {
870            predict(w, &[(i, d)]);
871            let hits = w.ccd_sweep();
872            (hits.len(), w.bodies[i].position)
873        };
874        let far = v3(8.0, 0.0, 0.0);
875
876        let mut w = bare(WorldCcdConfig::on());
877        plate(&mut w);
878        let s = w.add_body_with_radius(RigidBody::new_sensor(Vec3Fix::ZERO), f(0.25));
879        assert_eq!(run(&mut w, s, far), (0, far), "sensor");
880
881        let mut w = bare(WorldCcdConfig::on());
882        plate(&mut w);
883        let s = w.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
884        assert_eq!(run(&mut w, s, far), (0, far), "no radius");
885
886        let mut w = bare(WorldCcdConfig::on());
887        plate(&mut w);
888        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
889        assert_eq!(run(&mut w, s, Vec3Fix::ZERO), (0, Vec3Fix::ZERO), "still");
890
891        let mut w = bare(WorldCcdConfig::on().with_motion_threshold(Fix128::from_int(i64::MAX)));
892        plate(&mut w);
893        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 2.0, Vec3Fix::ZERO);
894        assert_eq!(run(&mut w, s, far), (0, far), "overflowing limit");
895    }
896
897    // ── Start overlaps ──────────────────────────────────────────────────────
898
899    /// A sphere rolling on a floor plane (overlapping it by `2⁻¹²`) and
900    /// resting in a still sphere body and in a still box body, none of which
901    /// it moves into, thrown along `+x`: every overlap is hidden at once and
902    /// the hit is on the plate ahead, at `t = 4.734375 / 8`.
903    #[test]
904    fn start_overlaps_it_does_not_move_into_are_hidden() {
905        let mut w = bare(WorldCcdConfig::on());
906        let p = plate(&mut w);
907        let lap = 1.0 / 4096.0;
908        w.add_static_collider(StaticCollider::Plane(PlaneCollider::new(
909            v3(0.0, 1.0, 0.0),
910            f(-0.25 + lap),
911        )));
912        // a still sphere body below-behind, no shape (normal from the centres)
913        w.add_body_with_radius(
914            RigidBody::new_static(v3(0.0, 0.0, -1.25 + lap)),
915            Fix128::ONE,
916        );
917        // a still box body on the other side
918        let b = w.add_body(RigidBody::new_static(v3(0.0, 0.0, 1.25 - lap)));
919        w.set_body_shape(
920            b,
921            &Shape::Box {
922                half_extents: v3(1.0, 1.0, 1.0),
923            },
924        );
925        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
926        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
927        let hits = w.ccd_sweep();
928        assert_eq!(hits.len(), 1);
929        assert_eq!(hits[0].other, Some(p));
930        assert_eq!(hits[0].t, stop_x() / Fix128::from_int(8));
931        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
932        close(w.bodies[s].position, x(stop_x()), POS_TOL, "placed");
933        // the hidden targets are back
934        assert_eq!(w.body_filter(b), CollisionFilter::DEFAULT);
935        assert!(matches!(w.static_colliders[0], StaticCollider::Plane(_)));
936    }
937
938    /// Starting in contact with a still sphere and moving into it is a hit at
939    /// `t = 0` along the separating normal: the sphere stays and leaves at
940    /// `−e · 512`. Moving away from it is free flight to `−1.5 − 8`.
941    #[test]
942    fn moving_into_a_start_overlap_is_a_hit_at_t_zero() {
943        let scene = |vx: f64| {
944            let mut w = bare(WorldCcdConfig::on());
945            let o = w.add_body_with_radius(RigidBody::new_static(Vec3Fix::ZERO), Fix128::ONE);
946            let s = sphere(&mut w, v3(-1.5, 0.0, 0.0), 1, 0.5, v3(vx, 0.0, 0.0));
947            half_bouncy(&mut w);
948            (w, o, s)
949        };
950
951        let (mut w, o, s) = scene(512.0);
952        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
953        let hits = w.ccd_sweep();
954        assert_eq!(hits.len(), 1);
955        assert_eq!(hits[0].other, Some(o));
956        assert_eq!(hits[0].t, Fix128::ZERO);
957        // the normal of the discrete contact is normalised: within one ulp (2⁻⁶⁴)
958        close(hits[0].normal, v3(-1.0, 0.0, 0.0), 1e-19, "normal");
959        close(hits[0].point, v3(-1.0, 0.0, 0.0), 1e-19, "point");
960
961        let (mut w, _, s) = scene(512.0);
962        w.step(h());
963        close(w.bodies[s].position, v3(-1.5, 0.0, 0.0), POS_TOL, "into");
964        close(w.bodies[s].velocity, v3(-256.0, 0.0, 0.0), VEL_TOL, "into");
965        assert_eq!(w.contact_events().len(), 1);
966
967        let (mut w, _, s) = scene(-512.0);
968        w.step(h());
969        assert_eq!(w.bodies[s].position, v3(-9.5, 0.0, 0.0), "away");
970        assert_eq!(w.bodies[s].velocity, v3(-512.0, 0.0, 0.0), "away");
971    }
972
973    /// A sphere of radius `1/4` already `2⁻¹⁰` into a thin box floor (half
974    /// thickness `1/64`) and moving `8` m down through it: the cast reports
975    /// the start overlap, which is a hit at `t = 0` along the floor's normal
976    /// `+y`, at `start − r n`. Stepped, the sphere does not pass the floor and
977    /// leaves upward.
978    #[test]
979    fn moving_into_a_box_it_starts_inside_of_is_a_hit_at_t_zero() {
980        let y0 = 1.0 / 64.0 + 0.25 - 1.0 / 1024.0;
981        let scene = || {
982            let mut w = bare(WorldCcdConfig::on());
983            let floor = w.add_body(RigidBody::new_static(Vec3Fix::ZERO));
984            w.set_body_shape(
985                floor,
986                &Shape::Box {
987                    half_extents: v3(3.0, 1.0 / 64.0, 3.0),
988                },
989            );
990            let s = sphere(&mut w, v3(0.0, y0, 0.0), 1, 0.25, v3(0.0, -512.0, 0.0));
991            half_bouncy(&mut w);
992            (w, floor, s)
993        };
994
995        let (mut w, floor, s) = scene();
996        predict(&mut w, &[(s, v3(0.0, -8.0, 0.0))]);
997        let hits = w.ccd_sweep();
998        assert_eq!(hits.len(), 1);
999        assert_eq!(hits[0].other, Some(floor));
1000        assert_eq!(hits[0].t, Fix128::ZERO);
1001        // the normal is the discrete box-sphere contact's, found iteratively
1002        // (here within 1e-7 of the face normal)
1003        close(hits[0].normal, v3(0.0, 1.0, 0.0), 1e-7, "normal");
1004        close(hits[0].point, v3(0.0, y0 - 0.25, 0.0), 1e-7, "point");
1005        // held at the start, up to the tangential slide `|d|` times the
1006        // normal's error: `8 · 1e-7`
1007        close(w.bodies[s].position, v3(0.0, y0, 0.0), 1e-6, "held");
1008
1009        let (mut w, _, s) = scene();
1010        w.step(h());
1011        let y = w.bodies[s].position.y.to_f64();
1012        assert!(y >= y0 - POS_TOL, "passed the floor: y = {y}");
1013        assert!(w.bodies[s].velocity.y.to_f64() > 0.0, "did not bounce");
1014    }
1015
1016    // ── Pair filter ─────────────────────────────────────────────────────────
1017
1018    /// A body in the path that the cast sees (its layer is on the swept
1019    /// body's mask) but the pair filter rejects (same non-zero group) is
1020    /// hidden and the cast repeated: the hit is on the plate behind it, both
1021    /// for a body ahead and for one the sphere starts inside of and moves
1022    /// into.
1023    #[test]
1024    fn a_target_the_pair_filter_rejects_is_retried() {
1025        let group = CollisionFilter::DEFAULT.with_group(7);
1026        for at in [2.5, 0.75] {
1027            let mut w = bare(WorldCcdConfig::on());
1028            let p = plate(&mut w);
1029            let g = w.add_body_with_radius(RigidBody::new_static(v3(at, 0.0, 0.0)), Fix128::ONE);
1030            let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
1031            w.set_body_filter(g, group);
1032            w.set_body_filter(s, group);
1033            predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
1034            let hits = w.ccd_sweep();
1035            assert_eq!(hits.len(), 1, "at {at}");
1036            assert_eq!(hits[0].other, Some(p), "at {at}");
1037            assert_eq!(hits[0].t, stop_x() / Fix128::from_int(8), "at {at}");
1038            assert_eq!(w.body_filter(g), group, "at {at}: filter not put back");
1039        }
1040
1041        // without the shared group the body ahead is the hit
1042        let mut w = bare(WorldCcdConfig::on());
1043        plate(&mut w);
1044        let g = w.add_body_with_radius(RigidBody::new_static(v3(2.5, 0.0, 0.0)), Fix128::ONE);
1045        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
1046        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
1047        let hits = w.ccd_sweep();
1048        assert_eq!(hits[0].other, Some(g));
1049        assert_eq!(hits[0].t, Fix128::from_ratio(5, 32));
1050    }
1051
1052    // ── Moving obstacles ────────────────────────────────────────────────────
1053
1054    /// Two spheres of radius `1/2` at `∓6` closing at `8` m each: they touch at
1055    /// `t = 11/16` (by the relative motion, the cast sees the other only at
1056    /// its start), at `∓1/2`. Equal masses leave at `∓e · 512`; masses 1 and 3
1057    /// move as one for the rest of the step (`−4 · 5/16`) and keep the
1058    /// momentum.
1059    #[test]
1060    fn two_moving_spheres_are_retimed_by_their_relative_motion() {
1061        let scene = |mass_b: i64| {
1062            let mut w = bare(WorldCcdConfig::on());
1063            let a = sphere(&mut w, v3(-6.0, 0.0, 0.0), 1, 0.5, v3(512.0, 0.0, 0.0));
1064            let b = sphere(&mut w, v3(6.0, 0.0, 0.0), mass_b, 0.5, v3(-512.0, 0.0, 0.0));
1065            half_bouncy(&mut w);
1066            (w, a, b)
1067        };
1068
1069        let (mut w, a, b) = scene(1);
1070        predict(&mut w, &[(a, v3(8.0, 0.0, 0.0)), (b, v3(-8.0, 0.0, 0.0))]);
1071        let hits = w.ccd_sweep();
1072        assert_eq!(hits.len(), 2);
1073        assert_eq!((hits[0].body, hits[0].other), (a, Some(b)));
1074        assert_eq!((hits[1].body, hits[1].other), (b, Some(a)));
1075        assert_eq!(hits[0].t, Fix128::from_ratio(11, 16));
1076        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
1077        assert_eq!(w.bodies[a].position, v3(-0.5, 0.0, 0.0));
1078        assert_eq!(w.bodies[b].position, v3(0.5, 0.0, 0.0));
1079
1080        let (mut w, a, b) = scene(1);
1081        w.step(h());
1082        close(w.bodies[a].position, v3(-0.5, 0.0, 0.0), POS_TOL, "a");
1083        close(w.bodies[b].position, v3(0.5, 0.0, 0.0), POS_TOL, "b");
1084        close(w.bodies[a].velocity, v3(-256.0, 0.0, 0.0), VEL_TOL, "a");
1085        close(w.bodies[b].velocity, v3(256.0, 0.0, 0.0), VEL_TOL, "b");
1086        // the pair hit from both sides adds one contact
1087        assert_eq!(w.contact_events().len(), 1);
1088
1089        let (mut w, a, b) = scene(3);
1090        w.step(h());
1091        close(
1092            w.bodies[a].position,
1093            v3(-0.5 - 1.25, 0.0, 0.0),
1094            POS_TOL,
1095            "a 1:3",
1096        );
1097        close(
1098            w.bodies[b].position,
1099            v3(0.5 - 1.25, 0.0, 0.0),
1100            POS_TOL,
1101            "b 1:3",
1102        );
1103        // v_a = (m_a u_a + m_b u_b + m_b e (u_b − u_a)) / (m_a + m_b)
1104        close(w.bodies[a].velocity, v3(-640.0, 0.0, 0.0), VEL_TOL, "a 1:3");
1105        close(w.bodies[b].velocity, v3(-128.0, 0.0, 0.0), VEL_TOL, "b 1:3");
1106    }
1107
1108    /// The cast sees a moving body where it starts; the hit is re-timed by
1109    /// the relative motion. A sphere moving `16` m behind one moving `4` m
1110    /// (gap `3`): they touch at `t = 3/12 = 1/4` (the cast alone would say
1111    /// `3/16`). A leader moving away faster (`16` m ahead of `8`) is never
1112    /// reached: the follower flies to exactly `8`.
1113    #[test]
1114    fn a_cast_hit_on_a_moving_body_is_retimed() {
1115        let mut w = bare(WorldCcdConfig::on());
1116        let a = sphere(&mut w, Vec3Fix::ZERO, 1, 0.5, Vec3Fix::ZERO);
1117        let b = sphere(&mut w, v3(4.0, 0.0, 0.0), 1, 0.5, Vec3Fix::ZERO);
1118        predict(&mut w, &[(a, v3(16.0, 0.0, 0.0)), (b, v3(4.0, 0.0, 0.0))]);
1119        let hits = w.ccd_sweep();
1120        assert_eq!(hits.len(), 2);
1121        assert_eq!((hits[0].body, hits[0].other), (a, Some(b)));
1122        assert_eq!(hits[0].t, Fix128::from_ratio(1, 4));
1123        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
1124        // equal masses: touching at 4 / 5, then the mean `10` along `n` for
1125        // the remaining 3/4
1126        assert_eq!(w.bodies[a].position, v3(4.0 + 7.5, 0.0, 0.0));
1127        assert_eq!(w.bodies[b].position, v3(5.0 + 7.5, 0.0, 0.0));
1128
1129        let mut w = bare(WorldCcdConfig::on());
1130        let a = sphere(&mut w, Vec3Fix::ZERO, 1, 0.5, v3(512.0, 0.0, 0.0));
1131        let b = sphere(&mut w, v3(4.0, 0.0, 0.0), 1, 0.5, v3(1024.0, 0.0, 0.0));
1132        predict(&mut w, &[(a, v3(8.0, 0.0, 0.0)), (b, v3(16.0, 0.0, 0.0))]);
1133        assert!(w.ccd_sweep().is_empty());
1134        assert_eq!(w.bodies[a].position, v3(8.0, 0.0, 0.0));
1135        assert_eq!(w.bodies[b].position, v3(20.0, 0.0, 0.0));
1136    }
1137
1138    /// The pass over moving bodies skips the ones it cannot answer: a moving
1139    /// body without a collision radius, one the pair filter rejects (same
1140    /// group), and one the swept body overlaps at the start (left to the
1141    /// discrete contacts). None of them is hit (the grouped one crosses the
1142    /// path at `(4, 0)` at `t = 1/2`, before the plate); the sphere hits the
1143    /// plate at `t = 4.734375 / 8` and the others end where they were
1144    /// predicted.
1145    #[test]
1146    fn the_moving_pass_skips_bodies_it_cannot_answer() {
1147        let group = CollisionFilter::DEFAULT.with_group(7);
1148        let mut w = bare(WorldCcdConfig::on());
1149        let p = plate(&mut w);
1150        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
1151        let bare_body = w.add_body(RigidBody::new_dynamic(v3(2.0, -4.0, 0.0), Fix128::ONE));
1152        let grouped = sphere(&mut w, v3(4.0, -4.0, 0.0), 1, 0.25, Vec3Fix::ZERO);
1153        let lapped = sphere(&mut w, v3(0.0, 0.0, 0.25), 1, 0.25, Vec3Fix::ZERO);
1154        w.set_body_filter(s, group);
1155        w.set_body_filter(grouped, group);
1156        let up = v3(0.0, 8.0, 0.0);
1157        let back = v3(0.0, 0.0, 8.0);
1158        predict(
1159            &mut w,
1160            &[
1161                (s, v3(8.0, 0.0, 0.0)),
1162                (bare_body, up),
1163                (grouped, up),
1164                (lapped, back),
1165            ],
1166        );
1167        let hits = w.ccd_sweep();
1168        assert_eq!(hits.len(), 1);
1169        assert_eq!((hits[0].body, hits[0].other), (s, Some(p)));
1170        assert_eq!(hits[0].t, stop_x() / Fix128::from_int(8));
1171        assert_eq!(w.bodies[bare_body].position, v3(2.0, 4.0, 0.0));
1172        assert_eq!(w.bodies[grouped].position, v3(4.0, 4.0, 0.0));
1173        assert_eq!(w.bodies[lapped].position, v3(0.0, 0.0, 8.25));
1174    }
1175
1176    /// The contacts of the hits: a pair hit from both sides adds one contact
1177    /// of depth `0` (one constraint, one event), a hit on a static collider
1178    /// adds none, and a sleeping body that is hit is woken.
1179    #[test]
1180    fn hits_add_one_contact_per_pair_and_wake_a_sleeping_body() {
1181        let mut w = bare(WorldCcdConfig::on());
1182        let a = sphere(&mut w, v3(-6.0, 0.0, 0.0), 1, 0.5, Vec3Fix::ZERO);
1183        let b = sphere(&mut w, v3(6.0, 0.0, 0.0), 1, 0.5, Vec3Fix::ZERO);
1184        predict(&mut w, &[(a, v3(8.0, 0.0, 0.0)), (b, v3(-8.0, 0.0, 0.0))]);
1185        let hits = w.ccd_sweep();
1186        assert_eq!(hits.len(), 2);
1187        w.clear_contacts();
1188        w.ccd_add_contacts(&hits);
1189        assert_eq!(w.contact_constraints.len(), 1);
1190        assert_eq!(w.contact_events().len(), 1);
1191        let c = &w.contact_constraints[0];
1192        assert_eq!((c.body_a, c.body_b), (a, b));
1193        assert_eq!(c.contact.depth, Fix128::ZERO);
1194
1195        // a still sleeping sphere hit by a fast one is woken
1196        let mut w = bare(WorldCcdConfig::on());
1197        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
1198        let still = sphere(&mut w, v3(4.0, 0.0, 0.0), 1, 0.25, Vec3Fix::ZERO);
1199        w.islands.sleep_data[still].state = crate::sleeping::SleepState::Sleeping;
1200        assert!(w.is_sleeping(still));
1201        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
1202        let hits = w.ccd_sweep();
1203        assert_eq!(hits.len(), 1);
1204        assert_eq!(hits[0].other, Some(still));
1205        assert_eq!(hits[0].t, Fix128::from_ratio(7, 16));
1206        w.clear_contacts();
1207        w.ccd_add_contacts(&hits);
1208        assert!(!w.is_sleeping(still));
1209        assert_eq!(w.contact_constraints.len(), 1);
1210
1211        // a static collider hit adds no contact
1212        let mut w = bare(WorldCcdConfig::on());
1213        w.add_static_collider(StaticCollider::Plane(PlaneCollider::new(
1214            v3(-1.0, 0.0, 0.0),
1215            f(-5.0),
1216        )));
1217        let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, Vec3Fix::ZERO);
1218        predict(&mut w, &[(s, v3(8.0, 0.0, 0.0))]);
1219        let hits = w.ccd_sweep();
1220        assert_eq!(hits.len(), 1);
1221        assert_eq!(hits[0].other, None);
1222        w.clear_contacts();
1223        w.ccd_add_contacts(&hits);
1224        assert!(w.contact_constraints.is_empty());
1225    }
1226
1227    // ── Kinematic ───────────────────────────────────────────────────────────
1228
1229    /// A kinematic sphere (radius `1/2`) driven from `−6` to `4` sweeps
1230    /// through a static box in its path (hidden: it only answers dynamic
1231    /// bodies) and hits a still dynamic sphere at the origin at `t = 1/2`.
1232    /// The kinematic body reaches its target bit for bit; the dynamic one is
1233    /// carried to touch it (`x = 5`) and leaves at `(1 + e) · 640 = 960` m/s.
1234    #[test]
1235    fn a_kinematic_body_sweeps_only_for_dynamic_bodies() {
1236        let scene = || {
1237            let mut w = bare(WorldCcdConfig::on());
1238            let wall = w.add_body(RigidBody::new_static(v3(-3.0, 0.0, 0.0)));
1239            w.set_body_shape(
1240                wall,
1241                &Shape::Box {
1242                    half_extents: v3(1.0 / 64.0, 2.0, 2.0),
1243                },
1244            );
1245            let mut k = RigidBody::new_kinematic(v3(-6.0, 0.0, 0.0));
1246            k.kinematic_target = Some((v3(4.0, 0.0, 0.0), k.rotation));
1247            let k = w.add_body_with_radius(k, f(0.5));
1248            let d = sphere(&mut w, Vec3Fix::ZERO, 1, 0.5, Vec3Fix::ZERO);
1249            half_bouncy(&mut w);
1250            (w, k, d)
1251        };
1252
1253        let (mut w, k, d) = scene();
1254        predict(&mut w, &[(k, v3(10.0, 0.0, 0.0))]);
1255        let hits = w.ccd_sweep();
1256        assert_eq!(hits.len(), 1);
1257        assert_eq!((hits[0].body, hits[0].other), (k, Some(d)));
1258        assert_eq!(hits[0].t, Fix128::from_ratio(1, 2));
1259        // from the obstacle (the dynamic sphere) toward the swept body
1260        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
1261        assert_eq!(w.bodies[k].position, v3(4.0, 0.0, 0.0));
1262        assert_eq!(w.bodies[d].position, v3(5.0, 0.0, 0.0));
1263
1264        let (mut w, k, d) = scene();
1265        w.step(h());
1266        assert_eq!(w.bodies[k].position, v3(4.0, 0.0, 0.0), "kinematic target");
1267        close(w.bodies[d].position, v3(5.0, 0.0, 0.0), POS_TOL, "dynamic");
1268        close(
1269            w.bodies[d].velocity,
1270            v3(960.0, 0.0, 0.0),
1271            VEL_TOL,
1272            "dynamic",
1273        );
1274    }
1275
1276    // ── Static colliders ────────────────────────────────────────────────────
1277
1278    /// A static triangle in the plane `x = 5` has no body to carry a contact:
1279    /// a sphere of radius `1/4` moving `(8, 4, 0)` stops on it at
1280    /// `t = 4.75 / 8` and slides along it, ending at `(4.75, 4, 0)`, with no
1281    /// contact added.
1282    #[test]
1283    fn a_static_collider_stops_the_body_which_then_slides() {
1284        let scene = || {
1285            let mut w = bare(WorldCcdConfig::on());
1286            let tri = TriMesh::from_indexed(
1287                &[
1288                    v3(5.0, -40.0, -40.0),
1289                    v3(5.0, 40.0, -40.0),
1290                    v3(5.0, 0.0, 40.0),
1291                ],
1292                &[0, 1, 2],
1293            );
1294            w.add_static_collider(StaticCollider::TriMesh(tri));
1295            let s = sphere(&mut w, Vec3Fix::ZERO, 1, 0.25, v3(512.0, 256.0, 0.0));
1296            (w, s)
1297        };
1298
1299        let (mut w, s) = scene();
1300        predict(&mut w, &[(s, v3(8.0, 4.0, 0.0))]);
1301        let hits = w.ccd_sweep();
1302        assert_eq!(hits.len(), 1);
1303        assert_eq!(hits[0].other, None);
1304        assert_eq!(hits[0].t, Fix128::from_ratio(19, 32));
1305        assert_eq!(hits[0].normal, v3(-1.0, 0.0, 0.0));
1306        close(w.bodies[s].position, v3(4.75, 4.0, 0.0), POS_TOL, "placed");
1307
1308        let (mut w, s) = scene();
1309        w.step(h());
1310        close(w.bodies[s].position, v3(4.75, 4.0, 0.0), POS_TOL, "stepped");
1311        assert!(w.contact_events().is_empty());
1312    }
1313}