feat(physics): collision resolution + proximity queries (P2)

Implement the kinematic physics & collision layer per ARCH-7 (no physics
engine; distance-based collision + spatial queries).

physics/
- components.rs (new): BodyMass, ProximitySensor, CollisionEvent, ProximityEvent
- geometry.rs: add sphere_overlap() + split_by_inverse_mass() with unit tests
- systems.rs: collision pipeline (detect_collisions -> resolve_collisions)
  with mass-weighted separation and inbound-velocity cancel, plus
  update_proximity_sensors spatial query and a log_physics_events observer.
  Core math factored into pure plan_collisions() with unit tests.
- mod.rs: PhysicsPlugin registers events, FrameCollisions resource, and the
  InGame systems ordered after kinematic integration.

in_system/scene.rs: give the player ship a BoundingVolume collider, BodyMass,
and a ProximitySensor so it participates in collision and spatial queries.

Bodies with a Velocity are movable; scenery is immovable. 19 unit tests pass;
no new compiler/clippy warnings introduced.
This commit is contained in:
2026-06-16 21:30:27 -04:00
parent 407bc4f790
commit b8e8f934d3
5 changed files with 629 additions and 15 deletions

View File

@@ -13,6 +13,7 @@ use crate::gameplay::galaxy::{
SystemContents,
};
use crate::gameplay::in_system::DockedState;
use crate::gameplay::physics::{BodyMass, ProximitySensor};
/// Tracks the currently active system for gameplay.
#[derive(Resource, Debug, Clone, Default)]
@@ -45,6 +46,16 @@ pub struct DockingTarget {
/// Offset from station where player ship spawns when docked.
const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0);
/// World-space collision radius for the player ship. Matches its scaled
/// visual extent (a ~0.12-scale cuboid) so it cannot overlap stations,
/// planets, or the star.
const PLAYER_COLLIDER_RADIUS: f32 = 0.15;
/// World-space radius of the player ship's proximity sensor — the range at
/// which nearby bodies (stations, belts, the star) register as in-range via
/// `ProximityEvent`s.
const PLAYER_SCAN_RADIUS: f32 = 8.0;
/// Represents a docking target (either a station or a habitable planet).
#[derive(Debug, Clone)]
struct DockingTargetInfo {
@@ -352,6 +363,15 @@ fn spawn_player_ship_docked(
Docked {
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
},
// Physical presence: a collider for the collision pipeline and a
// proximity sensor for spatial queries. `BodyMass` weights
// ship-to-ship separation; the ship has no `Velocity` while docked
// so the resolver treats it as immovable until undock.
BoundingVolume {
radius: PLAYER_COLLIDER_RADIUS,
},
BodyMass(1.0),
ProximitySensor::new(PLAYER_SCAN_RADIUS),
Transform::from_translation(ship_position).with_scale(Vec3::splat(0.12)),
Visibility::default(),
InheritedVisibility::default(),

View File

@@ -0,0 +1,81 @@
//! Component and event definitions for the kinematic physics/collision layer.
//!
//! Per ARCH-7 ("Movement & Collision — No Physics Engine") there is no
//! rigid-body solver: movement is point-and-shoot and collision is
//! distance-based. These components describe *who* participates in collision
//! and proximity queries; the actual resolution is pure position/velocity math
//! living in [`super::systems`] and [`super::geometry`].
//!
//! Collision radii come from [`crate::gameplay::galaxy::BoundingVolume`], which
//! is the single source of truth for "how big is this thing" already attached
//! to every spawned POI. [`BodyMass`] is the physics-owned counterpart for the
//! *momentum* of movable bodies (ships) — it is deliberately separate from the
//! galaxy [`crate::gameplay::galaxy::Massive`] component, which describes
//! gravity-well scenery rather than ship inertia.
use bevy::prelude::*;
/// Mass of a movable body, used to weight collision separation.
///
/// Heavier bodies are displaced less than lighter ones when two movable bodies
/// overlap. Static scenery (planets, stations, the star) has no
/// [`crate::gameplay::movement::components::Velocity`] and is treated as
/// immovable regardless of mass, so it does not need this component.
///
/// Defaults to `1.0`. Values are floored internally to avoid division by zero.
#[derive(Component, Debug, Clone, Copy)]
pub struct BodyMass(pub f32);
impl Default for BodyMass {
fn default() -> Self {
Self(1.0)
}
}
/// A proximity scanner. Each frame the owner queries the world for physical
/// bodies (entities with a [`crate::gameplay::galaxy::BoundingVolume`]) entering
/// or leaving `radius` (world units) and emits [`ProximityEvent`]s for the
/// transitions.
///
/// `occupants` is authoritative per-sensor state managed by
/// [`super::systems::update_proximity_sensors`] — do not edit it manually.
#[derive(Component, Debug)]
pub struct ProximitySensor {
/// World-space scan radius.
pub radius: f32,
/// Bodies currently inside the radius, tracked frame to frame.
pub occupants: Vec<Entity>,
}
impl ProximitySensor {
pub fn new(radius: f32) -> Self {
Self {
radius,
occupants: Vec::new(),
}
}
}
/// Fired whenever two physical bodies overlap, after positional separation has
/// been *planned* (but before it is applied). `normal` points from `a` toward
/// `b`; `penetration` is the overlap depth in world units.
///
/// Future combat/damage systems read this to apply hit effects; today it is the
/// observable signal that the collision pipeline is running.
#[derive(Event, Debug, Clone, Copy)]
pub struct CollisionEvent {
pub a: Entity,
pub b: Entity,
pub normal: Vec3,
pub penetration: f32,
}
/// A proximity transition. Emitted by [`ProximitySensor`]s when a body enters or
/// leaves the scan radius.
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProximityEvent {
/// `sensor` detected `other` entering its radius this frame.
Enter { sensor: Entity, other: Entity },
/// `other` left the sensor's radius this frame.
Exit { sensor: Entity, other: Entity },
}

View File

@@ -71,6 +71,49 @@ pub fn segment_vs_sphere(
Some(t)
}
/// Sphere-sphere overlap test. Returns `(penetration, normal)` where `normal`
/// is the unit vector pointing from `a` toward `b`, or `None` if they do not
/// overlap or their centers coincide (ambiguous direction).
///
/// `penetration` is always positive and equals the distance the two spheres
/// must be separated along `normal` to just stop overlapping.
pub fn sphere_overlap(
a_pos: Vec3,
a_radius: f32,
b_pos: Vec3,
b_radius: f32,
) -> Option<(f32, Vec3)> {
let delta = b_pos - a_pos;
let dist_sq = delta.length_squared();
let combined = a_radius + b_radius;
if dist_sq >= combined * combined || dist_sq == 0.0 {
return None;
}
let dist = dist_sq.sqrt();
Some((combined - dist, delta / dist))
}
/// Split a positional correction between two bodies by inverse mass.
///
/// `normal` points from body `a` toward body `b`; `penetration` is the overlap
/// depth. Returns `(push_a, push_b)` where `push_a` moves `a` away from `b`
/// and `push_b` moves `b` away from `a`. `inv_a` / `inv_b` are inverse masses
/// — pass `0.0` for an immovable body so it absorbs no correction.
pub fn split_by_inverse_mass(
normal: Vec3,
penetration: f32,
inv_a: f32,
inv_b: f32,
) -> (Vec3, Vec3) {
let inv_sum = inv_a + inv_b;
if inv_sum <= 0.0 {
return (Vec3::ZERO, Vec3::ZERO);
}
let push_a = -normal * penetration * (inv_a / inv_sum);
let push_b = normal * penetration * (inv_b / inv_sum);
(push_a, push_b)
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -144,4 +187,44 @@ mod tests {
);
assert_eq!(t, None);
}
#[test]
fn sphere_overlap_reports_penetration_and_normal() {
let (pen, normal) =
sphere_overlap(Vec3::ZERO, 1.0, Vec3::new(1.0, 0.0, 0.0), 1.0).unwrap();
assert!((pen - 1.0).abs() < 1e-5);
assert!((normal - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5);
}
#[test]
fn sphere_overlap_none_when_apart() {
assert!(sphere_overlap(Vec3::ZERO, 1.0, Vec3::new(5.0, 0.0, 0.0), 1.0).is_none());
}
#[test]
fn sphere_overlap_none_when_coincident() {
assert!(sphere_overlap(Vec3::ZERO, 1.0, Vec3::ZERO, 1.0).is_none());
}
#[test]
fn split_gives_full_correction_to_movable_body() {
// normal a→b = +X, penetration 2.0, b immovable (inv_b = 0).
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 1.0, 0.0);
assert!((push_a - Vec3::new(-2.0, 0.0, 0.0)).length() < 1e-5);
assert!(push_b.length() < 1e-5);
}
#[test]
fn split_halves_correction_for_equal_mass() {
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 1.0, 1.0);
assert!((push_a - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5);
assert!((push_b - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5);
}
#[test]
fn split_is_zero_when_both_immovable() {
let (push_a, push_b) = split_by_inverse_mass(Vec3::new(1.0, 0.0, 0.0), 2.0, 0.0, 0.0);
assert!(push_a.length() < 1e-5);
assert!(push_b.length() < 1e-5);
}
}

View File

@@ -1,14 +1,53 @@
//! Kinematic physics & collision layer.
//!
//! See ARCH-7 ("Movement & Collision — No Physics Engine"): there is no
//! rigid-body solver. Collision is distance-based sphere overlap, separation is
//! inverse-mass-weighted position correction, and proximity is a radius query.
//! Geometry primitives live in [`geometry`]; components/events in
//! [`components`]; the per-frame systems in [`systems`].
//!
//! The collision pipeline runs every frame during `AppState::InGame`, ordered
//! after kinematic velocity integration so it resolves the latest positions.
use bevy::prelude::*;
use crate::gameplay::movement;
use crate::state::AppState;
pub mod components;
pub mod geometry;
pub mod systems;
pub use components::{BodyMass, CollisionEvent, ProximityEvent, ProximitySensor};
pub use systems::FrameCollisions;
pub struct PhysicsPlugin;
impl Plugin for PhysicsPlugin {
fn build(&self, _app: &mut App) {
// Geometry primitives in `geometry` are pure functions called directly by
// gameplay systems. No global systems yet — wire them up when combat /
// collision is implemented.
fn build(&self, app: &mut App) {
app.add_event::<CollisionEvent>()
.add_event::<ProximityEvent>()
.init_resource::<FrameCollisions>()
// Collision pipeline: detect (read-only) → resolve (writes
// Transform + Velocity), ordered after kinematic integration so
// the positions being resolved are current.
.add_systems(
Update,
(systems::detect_collisions, systems::resolve_collisions)
.chain()
.run_if(in_state(AppState::InGame))
.after(movement::kinematic::integrate_velocity),
)
// Spatial queries: proximity sensors emit Enter/Exit events.
.add_systems(
Update,
systems::update_proximity_sensors.run_if(in_state(AppState::InGame)),
)
// Debug observer for both event streams (reads the event fields so
// they stay live until combat/docking systems consume them).
.add_systems(
Update,
systems::log_physics_events.run_if(in_state(AppState::InGame)),
);
}
}

View File

@@ -1,11 +1,402 @@
// Slice 1 has no collision systems yet — geometry primitives in `geometry.rs` are
// defined and unit-tested but not yet wired into any Bevy systems.
//
// Future systems to add here:
// - `projectile_hits`: query projectiles against damageable entities using `overlaps`
// or `segment_vs_sphere` (for fast-moving projectiles that might tunnel).
// - `ship_separation`: push ships apart when they get too close using `separate`.
// - `proximity_triggers`: emit events when entities enter/exit a radius.
//
// When adding these, register them in `PhysicsPlugin::build()`. They should run on
// `FixedUpdate` for determinism (see ARCH-9 in the docs).
//! Kinematic collision resolution and proximity queries.
//!
//! Per ARCH-7 there is no physics engine: movement is point-and-shoot and
//! collision is distance-based. This module wires the pure geometry in
//! [`super::geometry`] into Bevy systems that run each frame during gameplay.
//!
//! ## Collision pipeline
//!
//! 1. [`detect_collisions`] snapshots every physical body (anything with a
//! [`BoundingVolume`]) and runs an O(n²) broad+narrow phase, computing
//! mass-weighted position corrections and velocity cancels into
//! [`FrameCollisions`], and emitting [`CollisionEvent`]s.
//! 2. [`resolve_collisions`] drains [`FrameCollisions`] and applies the
//! corrections to `Transform` and `Velocity`.
//!
//! The split exists so detection (read-only) and resolution (write) never alias
//! the same component in a single system. A body is "movable" iff it has a
//! [`Velocity`]; everything else (planets, stations, the star, asteroid belts)
//! is immovable scenery the ship bounces off. Ship-to-ship separation is
//! weighted by inverse [`BodyMass`].
//!
//! ## Spatial queries
//!
//! [`update_proximity_sensors`] advances every [`ProximitySensor`], detecting
//! bodies entering/leaving its radius and emitting [`ProximityEvent`]s — the
//! building block for docking-range, mining-range, and mass-lock checks.
//!
//! ## Hierarchy assumption
//!
//! In-system entities are children of the scene root at the origin, so local
//! `Transform` equals world space. World-space corrections are therefore
//! applied directly to `Transform.translation`. This matches the existing
//! convention in `movement::orbit` and `galaxy::orbits`.
use std::collections::HashMap;
use bevy::prelude::*;
use crate::gameplay::galaxy::BoundingVolume;
use crate::gameplay::movement::components::Velocity;
use super::components::{BodyMass, CollisionEvent, ProximityEvent, ProximitySensor};
use super::geometry::{sphere_overlap, split_by_inverse_mass};
/// Minimum mass floor so inverse-mass never divides by zero.
const MASS_FLOOR: f32 = 1e-6;
/// Per-frame collision corrections: produced by [`detect_collisions`] and
/// consumed by [`resolve_collisions`].
#[derive(Resource, Default, Debug)]
pub struct FrameCollisions {
/// `(entity, world-space delta)` to add to each body's translation.
pub pushes: HashMap<Entity, Vec3>,
/// `(entity, world-space delta)` to subtract from each body's velocity.
pub cancels: HashMap<Entity, Vec3>,
}
/// Read-only snapshot of a body used by the pairwise resolver.
#[derive(Debug, Clone, Copy)]
struct BodySnapshot {
entity: Entity,
pos: Vec3,
radius: f32,
mass: f32,
movable: bool,
velocity: Vec3,
}
/// A planned contact, kept around to emit [`CollisionEvent`]s.
#[derive(Debug, Clone, Copy)]
struct PlannedContact {
a: Entity,
b: Entity,
normal: Vec3,
penetration: f32,
}
/// Pure output of [`plan_collisions`].
#[derive(Default)]
struct CollisionPlan {
pushes: HashMap<Entity, Vec3>,
cancels: HashMap<Entity, Vec3>,
contacts: Vec<PlannedContact>,
}
impl CollisionPlan {
fn add_push(&mut self, entity: Entity, delta: Vec3) {
self.pushes
.entry(entity)
.and_modify(|p| *p += delta)
.or_insert(delta);
}
fn add_cancel(&mut self, entity: Entity, delta: Vec3) {
self.cancels
.entry(entity)
.and_modify(|c| *c += delta)
.or_insert(delta);
}
}
/// Snapshot physical bodies and compute the collision plan for this frame.
///
/// Pure (no ECS access) so it can be unit-tested directly.
fn plan_collisions(bodies: &[BodySnapshot]) -> CollisionPlan {
let mut plan = CollisionPlan::default();
for (i, a) in bodies.iter().enumerate() {
for b in &bodies[i + 1..] {
if !a.movable && !b.movable {
continue;
}
let Some((penetration, normal)) = sphere_overlap(a.pos, a.radius, b.pos, b.radius)
else {
continue;
};
let inv_a = if a.movable { 1.0 / a.mass } else { 0.0 };
let inv_b = if b.movable { 1.0 / b.mass } else { 0.0 };
let (push_a, push_b) = split_by_inverse_mass(normal, penetration, inv_a, inv_b);
// Only movable bodies accrue positional correction; immovable
// scenery is never displaced.
if a.movable {
plan.add_push(a.entity, push_a);
}
if b.movable {
plan.add_push(b.entity, push_b);
}
// Cancel the velocity component driving each movable body into the
// other, so ships slide along obstacles instead of vibrating.
if a.movable {
let inbound = a.velocity.dot(normal);
if inbound > 0.0 {
plan.add_cancel(a.entity, normal * inbound);
}
}
if b.movable {
let toward_a = -normal;
let inbound = b.velocity.dot(toward_a);
if inbound > 0.0 {
plan.add_cancel(b.entity, toward_a * inbound);
}
}
plan.contacts.push(PlannedContact {
a: a.entity,
b: b.entity,
normal,
penetration,
});
}
}
plan
}
/// Snapshot physical bodies, compute the collision plan, stash it in
/// [`FrameCollisions`], and emit [`CollisionEvent`]s for every contact.
///
/// Three narrow queries keep the system's component access simple and
/// conflict-free (position/radius, mass, and velocity are disjoint
/// components), looked up per-entity while building snapshots.
pub fn detect_collisions(
bodies_query: Query<(Entity, &GlobalTransform, &BoundingVolume)>,
masses: Query<&BodyMass>,
velocities: Query<&Velocity>,
mut frame: ResMut<FrameCollisions>,
mut events: EventWriter<CollisionEvent>,
) {
let bodies: Vec<BodySnapshot> = bodies_query
.iter()
.map(|(entity, gtf, volume)| {
let movable = velocities.get(entity).is_ok();
let mass = masses
.get(entity)
.map(|m| m.0)
.unwrap_or(1.0)
.max(MASS_FLOOR);
let velocity = velocities.get(entity).map(|v| v.0).unwrap_or(Vec3::ZERO);
BodySnapshot {
entity,
pos: gtf.translation(),
radius: volume.radius,
mass,
movable,
velocity,
}
})
.collect();
let plan = plan_collisions(&bodies);
for contact in &plan.contacts {
events.write(CollisionEvent {
a: contact.a,
b: contact.b,
normal: contact.normal,
penetration: contact.penetration,
});
}
frame.pushes = plan.pushes;
frame.cancels = plan.cancels;
}
/// Apply the position and velocity corrections computed by
/// [`detect_collisions`].
pub fn resolve_collisions(
mut frame: ResMut<FrameCollisions>,
mut transforms: Query<&mut Transform>,
mut velocities: Query<&mut Velocity>,
) {
let pushes = std::mem::take(&mut frame.pushes);
for (entity, delta) in pushes {
if let Ok(mut transform) = transforms.get_mut(entity) {
transform.translation += delta;
}
}
let cancels = std::mem::take(&mut frame.cancels);
for (entity, delta) in cancels {
if let Ok(mut velocity) = velocities.get_mut(entity) {
velocity.0 -= delta;
}
}
}
/// Advance each [`ProximitySensor`]: detect physical bodies entering or leaving
/// its radius and emit [`ProximityEvent`]s. A body is "inside" when the gap
/// between their centers is within `sensor.radius + body.radius`.
pub fn update_proximity_sensors(
mut sensors: Query<(Entity, &GlobalTransform, &mut ProximitySensor)>,
bodies: Query<(Entity, &GlobalTransform, &BoundingVolume)>,
mut events: EventWriter<ProximityEvent>,
) {
for (sensor_entity, sensor_transform, mut sensor) in sensors.iter_mut() {
let center = sensor_transform.translation();
let radius = sensor.radius;
let current: Vec<Entity> = bodies
.iter()
.filter_map(|(entity, gtf, volume)| {
if entity == sensor_entity {
return None;
}
let reach = radius + volume.radius;
if center.distance(gtf.translation()) <= reach {
Some(entity)
} else {
None
}
})
.collect();
let previous = std::mem::replace(&mut sensor.occupants, current);
// Enter: present now, absent last frame.
for &entity in &sensor.occupants {
if !previous.contains(&entity) {
events.write(ProximityEvent::Enter {
sensor: sensor_entity,
other: entity,
});
}
}
// Exit: present last frame, absent now.
for &entity in &previous {
if !sensor.occupants.contains(&entity) {
events.write(ProximityEvent::Exit {
sensor: sensor_entity,
other: entity,
});
}
}
}
}
/// Debug observer for the physics pipeline.
///
/// Reads [`CollisionEvent`] and [`ProximityEvent`] each frame and logs them,
/// so the collision and spatial-query systems are observable before combat /
/// docking systems are wired up to consume the same events. Collisions are
/// logged at `debug` (they fire every frame an overlap persists); proximity
/// transitions are logged at `info` (they fire only on Enter/Exit).
pub fn log_physics_events(
mut collisions: EventReader<CollisionEvent>,
mut proximity: EventReader<ProximityEvent>,
) {
for event in collisions.read() {
bevy::log::debug!(
"collision: {:?} <-> {:?} penetration={:.3} normal={:?}",
event.a, event.b, event.penetration, event.normal
);
}
for event in proximity.read() {
match *event {
ProximityEvent::Enter { sensor, other } => {
bevy::log::info!("proximity: {other:?} entered sensor {sensor:?}");
}
ProximityEvent::Exit { sensor, other } => {
bevy::log::info!("proximity: {other:?} left sensor {sensor:?}");
}
}
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn snap(index: u32, x: f32, radius: f32, movable: bool, vel_x: f32) -> BodySnapshot {
BodySnapshot {
entity: Entity::from_raw(index),
pos: Vec3::new(x, 0.0, 0.0),
radius,
mass: 1.0,
movable,
velocity: Vec3::new(vel_x, 0.0, 0.0),
}
}
#[test]
fn equal_mass_splits_correction_and_cancels_inbound_velocity() {
// a at x=0 (r=1) moving +X; b at x=1 (r=1) moving -X. Overlap 1.0.
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 1.0, 1.0, true, -1.0)];
let plan = plan_collisions(&bodies);
assert!(
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-0.5, 0.0, 0.0)).length() < 1e-5,
"a should be pushed back half the overlap"
);
assert!(
(plan.pushes[&Entity::from_raw(1)] - Vec3::new(0.5, 0.0, 0.0)).length() < 1e-5,
"b should be pushed forward half the overlap"
);
assert!(
(plan.cancels[&Entity::from_raw(0)] - Vec3::new(1.0, 0.0, 0.0)).length() < 1e-5,
"a's inbound velocity should be canceled"
);
assert!(
(plan.cancels[&Entity::from_raw(1)] - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5,
"b's inbound velocity should be canceled"
);
assert_eq!(plan.contacts.len(), 1);
}
#[test]
fn static_body_takes_full_push_and_none_itself() {
// Movable a vs immovable b: a absorbs the full correction.
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 1.0, 1.0, false, 0.0)];
let plan = plan_collisions(&bodies);
assert!(
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-1.0, 0.0, 0.0)).length() < 1e-5
);
assert!(plan.pushes.get(&Entity::from_raw(1)).is_none());
}
#[test]
fn no_overlap_produces_empty_plan() {
let bodies = [snap(0, 0.0, 1.0, true, 1.0), snap(1, 5.0, 1.0, true, 0.0)];
let plan = plan_collisions(&bodies);
assert!(plan.pushes.is_empty());
assert!(plan.cancels.is_empty());
assert!(plan.contacts.is_empty());
}
#[test]
fn receding_body_keeps_its_velocity() {
// a moving away from b (-X) should not have velocity canceled.
let bodies = [snap(0, 0.0, 1.0, true, -1.0), snap(1, 1.0, 1.0, false, 0.0)];
let plan = plan_collisions(&bodies);
assert!(plan.cancels.is_empty());
}
#[test]
fn pair_of_immovable_bodies_is_skipped() {
let bodies = [snap(0, 0.0, 1.0, false, 0.0), snap(1, 0.5, 1.0, false, 0.0)];
let plan = plan_collisions(&bodies);
assert!(plan.pushes.is_empty());
assert!(plan.contacts.is_empty());
}
#[test]
fn heavier_body_moves_less() {
// a heavy (mass 9), b light (mass 1), equal inverse split → a moves 1/10.
let mut a = snap(0, 0.0, 1.0, true, 0.0);
a.mass = 9.0;
let mut b = snap(1, 1.0, 1.0, true, 0.0);
b.mass = 1.0;
let bodies = [a, b];
let plan = plan_collisions(&bodies);
// overlap 1.0; a share = inv_a/(inv_a+inv_b) = (1/9)/(1/9+1) = 0.1
assert!(
(plan.pushes[&Entity::from_raw(0)] - Vec3::new(-0.1, 0.0, 0.0)).length() < 1e-5
);
assert!(
(plan.pushes[&Entity::from_raw(1)] - Vec3::new(0.9, 0.0, 0.0)).length() < 1e-5
);
}
}