diff --git a/crates/rapier2d/tests/issue_985_ccd_stale_fixed_target.rs b/crates/rapier2d/tests/issue_985_ccd_stale_fixed_target.rs new file mode 100644 index 000000000..6883d548b --- /dev/null +++ b/crates/rapier2d/tests/issue_985_ccd_stale_fixed_target.rs @@ -0,0 +1,66 @@ +//! Regression test for https://github.com/dimforge/rapier/issues/985. +//! +//! A fixed-target cache built by one CCD pass must not retain a collider that is +//! removed during a later step where no body is fast enough to run CCD. + +use rapier2d::prelude::*; + +#[test] +fn fixed_target_removed_without_active_ccd_is_not_reused_by_later_sweep() { + let mut world = PhysicsWorld::default(); + world.gravity = Vector::new(0.0, -20.0); + + world.insert( + RigidBodyBuilder::fixed().translation(Vector::new(0.0, -0.5)), + ColliderBuilder::cuboid(50.0, 0.5), + ); + + let (obstacle, _) = world.insert( + RigidBodyBuilder::fixed().translation(Vector::new(10.0, 5.0)), + ColliderBuilder::cuboid(1.0, 1.0), + ); + + world.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::new(-10.0, 10.0)) + .linvel(Vector::new(0.0, -30.0)), + ColliderBuilder::ball(0.25).density(8.0), + ); + + let mut falling_ball = None; + for tick in 0..=202 { + if tick == 120 { + assert!( + world + .bodies + .iter() + .filter(|(_, body)| body.is_dynamic()) + .all(|(_, body)| !body.is_ccd_active()), + "the fixed target must be removed on a step with no active CCD body" + ); + assert!(world.remove_body(obstacle).is_some()); + } + if tick == 121 { + let ball = world + .insert( + RigidBodyBuilder::dynamic().translation(Vector::new(10.0, 25.0)), + ColliderBuilder::ball(0.25).density(8.0), + ) + .0; + assert!(!world.bodies[ball].is_ccd_active()); + falling_ball = Some(ball); + } + + world.step(); + } + + let falling_ball = falling_ball.unwrap(); + assert!( + world.bodies[falling_ball].is_ccd_active(), + "the later body must become fast enough to exercise the fixed-target sweep" + ); + assert!( + world.bodies[falling_ball].translation().y.is_finite(), + "the later CCD sweep should complete with only live fixed-target handles" + ); +} diff --git a/src/dynamics/ccd/ccd_solver.rs b/src/dynamics/ccd/ccd_solver.rs index ee5cd9b07..e6708b9d6 100644 --- a/src/dynamics/ccd/ccd_solver.rs +++ b/src/dynamics/ccd/ccd_solver.rs @@ -43,6 +43,11 @@ impl CCDSolver { Self::default() } + /// Invalidates the fixed-target list after user changes to the scene. + pub(crate) fn invalidate_fixed_targets_cache(&mut self) { + self.fixed_targets_cache = None; + } + /// Updates the set of bodies that needs CCD to be resolved. /// /// Returns `true` if any rigid-body must have CCD resolved. diff --git a/src/pipeline/physics_pipeline/substep.rs b/src/pipeline/physics_pipeline/substep.rs index 75b86d03e..3d0bb5044 100644 --- a/src/pipeline/physics_pipeline/substep.rs +++ b/src/pipeline/physics_pipeline/substep.rs @@ -339,6 +339,11 @@ impl PhysicsPipeline { let ccd_scene_changed = !modified_colliders.is_empty() || !removed_colliders.is_empty() || !modified_bodies.is_empty(); + if ccd_scene_changed { + // This step may have no fast body, so invalidate now instead of relying on + // the next motion-clamping pass to observe this per-step signal. + ccd_solver.invalidate_fixed_targets_cache(); + } // Join islands based on new joints. #[cfg(feature = "enhanced-determinism")]