diff --git a/crates/lambda-rs-platform/src/physics/mod.rs b/crates/lambda-rs-platform/src/physics/mod.rs index 1c33dcc4..93ebaddd 100644 --- a/crates/lambda-rs-platform/src/physics/mod.rs +++ b/crates/lambda-rs-platform/src/physics/mod.rs @@ -7,6 +7,7 @@ pub mod rapier2d; pub use rapier2d::{ + Collider2DBackendError, PhysicsBackend2D, RigidBody2DBackendError, RigidBodyType2D, diff --git a/crates/lambda-rs-platform/src/physics/rapier2d.rs b/crates/lambda-rs-platform/src/physics/rapier2d.rs index 2df5c1d0..ab211cb1 100644 --- a/crates/lambda-rs-platform/src/physics/rapier2d.rs +++ b/crates/lambda-rs-platform/src/physics/rapier2d.rs @@ -79,17 +79,96 @@ impl fmt::Display for RigidBody2DBackendError { impl Error for RigidBody2DBackendError {} +/// Backend errors for 2D collider operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Collider2DBackendError { + /// The referenced rigid body was not found. + BodyNotFound, + /// The provided polygon could not be represented as a convex hull. + InvalidPolygonDegenerate, +} + +impl fmt::Display for Collider2DBackendError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BodyNotFound => { + return write!(formatter, "rigid body not found"); + } + Self::InvalidPolygonDegenerate => { + return write!(formatter, "invalid polygon: degenerate"); + } + } + } +} + +impl Error for Collider2DBackendError {} + +/// The fallback mass applied to dynamic bodies before density colliders exist. +const DYNAMIC_BODY_FALLBACK_MASS_KG: f32 = 1.0; + +/// Stores per-body state that `lambda-rs` tracks alongside Rapier. +/// +/// This slot exists because `lambda-rs` defines integration semantics that are +/// stricter than the vendor backend: +/// - Forces are accumulated and cleared explicitly by the public API. +/// - Impulses update velocity immediately. +/// +/// # Invariants +/// - `rapier_handle` MUST reference a body in `PhysicsBackend2D::bodies`. +/// - `explicit_dynamic_mass_kg` MUST be `Some` only for dynamic bodies. +/// - `generation` MUST be non-zero and is used to validate handles. #[derive(Debug, Clone, Copy)] struct RigidBodySlot2D { + /// The rigid body's integration mode. body_type: RigidBodyType2D, - position: [f32; 2], - rotation: f32, - velocity: [f32; 2], + /// The handle to the Rapier rigid body stored in the `RigidBodySet`. + rapier_handle: RigidBodyHandle, + /// Accumulated forces applied by the public API, in Newtons. force_accumulator: [f32; 2], - dynamic_mass_kg: f32, + /// The explicitly configured body mass in kilograms, if set. + /// + /// When this value is `Some`, collider density MUST NOT affect body mass + /// properties. The backend enforces this by creating attached colliders with + /// zero density and using the configured value as the body's additional mass. + explicit_dynamic_mass_kg: Option, + /// Tracks whether the body has at least one positive-density collider. + /// + /// This flag supports the spec requirement that bodies with no positive + /// density colliders default to `1.0` kg, while bodies with at least one + /// positive-density collider compute mass from collider density alone. + has_positive_density_colliders: bool, + /// A monotonically increasing counter used to validate stale handles. + generation: u32, +} + +/// Stores per-collider state that `lambda-rs` tracks alongside Rapier. +/// +/// # Invariants +/// - `rapier_handle` MUST reference a collider in `PhysicsBackend2D::colliders`. +/// - `generation` MUST be non-zero and is used to validate stale handles. +#[derive(Debug, Clone, Copy)] +struct ColliderSlot2D { + /// The handle to the Rapier collider stored in the `ColliderSet`. + rapier_handle: ColliderHandle, + /// A monotonically increasing counter used to validate stale handles. generation: u32, } +/// Describes how collider attachment should affect dynamic-body mass semantics. +/// +/// This helper isolates `lambda-rs` mass rules from the Rapier attachment flow +/// so body creation and collider attachment share one backend policy source. +#[derive(Debug, Clone, Copy, PartialEq)] +struct ColliderAttachmentMassPlan2D { + /// The density value that MUST be passed to the Rapier collider builder. + rapier_density: f32, + /// Whether attaching this collider transitions the body to density-driven + /// mass computation. + should_mark_has_positive_density_colliders: bool, + /// Whether the initial fallback mass MUST be removed before insertion. + should_remove_fallback_mass: bool, +} + /// A 2D physics backend powered by `rapier2d`. /// /// This type is an internal implementation detail used by `lambda-rs`. @@ -106,6 +185,7 @@ pub struct PhysicsBackend2D { ccd_solver: CCDSolver, pipeline: PhysicsPipeline, rigid_body_slots_2d: Vec, + collider_slots_2d: Vec, } impl PhysicsBackend2D { @@ -138,6 +218,7 @@ impl PhysicsBackend2D { ccd_solver: CCDSolver::new(), pipeline: PhysicsPipeline::new(), rigid_body_slots_2d: Vec::new(), + collider_slots_2d: Vec::new(), }; } @@ -167,24 +248,248 @@ impl PhysicsBackend2D { validate_rotation(rotation)?; validate_velocity(velocity[0], velocity[1])?; - let mass_kg = resolve_dynamic_mass_kg(body_type, dynamic_mass_kg)?; + let explicit_dynamic_mass_kg = + resolve_explicit_dynamic_mass_kg(body_type, dynamic_mass_kg)?; + let additional_mass_kg = + resolve_additional_mass_kg(body_type, explicit_dynamic_mass_kg)?; let slot_index = self.rigid_body_slots_2d.len() as u32; let slot_generation = 1; - self.rigid_body_slots_2d.push(RigidBodySlot2D { + let rapier_body = build_rapier_rigid_body( body_type, position, rotation, velocity, + additional_mass_kg, + ); + let rapier_handle = self.bodies.insert(rapier_body); + + if body_type == RigidBodyType2D::Dynamic { + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + rapier_body.recompute_mass_properties_from_colliders(&self.colliders); + } + + self.rigid_body_slots_2d.push(RigidBodySlot2D { + body_type, + rapier_handle, force_accumulator: [0.0, 0.0], - dynamic_mass_kg: mass_kg, + explicit_dynamic_mass_kg, + has_positive_density_colliders: false, generation: slot_generation, }); return Ok((slot_index, slot_generation)); } + /// Creates and attaches a circle collider to a rigid body. + /// + /// The caller MUST validate all collider inputs before reaching this backend. + /// `lambda-rs` performs that validation in `Collider2DBuilder::build()`. + /// + /// # Arguments + /// - `parent_slot_index`: The rigid body slot index. + /// - `parent_slot_generation`: The rigid body slot generation counter. + /// - `radius`: The circle radius in meters. + /// - `local_offset`: The collider local translation in meters. + /// - `local_rotation`: The collider local rotation in radians. + /// - `density`: The density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created collider. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body does + /// not exist. + #[allow(clippy::too_many_arguments)] + pub fn create_circle_collider_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + radius: f32, + local_offset: [f32; 2], + local_rotation: f32, + density: f32, + friction: f32, + restitution: f32, + ) -> Result<(u32, u32), Collider2DBackendError> { + return self.attach_collider_2d( + parent_slot_index, + parent_slot_generation, + ColliderBuilder::ball(radius), + local_offset, + local_rotation, + density, + friction, + restitution, + ); + } + + /// Creates and attaches a rectangle collider to a rigid body. + /// + /// The caller MUST validate all collider inputs before reaching this backend. + /// `lambda-rs` performs that validation in `Collider2DBuilder::build()`. + /// + /// # Arguments + /// - `parent_slot_index`: The rigid body slot index. + /// - `parent_slot_generation`: The rigid body slot generation counter. + /// - `half_width`: The rectangle half-width in meters. + /// - `half_height`: The rectangle half-height in meters. + /// - `local_offset`: The collider local translation in meters. + /// - `local_rotation`: The collider local rotation in radians. + /// - `density`: The density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created collider. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body does + /// not exist. + #[allow(clippy::too_many_arguments)] + pub fn create_rectangle_collider_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + half_width: f32, + half_height: f32, + local_offset: [f32; 2], + local_rotation: f32, + density: f32, + friction: f32, + restitution: f32, + ) -> Result<(u32, u32), Collider2DBackendError> { + return self.attach_collider_2d( + parent_slot_index, + parent_slot_generation, + ColliderBuilder::cuboid(half_width, half_height), + local_offset, + local_rotation, + density, + friction, + restitution, + ); + } + + /// Creates and attaches a capsule collider to a rigid body. + /// + /// The capsule is aligned with the collider local Y axis. + /// The caller MUST validate all collider inputs before reaching this backend. + /// `lambda-rs` performs that validation in `Collider2DBuilder::build()`. + /// + /// # Arguments + /// - `parent_slot_index`: The rigid body slot index. + /// - `parent_slot_generation`: The rigid body slot generation counter. + /// - `half_height`: The half-height of the capsule segment in meters. + /// - `radius`: The capsule radius in meters. + /// - `local_offset`: The collider local translation in meters. + /// - `local_rotation`: The collider local rotation in radians. + /// - `density`: The density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created collider. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body does + /// not exist. + #[allow(clippy::too_many_arguments)] + pub fn create_capsule_collider_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + half_height: f32, + radius: f32, + local_offset: [f32; 2], + local_rotation: f32, + density: f32, + friction: f32, + restitution: f32, + ) -> Result<(u32, u32), Collider2DBackendError> { + let rapier_builder = if half_height == 0.0 { + ColliderBuilder::ball(radius) + } else { + ColliderBuilder::capsule_y(half_height, radius) + }; + + return self.attach_collider_2d( + parent_slot_index, + parent_slot_generation, + rapier_builder, + local_offset, + local_rotation, + density, + friction, + restitution, + ); + } + + /// Creates and attaches a convex polygon collider to a rigid body. + /// + /// The polygon vertices are interpreted as points in collider local space. + /// The caller MUST validate and normalize polygon inputs before reaching this + /// backend. `lambda-rs` performs that validation in + /// `Collider2DBuilder::build()`. + /// + /// # Arguments + /// - `parent_slot_index`: The rigid body slot index. + /// - `parent_slot_generation`: The rigid body slot generation counter. + /// - `vertices`: The polygon vertices in meters. + /// - `local_offset`: The collider local translation in meters. + /// - `local_rotation`: The collider local rotation in radians. + /// - `density`: The density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created collider. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body does + /// not exist. Returns `Collider2DBackendError::InvalidPolygonDegenerate` if + /// the validated polygon still cannot be represented as a Rapier convex + /// hull. + #[allow(clippy::too_many_arguments)] + pub fn create_convex_polygon_collider_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + vertices: Vec<[f32; 2]>, + local_offset: [f32; 2], + local_rotation: f32, + density: f32, + friction: f32, + restitution: f32, + ) -> Result<(u32, u32), Collider2DBackendError> { + let rapier_vertices: Vec = vertices + .iter() + .map(|vertex| Vector::new(vertex[0], vertex[1])) + .collect(); + + let Some(rapier_builder) = + ColliderBuilder::convex_hull(rapier_vertices.as_slice()) + else { + return Err(Collider2DBackendError::InvalidPolygonDegenerate); + }; + + return self.attach_collider_2d( + parent_slot_index, + parent_slot_generation, + rapier_builder, + local_offset, + local_rotation, + density, + friction, + restitution, + ); + } + /// Returns the rigid body type for the referenced body. /// /// # Arguments @@ -201,8 +506,8 @@ impl PhysicsBackend2D { slot_index: u32, slot_generation: u32, ) -> Result { - let body = self.rigid_body_2d(slot_index, slot_generation)?; - return Ok(body.body_type); + let body_slot = self.rigid_body_slot_2d(slot_index, slot_generation)?; + return Ok(body_slot.body_type); } /// Returns the current position for the referenced body. @@ -221,8 +526,9 @@ impl PhysicsBackend2D { slot_index: u32, slot_generation: u32, ) -> Result<[f32; 2], RigidBody2DBackendError> { - let body = self.rigid_body_2d(slot_index, slot_generation)?; - return Ok(body.position); + let rapier_body = self.rapier_rigid_body_2d(slot_index, slot_generation)?; + let translation = rapier_body.translation(); + return Ok([translation.x, translation.y]); } /// Returns the current rotation for the referenced body. @@ -241,8 +547,8 @@ impl PhysicsBackend2D { slot_index: u32, slot_generation: u32, ) -> Result { - let body = self.rigid_body_2d(slot_index, slot_generation)?; - return Ok(body.rotation); + let rapier_body = self.rapier_rigid_body_2d(slot_index, slot_generation)?; + return Ok(rapier_body.rotation().angle()); } /// Returns the current linear velocity for the referenced body. @@ -261,8 +567,27 @@ impl PhysicsBackend2D { slot_index: u32, slot_generation: u32, ) -> Result<[f32; 2], RigidBody2DBackendError> { - let body = self.rigid_body_2d(slot_index, slot_generation)?; - return Ok(body.velocity); + let rapier_body = self.rapier_rigid_body_2d(slot_index, slot_generation)?; + let velocity = rapier_body.linvel(); + return Ok([velocity.x, velocity.y]); + } + + /// Returns whether the referenced body slot resolves to a live rigid body. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns `true` when the slot is valid and the Rapier body still exists. + pub fn rigid_body_exists_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> bool { + return self + .rapier_rigid_body_2d(slot_index, slot_generation) + .is_ok(); } /// Sets the current position for the referenced body. @@ -285,8 +610,9 @@ impl PhysicsBackend2D { position: [f32; 2], ) -> Result<(), RigidBody2DBackendError> { validate_position(position[0], position[1])?; - let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; - body.position = position; + let rapier_body = + self.rapier_rigid_body_2d_mut(slot_index, slot_generation)?; + rapier_body.set_translation(Vector::new(position[0], position[1]), true); return Ok(()); } @@ -310,8 +636,9 @@ impl PhysicsBackend2D { rotation: f32, ) -> Result<(), RigidBody2DBackendError> { validate_rotation(rotation)?; - let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; - body.rotation = rotation; + let rapier_body = + self.rapier_rigid_body_2d_mut(slot_index, slot_generation)?; + rapier_body.set_rotation(Rotation::new(rotation), true); return Ok(()); } @@ -336,19 +663,20 @@ impl PhysicsBackend2D { velocity: [f32; 2], ) -> Result<(), RigidBody2DBackendError> { validate_velocity(velocity[0], velocity[1])?; - let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + let (body_type, rapier_handle) = { + let body_slot = self.rigid_body_slot_2d(slot_index, slot_generation)?; + (body_slot.body_type, body_slot.rapier_handle) + }; - match body.body_type { - RigidBodyType2D::Static => { - return Err(RigidBody2DBackendError::UnsupportedOperation { - body_type: body.body_type, - }); - } - RigidBodyType2D::Dynamic | RigidBodyType2D::Kinematic => { - body.velocity = velocity; - return Ok(()); - } + if body_type == RigidBodyType2D::Static { + return Err(RigidBody2DBackendError::UnsupportedOperation { body_type }); } + + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + rapier_body.set_linvel(Vector::new(velocity[0], velocity[1]), true); + return Ok(()); } /// Applies a force, in Newtons, at the center of mass. @@ -372,7 +700,7 @@ impl PhysicsBackend2D { force: [f32; 2], ) -> Result<(), RigidBody2DBackendError> { validate_force(force[0], force[1])?; - let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + let body = self.rigid_body_slot_2d_mut(slot_index, slot_generation)?; if body.body_type != RigidBodyType2D::Dynamic { return Err(RigidBody2DBackendError::UnsupportedOperation { @@ -407,16 +735,19 @@ impl PhysicsBackend2D { impulse: [f32; 2], ) -> Result<(), RigidBody2DBackendError> { validate_impulse(impulse[0], impulse[1])?; - let body = self.rigid_body_2d_mut(slot_index, slot_generation)?; + let (body_type, rapier_handle) = { + let body_slot = self.rigid_body_slot_2d(slot_index, slot_generation)?; + (body_slot.body_type, body_slot.rapier_handle) + }; - if body.body_type != RigidBodyType2D::Dynamic { - return Err(RigidBody2DBackendError::UnsupportedOperation { - body_type: body.body_type, - }); + if body_type != RigidBodyType2D::Dynamic { + return Err(RigidBody2DBackendError::UnsupportedOperation { body_type }); } - body.velocity[0] += impulse[0] / body.dynamic_mass_kg; - body.velocity[1] += impulse[1] / body.dynamic_mass_kg; + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + rapier_body.apply_impulse(Vector::new(impulse[0], impulse[1]), true); return Ok(()); } @@ -426,8 +757,17 @@ impl PhysicsBackend2D { /// # Returns /// Returns `()` after clearing force accumulators. pub fn clear_rigid_body_forces_2d(&mut self) { - for body in &mut self.rigid_body_slots_2d { - body.force_accumulator = [0.0, 0.0]; + for index in 0..self.rigid_body_slots_2d.len() { + let rapier_handle = { + let body_slot = &mut self.rigid_body_slots_2d[index]; + body_slot.force_accumulator = [0.0, 0.0]; + body_slot.rapier_handle + }; + + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + continue; + }; + rapier_body.reset_forces(true); } return; @@ -468,7 +808,13 @@ impl PhysicsBackend2D { pub fn step_with_timestep_seconds(&mut self, timestep_seconds: f32) { self.integration_parameters.dt = timestep_seconds; - self.step_rigid_bodies_2d(timestep_seconds); + if cfg!(debug_assertions) { + self.debug_validate_collider_slots_2d(); + } + + // Rapier consumes user forces during each integration step, so + // accumulated public forces must be re-synchronized before every substep. + self.sync_force_accumulators_2d(); self.pipeline.step( self.gravity, @@ -488,7 +834,19 @@ impl PhysicsBackend2D { return; } - fn rigid_body_2d( + /// Returns an immutable reference to a rigid body slot after validation. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns an immutable reference to the validated `RigidBodySlot2D`. + /// + /// # Errors + /// Returns `RigidBody2DBackendError::BodyNotFound` when the slot is missing + /// or the generation does not match. + fn rigid_body_slot_2d( &self, slot_index: u32, slot_generation: u32, @@ -504,7 +862,19 @@ impl PhysicsBackend2D { return Ok(body); } - fn rigid_body_2d_mut( + /// Returns a mutable reference to a rigid body slot after validation. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns a mutable reference to the validated `RigidBodySlot2D`. + /// + /// # Errors + /// Returns `RigidBody2DBackendError::BodyNotFound` when the slot is missing + /// or the generation does not match. + fn rigid_body_slot_2d_mut( &mut self, slot_index: u32, slot_generation: u32, @@ -521,35 +891,364 @@ impl PhysicsBackend2D { return Ok(body); } - fn step_rigid_bodies_2d(&mut self, timestep_seconds: f32) { - let gravity = [self.gravity.x, self.gravity.y]; - - for body in &mut self.rigid_body_slots_2d { - match body.body_type { - RigidBodyType2D::Static => {} - RigidBodyType2D::Kinematic => { - body.position[0] += body.velocity[0] * timestep_seconds; - body.position[1] += body.velocity[1] * timestep_seconds; - } - RigidBodyType2D::Dynamic => { - let acceleration_x = - gravity[0] + (body.force_accumulator[0] / body.dynamic_mass_kg); - let acceleration_y = - gravity[1] + (body.force_accumulator[1] / body.dynamic_mass_kg); - - body.velocity[0] += acceleration_x * timestep_seconds; - body.velocity[1] += acceleration_y * timestep_seconds; - - body.position[0] += body.velocity[0] * timestep_seconds; - body.position[1] += body.velocity[1] * timestep_seconds; - } + /// Returns an immutable reference to the Rapier rigid body for a slot. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns an immutable reference to the underlying Rapier `RigidBody`. + /// + /// # Errors + /// Returns `RigidBody2DBackendError::BodyNotFound` when the slot is invalid + /// or the Rapier body has been removed. + fn rapier_rigid_body_2d( + &self, + slot_index: u32, + slot_generation: u32, + ) -> Result<&RigidBody, RigidBody2DBackendError> { + let body_slot = self.rigid_body_slot_2d(slot_index, slot_generation)?; + let Some(rapier_body) = self.bodies.get(body_slot.rapier_handle) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + + return Ok(rapier_body); + } + + /// Returns a mutable reference to the Rapier rigid body for a slot. + /// + /// # Arguments + /// - `slot_index`: The body slot index. + /// - `slot_generation`: The slot generation counter. + /// + /// # Returns + /// Returns a mutable reference to the underlying Rapier `RigidBody`. + /// + /// # Errors + /// Returns `RigidBody2DBackendError::BodyNotFound` when the slot is invalid + /// or the Rapier body has been removed. + fn rapier_rigid_body_2d_mut( + &mut self, + slot_index: u32, + slot_generation: u32, + ) -> Result<&mut RigidBody, RigidBody2DBackendError> { + let rapier_handle = { + let body_slot = self.rigid_body_slot_2d(slot_index, slot_generation)?; + body_slot.rapier_handle + }; + + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + return Err(RigidBody2DBackendError::BodyNotFound); + }; + + return Ok(rapier_body); + } + + /// Syncs accumulated forces into Rapier prior to stepping. + /// + /// `lambda-rs` exposes a force accumulation API that persists forces until + /// explicitly cleared. Rapier stores forces on each rigid body. This function + /// overwrites Rapier's stored force with the value tracked by `lambda-rs` so + /// Rapier can integrate forces and gravity consistently during stepping. + /// Bodies with zero accumulated force are skipped because `clear_*` methods + /// and Rapier step completion already leave them with no user force to + /// reapply. + /// + /// # Returns + /// Returns `()` after updating Rapier force state for all dynamic bodies. + fn sync_force_accumulators_2d(&mut self) { + for index in 0..self.rigid_body_slots_2d.len() { + let (body_type, rapier_handle, force_accumulator) = { + let body_slot = &self.rigid_body_slots_2d[index]; + ( + body_slot.body_type, + body_slot.rapier_handle, + body_slot.force_accumulator, + ) + }; + + if body_type != RigidBodyType2D::Dynamic { + continue; } + + if force_accumulator[0] == 0.0 && force_accumulator[1] == 0.0 { + continue; + } + + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + continue; + }; + + let should_wake = + force_accumulator[0] != 0.0 || force_accumulator[1] != 0.0; + rapier_body.reset_forces(false); + rapier_body.add_force( + Vector::new(force_accumulator[0], force_accumulator[1]), + should_wake, + ); + } + + return; + } + + /// Attaches a prepared Rapier collider builder to a parent rigid body. + /// + /// This helper applies the shared local transform and material properties, + /// inserts the built collider into Rapier, recomputes parent mass + /// properties, and allocates the public collider slot. + /// + /// Lambda material semantics are encoded using Rapier's built-in combine + /// rules instead of a custom contact hook: + /// - friction stores `sqrt(requested_friction)` and uses `Multiply` + /// - restitution stores the requested value and uses `Max` + /// + /// # Arguments + /// - `parent_slot_index`: The parent rigid body slot index. + /// - `parent_slot_generation`: The parent slot generation counter. + /// - `rapier_builder`: The shape-specific Rapier collider builder. + /// - `local_offset`: The collider local translation in meters. + /// - `local_rotation`: The collider local rotation in radians. + /// - `density`: The requested density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a `(slot_index, slot_generation)` pair for the created collider. + /// + /// # Errors + /// Returns `Collider2DBackendError` if the parent body does not exist. + #[allow(clippy::too_many_arguments)] + fn attach_collider_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + rapier_builder: ColliderBuilder, + local_offset: [f32; 2], + local_rotation: f32, + density: f32, + friction: f32, + restitution: f32, + ) -> Result<(u32, u32), Collider2DBackendError> { + let (rapier_parent_handle, rapier_density) = self + .prepare_parent_body_for_collider_attachment_2d( + parent_slot_index, + parent_slot_generation, + density, + )?; + + let rapier_collider = rapier_builder + .translation(Vector::new(local_offset[0], local_offset[1])) + .rotation(local_rotation) + .density(rapier_density) + .friction(encode_rapier_friction_coefficient(friction)) + .friction_combine_rule(CoefficientCombineRule::Multiply) + .restitution(restitution) + .restitution_combine_rule(CoefficientCombineRule::Max) + .build(); + + let rapier_handle = self.colliders.insert_with_parent( + rapier_collider, + rapier_parent_handle, + &mut self.bodies, + ); + + self.recompute_parent_mass_after_collider_attachment_2d( + parent_slot_index, + parent_slot_generation, + rapier_parent_handle, + )?; + + let slot_index = self.collider_slots_2d.len() as u32; + let slot_generation = 1; + self.collider_slots_2d.push(ColliderSlot2D { + rapier_handle, + generation: slot_generation, + }); + + return Ok((slot_index, slot_generation)); + } + + /// Prepares a parent body for collider attachment and resolves the Rapier + /// density value to apply. + /// + /// This function enforces spec mass semantics: + /// - When a dynamic body's mass is explicitly configured, collider density + /// MUST NOT affect mass properties. The returned density is `0.0`. + /// - When a dynamic body's mass is not explicitly configured, the backend + /// starts with a `1.0` kg fallback mass. When attaching the first + /// positive-density collider, the fallback mass is removed so the body's + /// mass becomes the sum of collider mass contributions. + /// + /// # Arguments + /// - `parent_slot_index`: The parent rigid body slot index. + /// - `parent_slot_generation`: The parent slot generation counter. + /// - `requested_density`: The density requested by the public API. + /// + /// # Returns + /// Returns the Rapier body handle and the density to apply to the Rapier + /// collider. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body is + /// missing or the handle is stale. + fn prepare_parent_body_for_collider_attachment_2d( + &mut self, + parent_slot_index: u32, + parent_slot_generation: u32, + requested_density: f32, + ) -> Result<(RigidBodyHandle, f32), Collider2DBackendError> { + let ( + body_type, + rapier_handle, + explicit_dynamic_mass_kg, + has_positive_density_colliders, + ) = { + let body_slot = self + .rigid_body_slot_2d(parent_slot_index, parent_slot_generation) + .map_err(|_| Collider2DBackendError::BodyNotFound)?; + ( + body_slot.body_type, + body_slot.rapier_handle, + body_slot.explicit_dynamic_mass_kg, + body_slot.has_positive_density_colliders, + ) + }; + + if self.bodies.get(rapier_handle).is_none() { + return Err(Collider2DBackendError::BodyNotFound); + } + + let attachment_mass_plan = resolve_collider_attachment_mass_plan_2d( + body_type, + explicit_dynamic_mass_kg, + has_positive_density_colliders, + requested_density, + ); + + if attachment_mass_plan.should_mark_has_positive_density_colliders { + let body_slot = self + .rigid_body_slot_2d_mut(parent_slot_index, parent_slot_generation) + .map_err(|_| Collider2DBackendError::BodyNotFound)?; + body_slot.has_positive_density_colliders = true; + } + + if attachment_mass_plan.should_remove_fallback_mass { + let Some(rapier_body) = self.bodies.get_mut(rapier_handle) else { + return Err(Collider2DBackendError::BodyNotFound); + }; + rapier_body.set_additional_mass(0.0, true); + } + + return Ok((rapier_handle, attachment_mass_plan.rapier_density)); + } + + /// Recomputes mass properties for a parent body after collider attachment. + /// + /// # Arguments + /// - `parent_slot_index`: The parent rigid body slot index. + /// - `parent_slot_generation`: The parent slot generation counter. + /// - `rapier_parent_handle`: The Rapier body handle. + /// + /// # Returns + /// Returns `()` after recomputing mass properties. + /// + /// # Errors + /// Returns `Collider2DBackendError::BodyNotFound` if the parent body is + /// missing or the handle is stale. + fn recompute_parent_mass_after_collider_attachment_2d( + &mut self, + _parent_slot_index: u32, + _parent_slot_generation: u32, + rapier_parent_handle: RigidBodyHandle, + ) -> Result<(), Collider2DBackendError> { + let Some(rapier_body) = self.bodies.get_mut(rapier_parent_handle) else { + return Err(Collider2DBackendError::BodyNotFound); + }; + rapier_body.recompute_mass_properties_from_colliders(&self.colliders); + + return Ok(()); + } + + /// Validates that collider slots reference live Rapier colliders. + /// + /// This debug-only validation reads slot fields to prevent stale-handle + /// regressions during backend refactors. + /// + /// # Returns + /// Returns `()` after completing validation. + fn debug_validate_collider_slots_2d(&self) { + for slot in self.collider_slots_2d.iter() { + debug_assert!(slot.generation > 0, "collider slot generation is zero"); + debug_assert!( + self.colliders.get(slot.rapier_handle).is_some(), + "collider slot references missing Rapier collider" + ); } return; } } +/// Builds a Rapier rigid body builder with `lambda-rs` invariants applied. +/// +/// Bodies created by this backend do not lock the 2D rotation axis. Dynamic +/// bodies are expected to rotate in response to collisions. +/// +/// # Arguments +/// - `body_type`: The integration mode for the rigid body. +/// - `position`: The initial position in meters. +/// - `rotation`: The initial rotation in radians. +/// - `velocity`: The initial linear velocity in meters per second. +/// - `additional_mass_kg`: The additional mass in kilograms for dynamic bodies. +/// +/// # Returns +/// Returns a configured Rapier `RigidBodyBuilder`. +fn build_rapier_rigid_body( + body_type: RigidBodyType2D, + position: [f32; 2], + rotation: f32, + velocity: [f32; 2], + additional_mass_kg: f32, +) -> RigidBodyBuilder { + let translation = Vector::new(position[0], position[1]); + let linear_velocity = Vector::new(velocity[0], velocity[1]); + + match body_type { + RigidBodyType2D::Static => { + return RigidBodyBuilder::fixed() + .translation(translation) + .rotation(rotation) + .linvel(linear_velocity); + } + RigidBodyType2D::Kinematic => { + return RigidBodyBuilder::kinematic_velocity_based() + .translation(translation) + .rotation(rotation) + .linvel(linear_velocity); + } + RigidBodyType2D::Dynamic => { + return RigidBodyBuilder::dynamic() + .translation(translation) + .rotation(rotation) + .linvel(linear_velocity) + .additional_mass(additional_mass_kg); + } + } +} + +/// Validates a 2D position. +/// +/// # Arguments +/// - `x`: The X position in meters. +/// - `y`: The Y position in meters. +/// +/// # Returns +/// Returns `()` when the input is finite. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidPosition` when any component is +/// non-finite. fn validate_position(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { if !x.is_finite() || !y.is_finite() { return Err(RigidBody2DBackendError::InvalidPosition { x, y }); @@ -558,6 +1257,17 @@ fn validate_position(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } +/// Validates a rotation angle. +/// +/// # Arguments +/// - `radians`: The rotation in radians. +/// +/// # Returns +/// Returns `()` when the input is finite. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidRotation` when the angle is +/// non-finite. fn validate_rotation(radians: f32) -> Result<(), RigidBody2DBackendError> { if !radians.is_finite() { return Err(RigidBody2DBackendError::InvalidRotation { radians }); @@ -566,6 +1276,18 @@ fn validate_rotation(radians: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } +/// Validates a 2D linear velocity. +/// +/// # Arguments +/// - `x`: The X velocity in meters per second. +/// - `y`: The Y velocity in meters per second. +/// +/// # Returns +/// Returns `()` when the input is finite. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidVelocity` when any component is +/// non-finite. fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { if !x.is_finite() || !y.is_finite() { return Err(RigidBody2DBackendError::InvalidVelocity { x, y }); @@ -574,6 +1296,18 @@ fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } +/// Validates a 2D force vector. +/// +/// # Arguments +/// - `x`: The X force component in Newtons. +/// - `y`: The Y force component in Newtons. +/// +/// # Returns +/// Returns `()` when the input is finite. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidForce` when any component is +/// non-finite. fn validate_force(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { if !x.is_finite() || !y.is_finite() { return Err(RigidBody2DBackendError::InvalidForce { x, y }); @@ -582,6 +1316,18 @@ fn validate_force(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } +/// Validates a 2D impulse vector. +/// +/// # Arguments +/// - `x`: The X impulse component in Newton-seconds. +/// - `y`: The Y impulse component in Newton-seconds. +/// +/// # Returns +/// Returns `()` when the input is finite. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidImpulse` when any component is +/// non-finite. fn validate_impulse(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { if !x.is_finite() || !y.is_finite() { return Err(RigidBody2DBackendError::InvalidImpulse { x, y }); @@ -590,16 +1336,24 @@ fn validate_impulse(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } -fn resolve_dynamic_mass_kg( +/// Resolves the explicitly configured mass for a rigid body. +/// +/// # Arguments +/// - `body_type`: The integration mode for the rigid body. +/// - `dynamic_mass_kg`: The requested mass in kilograms for dynamic bodies. +/// +/// # Returns +/// Returns the explicitly configured dynamic mass. +/// +/// # Errors +/// Returns `RigidBody2DBackendError` if a mass is provided for a non-dynamic +/// body, or if the mass is non-finite or non-positive. +fn resolve_explicit_dynamic_mass_kg( body_type: RigidBodyType2D, dynamic_mass_kg: Option, -) -> Result { +) -> Result, RigidBody2DBackendError> { let Some(mass_kg) = dynamic_mass_kg else { - if body_type == RigidBodyType2D::Dynamic { - return Ok(1.0); - } - - return Ok(0.0); + return Ok(None); }; if body_type != RigidBodyType2D::Dynamic { @@ -610,13 +1364,114 @@ fn resolve_dynamic_mass_kg( return Err(RigidBody2DBackendError::InvalidMassKg { mass_kg }); } - return Ok(mass_kg); + return Ok(Some(mass_kg)); +} + +/// Resolves the additional mass in kilograms applied when creating a body. +/// +/// # Arguments +/// - `body_type`: The rigid body integration mode. +/// - `explicit_dynamic_mass_kg`: The explicitly configured mass for dynamic +/// bodies. +/// +/// # Returns +/// Returns the additional mass value in kilograms. +/// +/// # Errors +/// Returns `RigidBody2DBackendError::InvalidMassKg` if the fallback mass cannot +/// be represented as a positive finite value. +fn resolve_additional_mass_kg( + body_type: RigidBodyType2D, + explicit_dynamic_mass_kg: Option, +) -> Result { + if body_type != RigidBodyType2D::Dynamic { + return Ok(0.0); + } + + if let Some(explicit_mass_kg) = explicit_dynamic_mass_kg { + return Ok(explicit_mass_kg); + } + + let fallback_mass_kg = DYNAMIC_BODY_FALLBACK_MASS_KG; + if !fallback_mass_kg.is_finite() || fallback_mass_kg <= 0.0 { + return Err(RigidBody2DBackendError::InvalidMassKg { + mass_kg: fallback_mass_kg, + }); + } + + return Ok(fallback_mass_kg); +} + +/// Encodes a public friction coefficient for Rapier's `Multiply` rule. +/// +/// Lambda specifies `sqrt(friction_a * friction_b)` as the effective contact +/// friction. Rapier cannot express that rule directly, so the backend stores +/// `sqrt(requested_friction)` on each collider and relies on +/// `CoefficientCombineRule::Multiply` to recover the public result. +/// +/// # Arguments +/// - `requested_friction`: The public friction coefficient. +/// +/// # Returns +/// Returns the Rapier friction coefficient to store on the collider. +fn encode_rapier_friction_coefficient(requested_friction: f32) -> f32 { + return requested_friction.sqrt(); +} + +/// Resolves how attaching a collider affects a body's backend mass state. +/// +/// This helper encodes the public density semantics without directly mutating +/// Rapier state or backend slots. +/// +/// # Arguments +/// - `body_type`: The parent rigid body integration mode. +/// - `explicit_dynamic_mass_kg`: The explicitly configured dynamic mass, if +/// any. +/// - `has_positive_density_colliders`: Whether the body already has at least +/// one collider with `density > 0.0`. +/// - `requested_density`: The density requested for the new collider. +/// +/// # Returns +/// Returns a plan describing the Rapier density and any required backend state +/// transitions. +fn resolve_collider_attachment_mass_plan_2d( + body_type: RigidBodyType2D, + explicit_dynamic_mass_kg: Option, + has_positive_density_colliders: bool, + requested_density: f32, +) -> ColliderAttachmentMassPlan2D { + if body_type != RigidBodyType2D::Dynamic { + return ColliderAttachmentMassPlan2D { + rapier_density: requested_density, + should_mark_has_positive_density_colliders: false, + should_remove_fallback_mass: false, + }; + } + + if explicit_dynamic_mass_kg.is_some() { + return ColliderAttachmentMassPlan2D { + rapier_density: 0.0, + should_mark_has_positive_density_colliders: false, + should_remove_fallback_mass: false, + }; + } + + let is_first_positive_density_collider = + requested_density > 0.0 && !has_positive_density_colliders; + + return ColliderAttachmentMassPlan2D { + rapier_density: requested_density, + should_mark_has_positive_density_colliders: + is_first_positive_density_collider, + should_remove_fallback_mass: is_first_positive_density_collider, + }; } #[cfg(test)] mod tests { use super::*; + /// Verifies that the backend integrates gravity using symplectic Euler. #[test] fn dynamic_body_integrates_with_symplectic_euler() { let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); @@ -631,6 +1486,13 @@ mod tests { ) .unwrap(); + let rapier_handle = + backend.rigid_body_slots_2d[slot_index as usize].rapier_handle; + let rapier_body = backend.bodies.get(rapier_handle).unwrap(); + assert_eq!(rapier_body.linear_damping(), 0.0); + assert_eq!(rapier_body.gravity_scale(), 1.0); + assert_eq!(backend.integration_parameters.dt, 1.0); + backend.step_with_timestep_seconds(1.0); assert_eq!( @@ -664,6 +1526,7 @@ mod tests { return; } + /// Verifies that kinematic bodies advance via their linear velocity. #[test] fn kinematic_body_integrates_using_velocity() { let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); @@ -690,6 +1553,7 @@ mod tests { return; } + /// Verifies that static bodies remain fixed during stepping. #[test] fn static_body_does_not_move_during_step() { let mut backend = PhysicsBackend2D::new([0.0, -10.0], 1.0); @@ -716,6 +1580,7 @@ mod tests { return; } + /// Verifies force accumulation persists until explicitly cleared. #[test] fn force_accumulates_until_cleared() { let mut backend = PhysicsBackend2D::new([0.0, 0.0], 1.0); @@ -767,6 +1632,108 @@ mod tests { return; } + /// Reports rigid-body slot liveness without reading body state. + #[test] + fn rigid_body_exists_2d_reports_live_slots() { + let mut backend = PhysicsBackend2D::new([0.0, 0.0], 1.0); + + let (slot_index, slot_generation) = backend + .create_rigid_body_2d( + RigidBodyType2D::Dynamic, + [0.0, 0.0], + 0.0, + [0.0, 0.0], + Some(1.0), + ) + .unwrap(); + + assert!(backend.rigid_body_exists_2d(slot_index, slot_generation)); + assert!(!backend.rigid_body_exists_2d(slot_index, slot_generation + 1)); + assert!(!backend.rigid_body_exists_2d(slot_index + 1, 1)); + + return; + } + + /// Removes fallback mass only for the first positive-density collider. + #[test] + fn collider_attachment_mass_plan_marks_first_positive_density_collider() { + let plan = resolve_collider_attachment_mass_plan_2d( + RigidBodyType2D::Dynamic, + None, + false, + 1.0, + ); + + assert_eq!( + plan, + ColliderAttachmentMassPlan2D { + rapier_density: 1.0, + should_mark_has_positive_density_colliders: true, + should_remove_fallback_mass: true, + } + ); + + return; + } + + /// Preserves density-driven mass state after the first positive collider. + #[test] + fn collider_attachment_mass_plan_does_not_remove_fallback_mass_twice() { + let plan = resolve_collider_attachment_mass_plan_2d( + RigidBodyType2D::Dynamic, + None, + true, + 1.0, + ); + + assert_eq!( + plan, + ColliderAttachmentMassPlan2D { + rapier_density: 1.0, + should_mark_has_positive_density_colliders: false, + should_remove_fallback_mass: false, + } + ); + + return; + } + + /// Keeps explicit dynamic mass authoritative over collider density. + #[test] + fn collider_attachment_mass_plan_ignores_density_for_explicit_mass() { + let plan = resolve_collider_attachment_mass_plan_2d( + RigidBodyType2D::Dynamic, + Some(2.0), + false, + 5.0, + ); + + assert_eq!( + plan, + ColliderAttachmentMassPlan2D { + rapier_density: 0.0, + should_mark_has_positive_density_colliders: false, + should_remove_fallback_mass: false, + } + ); + + return; + } + + /// Encodes friction so Rapier `Multiply` matches the public rule. + #[test] + fn rapier_friction_encoding_preserves_public_combination_semantics() { + let encoded_friction_1 = encode_rapier_friction_coefficient(4.0); + let encoded_friction_2 = encode_rapier_friction_coefficient(9.0); + + assert_eq!(encoded_friction_1, 2.0); + assert_eq!(encoded_friction_2, 3.0); + assert_eq!(encoded_friction_1 * encoded_friction_2, 6.0); + + return; + } + + /// Verifies that applying an impulse updates velocity immediately. #[test] fn impulse_updates_velocity_immediately() { let mut backend = PhysicsBackend2D::new([0.0, 0.0], 1.0); diff --git a/crates/lambda-rs/src/physics/collider_2d.rs b/crates/lambda-rs/src/physics/collider_2d.rs new file mode 100644 index 00000000..7d673698 --- /dev/null +++ b/crates/lambda-rs/src/physics/collider_2d.rs @@ -0,0 +1,957 @@ +//! 2D collider support. +//! +//! This module defines backend-agnostic collider shapes and builders for +//! attaching collision geometry to a `RigidBody2D`. +//! +//! Collision detection and response is implemented by the platform backend. + +use std::{ + error::Error, + fmt, +}; + +use lambda_platform::physics::Collider2DBackendError; + +use super::{ + PhysicsWorld2D, + RigidBody2D, + RigidBody2DError, +}; + +/// Maximum supported vertices for `ColliderShape2D::ConvexPolygon`. +pub const MAX_CONVEX_POLYGON_VERTICES: usize = 64; + +const DEFAULT_LOCAL_OFFSET_X: f32 = 0.0; +const DEFAULT_LOCAL_OFFSET_Y: f32 = 0.0; +const DEFAULT_LOCAL_ROTATION_RADIANS: f32 = 0.0; + +const DEFAULT_DENSITY: f32 = 1.0; +const DEFAULT_FRICTION: f32 = 0.5; +const DEFAULT_RESTITUTION: f32 = 0.0; + +/// An opaque handle to a collider stored in a `PhysicsWorld2D`. +/// +/// This handle is world-scoped. All collider operations MUST validate that the +/// handle belongs to the provided world and that it references a live collider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Collider2D { + world_id: u64, + slot_index: u32, + slot_generation: u32, +} + +/// Supported 2D collider shapes. +#[derive(Debug, Clone, PartialEq)] +pub enum ColliderShape2D { + /// A circle centered at the collider origin. + Circle { radius: f32 }, + /// An axis-aligned rectangle in collider local space. + Rectangle { half_width: f32, half_height: f32 }, + /// A capsule aligned with the collider local Y axis. + Capsule { half_height: f32, radius: f32 }, + /// An arbitrary convex polygon in collider local space. + ConvexPolygon { vertices: Vec<[f32; 2]> }, +} + +/// Material parameters for a `Collider2D`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColliderMaterial2D { + density: f32, + friction: f32, + restitution: f32, +} + +impl ColliderMaterial2D { + /// Creates a new collider material configuration. + /// + /// The returned value is validated when `Collider2DBuilder::build()` is + /// called. + /// + /// # Arguments + /// - `density`: The density in kg/m². + /// - `friction`: The friction coefficient (unitless). + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns a new `ColliderMaterial2D` value. + pub fn new(density: f32, friction: f32, restitution: f32) -> Self { + return Self { + density, + friction, + restitution, + }; + } + + /// Returns the density in kg/m². + /// + /// # Returns + /// Returns the configured density. + pub fn density(self) -> f32 { + return self.density; + } + + /// Returns the friction coefficient (unitless). + /// + /// # Returns + /// Returns the configured friction coefficient. + pub fn friction(self) -> f32 { + return self.friction; + } + + /// Returns the restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns the configured restitution coefficient. + pub fn restitution(self) -> f32 { + return self.restitution; + } +} + +impl Default for ColliderMaterial2D { + fn default() -> Self { + return Self { + density: DEFAULT_DENSITY, + friction: DEFAULT_FRICTION, + restitution: DEFAULT_RESTITUTION, + }; + } +} + +/// Builder for `Collider2D`. +#[derive(Debug, Clone)] +pub struct Collider2DBuilder { + shape: ColliderShape2D, + local_offset: [f32; 2], + local_rotation: f32, + material: ColliderMaterial2D, +} + +impl Collider2DBuilder { + /// Creates a circle collider builder with stable defaults. + /// + /// Defaults + /// - Local offset: `(0.0, 0.0)` + /// - Local rotation: `0.0` + /// - Density: `1.0` + /// - Friction: `0.5` + /// - Restitution: `0.0` + /// + /// # Arguments + /// - `radius`: The circle radius in meters. + /// + /// # Returns + /// Returns a new builder instance. + pub fn circle(radius: f32) -> Self { + return Self::new(ColliderShape2D::Circle { radius }); + } + + /// Creates a rectangle collider builder with stable defaults. + /// + /// # Arguments + /// - `half_width`: The half-width in meters. + /// - `half_height`: The half-height in meters. + /// + /// # Returns + /// Returns a new builder instance. + pub fn rectangle(half_width: f32, half_height: f32) -> Self { + return Self::new(ColliderShape2D::Rectangle { + half_width, + half_height, + }); + } + + /// Creates a capsule collider builder with stable defaults. + /// + /// # Arguments + /// - `half_height`: The half-height of the capsule segment in meters. + /// - `radius`: The capsule radius in meters. + /// + /// # Returns + /// Returns a new builder instance. + pub fn capsule(half_height: f32, radius: f32) -> Self { + return Self::new(ColliderShape2D::Capsule { + half_height, + radius, + }); + } + + /// Creates a convex polygon collider builder with stable defaults. + /// + /// # Arguments + /// - `vertices`: The polygon vertices in meters, in collider local space. + /// + /// # Returns + /// Returns a new builder instance. + pub fn polygon(vertices: Vec<[f32; 2]>) -> Self { + return Self::new(ColliderShape2D::ConvexPolygon { vertices }); + } + + /// Creates a convex polygon collider builder by copying from a slice. + /// + /// # Arguments + /// - `vertices`: The polygon vertices in meters, in collider local space. + /// + /// # Returns + /// Returns a new builder instance. + pub fn polygon_from_slice(vertices: &[[f32; 2]]) -> Self { + return Self::polygon(vertices.to_vec()); + } + + /// Sets the collider local offset relative to the owning body origin. + /// + /// # Arguments + /// - `x`: The local translation X component in meters. + /// - `y`: The local translation Y component in meters. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_offset(mut self, x: f32, y: f32) -> Self { + self.local_offset = [x, y]; + return self; + } + + /// Sets the collider local rotation relative to the owning body. + /// + /// # Arguments + /// - `radians`: The local rotation in radians. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_local_rotation(mut self, radians: f32) -> Self { + self.local_rotation = radians; + return self; + } + + /// Sets the collider material parameters. + /// + /// # Arguments + /// - `material`: The collider material configuration. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_material(mut self, material: ColliderMaterial2D) -> Self { + self.material = material; + return self; + } + + /// Sets the collider density, in kg/m². + /// + /// When attaching a collider with `density > 0.0` to a dynamic body that did + /// not explicitly configure mass via `RigidBody2DBuilder::with_dynamic_mass_kg`, + /// the body's mass MAY change as mass properties are recomputed from attached + /// colliders. + /// + /// When the owning body explicitly configures mass, collider density MUST + /// NOT affect body mass properties. + /// + /// # Arguments + /// - `density`: The density in kg/m². + /// + /// # Returns + /// Returns the updated builder. + pub fn with_density(mut self, density: f32) -> Self { + self.material = ColliderMaterial2D::new( + density, + self.material.friction, + self.material.restitution, + ); + return self; + } + + /// Sets the friction coefficient (unitless, `>= 0.0`). + /// + /// # Arguments + /// - `friction`: The friction coefficient (unitless). + /// + /// # Returns + /// Returns the updated builder. + pub fn with_friction(mut self, friction: f32) -> Self { + self.material = ColliderMaterial2D::new( + self.material.density, + friction, + self.material.restitution, + ); + return self; + } + + /// Sets the restitution coefficient in `[0.0, 1.0]`. + /// + /// # Arguments + /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_restitution(mut self, restitution: f32) -> Self { + self.material = ColliderMaterial2D::new( + self.material.density, + self.material.friction, + restitution, + ); + return self; + } + + /// Attaches the collider to a body and returns a world-scoped handle. + /// + /// Attaching a collider to a dynamic body that does not explicitly configure + /// mass MAY recompute the body's mass properties based on collider density. + /// + /// # Arguments + /// - `world`: The physics world that owns the body. + /// - `body`: The rigid body handle to attach the collider to. + /// + /// # Returns + /// Returns a `Collider2D` handle on success. + /// + /// # Errors + /// Returns `Collider2DError` if any configuration value is invalid or the + /// `body` handle is invalid for the provided world. + pub fn build( + mut self, + world: &mut PhysicsWorld2D, + body: RigidBody2D, + ) -> Result { + validate_local_offset(self.local_offset[0], self.local_offset[1])?; + validate_local_rotation(self.local_rotation)?; + validate_material(self.material)?; + validate_and_normalize_shape(&mut self.shape)?; + + validate_body_handle(world, body)?; + + let (body_slot_index, body_slot_generation) = body.backend_slot(); + + let (slot_index, slot_generation) = match self.shape { + ColliderShape2D::Circle { radius } => world + .backend + .create_circle_collider_2d( + body_slot_index, + body_slot_generation, + radius, + self.local_offset, + self.local_rotation, + self.material.density(), + self.material.friction(), + self.material.restitution(), + ) + .map_err(map_backend_error)?, + ColliderShape2D::Rectangle { + half_width, + half_height, + } => world + .backend + .create_rectangle_collider_2d( + body_slot_index, + body_slot_generation, + half_width, + half_height, + self.local_offset, + self.local_rotation, + self.material.density(), + self.material.friction(), + self.material.restitution(), + ) + .map_err(map_backend_error)?, + ColliderShape2D::Capsule { + half_height, + radius, + } => world + .backend + .create_capsule_collider_2d( + body_slot_index, + body_slot_generation, + half_height, + radius, + self.local_offset, + self.local_rotation, + self.material.density(), + self.material.friction(), + self.material.restitution(), + ) + .map_err(map_backend_error)?, + ColliderShape2D::ConvexPolygon { vertices } => world + .backend + .create_convex_polygon_collider_2d( + body_slot_index, + body_slot_generation, + vertices, + self.local_offset, + self.local_rotation, + self.material.density(), + self.material.friction(), + self.material.restitution(), + ) + .map_err(map_backend_error)?, + }; + + return Ok(Collider2D { + world_id: world.world_id, + slot_index, + slot_generation, + }); + } + + /// Creates a builder for the given shape with stable defaults. + fn new(shape: ColliderShape2D) -> Self { + return Self { + shape, + local_offset: [DEFAULT_LOCAL_OFFSET_X, DEFAULT_LOCAL_OFFSET_Y], + local_rotation: DEFAULT_LOCAL_ROTATION_RADIANS, + material: ColliderMaterial2D::default(), + }; + } +} + +/// Errors for collider construction and operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Collider2DError { + /// The rigid body handle encoding is invalid. + InvalidBodyHandle, + /// The rigid body handle does not belong to the provided world. + WorldMismatch, + /// The referenced rigid body was not found. + BodyNotFound, + /// The collider backend is not available for this build. + BackendUnsupported, + /// The provided collider local offset is invalid. + InvalidLocalOffset { x: f32, y: f32 }, + /// The provided collider local rotation is invalid. + InvalidLocalRotation { radians: f32 }, + /// The provided circle radius is invalid. + InvalidCircleRadius { radius: f32 }, + /// The provided rectangle half-extents are invalid. + InvalidRectangleHalfExtents { half_width: f32, half_height: f32 }, + /// The provided capsule half-height is invalid. + InvalidCapsuleHalfHeight { half_height: f32 }, + /// The provided capsule radius is invalid. + InvalidCapsuleRadius { radius: f32 }, + /// The provided polygon has too few vertices. + InvalidPolygonTooFewVertices { vertex_count: usize }, + /// The provided polygon exceeds the maximum supported vertex count. + InvalidPolygonTooManyVertices { + vertex_count: usize, + max_vertices: usize, + }, + /// The provided polygon contains a non-finite vertex. + InvalidPolygonVertex { index: usize, x: f32, y: f32 }, + /// The provided polygon has zero signed area. + InvalidPolygonZeroArea, + /// The provided polygon is not strictly convex. + InvalidPolygonNonConvex, + /// The provided density is invalid. + InvalidDensity { density: f32 }, + /// The provided friction coefficient is invalid. + InvalidFriction { friction: f32 }, + /// The provided restitution coefficient is invalid. + InvalidRestitution { restitution: f32 }, +} + +impl fmt::Display for Collider2DError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBodyHandle => { + return write!(formatter, "invalid rigid body handle"); + } + Self::WorldMismatch => { + return write!(formatter, "rigid body handle does not match the world"); + } + Self::BodyNotFound => { + return write!(formatter, "rigid body not found"); + } + Self::BackendUnsupported => { + return write!(formatter, "colliders are not supported by this build"); + } + Self::InvalidLocalOffset { x, y } => { + return write!(formatter, "invalid local_offset: ({x}, {y})"); + } + Self::InvalidLocalRotation { radians } => { + return write!(formatter, "invalid local_rotation: {radians}"); + } + Self::InvalidCircleRadius { radius } => { + return write!(formatter, "invalid circle radius: {radius}"); + } + Self::InvalidRectangleHalfExtents { + half_width, + half_height, + } => { + return write!( + formatter, + "invalid rectangle half extents: ({half_width}, {half_height})" + ); + } + Self::InvalidCapsuleHalfHeight { half_height } => { + return write!(formatter, "invalid capsule half_height: {half_height}"); + } + Self::InvalidCapsuleRadius { radius } => { + return write!(formatter, "invalid capsule radius: {radius}"); + } + Self::InvalidPolygonTooFewVertices { vertex_count } => { + return write!( + formatter, + "invalid polygon vertex_count (too few): {vertex_count}" + ); + } + Self::InvalidPolygonTooManyVertices { + vertex_count, + max_vertices, + } => { + return write!( + formatter, + "invalid polygon vertex_count (too many): {vertex_count} (max: \ +{max_vertices})" + ); + } + Self::InvalidPolygonVertex { index, x, y } => { + return write!( + formatter, + "invalid polygon vertex at index {index}: ({x}, {y})" + ); + } + Self::InvalidPolygonZeroArea => { + return write!(formatter, "invalid polygon: zero area"); + } + Self::InvalidPolygonNonConvex => { + return write!(formatter, "invalid polygon: not strictly convex"); + } + Self::InvalidDensity { density } => { + return write!(formatter, "invalid density: {density}"); + } + Self::InvalidFriction { friction } => { + return write!(formatter, "invalid friction: {friction}"); + } + Self::InvalidRestitution { restitution } => { + return write!(formatter, "invalid restitution: {restitution}"); + } + } + } +} + +impl Error for Collider2DError {} + +/// Validates that the provided rigid body handle is usable in the world. +fn validate_body_handle( + world: &PhysicsWorld2D, + body: RigidBody2D, +) -> Result<(), Collider2DError> { + let result = body.validate_live_handle(world); + return result.map_err(map_body_error); +} + +/// Maps a `RigidBody2DError` into the collider error surface. +fn map_body_error(error: RigidBody2DError) -> Collider2DError { + match error { + RigidBody2DError::InvalidHandle => { + return Collider2DError::InvalidBodyHandle; + } + RigidBody2DError::WorldMismatch => { + return Collider2DError::WorldMismatch; + } + RigidBody2DError::BodyNotFound => { + return Collider2DError::BodyNotFound; + } + other => { + debug_assert!( + matches!( + other, + RigidBody2DError::InvalidPosition { .. } + | RigidBody2DError::InvalidRotation { .. } + | RigidBody2DError::InvalidVelocity { .. } + | RigidBody2DError::InvalidForce { .. } + | RigidBody2DError::InvalidImpulse { .. } + | RigidBody2DError::InvalidMassKg { .. } + | RigidBody2DError::UnsupportedOperation { .. } + ), + "unexpected RigidBody2DError mapping: {other:?}" + ); + return Collider2DError::BodyNotFound; + } + } +} + +/// Maps a backend collider error into the public collider error surface. +fn map_backend_error(error: Collider2DBackendError) -> Collider2DError { + match error { + Collider2DBackendError::BodyNotFound => { + return Collider2DError::BodyNotFound; + } + Collider2DBackendError::InvalidPolygonDegenerate => { + return Collider2DError::InvalidPolygonZeroArea; + } + } +} + +/// Validates that the provided local offset is finite. +fn validate_local_offset(x: f32, y: f32) -> Result<(), Collider2DError> { + if !x.is_finite() || !y.is_finite() { + return Err(Collider2DError::InvalidLocalOffset { x, y }); + } + + return Ok(()); +} + +/// Validates that the provided local rotation is finite. +fn validate_local_rotation(radians: f32) -> Result<(), Collider2DError> { + if !radians.is_finite() { + return Err(Collider2DError::InvalidLocalRotation { radians }); + } + + return Ok(()); +} + +/// Validates material parameters. +fn validate_material( + material: ColliderMaterial2D, +) -> Result<(), Collider2DError> { + if !material.density.is_finite() || material.density < 0.0 { + return Err(Collider2DError::InvalidDensity { + density: material.density, + }); + } + + if !material.friction.is_finite() || material.friction < 0.0 { + return Err(Collider2DError::InvalidFriction { + friction: material.friction, + }); + } + + if !material.restitution.is_finite() + || material.restitution < 0.0 + || material.restitution > 1.0 + { + return Err(Collider2DError::InvalidRestitution { + restitution: material.restitution, + }); + } + + return Ok(()); +} + +/// Validates a shape and normalizes polygon winding to counterclockwise. +fn validate_and_normalize_shape( + shape: &mut ColliderShape2D, +) -> Result<(), Collider2DError> { + match shape { + ColliderShape2D::Circle { radius } => { + if !radius.is_finite() || *radius <= 0.0 { + return Err(Collider2DError::InvalidCircleRadius { radius: *radius }); + } + + return Ok(()); + } + ColliderShape2D::Rectangle { + half_width, + half_height, + } => { + if !half_width.is_finite() + || !half_height.is_finite() + || *half_width <= 0.0 + || *half_height <= 0.0 + { + return Err(Collider2DError::InvalidRectangleHalfExtents { + half_width: *half_width, + half_height: *half_height, + }); + } + + return Ok(()); + } + ColliderShape2D::Capsule { + half_height, + radius, + } => { + if !half_height.is_finite() || *half_height < 0.0 { + return Err(Collider2DError::InvalidCapsuleHalfHeight { + half_height: *half_height, + }); + } + + if !radius.is_finite() || *radius <= 0.0 { + return Err(Collider2DError::InvalidCapsuleRadius { radius: *radius }); + } + + return Ok(()); + } + ColliderShape2D::ConvexPolygon { vertices } => { + return validate_and_normalize_convex_polygon(vertices.as_mut_slice()); + } + } +} + +/// Validates that the polygon is strictly convex, non-degenerate, and finite. +fn validate_and_normalize_convex_polygon( + vertices: &mut [[f32; 2]], +) -> Result<(), Collider2DError> { + let vertex_count = vertices.len(); + + if vertex_count < 3 { + return Err(Collider2DError::InvalidPolygonTooFewVertices { vertex_count }); + } + + if vertex_count > MAX_CONVEX_POLYGON_VERTICES { + return Err(Collider2DError::InvalidPolygonTooManyVertices { + vertex_count, + max_vertices: MAX_CONVEX_POLYGON_VERTICES, + }); + } + + for (index, vertex) in vertices.iter().enumerate() { + let x = vertex[0]; + let y = vertex[1]; + if !x.is_finite() || !y.is_finite() { + return Err(Collider2DError::InvalidPolygonVertex { index, x, y }); + } + } + + let area2 = polygon_signed_area_times_two(vertices); + if area2 == 0.0 { + return Err(Collider2DError::InvalidPolygonZeroArea); + } + + if area2 < 0.0 { + vertices.reverse(); + } + + validate_polygon_is_strictly_convex(vertices)?; + + return Ok(()); +} + +/// Computes twice the signed area of the polygon using the shoelace formula. +fn polygon_signed_area_times_two(vertices: &[[f32; 2]]) -> f32 { + let mut area2 = 0.0; + let vertex_count = vertices.len(); + + for index in 0..vertex_count { + let next_index = (index + 1) % vertex_count; + let a = vertices[index]; + let b = vertices[next_index]; + area2 += (a[0] * b[1]) - (b[0] * a[1]); + } + + return area2; +} + +/// Validates that all internal angles have the same winding and are non-zero. +fn validate_polygon_is_strictly_convex( + vertices: &[[f32; 2]], +) -> Result<(), Collider2DError> { + let vertex_count = vertices.len(); + let mut cross_sign: f32 = 0.0; + + for index in 0..vertex_count { + let a = vertices[index]; + let b = vertices[(index + 1) % vertex_count]; + let c = vertices[(index + 2) % vertex_count]; + + let ab_x = b[0] - a[0]; + let ab_y = b[1] - a[1]; + let bc_x = c[0] - b[0]; + let bc_y = c[1] - b[1]; + + let cross = (ab_x * bc_y) - (ab_y * bc_x); + if cross == 0.0 { + return Err(Collider2DError::InvalidPolygonNonConvex); + } + + if cross_sign == 0.0 { + cross_sign = cross.signum(); + continue; + } + + if cross.signum() != cross_sign { + return Err(Collider2DError::InvalidPolygonNonConvex); + } + } + + return Ok(()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::{ + PhysicsWorld2DBuilder, + RigidBody2DBuilder, + RigidBodyType, + }; + + /// Rejects non-positive circle radii during build validation. + #[test] + fn build_rejects_non_positive_circle_radius() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let error = Collider2DBuilder::circle(0.0) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!(error, Collider2DError::InvalidCircleRadius { radius: 0.0 }); + + return; + } + + /// Rejects non-positive rectangle half-extents during build validation. + #[test] + fn build_rejects_non_positive_rectangle_half_extents() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let error = Collider2DBuilder::rectangle(1.0, 0.0) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!( + error, + Collider2DError::InvalidRectangleHalfExtents { + half_width: 1.0, + half_height: 0.0, + } + ); + + return; + } + + /// Rejects invalid restitution values during build validation. + #[test] + fn build_rejects_invalid_restitution() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let error = Collider2DBuilder::circle(1.0) + .with_restitution(2.0) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!( + error, + Collider2DError::InvalidRestitution { restitution: 2.0 } + ); + + return; + } + + /// Rejects non-finite local offsets during build validation. + #[test] + fn build_rejects_non_finite_local_offset() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let error = Collider2DBuilder::circle(1.0) + .with_offset(f32::NAN, 0.0) + .build(&mut world, body) + .unwrap_err(); + + match error { + Collider2DError::InvalidLocalOffset { x, y } => { + assert!(x.is_nan()); + assert_eq!(y, 0.0); + } + other => panic!("unexpected error: {other:?}"), + } + + return; + } + + /// Rejects negative friction during build validation. + #[test] + fn build_rejects_negative_friction() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let error = Collider2DBuilder::circle(1.0) + .with_friction(-0.1) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!(error, Collider2DError::InvalidFriction { friction: -0.1 }); + + return; + } + + /// Accepts clockwise polygon winding by reversing the vertex order. + #[test] + fn build_accepts_clockwise_polygon_by_reversing_vertices() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let vertices = vec![[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]]; + assert!(Collider2DBuilder::polygon(vertices) + .build(&mut world, body) + .is_ok()); + + return; + } + + /// Rejects polygons that exceed the maximum supported vertex count. + #[test] + fn build_rejects_polygon_too_many_vertices() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let vertices = vec![[0.0, 0.0]; MAX_CONVEX_POLYGON_VERTICES + 1]; + let error = Collider2DBuilder::polygon(vertices) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!( + error, + Collider2DError::InvalidPolygonTooManyVertices { + vertex_count: MAX_CONVEX_POLYGON_VERTICES + 1, + max_vertices: MAX_CONVEX_POLYGON_VERTICES, + } + ); + + return; + } + + /// Rejects non-convex polygons during build validation. + #[test] + fn build_rejects_non_convex_polygon() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world) + .unwrap(); + + let vertices = + vec![[0.0, 0.0], [2.0, 0.0], [1.0, 0.5], [2.0, 1.0], [0.0, 1.0]]; + + let error = Collider2DBuilder::polygon(vertices) + .build(&mut world, body) + .unwrap_err(); + + assert_eq!(error, Collider2DError::InvalidPolygonNonConvex); + + return; + } + + /// Rejects using a rigid body from a different world. + #[test] + fn build_rejects_cross_world_body_handle() { + let mut world_a = PhysicsWorld2DBuilder::new().build().unwrap(); + let mut world_b = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(&mut world_a) + .unwrap(); + + let error = Collider2DBuilder::circle(1.0) + .build(&mut world_b, body) + .unwrap_err(); + + assert_eq!(error, Collider2DError::WorldMismatch); + + return; + } +} diff --git a/crates/lambda-rs/src/physics/mod.rs b/crates/lambda-rs/src/physics/mod.rs index e2b5aec4..0cc9d847 100644 --- a/crates/lambda-rs/src/physics/mod.rs +++ b/crates/lambda-rs/src/physics/mod.rs @@ -14,8 +14,17 @@ use std::{ use lambda_platform::physics::PhysicsBackend2D; +mod collider_2d; mod rigid_body_2d; +pub use collider_2d::{ + Collider2D, + Collider2DBuilder, + Collider2DError, + ColliderMaterial2D, + ColliderShape2D, + MAX_CONVEX_POLYGON_VERTICES, +}; pub use rigid_body_2d::{ RigidBody2D, RigidBody2DBuilder, diff --git a/crates/lambda-rs/src/physics/rigid_body_2d.rs b/crates/lambda-rs/src/physics/rigid_body_2d.rs index b922ceed..064d8ff9 100644 --- a/crates/lambda-rs/src/physics/rigid_body_2d.rs +++ b/crates/lambda-rs/src/physics/rigid_body_2d.rs @@ -44,6 +44,18 @@ pub struct RigidBody2D { } impl RigidBody2D { + /// Returns the backend slot identifiers for this handle. + /// + /// This function is an internal implementation detail used by other + /// `PhysicsWorld2D` APIs (for example, collider attachment) to reference the + /// underlying backend storage. + /// + /// # Returns + /// Returns `(slot_index, slot_generation)` for this handle. + pub(super) fn backend_slot(self) -> (u32, u32) { + return (self.slot_index, self.slot_generation); + } + /// Returns the body type. /// /// # Arguments @@ -301,6 +313,36 @@ impl RigidBody2D { return Ok(()); } + + /// Validates that this handle references a live rigid body in the world. + /// + /// This helper exists for APIs that only need liveness validation and do not + /// need to read any rigid-body state from the backend. + /// + /// # Arguments + /// - `world`: The physics world that should own the body. + /// + /// # Returns + /// Returns `()` when the handle is non-zero, world-matched, and live. + /// + /// # Errors + /// Returns `RigidBody2DError` if the handle is invalid, belongs to a + /// different world, or does not reference a live body. + pub(super) fn validate_live_handle( + self, + world: &PhysicsWorld2D, + ) -> Result<(), RigidBody2DError> { + self.validate_handle(world)?; + + if !world + .backend + .rigid_body_exists_2d(self.slot_index, self.slot_generation) + { + return Err(RigidBody2DError::BodyNotFound); + } + + return Ok(()); + } } /// Builder for `RigidBody2D`. diff --git a/crates/lambda-rs/tests/integration.rs b/crates/lambda-rs/tests/integration.rs new file mode 100644 index 00000000..2667c60a --- /dev/null +++ b/crates/lambda-rs/tests/integration.rs @@ -0,0 +1,11 @@ +//! Integration tests for `lambda-rs`. +//! +//! This file is the primary integration-test entrypoint. Feature-specific +//! integration tests live in dedicated modules under `tests/`. + +#![allow(clippy::needless_return)] +// Tests in this repository use explicit `return` statements for consistency +// with the engine's style guidelines. + +#[cfg(feature = "physics-2d")] +mod physics_2d; diff --git a/crates/lambda-rs/tests/physics_2d/colliders.rs b/crates/lambda-rs/tests/physics_2d/colliders.rs new file mode 100644 index 00000000..dba93b5f --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/colliders.rs @@ -0,0 +1,226 @@ +//! 2D collider integration tests. +//! +//! These tests validate collider attachment through the public API and ensure +//! that contact resolution influences motion for common shape combinations. + +use lambda::physics::{ + Collider2DBuilder, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2DBuilder, + RigidBodyType, +}; + +const DEFAULT_STEP_COUNT: u32 = 300; + +/// Steps a world forward for the given number of fixed timesteps. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `steps`: The number of steps to execute. +/// +/// # Returns +/// Returns `()` after stepping the world. +fn step_world(world: &mut PhysicsWorld2D, steps: u32) { + for _ in 0..steps { + world.step(); + } + + return; +} + +/// Asserts that `value` is within the inclusive range `[min_value, max_value]`. +/// +/// # Arguments +/// - `label`: A short label used in assertion messages. +/// - `value`: The value to validate. +/// - `min_value`: The inclusive lower bound. +/// - `max_value`: The inclusive upper bound. +/// +/// # Returns +/// Returns `()` when the range check passes. +fn assert_in_range(label: &str, value: f32, min_value: f32, max_value: f32) { + assert!( + value >= min_value && value <= max_value, + "{label} out of range: {value} (expected [{min_value}, {max_value}])", + ); + + return; +} + +/// Ensures a dynamic circle collider collides with a static ground rectangle. +#[test] +fn physics_2d_circle_collider_collides_with_ground_rectangle() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -1.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::rectangle(20.0, 0.5) + .build(&mut world, ground) + .unwrap(); + + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 4.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::circle(0.5) + .build(&mut world, ball) + .unwrap(); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = ball.position(&world).unwrap(); + let velocity = ball.velocity(&world).unwrap(); + + assert_in_range("x", position[0], -0.05, 0.05); + + // Ground top is at `-1.0 + 0.5 = -0.5`, and the circle should come to rest + // around `y = -0.5 + 0.5 = 0.0` with tolerance for solver jitter. + assert_in_range("y", position[1], -0.10, 0.20); + assert_in_range("vy_abs", velocity[1].abs(), 0.0, 0.50); + + return; +} + +/// Ensures a dynamic capsule collider collides with a static ground rectangle. +#[test] +fn physics_2d_capsule_collider_collides_with_ground_rectangle() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -1.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::rectangle(20.0, 0.5) + .build(&mut world, ground) + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 4.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::capsule(0.75, 0.25) + .build(&mut world, body) + .unwrap(); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = body.position(&world).unwrap(); + let velocity = body.velocity(&world).unwrap(); + + assert_in_range("x", position[0], -0.05, 0.05); + + // Ground top is at `-1.0 + 0.5 = -0.5`, and the capsule rests when its + // bottom reaches that plane. The capsule vertical extent from center to + // bottom is `half_height + radius = 0.75 + 0.25 = 1.0`, so the body center + // should settle near `y = -0.5 + 1.0 = 0.5`. + assert_in_range("y", position[1], 0.35, 0.70); + assert_in_range("vy_abs", velocity[1].abs(), 0.0, 0.50); + + return; +} + +/// Ensures a dynamic convex polygon collider collides with a static ground +/// rectangle. +#[test] +fn physics_2d_convex_polygon_collider_collides_with_ground_rectangle() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -1.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::rectangle(20.0, 0.5) + .build(&mut world, ground) + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 4.0) + .build(&mut world) + .unwrap(); + + let vertices = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; + + Collider2DBuilder::polygon(vertices) + .build(&mut world, body) + .unwrap(); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = body.position(&world).unwrap(); + let velocity = body.velocity(&world).unwrap(); + + assert_in_range("x", position[0], -0.05, 0.05); + + // Ground top is at `-1.0 + 0.5 = -0.5`, and the polygon rests when its + // bottom reaches that plane. The polygon extends `0.5` meters down from the + // center, so the body center should settle near `y = -0.5 + 0.5 = 0.0`. + assert_in_range("y", position[1], -0.10, 0.20); + assert_in_range("vy_abs", velocity[1].abs(), 0.0, 0.50); + + return; +} + +/// Ensures rectangle collider local rotation affects contact response. +/// +/// This test compares a flat platform to a sloped platform created by applying +/// a local rotation to a rectangle collider. The circle's X translation MUST +/// remain close to zero for the flat platform and MUST move significantly on +/// the sloped platform. +#[test] +fn physics_2d_rectangle_collider_local_rotation_changes_motion() { + let flat_x = simulate_circle_on_platform(0.0); + let sloped_x = simulate_circle_on_platform(0.4); + + assert_in_range("flat_x", flat_x, -0.10, 0.10); + assert!( + sloped_x.abs() > 0.50, + "sloped_x did not move enough: {sloped_x}", + ); + + return; +} + +/// Simulates a circle falling onto a platform with a rotated rectangle collider. +/// +/// # Arguments +/// - `platform_local_rotation`: The collider local rotation in radians. +/// +/// # Returns +/// Returns the circle X position after stepping. +fn simulate_circle_on_platform(platform_local_rotation: f32) -> f32 { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let platform = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, 0.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::rectangle(8.0, 0.25) + .with_local_rotation(platform_local_rotation) + .with_friction(0.0) + .build(&mut world, platform) + .unwrap(); + + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 5.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::circle(0.5) + .with_friction(0.0) + .build(&mut world, ball) + .unwrap(); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = ball.position(&world).unwrap(); + return position[0]; +} diff --git a/crates/lambda-rs/tests/physics_2d/compound_colliders.rs b/crates/lambda-rs/tests/physics_2d/compound_colliders.rs new file mode 100644 index 00000000..fb747299 --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/compound_colliders.rs @@ -0,0 +1,115 @@ +//! Compound collider integration tests. +//! +//! These tests validate that multiple colliders can be attached to a single +//! rigid body and that all attached shapes participate in collision response. + +use lambda::physics::{ + Collider2DBuilder, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2DBuilder, + RigidBodyType, +}; + +const WALL_X: f32 = 1.0; +const WALL_HALF_WIDTH: f32 = 0.1; + +const CIRCLE_RADIUS: f32 = 0.25; +const COMPOUND_CIRCLE_OFFSET_X: f32 = 0.5; + +const STEP_COUNT: u32 = 240; + +/// Steps a world forward for the given number of fixed timesteps. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `steps`: The number of steps to execute. +/// +/// # Returns +/// Returns `()` after stepping the world. +fn step_world(world: &mut PhysicsWorld2D, steps: u32) { + for _ in 0..steps { + world.step(); + } + + return; +} + +/// Ensures multiple colliders attached to one body affect collision extent. +/// +/// This test simulates a dynamic body moving towards a static wall. A body +/// with two offset circle colliders MUST come to rest with its center farther +/// from the wall than the equivalent body with a single circle collider. +#[test] +fn physics_2d_multiple_colliders_on_one_body_affect_collision_extent() { + let single_circle_x = simulate_body_hitting_wall(false); + let compound_x = simulate_body_hitting_wall(true); + + // With the wall centered at `WALL_X` and having half-width + // `WALL_HALF_WIDTH`, the stopping distance from the wall should reflect the + // collider extent along the X axis: + // - Single circle extent: `CIRCLE_RADIUS`. + // - Compound extent: `COMPOUND_CIRCLE_OFFSET_X + CIRCLE_RADIUS`. + assert!( + compound_x + 0.30 < single_circle_x, + "compound_x ({compound_x}) was not far enough from single_circle_x \ +({single_circle_x})", + ); + + return; +} + +/// Simulates a dynamic body moving into a static wall. +/// +/// # Arguments +/// - `use_compound`: When true, attaches two offset circle colliders. When +/// false, attaches a single circle collider. +/// +/// # Returns +/// Returns the body's final X position after stepping. +fn simulate_body_hitting_wall(use_compound: bool) -> f32 { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let wall = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(WALL_X, 0.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::rectangle(WALL_HALF_WIDTH, 5.0) + .with_restitution(0.0) + .build(&mut world, wall) + .unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-2.0, 0.0) + .with_velocity(2.0, 0.0) + .build(&mut world) + .unwrap(); + + if use_compound { + Collider2DBuilder::circle(CIRCLE_RADIUS) + .with_offset(-COMPOUND_CIRCLE_OFFSET_X, 0.0) + .with_restitution(0.0) + .build(&mut world, body) + .unwrap(); + + Collider2DBuilder::circle(CIRCLE_RADIUS) + .with_offset(COMPOUND_CIRCLE_OFFSET_X, 0.0) + .with_restitution(0.0) + .build(&mut world, body) + .unwrap(); + } else { + Collider2DBuilder::circle(CIRCLE_RADIUS) + .with_restitution(0.0) + .build(&mut world, body) + .unwrap(); + } + + step_world(&mut world, STEP_COUNT); + + let position = body.position(&world).unwrap(); + return position[0]; +} diff --git a/crates/lambda-rs/tests/physics_2d/materials.rs b/crates/lambda-rs/tests/physics_2d/materials.rs new file mode 100644 index 00000000..dd480920 --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/materials.rs @@ -0,0 +1,229 @@ +//! Collider material integration tests. +//! +//! These tests validate that material properties influence collision response +//! and that density-driven mass semantics match the public specification. + +use lambda::physics::{ + Collider2DBuilder, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, +}; + +const BALL_RADIUS: f32 = 0.5; +const GROUND_Y: f32 = -1.0; +const GROUND_HALF_HEIGHT: f32 = 0.5; + +const DEFAULT_STEP_COUNT: u32 = 420; + +/// Steps a world forward for the given number of fixed timesteps. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `steps`: The number of steps to execute. +/// +/// # Returns +/// Returns `()` after stepping the world. +fn step_world(world: &mut PhysicsWorld2D, steps: u32) { + for _ in 0..steps { + world.step(); + } + + return; +} + +/// Builds a static ground body with a wide rectangle collider. +/// +/// # Arguments +/// - `world`: The world that owns the ground. +/// - `friction`: The ground friction coefficient. +/// - `restitution`: The ground restitution coefficient. +/// +/// # Returns +/// Returns the ground rigid body handle. +fn build_ground( + world: &mut PhysicsWorld2D, + friction: f32, + restitution: f32, +) -> RigidBody2D { + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, GROUND_Y) + .build(world) + .unwrap(); + + Collider2DBuilder::rectangle(40.0, GROUND_HALF_HEIGHT) + .with_friction(friction) + .with_restitution(restitution) + .build(world, ground) + .unwrap(); + + return ground; +} + +/// Ensures restitution influences bounce response. +#[test] +fn physics_2d_restitution_changes_bounce_height() { + let low_restitution_peak_y = simulate_bounce_peak_y(0.0); + let high_restitution_peak_y = simulate_bounce_peak_y(1.0); + + assert!( + high_restitution_peak_y > low_restitution_peak_y + 1.0, + "bounce peak did not increase enough: low={low_restitution_peak_y}, \ +high={high_restitution_peak_y}", + ); + + return; +} + +/// Simulates a falling ball and returns the peak height after the first hit. +/// +/// # Arguments +/// - `ball_restitution`: The ball restitution coefficient. +/// +/// # Returns +/// Returns the maximum Y value observed after the ball first reaches the +/// ground plane. +fn simulate_bounce_peak_y(ball_restitution: f32) -> f32 { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + build_ground(&mut world, 0.0, 0.0); + + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 5.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::circle(BALL_RADIUS) + .with_friction(0.0) + .with_restitution(ball_restitution) + .build(&mut world, ball) + .unwrap(); + + let mut has_reached_ground = false; + let mut peak_y_after_hit = f32::NEG_INFINITY; + + for _ in 0..DEFAULT_STEP_COUNT { + world.step(); + + let position = ball.position(&world).unwrap(); + let y = position[1]; + + if has_reached_ground { + peak_y_after_hit = peak_y_after_hit.max(y); + continue; + } + + // Ground top is at `GROUND_Y + GROUND_HALF_HEIGHT = -0.5`. When the ball + // reaches that plane, it has contacted the ground. + if y <= (GROUND_Y + GROUND_HALF_HEIGHT) + BALL_RADIUS + 0.05 { + has_reached_ground = true; + peak_y_after_hit = y; + } + } + + return peak_y_after_hit; +} + +/// Ensures friction affects tangential sliding response. +#[test] +fn physics_2d_friction_changes_sliding_velocity_decay() { + let low_friction_velocity_x = simulate_slide_velocity_x(0.0); + let high_friction_velocity_x = simulate_slide_velocity_x(4.0); + + assert!( + low_friction_velocity_x > high_friction_velocity_x + 1.0, + "friction did not reduce sliding enough: low={low_friction_velocity_x}, \ +high={high_friction_velocity_x}", + ); + + return; +} + +/// Simulates a ball sliding along the ground and returns its final X velocity. +/// +/// # Arguments +/// - `ball_friction`: The ball friction coefficient. +/// +/// # Returns +/// Returns the absolute X velocity after stepping. +fn simulate_slide_velocity_x(ball_friction: f32) -> f32 { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + build_ground(&mut world, 1.0, 0.0); + + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 2.0) + .with_velocity(6.0, 0.0) + .build(&mut world) + .unwrap(); + + Collider2DBuilder::circle(BALL_RADIUS) + .with_friction(ball_friction) + .with_restitution(0.0) + .build(&mut world, ball) + .unwrap(); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let velocity = ball.velocity(&world).unwrap(); + return velocity[0].abs(); +} + +/// Ensures density affects dynamic body mass when mass is not explicitly set. +#[test] +fn physics_2d_density_affects_impulse_velocity_delta() { + let low_density_velocity_x = simulate_impulse_velocity_x(None, 1.0); + let high_density_velocity_x = simulate_impulse_velocity_x(None, 4.0); + + assert!( + low_density_velocity_x > high_density_velocity_x * 2.5, + "density did not change Δv enough: low={low_density_velocity_x}, \ +high={high_density_velocity_x}", + ); + + let explicit_mass_velocity_x = simulate_impulse_velocity_x(Some(2.0), 50.0); + assert!( + (explicit_mass_velocity_x - 0.5).abs() < 0.05, + "explicit mass did not override density: velocity_x={explicit_mass_velocity_x}", + ); + + return; +} + +/// Applies an impulse to a ball and returns its X velocity. +/// +/// # Arguments +/// - `explicit_mass_kg`: When `Some`, configures the body mass explicitly. +/// - `density`: The collider density in kg/m². +/// +/// # Returns +/// Returns the X velocity after applying a unit impulse. +fn simulate_impulse_velocity_x( + explicit_mass_kg: Option, + density: f32, +) -> f32 { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let mut builder = + RigidBody2DBuilder::new(RigidBodyType::Dynamic).with_position(0.0, 0.0); + if let Some(mass_kg) = explicit_mass_kg { + builder = builder.with_dynamic_mass_kg(mass_kg); + } + + let body = builder.build(&mut world).unwrap(); + + Collider2DBuilder::circle(BALL_RADIUS) + .with_density(density) + .with_friction(0.0) + .with_restitution(0.0) + .build(&mut world, body) + .unwrap(); + + body.apply_impulse(&mut world, 1.0, 0.0).unwrap(); + + let velocity = body.velocity(&world).unwrap(); + return velocity[0]; +} diff --git a/crates/lambda-rs/tests/physics_2d/mod.rs b/crates/lambda-rs/tests/physics_2d/mod.rs new file mode 100644 index 00000000..88bfd791 --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/mod.rs @@ -0,0 +1,49 @@ +//! 2D physics integration tests. +//! +//! These tests validate `lambda-rs` 2D physics behavior through the public API +//! surface, including cross-crate wiring through `lambda-rs-platform`. + +mod colliders; +mod compound_colliders; +mod materials; + +use lambda::physics::{ + PhysicsWorld2DBuilder, + RigidBody2DBuilder, + RigidBodyType, +}; + +/// Ensures an empty 2D world can be stepped without panicking. +#[test] +fn physics_2d_world_smoke_steps_empty_world() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + world.step(); + world.step(); + + return; +} + +/// Ensures a dynamic rigid body can be created and advances under gravity. +#[test] +fn physics_2d_rigid_body_smoke_builds_and_steps() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 10.0) + .build(&mut world) + .unwrap(); + + let initial_position = body.position(&world).unwrap(); + let initial_velocity = body.velocity(&world).unwrap(); + + world.step(); + + let stepped_position = body.position(&world).unwrap(); + let stepped_velocity = body.velocity(&world).unwrap(); + + assert!(stepped_position[1] < initial_position[1]); + assert!(stepped_velocity[1] < initial_velocity[1]); + + return; +} diff --git a/demos/README.md b/demos/README.md index 8d5c5f76..794ccdc3 100644 --- a/demos/README.md +++ b/demos/README.md @@ -3,13 +3,13 @@ title: "Demo Crates" document_id: "demo-crates-readme-2026-02-04" status: "draft" created: "2026-02-04T00:00:00Z" -last_updated: "2026-02-05T22:52:14Z" -version: "0.1.1" +last_updated: "2026-02-23T00:00:00Z" +version: "0.1.2" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "544444652b4dc3639f8b3e297e56c302183a7a0b" +repo_commit: "23b883f8e8509a8fa6343ee55abafb10c4f7a86a" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["demos", "guide"] @@ -34,6 +34,7 @@ documentation and rustdoc usage. demos/ audio/ # Audio-focused demos (output device, decoding) minimal/ # Smallest window + render context demo + physics/ # Physics demos (2D rigid bodies, colliders) render/ # Rendering demos (validation flags, advanced passes) ``` @@ -53,6 +54,14 @@ cargo run -p lambda-demos-audio --bin sound_buffer cargo run -p lambda-demos-audio --bin play_sound ``` +Physics demos (requires `physics-2d`, enabled by default in the demo crate): + +```bash +cargo run -p lambda-demos-physics --bin physics_falling_quad +cargo run -p lambda-demos-physics --bin physics_rigid_bodies_2d +cargo run -p lambda-demos-physics --bin physics_colliders_2d +``` + Render demos: ```bash diff --git a/demos/physics/Cargo.toml b/demos/physics/Cargo.toml index 19482ddb..732a11f4 100644 --- a/demos/physics/Cargo.toml +++ b/demos/physics/Cargo.toml @@ -24,3 +24,8 @@ required-features = ["physics-2d"] name = "physics_rigid_bodies_2d" path = "src/bin/physics_rigid_bodies_2d.rs" required-features = ["physics-2d"] + +[[bin]] +name = "physics_colliders_2d" +path = "src/bin/physics_colliders_2d.rs" +required-features = ["physics-2d"] diff --git a/demos/physics/src/bin/physics_colliders_2d.rs b/demos/physics/src/bin/physics_colliders_2d.rs new file mode 100644 index 00000000..aadedd39 --- /dev/null +++ b/demos/physics/src/bin/physics_colliders_2d.rs @@ -0,0 +1,1082 @@ +#![allow(clippy::needless_return)] + +//! Demo: Render several 2D colliders and step the simulation. +//! +//! This demo showcases `Collider2DBuilder` usage for common primitives: +//! - Circle colliders (restitution and density) +//! - Rectangle/box colliders (including local rotation) +//! - Capsule colliders (character-like shape) +//! - Convex polygon colliders +//! - Multiple colliders attached to one body (compound) +//! - Friction and restitution affecting collision response +//! +//! Controls: +//! - Space: apply the same impulse to the density demo bodies + +use std::ops::Range; + +use lambda::{ + component::Component, + events::{ + EventMask, + Key, + VirtualKey, + WindowEvent, + }, + physics::{ + Collider2DBuilder, + ColliderMaterial2D, + ColliderShape2D, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, + }, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + Buffer, + BufferBuilder, + Properties, + Usage, + }, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +// ------------------------------ SHADER SOURCE -------------------------------- + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; + +layout (location = 0) out vec3 frag_color; + +layout (set = 0, binding = 0) uniform ColliderGlobals { + vec4 offset_rotation; + vec4 tint; +} globals; + +void main() { + float radians = globals.offset_rotation.z; + float cosine = cos(radians); + float sine = sin(radians); + + mat2 rotation = mat2( + cosine, -sine, + sine, cosine + ); + + vec2 rotated = rotation * vertex_position.xy; + vec2 translated = rotated + globals.offset_rotation.xy; + + gl_Position = vec4(translated, vertex_position.z, 1.0); + frag_color = vertex_color * globals.tint.xyz; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 frag_color; +layout (location = 0) out vec4 fragment_color; + +void main() { + fragment_color = vec4(frag_color, 1.0); +} + +"#; + +// ---------------------------- UNIFORM STRUCTURE ------------------------------ + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ColliderGlobalsUniform { + pub offset_rotation: [f32; 4], + pub tint: [f32; 4], +} + +unsafe impl lambda::pod::PlainOldData for ColliderGlobalsUniform {} + +// -------------------------------- DEMO TYPES --------------------------------- + +#[derive(Debug, Clone)] +struct ColliderRenderInit { + body: RigidBody2D, + shape: ColliderShape2D, + local_offset: [f32; 2], + local_rotation: f32, + tint: [f32; 4], +} + +struct RenderCollider { + init: ColliderRenderInit, + vertices: Range, + uniform_buffer: Buffer, + bind_group_id: ResourceId, +} + +pub struct Colliders2DDemo { + physics_world: PhysicsWorld2D, + physics_accumulator_seconds: f32, + + pending_impulse: bool, + impulse_cooldown_remaining_seconds: f32, + density_light_body: RigidBody2D, + density_heavy_body: RigidBody2D, + + vertex_shader: Shader, + fragment_shader: Shader, + mesh: Option, + render_pipeline_id: Option, + render_pass_id: Option, + colliders: Vec, + collider_inits: Vec, + + width: u32, + height: u32, +} + +impl Colliders2DDemo { + fn push_vertex( + mesh_builder: &mut MeshBuilder, + x: f32, + y: f32, + ) -> &mut MeshBuilder { + return mesh_builder.with_vertex( + VertexBuilder::new() + .with_position([x, y, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 1.0]) + .build(), + ); + } + + fn append_rectangle( + mesh_builder: &mut MeshBuilder, + vertex_count: &mut u32, + half_width: f32, + half_height: f32, + ) -> Range { + let start = *vertex_count; + + let left = -half_width; + let right = half_width; + let bottom = -half_height; + let top = half_height; + + Self::push_vertex(mesh_builder, left, bottom); + Self::push_vertex(mesh_builder, right, bottom); + Self::push_vertex(mesh_builder, right, top); + + Self::push_vertex(mesh_builder, left, bottom); + Self::push_vertex(mesh_builder, right, top); + Self::push_vertex(mesh_builder, left, top); + + *vertex_count += 6; + let end = *vertex_count; + return start..end; + } + + fn append_convex_polygon( + mesh_builder: &mut MeshBuilder, + vertex_count: &mut u32, + vertices: &[[f32; 2]], + ) -> Range { + let start = *vertex_count; + + let mut centroid = [0.0_f32, 0.0_f32]; + for vertex in vertices.iter() { + centroid[0] += vertex[0]; + centroid[1] += vertex[1]; + } + centroid[0] /= vertices.len() as f32; + centroid[1] /= vertices.len() as f32; + + for index in 0..vertices.len() { + let a = vertices[index]; + let b = vertices[(index + 1) % vertices.len()]; + + Self::push_vertex(mesh_builder, centroid[0], centroid[1]); + Self::push_vertex(mesh_builder, a[0], a[1]); + Self::push_vertex(mesh_builder, b[0], b[1]); + } + + *vertex_count += 3 * vertices.len() as u32; + let end = *vertex_count; + return start..end; + } + + fn append_circle( + mesh_builder: &mut MeshBuilder, + vertex_count: &mut u32, + radius: f32, + segments: u32, + ) -> Range { + let start = *vertex_count; + + for index in 0..segments { + let t0 = index as f32 / segments as f32; + let t1 = (index + 1) as f32 / segments as f32; + + let angle0 = t0 * std::f32::consts::TAU; + let angle1 = t1 * std::f32::consts::TAU; + + let x0 = angle0.cos() * radius; + let y0 = angle0.sin() * radius; + let x1 = angle1.cos() * radius; + let y1 = angle1.sin() * radius; + + Self::push_vertex(mesh_builder, 0.0, 0.0); + Self::push_vertex(mesh_builder, x0, y0); + Self::push_vertex(mesh_builder, x1, y1); + } + + *vertex_count += 3 * segments; + let end = *vertex_count; + return start..end; + } + + fn append_capsule( + mesh_builder: &mut MeshBuilder, + vertex_count: &mut u32, + half_height: f32, + radius: f32, + segments: u32, + ) -> Range { + let mut vertices = Vec::with_capacity((segments as usize + 1) * 2); + + for index in 0..=segments { + let t = index as f32 / segments as f32; + let angle = t * std::f32::consts::PI; + vertices.push([angle.cos() * radius, angle.sin() * radius + half_height]); + } + + for index in 0..=segments { + let t = index as f32 / segments as f32; + let angle = std::f32::consts::PI + t * std::f32::consts::PI; + vertices.push([angle.cos() * radius, angle.sin() * radius - half_height]); + } + + return Self::append_convex_polygon(mesh_builder, vertex_count, &vertices); + } + + fn append_shape( + mesh_builder: &mut MeshBuilder, + vertex_count: &mut u32, + shape: &ColliderShape2D, + ) -> Range { + match shape { + ColliderShape2D::Circle { radius } => { + return Self::append_circle(mesh_builder, vertex_count, *radius, 32); + } + ColliderShape2D::Rectangle { + half_width, + half_height, + } => { + return Self::append_rectangle( + mesh_builder, + vertex_count, + *half_width, + *half_height, + ); + } + ColliderShape2D::Capsule { + half_height, + radius, + } => { + return Self::append_capsule( + mesh_builder, + vertex_count, + *half_height, + *radius, + 16, + ); + } + ColliderShape2D::ConvexPolygon { vertices } => { + return Self::append_convex_polygon( + mesh_builder, + vertex_count, + vertices, + ); + } + } + } + + fn rotate_vector(radians: f32, vector: [f32; 2]) -> [f32; 2] { + let cosine = radians.cos(); + let sine = radians.sin(); + return [ + cosine * vector[0] - sine * vector[1], + sine * vector[0] + cosine * vector[1], + ]; + } +} + +impl Component for Colliders2DDemo { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + let render_pass = RenderPassBuilder::new() + .with_label("physics-colliders-2d-pass") + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .build(render_context.gpu()); + + let attributes = vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]; + + let mut mesh_builder = MeshBuilder::new(); + mesh_builder.with_attributes(attributes.clone()); + let mut vertex_count = 0_u32; + + let mut colliders = Vec::with_capacity(self.collider_inits.len()); + + for init in self.collider_inits.iter() { + let vertices = + Self::append_shape(&mut mesh_builder, &mut vertex_count, &init.shape); + + let initial_uniform = ColliderGlobalsUniform { + offset_rotation: [0.0, 0.0, 0.0, 0.0], + tint: init.tint, + }; + + let uniform_buffer = BufferBuilder::new() + .with_length(std::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("collider-globals") + .build(render_context.gpu(), vec![initial_uniform]) + .map_err(|error| error.to_string())?; + + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform_buffer, 0, None) + .build(render_context.gpu()); + + colliders.push(RenderCollider { + init: init.clone(), + vertices, + uniform_buffer, + bind_group_id: render_context.attach_bind_group(bind_group), + }); + } + + let mesh = mesh_builder.build(); + + let pipeline = RenderPipelineBuilder::new() + .with_label("physics-colliders-2d-pipeline") + .with_culling(CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context.gpu()) + .map_err(|error| error.to_string())?, + mesh.attributes().to_vec(), + ) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &render_pass, + &self.vertex_shader, + Some(&self.fragment_shader), + ); + + self.render_pass_id = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline_id = Some(render_context.attach_pipeline(pipeline)); + self.mesh = Some(mesh); + self.colliders = colliders; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn event_mask(&self) -> EventMask { + return EventMask::WINDOW | EventMask::KEYBOARD; + } + + fn on_window_event(&mut self, event: &WindowEvent) -> Result<(), String> { + match event { + WindowEvent::Resize { width, height } => { + self.width = *width; + self.height = *height; + } + WindowEvent::Close => {} + } + + return Ok(()); + } + + fn on_keyboard_event(&mut self, event: &Key) -> Result<(), String> { + let Key::Pressed { virtual_key, .. } = event else { + return Ok(()); + }; + + if virtual_key != &Some(VirtualKey::Space) { + return Ok(()); + } + + if self.impulse_cooldown_remaining_seconds > 0.0 { + return Ok(()); + } + + self.pending_impulse = true; + self.impulse_cooldown_remaining_seconds = 0.25; + println!("Space pressed: applying impulse to density demo bodies"); + + return Ok(()); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + self.impulse_cooldown_remaining_seconds = + (self.impulse_cooldown_remaining_seconds - last_frame.as_secs_f32()) + .max(0.0); + + self.physics_accumulator_seconds += last_frame.as_secs_f32(); + + let timestep_seconds = self.physics_world.timestep_seconds(); + + while self.physics_accumulator_seconds >= timestep_seconds { + if self.pending_impulse { + let impulse_x_newton_seconds = 0.0; + let impulse_y_newton_seconds = 1.2; + + self + .density_light_body + .set_velocity(&mut self.physics_world, 0.0, 0.0) + .map_err(|error| error.to_string())?; + self + .density_heavy_body + .set_velocity(&mut self.physics_world, 0.0, 0.0) + .map_err(|error| error.to_string())?; + + self + .density_light_body + .apply_impulse( + &mut self.physics_world, + impulse_x_newton_seconds, + impulse_y_newton_seconds, + ) + .map_err(|error| error.to_string())?; + self + .density_heavy_body + .apply_impulse( + &mut self.physics_world, + impulse_x_newton_seconds, + impulse_y_newton_seconds, + ) + .map_err(|error| error.to_string())?; + + let velocity_light = self + .density_light_body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + let velocity_heavy = self + .density_heavy_body + .velocity(&self.physics_world) + .map_err(|error| error.to_string())?; + + println!( + "Impulse applied. light v=({:.2}, {:.2}) heavy v=({:.2}, {:.2})", + velocity_light[0], + velocity_light[1], + velocity_heavy[0], + velocity_heavy[1], + ); + + self.pending_impulse = false; + } + + self.physics_world.step(); + self.physics_accumulator_seconds -= timestep_seconds; + } + + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Vec { + let viewport = ViewportBuilder::new().build(self.width, self.height); + + for collider in self.colliders.iter() { + let body_position = collider + .init + .body + .position(&self.physics_world) + .expect("RigidBody2D position failed"); + let body_rotation = collider + .init + .body + .rotation(&self.physics_world) + .expect("RigidBody2D rotation failed"); + + // The clip-space coordinate system used by this demo mirrors Rapier's + // angular sign convention on screen. Negating the sampled physics angle + // keeps rendered collider orientation aligned with the collision shape + // that Rapier is simulating. + let visual_body_rotation = -body_rotation; + + let rotated_offset = + Self::rotate_vector(visual_body_rotation, collider.init.local_offset); + + let offset = [ + body_position[0] + rotated_offset[0], + body_position[1] + rotated_offset[1], + ]; + + let rotation = -(body_rotation + collider.init.local_rotation); + + let uniform = ColliderGlobalsUniform { + offset_rotation: [offset[0], offset[1], rotation, 0.0], + tint: collider.init.tint, + }; + + collider + .uniform_buffer + .write_value(render_context.gpu(), 0, &uniform); + } + + let render_pass = self.render_pass_id.expect("render pass missing"); + let render_pipeline = + self.render_pipeline_id.expect("render pipeline missing"); + + let mut commands = vec![ + RenderCommand::BeginRenderPass { + render_pass, + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: render_pipeline, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::BindVertexBuffer { + pipeline: render_pipeline, + buffer: 0, + }, + ]; + + for collider in self.colliders.iter() { + commands.push(RenderCommand::SetBindGroup { + set: 0, + group: collider.bind_group_id, + dynamic_offsets: Vec::new(), + }); + commands.push(RenderCommand::Draw { + vertices: collider.vertices.clone(), + instances: 0..1, + }); + } + + commands.push(RenderCommand::EndRenderPass); + return commands; + } +} + +impl Default for Colliders2DDemo { + fn default() -> Self { + let mut physics_world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -3.2) + .with_substeps(4) + .build() + .expect("Failed to create PhysicsWorld2D"); + + let mut collider_inits = Vec::new(); + + let ground_material = ColliderMaterial2D::new(0.0, 0.8, 0.0); + let ramp_material = ColliderMaterial2D::new(0.0, 1.0, 0.0); + + let ground_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -0.86) + .build(&mut physics_world) + .expect("Failed to create ground body"); + Collider2DBuilder::rectangle(0.95, 0.05) + .with_density(ground_material.density()) + .with_friction(ground_material.friction()) + .with_restitution(ground_material.restitution()) + .build(&mut physics_world, ground_body) + .expect("Failed to create ground collider"); + collider_inits.push(ColliderRenderInit { + body: ground_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.95, + half_height: 0.05, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.25, 0.25, 0.25, 1.0], + }); + + let left_wall_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(-0.98, 0.0) + .build(&mut physics_world) + .expect("Failed to create left wall body"); + Collider2DBuilder::rectangle(0.03, 0.95) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, left_wall_body) + .expect("Failed to create left wall collider"); + collider_inits.push(ColliderRenderInit { + body: left_wall_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.03, + half_height: 0.95, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.18, 0.18, 0.18, 1.0], + }); + + let right_wall_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.98, 0.0) + .build(&mut physics_world) + .expect("Failed to create right wall body"); + Collider2DBuilder::rectangle(0.03, 0.95) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, right_wall_body) + .expect("Failed to create right wall collider"); + collider_inits.push(ColliderRenderInit { + body: right_wall_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.03, + half_height: 0.95, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.18, 0.18, 0.18, 1.0], + }); + + let ceiling_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, 0.92) + .build(&mut physics_world) + .expect("Failed to create ceiling body"); + Collider2DBuilder::rectangle(0.95, 0.03) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, ceiling_body) + .expect("Failed to create ceiling collider"); + collider_inits.push(ColliderRenderInit { + body: ceiling_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.95, + half_height: 0.03, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.18, 0.18, 0.18, 1.0], + }); + + // Divider keeps the density demo isolated so Space remains visible. + let divider_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.32, 0.0) + .build(&mut physics_world) + .expect("Failed to create divider body"); + Collider2DBuilder::rectangle(0.02, 0.95) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, divider_body) + .expect("Failed to create divider collider"); + collider_inits.push(ColliderRenderInit { + body: divider_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.02, + half_height: 0.95, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.18, 0.18, 0.18, 1.0], + }); + + let ramp_rotation = 0.5_f32; + let ramp_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(-0.45, -0.25) + .with_rotation(ramp_rotation) + .build(&mut physics_world) + .expect("Failed to create ramp body"); + Collider2DBuilder::rectangle(0.55, 0.03) + .with_density(ramp_material.density()) + // Keep the ramp somewhat slippery so bodies settle on the floor instead + // of sticking on the slope for long periods. + .with_friction(0.4) + .with_restitution(ramp_material.restitution()) + .build(&mut physics_world, ramp_body) + .expect("Failed to create ramp collider"); + collider_inits.push(ColliderRenderInit { + body: ramp_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.55, + half_height: 0.03, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.3, 0.28, 0.25, 1.0], + }); + + // Restitution demo: bouncy and non-bouncy circles. + let bouncy_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.75, 0.65) + .build(&mut physics_world) + .expect("Failed to create bouncy body"); + Collider2DBuilder::circle(0.06) + .with_density(110.0) + .with_friction(0.2) + .with_restitution(0.95) + .build(&mut physics_world, bouncy_body) + .expect("Failed to create bouncy collider"); + collider_inits.push(ColliderRenderInit { + body: bouncy_body, + shape: ColliderShape2D::Circle { radius: 0.06 }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [1.0, 0.25, 0.25, 1.0], + }); + + let dull_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.6, 0.65) + .build(&mut physics_world) + .expect("Failed to create dull body"); + Collider2DBuilder::circle(0.06) + .with_density(110.0) + .with_friction(0.2) + .with_restitution(0.0) + .build(&mut physics_world, dull_body) + .expect("Failed to create dull collider"); + collider_inits.push(ColliderRenderInit { + body: dull_body, + shape: ColliderShape2D::Circle { radius: 0.06 }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.65, 0.15, 0.15, 1.0], + }); + + // Friction demo: two boxes on the ramp. + let slippery_box_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.35, 0.25) + .build(&mut physics_world) + .expect("Failed to create slippery box body"); + Collider2DBuilder::rectangle(0.07, 0.07) + .with_density(100.0) + .with_friction(0.0) + .with_restitution(0.0) + .build(&mut physics_world, slippery_box_body) + .expect("Failed to create slippery box collider"); + collider_inits.push(ColliderRenderInit { + body: slippery_box_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.07, + half_height: 0.07, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.9, 0.9, 0.3, 1.0], + }); + + let grippy_box_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.15, 0.25) + .build(&mut physics_world) + .expect("Failed to create grippy box body"); + Collider2DBuilder::rectangle(0.07, 0.07) + .with_density(100.0) + .with_friction(1.0) + .with_restitution(0.0) + .build(&mut physics_world, grippy_box_body) + .expect("Failed to create grippy box collider"); + collider_inits.push(ColliderRenderInit { + body: grippy_box_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.07, + half_height: 0.07, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.3, 0.9, 0.45, 1.0], + }); + + // Capsule demo: a character-like capsule. + let capsule_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.25, 0.65) + .build(&mut physics_world) + .expect("Failed to create capsule body"); + Collider2DBuilder::capsule(0.09, 0.04) + .with_density(120.0) + .with_friction(0.6) + .with_restitution(0.0) + .build(&mut physics_world, capsule_body) + .expect("Failed to create capsule collider"); + collider_inits.push(ColliderRenderInit { + body: capsule_body, + shape: ColliderShape2D::Capsule { + half_height: 0.09, + radius: 0.04, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.3, 0.55, 0.95, 1.0], + }); + + // Convex polygon demo: a simple triangle. + let polygon_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.15, 0.72) + .with_rotation(0.25) + .build(&mut physics_world) + .expect("Failed to create polygon body"); + let triangle_vertices = vec![[0.0, 0.1], [-0.09, -0.06], [0.09, -0.06]]; + Collider2DBuilder::polygon(triangle_vertices.clone()) + .with_density(100.0) + .with_friction(0.5) + .with_restitution(0.15) + .build(&mut physics_world, polygon_body) + .expect("Failed to create polygon collider"); + collider_inits.push(ColliderRenderInit { + body: polygon_body, + shape: ColliderShape2D::ConvexPolygon { + vertices: triangle_vertices, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.45, 0.95, 0.4, 1.0], + }); + + // Local rotation demo: a thin rectangle rotated in local space. + let local_rotation_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.05, 0.55) + .build(&mut physics_world) + .expect("Failed to create local rotation body"); + Collider2DBuilder::rectangle(0.11, 0.03) + .with_local_rotation(0.85) + .with_density(100.0) + .with_friction(0.4) + .with_restitution(0.1) + .build(&mut physics_world, local_rotation_body) + .expect("Failed to create local rotation collider"); + collider_inits.push(ColliderRenderInit { + body: local_rotation_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.11, + half_height: 0.03, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.85, + tint: [0.95, 0.6, 0.25, 1.0], + }); + + // Compound collider demo: a dumbbell made of two circles on one body. + let compound_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(-0.02, 0.68) + .build(&mut physics_world) + .expect("Failed to create compound body"); + + Collider2DBuilder::rectangle(0.09, 0.025) + .with_density(100.0) + .with_friction(0.25) + .with_restitution(0.0) + .build(&mut physics_world, compound_body) + .expect("Failed to create compound center collider"); + collider_inits.push(ColliderRenderInit { + body: compound_body, + shape: ColliderShape2D::Rectangle { + half_width: 0.09, + half_height: 0.025, + }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.55, 0.32, 0.72, 1.0], + }); + + for offset_x in [-0.07, 0.07] { + Collider2DBuilder::circle(0.05) + .with_offset(offset_x, 0.0) + .with_density(100.0) + .with_friction(0.25) + .with_restitution(0.0) + .build(&mut physics_world, compound_body) + .expect("Failed to create compound circle collider"); + collider_inits.push(ColliderRenderInit { + body: compound_body, + shape: ColliderShape2D::Circle { radius: 0.05 }, + local_offset: [offset_x, 0.0], + local_rotation: 0.0, + tint: [0.75, 0.45, 0.95, 1.0], + }); + } + + // Density demo: same size circles, different density (mass). + let density_light_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.62, 0.65) + .build(&mut physics_world) + .expect("Failed to create density light body"); + Collider2DBuilder::circle(0.06) + .with_density(100.0) + .with_friction(0.0) + .with_restitution(0.0) + .build(&mut physics_world, density_light_body) + .expect("Failed to create density light collider"); + collider_inits.push(ColliderRenderInit { + body: density_light_body, + shape: ColliderShape2D::Circle { radius: 0.06 }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.35, 0.75, 1.0, 1.0], + }); + + let density_heavy_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.82, 0.65) + .build(&mut physics_world) + .expect("Failed to create density heavy body"); + Collider2DBuilder::circle(0.06) + // Keep the mass ratio small enough that a unit impulse produces visible + // motion for both bodies. + .with_density(250.0) + .with_friction(0.0) + .with_restitution(0.0) + .build(&mut physics_world, density_heavy_body) + .expect("Failed to create density heavy collider"); + collider_inits.push(ColliderRenderInit { + body: density_heavy_body, + shape: ColliderShape2D::Circle { radius: 0.06 }, + local_offset: [0.0, 0.0], + local_rotation: 0.0, + tint: [0.15, 0.35, 0.95, 1.0], + }); + + let mut shader_builder = ShaderBuilder::new(); + let vertex_shader = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "physics-colliders-2d".to_string(), + }); + let fragment_shader = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "physics-colliders-2d".to_string(), + }); + + return Self { + physics_world, + physics_accumulator_seconds: 0.0, + + pending_impulse: false, + impulse_cooldown_remaining_seconds: 0.0, + density_light_body, + density_heavy_body, + + vertex_shader, + fragment_shader, + mesh: None, + render_pipeline_id: None, + render_pass_id: None, + colliders: Vec::new(), + collider_inits, + + width: 1200, + height: 600, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Physics: 2D Colliders") + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(1200, 600) + .with_name("Physics: 2D Colliders"); + }) + .with_component(move |runtime, demo: Colliders2DDemo| { + return (runtime, demo); + }) + .build(); + + start_runtime(runtime); +} diff --git a/docs/features.md b/docs/features.md index 94cc458a..446bb28e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2026-02-13T20:47:25Z" -version: "0.1.16" +last_updated: "2026-03-14T22:54:24Z" +version: "0.1.17" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "c66de56f04d6d3f9866eb8d7e48847f3882aad28" +repo_commit: "23dc1cbe0b87e772e92071ad170dfb70ced36f88" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio", "physics"] @@ -89,11 +89,13 @@ Audio Physics - `physics-2d` (umbrella, disabled by default): enables the 2D physics world - APIs (for example, `lambda::physics::PhysicsWorld2D` and - `lambda::physics::RigidBody2D`). This feature enables the platform physics - backend via `lambda-rs-platform/physics-2d` (currently backed by `rapier2d`). - Expected runtime cost depends on simulation workload; no runtime cost is - incurred unless a physics world is constructed, populated, and stepped. + APIs (for example, `lambda::physics::PhysicsWorld2D`, + `lambda::physics::RigidBody2D`, `lambda::physics::Collider2D`, and + `lambda::physics::Collider2DBuilder`). This feature enables the platform + physics backend via `lambda-rs-platform/physics-2d` (currently backed by + `rapier2d`). Expected runtime cost depends on simulation workload, collider + count, and contact density; no runtime cost is incurred unless a physics + world is constructed, populated, and stepped. Render validation @@ -173,6 +175,7 @@ Physics `rapier2d` directly via this crate. ## Changelog +- 0.1.17 (2026-03-14): Update `physics-2d` coverage to include 2D colliders. - 0.1.16 (2026-02-13): Document 2D rigid bodies under `physics-2d` and update metadata. - 0.1.15 (2026-02-10): Document `audio-playback` in `lambda-rs` and update diff --git a/docs/specs/README.md b/docs/specs/README.md index d2e780b9..c4e9c583 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,9 +3,9 @@ title: "Specifications Index" document_id: "specs-index-2026-02-07" status: "living" created: "2026-02-07T00:00:00Z" -last_updated: "2026-02-15T00:00:00Z" -version: "0.1.3" -repo_commit: "6f96052fae896a095b658f29af1eff96e5aaa348" +last_updated: "2026-02-17T23:08:44Z" +version: "0.1.4" +repo_commit: "43c91a76dec71326cc255ebb6fb6c6402e95735c" owners: ["lambda-sh"] reviewers: ["engine"] tags: ["index", "specs", "docs"] @@ -35,6 +35,7 @@ tags: ["index", "specs", "docs"] - 2D Physics World — [physics/physics-world-2d.md](physics/physics-world-2d.md) - 2D Rigid Bodies — [physics/rigid-bodies-2d.md](physics/rigid-bodies-2d.md) +- 2D Colliders — [physics/colliders-2d.md](physics/colliders-2d.md) ## Templates diff --git a/docs/specs/physics/colliders-2d.md b/docs/specs/physics/colliders-2d.md new file mode 100644 index 00000000..1c91ce44 --- /dev/null +++ b/docs/specs/physics/colliders-2d.md @@ -0,0 +1,445 @@ +--- +title: "2D Colliders" +document_id: "colliders-2d-2026-02-17" +status: "living" +created: "2026-02-17T23:08:44Z" +last_updated: "2026-03-14T22:54:24Z" +version: "0.1.3" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "23dc1cbe0b87e772e92071ad170dfb70ced36f88" +owners: ["lambda-sh"] +reviewers: ["engine"] +tags: ["spec", "physics", "2d", "lambda-rs", "platform"] +--- + +# 2D Colliders + +## Table of Contents + +- [Summary](#summary) +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) + - [API Surface](#api-surface) + - [lambda-rs Public API](#lambda-rs-public-api) + - [Behavior](#behavior) + - [Validation and Errors](#validation-and-errors) + - [Cargo Features](#cargo-features) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Introduce `Collider2D` handles and a `Collider2DBuilder` for attaching one or + more collision shapes to a `RigidBody2D`. +- Support common 2D primitive shapes (`Circle`, `Rectangle`, `Capsule`) and + arbitrary convex polygons. +- Provide per-collider material properties (`density`, `friction`, + `restitution`) that influence contact resolution for participating bodies. +- Support a collider-local transform (offset and rotation) to enable oriented + shapes and compound-body contact layouts. +- Define a backend-agnostic behavior contract while allowing implementation via + `lambda-rs-platform` (initially backed by `rapier2d`). + +Rationale +- Rigid bodies without colliders cannot participate in collision detection or + contact resolution. +- Attaching multiple colliders to a single body enables common gameplay shapes + (compound characters, triggers-as-geometry in later work, and approximated + concave shapes without decomposition support). + +## Scope + +### Goals + +- Provide the following collider shapes: + - Circle (radius) + - Rectangle/box (half-extents) + - Capsule (half-height, radius) + - Convex polygon (vertex list) +- Support attaching multiple colliders to one rigid body. +- Support static, dynamic, and kinematic bodies as collider owners. +- Support collider-local offset and rotation for oriented shapes. +- Provide `density`, `friction`, and `restitution` configuration on colliders. +- Ensure friction and restitution influence collision response during world + stepping. + +### Non-Goals + +- Concave shapes and automatic decomposition. +- Collision detection callbacks / event streams. +- Collision layers/masks and query filtering. +- Sensors, triggers, and overlap-only colliders. +- Body/collider destruction and mutation beyond construction-time parameters. +- Public exposure of backend/vendor types. + +## Terminology + +- Collider: a shape attached to a rigid body that participates in collision + detection and contact resolution. +- Shape: the geometric primitive used by a collider. +- Material: per-collider properties (`density`, `friction`, `restitution`) + affecting physical response. +- Local transform: the collider transform relative to the owning body origin. +- Contact: a collision manifold between two colliders produced by the backend. + +## Architecture Overview + +Dependencies +- This work item depends on `PhysicsWorld2D` and `RigidBody2D` as specified by: + - `docs/specs/physics/physics-world-2d.md` + - `docs/specs/physics/rigid-bodies-2d.md` + +Crate boundaries +- Crate `lambda` (package: `lambda-rs`) + - Public module `physics` MUST expose backend-agnostic collider APIs. + - Public types MUST NOT expose vendor types (for example, `rapier2d`). +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - MUST own the vendor collider representation and attach it to vendor rigid + bodies. + - MUST translate backend behavior into the stable contract defined by this + document. + +Data flow + +``` +application + └── lambda::physics::{PhysicsWorld2D, RigidBody2D, Collider2D} + └── lambda_platform::physics::PhysicsBackend2D (internal) + └── vendor crate (for example, rapier2d) +``` + +## Design + +### API Surface + +Module layout (new) + +- `crates/lambda-rs/src/physics/mod.rs` + - Public `Collider2D`, `Collider2DBuilder`, `ColliderShape2D`, + and `Collider2DError`. +- `crates/lambda-rs-platform/src/physics/mod.rs` + - Internal backend collider APIs and storage. + +### lambda-rs Public API + +Public entry points (implemented) + +```rust +/// Maximum supported vertices for `ColliderShape2D::ConvexPolygon`. +pub const MAX_CONVEX_POLYGON_VERTICES: usize = 64; + +/// An opaque handle to a collider stored in a `PhysicsWorld2D`. +/// +/// This handle is world-scoped. Operations MUST validate that the handle +/// belongs to the provided world. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Collider2D { + world_id: u64, + slot_index: u32, + slot_generation: u32, +} + +/// Supported 2D collider shapes. +#[derive(Debug, Clone, PartialEq)] +pub enum ColliderShape2D { + Circle { radius: f32 }, + Rectangle { half_width: f32, half_height: f32 }, + Capsule { half_height: f32, radius: f32 }, + ConvexPolygon { vertices: Vec<[f32; 2]> }, +} + +/// Material parameters for a `Collider2D`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColliderMaterial2D { + density: f32, + friction: f32, + restitution: f32, +} + +/// Builder for `Collider2D`. +#[derive(Debug, Clone)] +pub struct Collider2DBuilder { + shape: ColliderShape2D, + local_offset: [f32; 2], + local_rotation: f32, + material: ColliderMaterial2D, +} + +impl Collider2DBuilder { + /// Creates a circle collider builder with stable defaults. + pub fn circle(radius: f32) -> Self; + + /// Creates a rectangle collider builder with stable defaults. + pub fn rectangle(half_width: f32, half_height: f32) -> Self; + + /// Creates a capsule collider builder with stable defaults. + pub fn capsule(half_height: f32, radius: f32) -> Self; + + /// Creates a convex polygon collider builder with stable defaults. + pub fn polygon(vertices: Vec<[f32; 2]>) -> Self; + + /// Creates a convex polygon collider builder by copying from a slice. + pub fn polygon_from_slice(vertices: &[[f32; 2]]) -> Self; + + /// Sets the collider local offset relative to the owning body origin, in + /// meters. + pub fn with_offset(self, x: f32, y: f32) -> Self; + + /// Sets the collider local rotation relative to the owning body, in radians. + pub fn with_local_rotation(self, radians: f32) -> Self; + + /// Sets the collider material parameters. + pub fn with_material(self, material: ColliderMaterial2D) -> Self; + + /// Sets the density, in kg/m², for mass property computation. + pub fn with_density(self, density: f32) -> Self; + + /// Sets the friction coefficient (unitless, `>= 0.0`). + pub fn with_friction(self, friction: f32) -> Self; + + /// Sets the restitution coefficient in `[0.0, 1.0]`. + pub fn with_restitution(self, restitution: f32) -> Self; + + /// Attaches the collider to a body and returns a world-scoped handle. + pub fn build( + self, + world: &mut PhysicsWorld2D, + body: RigidBody2D, + ) -> Result; +} +``` + +Notes +- `Collider2D` is a handle and MUST NOT own simulation state. +- The `build()` signature includes `PhysicsWorld2D` to match handle validation + patterns established by `RigidBody2D`. + +### Behavior + +Coordinate system and units +- Shape dimensions, local offsets, and positions MUST be interpreted as meters. +- Local rotations MUST be interpreted as radians around the 2D Z axis, with + positive values rotating counterclockwise. + +Local transform (normative) +- Each collider MUST have a local transform relative to the owning body: + - `local_offset`: translation in meters. + - `local_rotation`: rotation in radians. +- The collider pose in world space MUST be the composition of: + - body pose (position and rotation), then + - collider local transform (offset and rotation). + +Attachment and ownership +- `Collider2DBuilder::build()` MUST attach the collider to the given `body` in + the given `world`. +- Multiple colliders MAY be attached to a single body. Each collider MUST + participate independently in collision detection and contact generation. +- Colliders MUST be supported on `Static`, `Dynamic`, and `Kinematic` bodies. + +Builder defaults (normative) +- The builder MUST provide stable defaults: + - `local_offset`: `(0.0, 0.0)` + - `local_rotation`: `0.0` + - `material.density`: `1.0` + - `material.friction`: `0.5` + - `material.restitution`: `0.0` + +Shape semantics +- `Circle { radius }` represents a circle centered at the collider origin. +- `Rectangle { half_width, half_height }` represents an axis-aligned rectangle + in collider local space before applying the collider local rotation. +- `Capsule { half_height, radius }` represents a vertical capsule aligned with + the collider local Y axis before applying the collider local rotation, with: + - a central segment of length `2.0 * half_height` + - semicircular end caps of radius `radius` +- `ConvexPolygon { vertices }` represents a convex polygon in collider local + space before applying the collider local rotation, with the vertex order + determining the boundary. + +Collision detection +- During `PhysicsWorld2D::step()`, colliders MUST participate in broad-phase + and narrow-phase collision detection as implemented by the backend. +- A dynamic body with at least one collider MUST respond to collisions with: + - static bodies with colliders + - kinematic bodies with colliders + - other dynamic bodies with colliders + +Collision response (normative) +- Contact resolution MUST update dynamic-body motion such that penetrations are + resolved and restitution and friction affect motion. +- Collision response MAY change `RigidBody2D` rotation for dynamic bodies when + contacts introduce torque through collider shape or local offset. +- The public API does not currently expose explicit angular-velocity controls, + but dynamic-body rotation observed through `RigidBody2D` state MUST remain + backend-consistent. + +Material properties +- `density` MUST affect mass properties for dynamic bodies when the body mass + is not explicitly specified by `RigidBody2DBuilder::with_dynamic_mass_kg`. +- `density` MUST NOT affect mass properties for static or kinematic bodies. +- `friction` MUST affect tangential contact response. +- `friction` values greater than `1.0` MUST be supported. +- `restitution` MUST affect normal contact response (bounce). + +Material combination rules (backend-agnostic) +- When two colliders contact, the effective coefficients MUST be: + - `combined_friction = sqrt(friction_a * friction_b)` + - `combined_restitution = max(restitution_a, restitution_b)` + +Mass properties +- When a dynamic body has no explicitly configured mass, the body mass MUST be + computed from attached colliders with `density > 0.0` as: + - `mass_kg = sum(shape_area_m2 * density)` +- If the computed mass is zero (no positive-density colliders), mass MUST + default to `1.0` kg. +- When attaching a collider to a dynamic body without an explicitly configured + mass, the body mass MUST be recomputed to include the newly attached + collider. + +Compound shapes +- With multiple colliders, collision detection and response MUST consider all + colliders for contact generation. +- Mass computation MUST include all attached colliders that contribute mass. + +### Validation and Errors + +Validation principles +- Builder construction MUST accept potentially invalid parameters. +- `Collider2DBuilder::build()` MUST validate parameters and return actionable + errors without panicking. + +Input validation (normative) +- All floating-point inputs MUST be finite, including shape parameters, local + transform parameters, and material parameters. + +Shape validation (normative) +- Circle: + - `radius` MUST be `> 0.0`. +- Rectangle: + - `half_width` MUST be `> 0.0`. + - `half_height` MUST be `> 0.0`. +- Capsule: + - `radius` MUST be `> 0.0`. + - `half_height` MUST be `>= 0.0` (zero yields a circle). +- Convex polygon: + - `vertices.len()` MUST be `>= 3`. + - `vertices.len()` MUST be `<= MAX_CONVEX_POLYGON_VERTICES`. + - Polygon MUST be strictly convex. + - Polygon MUST have non-zero signed area. + - Vertices SHOULD be supplied in counterclockwise order. If the polygon is + valid but clockwise, the implementation SHOULD accept it by reversing the + vertex order. + +Material validation (normative) +- `density` MUST be `>= 0.0`. +- `friction` MUST be `>= 0.0`. +- `restitution` MUST be in `[0.0, 1.0]`. + +Handle validation (normative) +- All collider operations MUST validate: + - The `Collider2D` world identifier matches the provided `PhysicsWorld2D`. + - The slot exists and the generation matches (stale-handle detection). + +Errors (draft) +- `Collider2DError` MUST include actionable variants for: + - world mismatch / invalid handle + - body not found + - invalid shape parameters (including per-shape details) + - polygon too many vertices + - invalid material parameters + +### Cargo Features + +- All public APIs in this work item MUST be gated under the existing umbrella + feature `physics-2d` (crate: `lambda-rs`). +- The platform backend support MUST be enabled via + `lambda-rs-platform/physics-2d`. +- No additional feature flags are introduced by this specification. + +## Constraints and Rules + +- Public APIs MUST remain backend-agnostic and MUST NOT expose vendor types. +- Public APIs MUST NOT panic on invalid user-provided parameters. +- The collider attachment API MUST be world-scoped and MUST reject cross-world + handles. +- This work item MUST NOT add collision callbacks, layers/masks, or concave + shapes. + +## Performance Considerations + +- Collider insertion SHOULD avoid allocations beyond what is required for + storing polygon vertices. +- Convex polygon validation SHOULD be linear or near-linear in vertex count + and MUST avoid quadratic algorithms for common sizes where feasible. +- The polygon vertex cap MUST prevent backend-dependent allocation blowups and + MUST fail with a deterministic error if exceeded. +- Attaching multiple colliders to a body MAY increase contact pairs; tests MUST + include a compound-body scenario to detect regressions. + +## Requirements Checklist + +- [x] Circle colliders detect collisions correctly. +- [x] Rectangle colliders detect collisions correctly. +- [x] Collider local rotation produces oriented shapes correctly. +- [x] Capsule colliders support character-like shapes. +- [x] Polygon colliders support arbitrary convex shapes. +- [x] Multiple colliders attached to one body work together. +- [x] Friction and restitution affect collision response. +- [x] Density affects dynamic body mass when not explicitly set. +- [x] No public vendor types are exposed from `lambda-rs`. + +## Verification and Testing + +Unit tests (crate: `lambda-rs`) +- Validate `Collider2DBuilder::build()` rejects invalid parameters. +- Validate world mismatch and stale-handle behavior for `Collider2D`. + +Integration tests (crate: `lambda-rs`) +- Integration entrypoint: `crates/lambda-rs/tests/integration.rs`. +- Feature-specific physics tests: `crates/lambda-rs/tests/physics_2d/`. +- Shape coverage: + - A dynamic circle collides with a static ground rectangle. + - A dynamic capsule collides with a static ground rectangle. + - A dynamic convex polygon collides with a static ground rectangle. + - A rotated rectangle collider changes motion relative to an unrotated case. +- Compound shape coverage: + - A dynamic body with multiple colliders produces a wider effective collision + extent than a single-collider body. +- Material coverage: + - Restitution affects bounce height. + - Friction affects sliding velocity decay. + - Density affects impulse-driven velocity change. + +Manual verification +- Demo binary: `demos/physics/src/bin/physics_colliders_2d.rs`. +- The demo SHOULD be used to verify: + - primitive shape rendering and contact behavior + - local rotation and local offset behavior + - compound-collider motion as one rigid body + - density, friction, and restitution behavior + +## Compatibility and Migration + +- This work item adds new APIs under `physics-2d` and is additive for existing + users. +- Mass computation for dynamic bodies MAY be affected for bodies that do not + explicitly set mass and then attach positive-density colliders. This behavior + is required for density-based mass, and must be documented in the public API + docs for `Collider2DBuilder::with_density` and `build()`. + +## Changelog + +- 2026-02-17 0.1.0: Define 2D collider shapes and attachment APIs. +- 2026-02-17 0.1.1: Specify defaults and mass recomputation rules. +- 2026-02-17 0.1.2: Add local rotation, material struct, and polygon limits. +- 2026-03-14 0.1.3: Align the specification with the implemented rotation, + testing, and demo behavior.