use arcane_core::physics::broadphase::SpatialHash;
use arcane_core::physics::integrate::integrate;
use arcane_core::physics::narrowphase::test_collision;
use arcane_core::physics::sleep::update_sleep;
use arcane_core::physics::types::*;
use arcane_core::physics::world::PhysicsWorld;
fn make_body(id: BodyId, body_type: BodyType, shape: Shape, x: f32, y: f32, mass: f32) -> RigidBody {
let (inv_mass, inertia, inv_inertia) = compute_mass_and_inertia(&shape, mass, body_type);
RigidBody {
id,
body_type,
shape,
material: Material::default(),
x,
y,
angle: 0.0,
vx: 0.0,
vy: 0.0,
angular_velocity: 0.0,
fx: 0.0,
fy: 0.0,
torque: 0.0,
mass,
inv_mass,
inertia,
inv_inertia,
layer: 0xFFFF,
mask: 0xFFFF,
sleeping: false,
sleep_timer: 0.0,
}
}
#[test]
fn test_static_body_has_zero_inverse_mass() {
let (inv_mass, _inertia, inv_inertia) =
compute_mass_and_inertia(&Shape::Circle { radius: 5.0 }, 10.0, BodyType::Static);
assert_eq!(inv_mass, 0.0);
assert_eq!(inv_inertia, 0.0);
}
#[test]
fn test_dynamic_circle_mass_inertia() {
let mass = 4.0;
let radius = 2.0;
let (inv_mass, inertia, inv_inertia) =
compute_mass_and_inertia(&Shape::Circle { radius }, mass, BodyType::Dynamic);
assert!((inv_mass - 0.25).abs() < 1e-6);
assert!((inertia - 8.0).abs() < 1e-6);
assert!((inv_inertia - 0.125).abs() < 1e-6);
}
#[test]
fn test_dynamic_aabb_mass_inertia() {
let mass = 12.0;
let hw = 3.0;
let hh = 2.0;
let (inv_mass, inertia, inv_inertia) =
compute_mass_and_inertia(&Shape::AABB { half_w: hw, half_h: hh }, mass, BodyType::Dynamic);
assert!((inv_mass - 1.0 / 12.0).abs() < 1e-6);
assert_eq!(inertia, 0.0);
assert_eq!(inv_inertia, 0.0);
}
#[test]
fn test_zero_mass_is_like_static() {
let (inv_mass, _, inv_inertia) =
compute_mass_and_inertia(&Shape::Circle { radius: 1.0 }, 0.0, BodyType::Dynamic);
assert_eq!(inv_mass, 0.0);
assert_eq!(inv_inertia, 0.0);
}
#[test]
fn test_kinematic_has_mass_properties() {
let (inv_mass, _, _) =
compute_mass_and_inertia(&Shape::Circle { radius: 1.0 }, 5.0, BodyType::Kinematic);
assert!((inv_mass - 0.2).abs() < 1e-6);
}
#[test]
fn test_circle_aabb() {
let body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 10.0, 20.0, 1.0);
let (min_x, min_y, max_x, max_y) = get_shape_aabb(&body);
assert!((min_x - 5.0).abs() < 1e-6);
assert!((min_y - 15.0).abs() < 1e-6);
assert!((max_x - 15.0).abs() < 1e-6);
assert!((max_y - 25.0).abs() < 1e-6);
}
#[test]
fn test_aabb_shape_aabb() {
let body = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 3.0, half_h: 2.0 }, 0.0, 0.0, 1.0);
let (min_x, min_y, max_x, max_y) = get_shape_aabb(&body);
assert!((min_x + 3.0).abs() < 1e-6);
assert!((min_y + 2.0).abs() < 1e-6);
assert!((max_x - 3.0).abs() < 1e-6);
assert!((max_y - 2.0).abs() < 1e-6);
}
#[test]
fn test_polygon_aabb() {
let body = make_body(
0,
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
},
5.0,
5.0,
1.0,
);
let (min_x, min_y, max_x, max_y) = get_shape_aabb(&body);
assert!((min_x - 4.0).abs() < 1e-6);
assert!((min_y - 4.0).abs() < 1e-6);
assert!((max_x - 6.0).abs() < 1e-6);
assert!((max_y - 6.0).abs() < 1e-6);
}
#[test]
fn test_gravity_moves_dynamic_body() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 1.0);
integrate(&mut body, 0.0, 9.81, 1.0 / 60.0);
assert!(body.vy > 0.0, "Gravity should accelerate body downward");
assert!(body.y > 0.0, "Body should have moved down");
}
#[test]
fn test_static_body_not_moved_by_gravity() {
let mut body = make_body(0, BodyType::Static, Shape::Circle { radius: 1.0 }, 5.0, 5.0, 1.0);
integrate(&mut body, 0.0, 9.81, 1.0 / 60.0);
assert_eq!(body.x, 5.0);
assert_eq!(body.y, 5.0);
assert_eq!(body.vx, 0.0);
assert_eq!(body.vy, 0.0);
}
#[test]
fn test_sleeping_body_not_integrated() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 1.0);
body.sleeping = true;
integrate(&mut body, 0.0, 9.81, 1.0 / 60.0);
assert_eq!(body.y, 0.0);
assert_eq!(body.vy, 0.0);
}
#[test]
fn test_force_accelerates_body() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 2.0);
body.fx = 10.0;
integrate(&mut body, 0.0, 0.0, 1.0);
assert!((body.vx - 5.0).abs() < 1e-4);
assert!((body.x - 5.0).abs() < 1e-4);
}
#[test]
fn test_forces_cleared_after_integration() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 1.0);
body.fx = 100.0;
body.fy = 200.0;
body.torque = 50.0;
integrate(&mut body, 0.0, 0.0, 0.01);
assert_eq!(body.fx, 0.0);
assert_eq!(body.fy, 0.0);
assert_eq!(body.torque, 0.0);
}
#[test]
fn test_angular_velocity_updates_angle() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 1.0);
body.angular_velocity = std::f32::consts::PI;
integrate(&mut body, 0.0, 0.0, 1.0);
assert!((body.angle - std::f32::consts::PI).abs() < 1e-4);
}
#[test]
fn test_nearby_bodies_produce_pairs() {
let mut hash = SpatialHash::new(64.0);
hash.insert(0, 0.0, 0.0, 10.0, 10.0);
hash.insert(1, 5.0, 5.0, 15.0, 15.0);
let pairs = hash.get_pairs();
assert_eq!(pairs.len(), 1);
assert!(pairs.contains(&(0, 1)));
}
#[test]
fn test_far_apart_bodies_no_pairs() {
let mut hash = SpatialHash::new(64.0);
hash.insert(0, 0.0, 0.0, 10.0, 10.0);
hash.insert(1, 500.0, 500.0, 510.0, 510.0);
let pairs = hash.get_pairs();
assert!(pairs.is_empty());
}
#[test]
fn test_broadphase_no_duplicate_pairs() {
let mut hash = SpatialHash::new(32.0);
hash.insert(0, 0.0, 0.0, 50.0, 50.0);
hash.insert(1, 10.0, 10.0, 60.0, 60.0);
let pairs = hash.get_pairs();
assert_eq!(pairs.len(), 1);
}
#[test]
fn test_broadphase_clear() {
let mut hash = SpatialHash::new(64.0);
hash.insert(0, 0.0, 0.0, 10.0, 10.0);
hash.clear();
hash.insert(1, 0.0, 0.0, 10.0, 10.0);
let pairs = hash.get_pairs();
assert!(pairs.is_empty());
}
#[test]
fn test_circle_circle_overlap() {
let a = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 8.0, 0.0, 1.0);
let contact = test_collision(&a, &b);
assert!(contact.is_some());
let c = contact.unwrap();
assert_eq!(c.body_a, 0);
assert_eq!(c.body_b, 1);
assert!((c.penetration - 2.0).abs() < 1e-4);
assert!(c.normal.0 > 0.0);
}
#[test]
fn test_circle_circle_no_overlap() {
let a = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 20.0, 0.0, 1.0);
assert!(test_collision(&a, &b).is_none());
}
#[test]
fn test_aabb_aabb_overlap() {
let a = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 8.0, 0.0, 1.0);
let contact = test_collision(&a, &b);
assert!(contact.is_some());
let c = contact.unwrap();
assert!((c.penetration - 2.0).abs() < 1e-4);
}
#[test]
fn test_aabb_aabb_no_overlap() {
let a = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 20.0, 0.0, 1.0);
assert!(test_collision(&a, &b).is_none());
}
#[test]
fn test_circle_aabb_overlap() {
let circle = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 1.0);
let aabb = make_body(1, BodyType::Static, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 8.0, 0.0, 1.0);
let contact = test_collision(&circle, &aabb);
assert!(contact.is_some());
}
#[test]
fn test_circle_aabb_no_overlap() {
let circle = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 2.0 }, 0.0, 0.0, 1.0);
let aabb = make_body(1, BodyType::Static, Shape::AABB { half_w: 2.0, half_h: 2.0 }, 10.0, 0.0, 1.0);
assert!(test_collision(&circle, &aabb).is_none());
}
#[test]
fn test_aabb_circle_overlap_reversed() {
let aabb = make_body(0, BodyType::Static, Shape::AABB { half_w: 5.0, half_h: 5.0 }, 0.0, 0.0, 1.0);
let circle = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 8.0, 0.0, 1.0);
let contact = test_collision(&aabb, &circle);
assert!(contact.is_some());
let c = contact.unwrap();
assert_eq!(c.body_a, 0);
assert_eq!(c.body_b, 1);
}
#[test]
fn test_polygon_polygon_overlap() {
let a = make_body(
0,
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)],
},
0.0,
0.0,
1.0,
);
let b = make_body(
1,
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)],
},
3.0,
0.0,
1.0,
);
assert!(test_collision(&a, &b).is_some());
}
#[test]
fn test_polygon_polygon_no_overlap() {
let a = make_body(
0,
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
},
0.0,
0.0,
1.0,
);
let b = make_body(
1,
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
},
10.0,
0.0,
1.0,
);
assert!(test_collision(&a, &b).is_none());
}
#[test]
fn test_ball_bounces_off_static_ground() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 10.0 },
0.0, 200.0, 0.0,
Material { restitution: 0.5, friction: 0.3 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
0.0, 100.0, 1.0,
Material { restitution: 1.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball_id, 0.0, 200.0);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let body = world.get_body(ball_id).unwrap();
assert!(
body.vy < 0.0 || body.y < 190.0,
"Ball should bounce: vy={}, y={}",
body.vy, body.y,
);
}
#[test]
fn test_solver_does_not_move_two_static_bodies() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id_a = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 5.0, half_h: 5.0 },
0.0, 0.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let id_b = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 5.0, half_h: 5.0 },
3.0, 0.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.step(1.0 / 60.0);
assert_eq!(world.get_body(id_a).unwrap().x, 0.0);
assert_eq!(world.get_body(id_b).unwrap().x, 3.0);
}
#[test]
fn test_bouncy_ball_rebounds_to_visible_height() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 400.0, 0.0,
Material { restitution: 0.2, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 10.0 },
400.0, 200.0, 0.5,
Material { restitution: 0.6, friction: 0.3 },
0xFFFF, 0xFFFF,
);
let mut was_falling = false;
let mut max_bounce_vy = 0.0_f32; let mut min_y_after_bounce = 400.0_f32;
for _ in 0..120 {
world.step(1.0 / 60.0);
let body = world.get_body(ball_id).unwrap();
if body.vy > 50.0 {
was_falling = true;
}
if was_falling && body.vy < max_bounce_vy {
max_bounce_vy = body.vy;
}
if max_bounce_vy < -10.0 {
min_y_after_bounce = min_y_after_bounce.min(body.y);
}
}
assert!(was_falling, "Ball should have been falling");
assert!(
max_bounce_vy < -50.0,
"Ball should have bounced with significant upward velocity, got vy={}",
max_bounce_vy,
);
assert!(
min_y_after_bounce < 360.0,
"Ball should bounce to visible height, but min_y after bounce was {:.1}",
min_y_after_bounce,
);
}
#[test]
fn test_body_sleeps_after_being_still() {
let mut bodies: Vec<Option<RigidBody>> = vec![Some(make_body(
0,
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
))];
for _ in 0..100 {
update_sleep(&mut bodies, &[], 0.016);
}
assert!(bodies[0].as_ref().unwrap().sleeping);
}
#[test]
fn test_moving_body_does_not_sleep() {
let mut bodies: Vec<Option<RigidBody>> = vec![Some(make_body(
0,
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
))];
bodies[0].as_mut().unwrap().vx = 10.0;
for _ in 0..100 {
update_sleep(&mut bodies, &[], 0.016);
}
assert!(!bodies[0].as_ref().unwrap().sleeping);
}
#[test]
fn test_sleeping_body_wakes_on_contact_with_awake_body() {
let mut bodies: Vec<Option<RigidBody>> = vec![
Some(make_body(0, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 0.0, 0.0, 1.0)),
Some(make_body(1, BodyType::Dynamic, Shape::Circle { radius: 1.0 }, 2.0, 0.0, 1.0)),
];
bodies[0].as_mut().unwrap().sleeping = true;
bodies[1].as_mut().unwrap().vx = 20.0;
let contacts = vec![Contact {
body_a: 0,
body_b: 1,
normal: (1.0, 0.0),
penetration: 0.1,
contact_point: (1.0, 0.0),
accumulated_jn: 0.0,
accumulated_jt: 0.0,
velocity_bias: 0.0,
tangent: (0.0, 0.0),
}];
update_sleep(&mut bodies, &contacts, 0.016);
assert!(!bodies[0].as_ref().unwrap().sleeping);
}
#[test]
fn test_world_create_and_add_body() {
let mut world = PhysicsWorld::new(0.0, 9.81);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
assert!(world.get_body(id).is_some());
}
#[test]
fn test_world_remove_body() {
let mut world = PhysicsWorld::new(0.0, 9.81);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.remove_body(id);
assert!(world.get_body(id).is_none());
}
#[test]
fn test_world_recycle_ids() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id1 = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.remove_body(id1);
let id2 = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
assert_eq!(id1, id2);
}
#[test]
fn test_world_step_applies_gravity() {
let mut world = PhysicsWorld::new(0.0, 100.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.step(1.0 / 60.0);
let body = world.get_body(id).unwrap();
assert!(body.y > 0.0, "Body should fall due to gravity");
assert!(body.vy > 0.0, "Body should have downward velocity");
}
#[test]
fn test_world_set_velocity() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.set_velocity(id, 10.0, 5.0);
let body = world.get_body(id).unwrap();
assert_eq!(body.vx, 10.0);
assert_eq!(body.vy, 5.0);
}
#[test]
fn test_world_apply_force() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.apply_force(id, 100.0, 0.0);
world.step(1.0 / 60.0);
let body = world.get_body(id).unwrap();
assert!(body.vx > 0.0, "Force should accelerate body");
}
#[test]
fn test_world_apply_impulse() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
2.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.apply_impulse(id, 10.0, 0.0);
let body = world.get_body(id).unwrap();
assert!((body.vx - 5.0).abs() < 1e-4);
}
#[test]
fn test_world_set_position() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.set_position(id, 50.0, 100.0);
let body = world.get_body(id).unwrap();
assert_eq!(body.x, 50.0);
assert_eq!(body.y, 100.0);
}
#[test]
fn test_world_collision_layers() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
0.0,
0.0,
1.0,
Material::default(),
0x0001,
0x0002,
);
let _b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
3.0,
0.0,
1.0,
Material::default(),
0x0004,
0x0008,
);
world.step(1.0 / 60.0);
assert!(world.get_contacts().is_empty());
}
#[test]
fn test_world_collision_layers_matching() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
0.0,
0.0,
1.0,
Material::default(),
0x0001,
0xFFFF,
);
let _b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
3.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.step(1.0 / 60.0);
assert!(!world.get_contacts().is_empty());
}
#[test]
fn test_world_set_angular_velocity() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.set_angular_velocity(id, 2.0);
let body = world.get_body(id).unwrap();
assert_eq!(body.angular_velocity, 2.0);
}
#[test]
fn test_query_aabb_finds_bodies() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
5.0,
5.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let _b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
100.0,
100.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let results = world.query_aabb(0.0, 0.0, 10.0, 10.0);
assert_eq!(results.len(), 1);
assert_eq!(results[0], a);
}
#[test]
fn test_query_aabb_empty_region() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
50.0,
50.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let results = world.query_aabb(0.0, 0.0, 10.0, 10.0);
assert!(results.is_empty());
}
#[test]
fn test_raycast_hits_circle() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 2.0 },
10.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let result = world.raycast(0.0, 0.0, 1.0, 0.0, 100.0);
assert!(result.is_some());
let (hit_id, _hx, _hy, dist) = result.unwrap();
assert_eq!(hit_id, id);
assert!((dist - 8.0).abs() < 1e-3);
}
#[test]
fn test_raycast_hits_aabb() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 2.0, half_h: 2.0 },
10.0,
0.0,
0.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let result = world.raycast(0.0, 0.0, 1.0, 0.0, 100.0);
assert!(result.is_some());
let (hit_id, _hx, _hy, dist) = result.unwrap();
assert_eq!(hit_id, id);
assert!((dist - 8.0).abs() < 1e-3);
}
#[test]
fn test_raycast_miss() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
10.0,
10.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let result = world.raycast(0.0, 0.0, -1.0, 0.0, 100.0);
assert!(result.is_none());
}
#[test]
fn test_raycast_max_distance() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
100.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let result = world.raycast(0.0, 0.0, 1.0, 0.0, 10.0);
assert!(result.is_none());
}
#[test]
fn test_raycast_closest_body() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let near = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
5.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let _far = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
20.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let result = world.raycast(0.0, 0.0, 1.0, 0.0, 100.0);
assert!(result.is_some());
assert_eq!(result.unwrap().0, near);
}
#[test]
fn test_distance_constraint_maintains_distance() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
10.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a: a,
body_b: b,
distance: 10.0,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
world.set_velocity(a, -5.0, 0.0);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let ba = world.get_body(a).unwrap();
let bb = world.get_body(b).unwrap();
let dist = ((ba.x - bb.x).powi(2) + (ba.y - bb.y).powi(2)).sqrt();
assert!(
(dist - 10.0).abs() < 2.0,
"Distance should be approximately 10.0, got {}",
dist
);
}
#[test]
fn test_revolute_constraint() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
5.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let cid = world.add_constraint(Constraint::Revolute { soft: None, accumulated_impulse: (0.0, 0.0),
id: 0,
body_a: a,
body_b: b,
anchor_a: (2.5, 0.0),
anchor_b: (-2.5, 0.0),
});
world.step(1.0 / 60.0);
assert!(cid < u32::MAX);
}
#[test]
fn test_remove_constraint() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
10.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
let cid = world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a: a,
body_b: b,
distance: 10.0,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
world.remove_constraint(cid);
world.step(1.0 / 60.0);
}
#[test]
fn test_ball_on_ground_contacts() {
let mut world = PhysicsWorld::new(0.0, 100.0);
let _ball = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
-3.0,
1.0,
Material { restitution: 0.5, friction: 0.5 },
0xFFFF,
0xFFFF,
);
let _ground = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 100.0, half_h: 1.0 },
0.0,
0.0,
0.0,
Material { restitution: 0.5, friction: 0.5 },
0xFFFF,
0xFFFF,
);
for _ in 0..10 {
world.step(1.0 / 60.0);
}
let _contacts = world.get_contacts();
let ball = world.get_body(_ball).unwrap();
assert!(ball.y > -3.0, "Ball should have fallen");
}
#[test]
fn test_world_multiple_bodies() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let ids: Vec<u32> = (0..10)
.map(|i| {
world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
i as f32 * 100.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
)
})
.collect();
assert_eq!(ids.len(), 10);
for (i, &id) in ids.iter().enumerate() {
let body = world.get_body(id).unwrap();
assert!((body.x - i as f32 * 100.0).abs() < 1e-6);
}
}
#[test]
fn test_world_get_body_mut() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0xFFFF,
0xFFFF,
);
{
let body = world.get_body_mut(id).unwrap();
body.x = 42.0;
}
assert_eq!(world.get_body(id).unwrap().x, 42.0);
}
#[test]
fn test_world_set_collision_layers() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 1.0 },
0.0,
0.0,
1.0,
Material::default(),
0x0001,
0x0001,
);
world.set_collision_layers(id, 0x0002, 0x0004);
let body = world.get_body(id).unwrap();
assert_eq!(body.layer, 0x0002);
assert_eq!(body.mask, 0x0004);
}
#[test]
fn test_material_default() {
let m = Material::default();
assert!((m.restitution - 0.3).abs() < 1e-6);
assert!((m.friction - 0.5).abs() < 1e-6);
}
#[test]
fn test_constraint_id() {
let c = Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 42,
body_a: 0,
body_b: 1,
distance: 5.0,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
};
assert_eq!(c.id(), 42);
let r = Constraint::Revolute { soft: None, accumulated_impulse: (0.0, 0.0),
id: 7,
body_a: 0,
body_b: 1,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
};
assert_eq!(r.id(), 7);
}
#[test]
fn test_polygon_mass_inertia() {
let shape = Shape::Polygon {
vertices: vec![(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
};
let (inv_mass, inertia, inv_inertia) = compute_mass_and_inertia(&shape, 4.0, BodyType::Dynamic);
assert!((inv_mass - 0.25).abs() < 1e-6);
assert!(inertia > 0.0);
assert!(inv_inertia > 0.0);
}
#[test]
fn test_physics_ops_create_world() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
"Deno.core.ops.op_create_physics_world(0.0, 9.81)",
)
.unwrap();
}
#[test]
fn test_physics_ops_create_body_and_step() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 100.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_physics_step(1.0 / 60.0);
const s = Deno.core.ops.op_get_body_state(id);
if (s.length !== 7) throw new Error("Expected 7 elements");
if (s[1] <= 0) throw new Error("Body should have moved down");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_set_velocity() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_set_body_velocity(id, 10.0, 5.0);
const s = Deno.core.ops.op_get_body_state(id);
if (Math.abs(s[3] - 10.0) > 0.01) throw new Error("vx should be 10");
if (Math.abs(s[4] - 5.0) > 0.01) throw new Error("vy should be 5");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_apply_force_and_impulse() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_apply_impulse(id, 10.0, 0.0);
const s1 = Deno.core.ops.op_get_body_state(id);
if (Math.abs(s1[3] - 10.0) > 0.01) throw new Error("vx after impulse should be 10");
Deno.core.ops.op_apply_force(id, 100.0, 0.0);
Deno.core.ops.op_physics_step(1.0 / 60.0);
const s2 = Deno.core.ops.op_get_body_state(id);
if (s2[3] <= 10.0) throw new Error("vx should have increased from force");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_remove_body() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_remove_body(id);
const s = Deno.core.ops.op_get_body_state(id);
if (s.length !== 0) throw new Error("Removed body should return empty state");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_query_aabb() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 5.0, 5.0, 1.0, 0.3, 0.5, 65535, 65535);
const results = Deno.core.ops.op_query_aabb(0.0, 0.0, 10.0, 10.0);
if (results.length !== 1) throw new Error("Should find 1 body, found " + results.length);
if (results[0] !== id) throw new Error("Should find the body we created");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_raycast() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 2.0, 0.0, 10.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
const hit = Deno.core.ops.op_raycast(0.0, 0.0, 1.0, 0.0, 100.0);
if (hit.length !== 4) throw new Error("Should return [id, hx, hy, dist]");
if (hit[0] !== id) throw new Error("Should hit our body");
if (Math.abs(hit[3] - 8.0) > 0.1) throw new Error("Distance should be ~8, got " + hit[3]);
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_raycast_miss() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 10.0, 10.0, 1.0, 0.3, 0.5, 65535, 65535);
const hit = Deno.core.ops.op_raycast(0.0, 0.0, -1.0, 0.0, 100.0);
if (hit.length !== 0) throw new Error("Should miss");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_joints() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const a = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
const b = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 10.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
const jid = Deno.core.ops.op_create_distance_joint(a, b, 10.0);
if (jid === 4294967295) throw new Error("Joint creation failed");
Deno.core.ops.op_physics_step(1.0 / 60.0);
Deno.core.ops.op_remove_constraint(jid);
Deno.core.ops.op_physics_step(1.0 / 60.0);
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_revolute_joint() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const a = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
const b = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 5.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
const jid = Deno.core.ops.op_create_revolute_joint(a, b, 2.5, 0.0);
if (jid === 4294967295) throw new Error("Revolute joint creation failed");
Deno.core.ops.op_physics_step(1.0 / 60.0);
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_set_position() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_set_body_position(id, 42.0, 99.0);
const s = Deno.core.ops.op_get_body_state(id);
if (Math.abs(s[0] - 42.0) > 0.01) throw new Error("x should be 42");
if (Math.abs(s[1] - 99.0) > 0.01) throw new Error("y should be 99");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_set_angular_velocity() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_set_body_angular_velocity(id, 3.14);
const s = Deno.core.ops.op_get_body_state(id);
if (Math.abs(s[5] - 3.14) > 0.01) throw new Error("angular velocity should be 3.14");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_collision_layers() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
const id = Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 1, 1);
Deno.core.ops.op_set_collision_layers(id, 2, 4);
// Can't easily verify layers via ops, but at least it shouldn't crash
Deno.core.ops.op_physics_step(1.0 / 60.0);
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_get_contacts() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 0.0);
// Two overlapping circles
Deno.core.ops.op_create_body(1, 0, 5.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_create_body(1, 0, 5.0, 0.0, 3.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_physics_step(1.0 / 60.0);
const contacts = Deno.core.ops.op_get_contacts();
// Contacts are flattened in groups of 7
if (contacts.length < 7) throw new Error("Should have at least one contact, got " + contacts.length);
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_destroy_world() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 9.81);
Deno.core.ops.op_create_body(1, 0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_destroy_physics_world();
// After destroy, ops should gracefully return defaults
const s = Deno.core.ops.op_get_body_state(0);
if (s.length !== 0) throw new Error("Destroyed world should return empty");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_aabb_body() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 100.0);
// Create an AABB body (shape_type=1)
const id = Deno.core.ops.op_create_body(1, 1, 2.0, 3.0, 0.0, 0.0, 1.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_physics_step(1.0 / 60.0);
const s = Deno.core.ops.op_get_body_state(id);
if (s.length !== 7) throw new Error("Should return state");
if (s[1] <= 0) throw new Error("AABB body should fall with gravity");
"#,
)
.unwrap();
}
#[test]
fn test_physics_ops_static_body() {
let mut rt = arcane_core::scripting::ArcaneRuntime::new();
rt.execute_script(
"<test>",
r#"
Deno.core.ops.op_create_physics_world(0.0, 100.0);
// Create a static body (body_type=0)
const id = Deno.core.ops.op_create_body(0, 0, 5.0, 0.0, 10.0, 20.0, 0.0, 0.3, 0.5, 65535, 65535);
Deno.core.ops.op_physics_step(1.0 / 60.0);
const s = Deno.core.ops.op_get_body_state(id);
if (Math.abs(s[0] - 10.0) > 0.01) throw new Error("Static body should not move");
if (Math.abs(s[1] - 20.0) > 0.01) throw new Error("Static body should not fall");
"#,
)
.unwrap();
}
#[test]
fn test_aabb_settles_on_ground() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 0.2, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 15.0, half_h: 15.0 },
250.0, 500.0, 2.0,
Material { restitution: 0.3, friction: 0.6 },
0xFFFF, 0xFFFF,
);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let body = world.get_body(box_id).unwrap();
assert!(body.sleeping, "Box should be asleep after 5 seconds, vy={:.4} vx={:.4} timer={:.3}",
body.vy, body.vx, body.sleep_timer);
}
#[test]
fn test_circle_settles_on_ground() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 0.2, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 10.0 },
300.0, 500.0, 0.5,
Material { restitution: 0.3, friction: 0.3 },
0xFFFF, 0xFFFF,
);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let body = world.get_body(ball_id).unwrap();
assert!(body.sleeping, "Ball should be asleep after 5 seconds");
assert!(body.vy.abs() < 0.5, "Ball should have near-zero vy, got {}", body.vy);
}
#[test]
fn test_stacked_boxes_no_lateral_drift() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 0.2, friction: 0.8 },
0xFFFF, 0xFFFF,
);
world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 20.0, half_h: 20.0 },
400.0, 520.0, 4.0,
Material { restitution: 0.2, friction: 0.6 },
0xFFFF, 0xFFFF,
);
let top_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 15.0, half_h: 15.0 },
405.0, 490.0, 2.0,
Material { restitution: 0.2, friction: 0.6 },
0xFFFF, 0xFFFF,
);
let initial_x = 405.0;
for _ in 0..480 {
world.step(1.0 / 60.0);
}
let body = world.get_body(top_id).unwrap();
let drift = (body.x - initial_x).abs();
assert!(
drift < 5.0,
"Top box should not drift far laterally, drifted {} pixels",
drift,
);
assert!(body.sleeping, "Stacked boxes should eventually sleep");
}
#[test]
fn test_aabb_no_angular_velocity_from_contacts() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0, Material::default(),
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 15.0, half_h: 15.0 },
350.0, 200.0, 2.0, Material::default(),
0xFFFF, 0xFFFF,
);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let body = world.get_body(box_id).unwrap();
assert_eq!(
body.angular_velocity, 0.0,
"AABB should have zero angular velocity (inv_inertia=0)"
);
assert_eq!(body.angle, 0.0, "AABB angle should remain 0");
}
#[test]
fn test_aabb_contact_point_on_collision_surface() {
let a = make_body(
0, BodyType::Dynamic,
Shape::AABB { half_w: 10.0, half_h: 10.0 },
100.0, 82.0, 1.0,
);
let b = make_body(
1, BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 20.0 },
200.0, 110.0, 0.0,
);
let contact = test_collision(&a, &b).unwrap();
assert!(
(contact.contact_point.1 - 92.0).abs() < 1e-4,
"Contact y should be at A's bottom edge (92), got {}",
contact.contact_point.1,
);
assert!(
(contact.contact_point.0 - 100.0).abs() < 1e-4,
"Contact x should be centered in overlap (100), got {}",
contact.contact_point.0,
);
}
#[test]
fn test_restitution_killed_for_slow_contacts() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 1.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
400.0, 554.9, 1.0,
Material { restitution: 1.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let body = world.get_body(ball_id).unwrap();
assert!(
body.sleeping,
"Ball with restitution=1.0 should settle when approach speed < threshold"
);
}
#[test]
fn test_tall_stack_does_not_fuse() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let half_h = 15.0;
let box_size = half_h * 2.0;
let ground_top = 560.0;
let mut box_ids = Vec::new();
for i in 0..7 {
let y = ground_top - half_h - (i as f32 * box_size);
let id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 15.0, half_h },
400.0, y, 2.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
for _ in 0..180 {
world.step(1.0 / 60.0);
}
for i in 0..6 {
let a = world.get_body(box_ids[i]).unwrap();
let b = world.get_body(box_ids[i + 1]).unwrap();
let gap = a.y - b.y; assert!(
gap > box_size * 0.8,
"Box {} and {} should be ~{} apart, got {:.1} (fusing!)",
i, i + 1, box_size, gap,
);
}
}
#[test]
fn test_tall_tower_lateral_stability() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 400.0, half_h: 20.0 },
400.0, 580.0, 0.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let half_w = 15.0;
let half_h = 15.0;
let box_size = half_h * 2.0;
let ground_top = 560.0;
let center_x = 400.0;
let mut box_ids = Vec::new();
for i in 0..10 {
let y = ground_top - half_h - (i as f32 * box_size);
let id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w, half_h },
center_x, y, 2.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
let expected_height = 9.0 * box_size;
let mut max_drift = 0.0f32;
let mut min_height = f32::MAX;
for frame in 0..600 {
world.step(1.0 / 60.0);
for &id in &box_ids {
let body = world.get_body(id).unwrap();
let drift = (body.x - center_x).abs();
if drift > max_drift {
max_drift = drift;
}
}
let top = world.get_body(box_ids[9]).unwrap();
let bot = world.get_body(box_ids[0]).unwrap();
let height = bot.y - top.y;
if height < min_height {
min_height = height;
}
if frame % 60 == 59 {
eprintln!(
"t={:.0}s: height={:.1}/{:.0} ({:.1}%) max_drift={:.2}px",
(frame + 1) as f32 / 60.0, height, expected_height,
height / expected_height * 100.0, max_drift,
);
}
}
assert!(
max_drift < 2.0,
"Max lateral drift was {:.2}px (should be <2px)",
max_drift,
);
assert!(
min_height > expected_height * 0.95,
"Stack compressed to {:.1}/{:.0} ({:.1}%) — should retain >95% height",
min_height, expected_height, min_height / expected_height * 100.0,
);
}
#[test]
fn test_integration_f_equals_ma() {
let mut body = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 2.0);
body.fx = 100.0;
integrate(&mut body, 0.0, 0.0, 1.0 / 60.0);
let expected_v = 100.0 * body.inv_mass * (1.0 / 60.0);
assert!((body.vx - expected_v).abs() < 1e-6, "vx={} expected {}", body.vx, expected_v);
let expected_x = expected_v * (1.0 / 60.0);
assert!((body.x - expected_x).abs() < 1e-6, "x={} expected {}", body.x, expected_x);
assert_eq!(body.fx, 0.0, "Force should be cleared after integration");
assert_eq!(body.fy, 0.0);
}
#[test]
fn test_integration_gravity_is_acceleration_not_force() {
let dt = 1.0 / 60.0;
let gravity_y = 400.0;
let mut light = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 0.5);
let mut heavy = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 10.0);
integrate(&mut light, 0.0, gravity_y, dt);
integrate(&mut heavy, 0.0, gravity_y, dt);
assert!(
(light.vy - heavy.vy).abs() < 1e-6,
"All bodies should fall at same rate: light vy={}, heavy vy={}",
light.vy, heavy.vy
);
assert!(
(light.y - heavy.y).abs() < 1e-6,
"All bodies should fall same distance: light y={}, heavy y={}",
light.y, heavy.y
);
}
#[test]
fn test_contact_normal_points_a_to_b() {
let a = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 15.0, 0.0, 1.0);
let c = test_collision(&a, &b).unwrap();
assert!(c.normal.0 > 0.0, "Circle-circle: normal x should point from A to B (right), got {:?}", c.normal);
assert_eq!(c.body_a, 0);
assert_eq!(c.body_b, 1);
let a = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 0.0, 18.0, 1.0);
let c = test_collision(&a, &b).unwrap();
assert!(c.normal.1 > 0.0, "AABB-AABB: normal y should point from A to B (down), got {:?}", c.normal);
let circ = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 0.0, 0.0, 1.0);
let aabb = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 15.0, 0.0, 1.0);
let c = test_collision(&circ, &aabb).unwrap();
assert!(c.normal.0 > 0.0, "Circle-AABB: normal x should point from circle to AABB (right), got {:?}", c.normal);
}
#[test]
fn test_impulse_conserves_momentum() {
let mut world = PhysicsWorld::new(0.0, 0.0); let a_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
0.0, 0.0, 1.0,
Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
let b_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
15.0, 0.0, 1.0, Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
world.set_velocity(a_id, 100.0, 0.0);
let mass_a = world.get_body(a_id).unwrap().mass;
let mass_b = world.get_body(b_id).unwrap().mass;
let p_before = mass_a * 100.0 + mass_b * 0.0;
world.step(1.0 / 60.0);
let va = world.get_body(a_id).unwrap().vx;
let vb = world.get_body(b_id).unwrap().vx;
let p_after = mass_a * va + mass_b * vb;
assert!(
(p_before - p_after).abs() < 1.0,
"Momentum should be conserved: before={} after={} (va={}, vb={})",
p_before, p_after, va, vb
);
}
#[test]
fn test_elastic_collision_velocity_exchange() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
0.0, 0.0, 2.0,
Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
let b_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
18.0, 0.0, 2.0, Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
world.set_velocity(a_id, 50.0, 0.0);
for _ in 0..5 {
world.step(1.0 / 60.0);
}
let va = world.get_body(a_id).unwrap().vx;
let vb = world.get_body(b_id).unwrap().vx;
assert!(va.abs() < 5.0, "After elastic collision, A should be ~stopped, got vx={}", va);
assert!((vb - 50.0).abs() < 10.0, "After elastic collision, B should have A's velocity ~50, got vx={}", vb);
}
#[test]
fn test_static_body_absorbs_all_momentum() {
let mut world = PhysicsWorld::new(0.0, 0.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 100.0, half_h: 10.0 },
0.0, 20.0, 0.0,
Material { restitution: 0.5, friction: 0.0 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 5.0 },
0.0, 8.0, 1.0, Material { restitution: 0.5, friction: 0.0 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball_id, 0.0, 100.0); world.step(1.0 / 60.0);
let ball = world.get_body(ball_id).unwrap();
assert!(ball.vy < 0.0, "Ball should bounce upward after hitting static body, vy={}", ball.vy);
let static_body = world.get_body(0).unwrap();
assert_eq!(static_body.x, 0.0);
assert_eq!(static_body.y, 20.0);
}
#[test]
fn test_friction_slows_sliding_body() {
let mut world = PhysicsWorld::new(0.0, 400.0); world.add_body(
BodyType::Static, Shape::AABB { half_w: 400.0, half_h: 20.0 },
0.0, 30.0, 0.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w: 5.0, half_h: 5.0 },
0.0, 4.0, 1.0, Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
world.set_velocity(box_id, 200.0, 0.0); let v0 = 200.0f32;
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let body = world.get_body(box_id).unwrap();
assert!(
body.vx.abs() < v0 * 0.5,
"Friction should slow the body significantly, vx={} (started at {})",
body.vx, v0,
);
}
#[test]
fn test_box_on_slope_sticks_below_friction_angle() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 400.0, half_h: 20.0 },
0.0, 100.0, 0.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 },
0.0, 69.0, 1.0, Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
world.apply_force(box_id, 50.0, 0.0);
let initial_x = world.get_body(box_id).unwrap().x;
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let body = world.get_body(box_id).unwrap();
assert!(
(body.x - initial_x).abs() < 20.0,
"Box should resist sliding due to friction anchor, moved {} from start",
(body.x - initial_x).abs()
);
}
#[test]
fn test_friction_anchor_resets_on_slide() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 400.0, half_h: 20.0 },
0.0, 100.0, 0.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 },
0.0, 69.0, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
world.set_velocity(box_id, 500.0, 0.0);
let initial_x = world.get_body(box_id).unwrap().x;
for _ in 0..30 {
world.step(1.0 / 60.0);
}
let body = world.get_body(box_id).unwrap();
assert!(
(body.x - initial_x).abs() > 50.0,
"Box should have slid when friction limit exceeded, only moved {}",
(body.x - initial_x).abs()
);
assert!(
body.vx.abs() < 500.0,
"Friction should still slow the sliding body, vx={}",
body.vx
);
}
#[test]
fn test_zero_restitution_no_bounce() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 400.0, half_h: 20.0 },
0.0, 100.0, 0.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 5.0 },
0.0, 50.0, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let body = world.get_body(ball_id).unwrap();
let surface_y = 100.0 - 20.0 - 5.0; assert!(
(body.y - surface_y).abs() < 2.0,
"Zero-restitution ball should rest on surface at y~{}, got y={}",
surface_y, body.y,
);
}
#[test]
fn test_collision_layer_filtering() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let a_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
0.0, 0.0, 1.0, Material::default(),
0x0001, 0x0002, );
let b_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 10.0 },
15.0, 0.0, 1.0, Material::default(),
0x0004, 0x0008, );
world.set_velocity(a_id, 50.0, 0.0);
world.set_velocity(b_id, -50.0, 0.0);
for _ in 0..30 {
world.step(1.0 / 60.0);
}
let a = world.get_body(a_id).unwrap();
let b = world.get_body(b_id).unwrap();
assert!(a.vx > 40.0, "Body A should pass through B (non-matching layers), vx={}", a.vx);
assert!(b.vx < -40.0, "Body B should pass through A, vx={}", b.vx);
}
#[test]
fn test_penetration_depth_correctness() {
let a = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 15.0, 0.0, 1.0);
let c = test_collision(&a, &b).unwrap();
assert!(
(c.penetration - 5.0).abs() < 1e-4,
"Circle penetration should be 5.0, got {}",
c.penetration,
);
let a = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 16.0, 0.0, 1.0);
let c = test_collision(&a, &b).unwrap();
assert!(
(c.penetration - 4.0).abs() < 1e-4,
"AABB penetration should be 4.0, got {}",
c.penetration,
);
}
#[test]
fn test_no_collision_when_separated() {
let a = make_body(0, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::Circle { radius: 10.0 }, 25.0, 0.0, 1.0);
assert!(test_collision(&a, &b).is_none(), "Separated circles should not collide");
let a = make_body(0, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 0.0, 0.0, 1.0);
let b = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 10.0, half_h: 10.0 }, 25.0, 0.0, 1.0);
assert!(test_collision(&a, &b).is_none(), "Separated AABBs should not collide");
}
#[test]
fn test_distance_constraint_spring_behavior() {
let mut world = PhysicsWorld::new(0.0, 0.0); let a_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 5.0 },
0.0, 0.0, 1.0, Material::default(),
0xFFFF, 0xFFFF,
);
let b_id = world.add_body(
BodyType::Dynamic, Shape::Circle { radius: 5.0 },
100.0, 0.0, 1.0, Material::default(),
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a: a_id,
body_b: b_id,
distance: 100.0,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
world.set_velocity(a_id, -50.0, 0.0);
world.set_velocity(b_id, 50.0, 0.0);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let a = world.get_body(a_id).unwrap();
let b = world.get_body(b_id).unwrap();
let dist = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
assert!(
(dist - 100.0).abs() < 5.0,
"Distance constraint should maintain ~100px distance, got {}",
dist,
);
}
#[test]
fn test_raycast_returns_closest_hit() {
let mut world = PhysicsWorld::new(0.0, 0.0);
world.add_body(
BodyType::Static, Shape::Circle { radius: 5.0 },
50.0, 0.0, 0.0, Material::default(),
0xFFFF, 0xFFFF,
);
world.add_body(
BodyType::Static, Shape::Circle { radius: 5.0 },
100.0, 0.0, 0.0, Material::default(),
0xFFFF, 0xFFFF,
);
let hit = world.raycast(0.0, 0.0, 1.0, 0.0, 200.0);
assert!(hit.is_some(), "Raycast should hit something");
let (id, _, _, t) = hit.unwrap();
assert_eq!(id, 0, "Should hit closer body (id=0), got id={}", id);
assert!((t - 45.0).abs() < 1.0, "Hit distance should be ~45 (50-radius), got {}", t);
}
#[test]
fn test_stacked_boxes_reach_sleep_within_2_seconds() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 200.0, half_h: 10.0 },
200.0, 310.0, 0.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let box_size = 15.0; let mut box_ids = Vec::new();
for i in 0..5 {
let y = 280.0 - (i as f32 * 32.0); let id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w: box_size, half_h: box_size },
200.0, y, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
for _ in 0..120 {
world.step(1.0 / 60.0);
}
let all_sleeping = box_ids.iter().all(|&id| {
world.get_body(id).unwrap().sleeping
});
assert!(all_sleeping,
"All stacked boxes should be sleeping after 2s. States: {:?}",
box_ids.iter().map(|&id| {
let b = world.get_body(id).unwrap();
(id, b.sleeping, b.vx, b.vy, b.sleep_timer)
}).collect::<Vec<_>>()
);
}
#[test]
fn test_12_stacked_boxes_reach_sleep_within_5_seconds() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static, Shape::AABB { half_w: 200.0, half_h: 10.0 },
200.0, 310.0, 0.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let box_size = 15.0;
let mut box_ids = Vec::new();
for i in 0..12 {
let y = 280.0 - (i as f32 * 32.0);
let id = world.add_body(
BodyType::Dynamic, Shape::AABB { half_w: box_size, half_h: box_size },
200.0, y, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
let mut all_asleep_frame = None;
for frame in 0..600 { world.step(1.0 / 60.0);
let all_sleeping = box_ids.iter().all(|&id| world.get_body(id).unwrap().sleeping);
if all_sleeping {
all_asleep_frame = Some(frame);
break;
}
}
let states: Vec<_> = box_ids.iter().map(|&id| {
let b = world.get_body(id).unwrap();
(id, b.sleeping, b.vx, b.vy, b.sleep_timer)
}).collect();
assert!(all_asleep_frame.is_some(),
"12 stacked boxes should all sleep within 10s. States: {:?}", states);
let frame = all_asleep_frame.unwrap();
let seconds = frame as f32 / 60.0;
eprintln!("12-box stack settled at frame {} ({:.1}s)", frame, seconds);
assert!(frame < 300,
"12-box stack should sleep within 5s, took {:.1}s (frame {})", seconds, frame);
}
#[test]
fn test_single_box_no_ground_clipping() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let box_size = 20.0;
let half = box_size / 2.0;
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 500.0, half_h: 20.0 },
250.0, 220.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let box_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
250.0, 50.0, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
let ground_top = 200.0; let max_allowed_penetration = 0.5;
for frame in 0..300 {
world.step(1.0 / 60.0);
let body = world.get_body(box_id).unwrap();
let box_bottom = body.y + half;
assert!(
box_bottom <= ground_top + max_allowed_penetration,
"Frame {}: box bottom ({:.3}) penetrates ground top ({:.3}) by {:.3} units (max allowed: {:.3})",
frame, box_bottom, ground_top, box_bottom - ground_top, max_allowed_penetration,
);
}
}
#[test]
fn test_stacked_boxes_no_ground_clipping() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let box_size = 20.0;
let half = box_size / 2.0;
let ground_center_y = 520.0;
let ground_half_h = 20.0;
let ground_top = ground_center_y - ground_half_h;
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 500.0, half_h: ground_half_h },
250.0, ground_center_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let mut box_ids = Vec::new();
for i in 0..20 {
let y = ground_top - half - (i as f32 * box_size) - 1.0;
let id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
250.0, y, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
let max_allowed_penetration = 1.0;
for frame in 0..300 {
world.step(1.0 / 60.0);
let bottom_box = world.get_body(box_ids[0]).unwrap();
let box_bottom = bottom_box.y + half;
assert!(
box_bottom <= ground_top + max_allowed_penetration,
"Frame {}: bottom box clips ground by {:.3} units (bottom={:.3}, ground_top={:.3}, max={:.3})",
frame, box_bottom - ground_top, box_bottom, ground_top, max_allowed_penetration,
);
}
}
#[test]
fn test_cluster_drop_no_ground_clipping() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let box_size = 15.0;
let half = box_size / 2.0;
let ground_center_y = 400.0;
let ground_half_h = 20.0;
let ground_top = ground_center_y - ground_half_h;
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 300.0, half_h: ground_half_h },
200.0, ground_center_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 20.0, half_h: 200.0 },
-20.0, 280.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 20.0, half_h: 200.0 },
420.0, 280.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let mut box_ids = Vec::new();
for row in 0..10 {
for col in 0..10 {
let x = 80.0 + col as f32 * (box_size + 2.0);
let y = 100.0 - row as f32 * (box_size + 2.0);
let id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
x, y, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
box_ids.push(id);
}
}
let max_transient_penetration = 1.0;
let mut worst_transient = 0.0f32;
let mut worst_frame = 0;
for frame in 0..180 {
world.step(1.0 / 60.0);
for &id in &box_ids {
if let Some(body) = world.get_body(id) {
let penetration = (body.y + half) - ground_top;
if penetration > worst_transient {
worst_transient = penetration;
worst_frame = frame;
}
}
}
}
eprintln!(
"100-body cluster: worst transient penetration = {:.3} at frame {} ({:.1}s)",
worst_transient, worst_frame, worst_frame as f32 / 60.0,
);
assert!(
worst_transient <= max_transient_penetration,
"Cluster impact: worst transient penetration {:.3} exceeds {:.3} at frame {}",
worst_transient, max_transient_penetration, worst_frame,
);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let max_steady_penetration = 0.1;
let mut worst_steady = 0.0f32;
let mut worst_body = 0u32;
for &id in &box_ids {
if let Some(body) = world.get_body(id) {
let penetration = (body.y + half) - ground_top;
if penetration > worst_steady {
worst_steady = penetration;
worst_body = id;
}
}
}
eprintln!(
"100-body cluster steady state: worst penetration = {:.3} (body {})",
worst_steady, worst_body,
);
assert!(
worst_steady <= max_steady_penetration,
"Cluster steady-state: body {} penetrates ground by {:.3} (max allowed: {:.3}). \
Bodies at rest must not visibly clip through surfaces.",
worst_body, worst_steady, max_steady_penetration,
);
}
#[test]
fn test_overlapping_boxes_midair_separate() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let half = 10.0;
let a = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
100.0, 100.0, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
let b = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
100.0, 110.0, 1.0, Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
let start_y_a = world.get_body(a).unwrap().y;
let start_y_b = world.get_body(b).unwrap().y;
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let end_a = world.get_body(a).unwrap();
let end_b = world.get_body(b).unwrap();
let fall_a = end_a.y - start_y_a;
let fall_b = end_b.y - start_y_b;
eprintln!(
"Overlapping midair: A fell {:.1}, B fell {:.1}, separation = {:.1}",
fall_a, fall_b, (end_b.y - end_a.y).abs(),
);
assert!(
fall_a > 50.0,
"Body A stuck midair: only fell {:.1} units in 1 second (expected >50 with gravity=300)",
fall_a,
);
assert!(
fall_b > 50.0,
"Body B stuck midair: only fell {:.1} units in 1 second (expected >50 with gravity=300)",
fall_b,
);
let gap = (end_b.y - end_a.y).abs();
assert!(
gap >= half * 2.0 - 1.0, "Bodies still overlapping after 1 second: gap={:.1}, expected >= {:.1}",
gap, half * 2.0,
);
}
#[test]
fn test_overlapping_circle_column_falls() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let radius = 8.0;
let mut ids = Vec::new();
for i in 0..5 {
let id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius },
100.0, 50.0 + i as f32 * radius, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
);
ids.push(id);
}
let start_y: Vec<f32> = ids.iter().map(|&id| world.get_body(id).unwrap().y).collect();
for _ in 0..120 {
world.step(1.0 / 60.0);
}
for (i, &id) in ids.iter().enumerate() {
let body = world.get_body(id).unwrap();
let fall = body.y - start_y[i];
eprintln!("Circle {}: fell {:.1} units", i, fall);
assert!(
fall > 100.0,
"Circle {} stuck midair: only fell {:.1} units in 2 seconds (gravity=300)",
i, fall,
);
}
}
#[test]
fn test_bodies_at_same_position_fully_separate() {
let mut world = PhysicsWorld::new(0.0, 400.0);
let half = 15.0;
let mut ids = Vec::new();
for _ in 0..4 {
let id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
200.0, 100.0, 1.0,
Material { restitution: 0.3, friction: 0.6 },
0xFFFF, 0xFFFF,
);
ids.push(id);
}
for _ in 0..180 {
world.step(1.0 / 60.0);
}
for i in 0..ids.len() {
for j in (i + 1)..ids.len() {
let a = world.get_body(ids[i]).unwrap();
let b = world.get_body(ids[j]).unwrap();
let dx = (b.x - a.x).abs();
let dy = (b.y - a.y).abs();
let overlap_x = (2.0 * half - dx).max(0.0);
let overlap_y = (2.0 * half - dy).max(0.0);
let overlapping = overlap_x > 0.5 && overlap_y > 0.5;
eprintln!(
"Pair ({},{}): dx={:.1} dy={:.1} overlap_x={:.1} overlap_y={:.1} overlapping={}",
i, j, dx, dy, overlap_x, overlap_y, overlapping,
);
assert!(
!overlapping,
"Bodies {} and {} still overlapping after 3 seconds: dx={:.1} dy={:.1}",
i, j, dx, dy,
);
}
}
}
#[test]
fn test_successive_spawns_same_position_separate() {
let mut world = PhysicsWorld::new(0.0, 400.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 500.0, half_h: 20.0 },
250.0, 500.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let half = 20.0;
let mut ids = Vec::new();
for frame in 0..360 {
if frame % 10 == 0 && ids.len() < 6 {
let id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
200.0, 100.0, 1.0,
Material { restitution: 0.3, friction: 0.6 },
0xFFFF, 0xFFFF,
);
ids.push(id);
}
world.step(1.0 / 60.0);
}
let positions: Vec<(f32, f32)> = ids.iter()
.map(|&id| {
let b = world.get_body(id).unwrap();
(b.x, b.y)
})
.collect();
for i in 0..positions.len() {
for j in (i + 1)..positions.len() {
let dx = (positions[j].0 - positions[i].0).abs();
let dy = (positions[j].1 - positions[i].1).abs();
let overlap_x = (2.0 * half - dx).max(0.0);
let overlap_y = (2.0 * half - dy).max(0.0);
let overlapping = overlap_x > 1.0 && overlap_y > 1.0;
assert!(
!overlapping,
"Successively spawned bodies {} and {} still overlap after settling: \
pos_i=({:.1},{:.1}) pos_j=({:.1},{:.1}) overlap=({:.1},{:.1})",
i, j, positions[i].0, positions[i].1,
positions[j].0, positions[j].1,
overlap_x, overlap_y,
);
}
}
}
#[test]
fn test_mixed_shapes_overlapping_midair_dont_stick() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let mut ids = Vec::new();
let cx = 100.0;
let cy = 80.0;
ids.push(world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 12.0, half_h: 12.0 },
cx, cy, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
));
ids.push(world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 10.0 },
cx + 5.0, cy + 5.0, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
));
ids.push(world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 8.0, half_h: 8.0 },
cx - 3.0, cy + 10.0, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
));
ids.push(world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 6.0 },
cx + 2.0, cy - 5.0, 1.0,
Material { restitution: 0.0, friction: 0.3 },
0xFFFF, 0xFFFF,
));
let start_y: Vec<f32> = ids.iter().map(|&id| world.get_body(id).unwrap().y).collect();
for _ in 0..90 {
world.step(1.0 / 60.0);
}
for (i, &id) in ids.iter().enumerate() {
let body = world.get_body(id).unwrap();
let fall = body.y - start_y[i];
eprintln!("Mixed body {}: fell {:.1}", i, fall);
assert!(
fall > 50.0,
"Body {} stuck midair: fell only {:.1} in 1.5s",
i, fall,
);
}
}
#[test]
fn test_contacts_accumulated_across_substeps() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let wall_id = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 10.0 },
200.0, 0.0, 1.0,
Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 6.0 },
200.0, 20.0, 1.0,
Material { restitution: 1.0, friction: 0.0 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball_id, 0.0, -350.0);
world.step(1.0 / 60.0);
let contacts = world.get_contacts();
let has_wall_contact = contacts.iter().any(|c| {
(c.body_a == ball_id && c.body_b == wall_id) ||
(c.body_a == wall_id && c.body_b == ball_id)
});
let ball_state = world.get_body(ball_id).unwrap();
eprintln!(
"Ball after step: y={:.1} vy={:.1}, contacts={}, wall_contact={}",
ball_state.y, ball_state.vy, contacts.len(), has_wall_contact,
);
assert!(
has_wall_contact,
"Ball-wall contact missing from get_contacts(). Ball likely bounced during \
an earlier sub-step and the contact was lost. Found {} contacts: {:?}",
contacts.len(),
contacts.iter().map(|c| (c.body_a, c.body_b)).collect::<Vec<_>>(),
);
}
#[test]
fn test_box_on_platform_edge_no_clipping() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let box_size = 20.0;
let half = box_size / 2.0;
let plat_center_x = 100.0;
let plat_half_w = 50.0;
let plat_center_y = 220.0;
let plat_half_h = 10.0;
let plat_top = plat_center_y - plat_half_h; let plat_right = plat_center_x + plat_half_w;
world.add_body(
BodyType::Static,
Shape::AABB { half_w: plat_half_w, half_h: plat_half_h },
plat_center_x, plat_center_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let box_x = plat_right - 5.0; let box_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: half, half_h: half },
box_x, 50.0, 1.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let max_allowed_penetration = 1.0;
for frame in 0..300 {
world.step(1.0 / 60.0);
let body = world.get_body(box_id).unwrap();
let box_bottom = body.y + half;
if body.x + half > plat_center_x - plat_half_w && body.x - half < plat_right {
assert!(
box_bottom <= plat_top + max_allowed_penetration,
"Frame {}: edge box penetrates platform by {:.3} (bottom={:.3}, plat_top={:.3})",
frame, box_bottom - plat_top, box_bottom, plat_top,
);
}
}
}
#[test]
fn test_revolute_joint_allows_rotation() {
let mut world = PhysicsWorld::new(0.0, 200.0);
let pivot_id = world.add_body(
BodyType::Static,
Shape::Circle { radius: 2.0 },
100.0, 100.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let plank_id = world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 40.0, half_h: 3.0 },
100.0, 100.0, 2.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Revolute { soft: None, accumulated_impulse: (0.0, 0.0),
id: 0,
body_a: plank_id,
body_b: pivot_id,
anchor_a: (0.0, 0.0), anchor_b: (0.0, 0.0), });
if let Some(body) = world.get_body_mut(plank_id) {
body.angular_velocity = 2.0; }
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let plank = world.get_body(plank_id).unwrap();
assert!(
plank.angle.abs() > 0.5,
"Revolute joint should allow rotation. Plank angle: {} rad ({} deg)",
plank.angle, plank.angle.to_degrees()
);
let dist_from_pivot = ((plank.x - 100.0).powi(2) + (plank.y - 100.0).powi(2)).sqrt();
assert!(
dist_from_pivot < 5.0,
"Revolute joint should keep plank centered on pivot. Distance: {}",
dist_from_pivot
);
}
#[test]
fn test_distance_joint_rope_does_not_stretch() {
let mut world = PhysicsWorld::new(0.0, 200.0);
let anchor_id = world.add_body(
BodyType::Static,
Shape::Circle { radius: 3.0 },
100.0, 50.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let segment_dist = 20.0;
let mut prev_id = anchor_id;
let mut segment_ids = vec![];
for i in 0..5 {
let seg_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 3.0 },
100.0, 50.0 + (i + 1) as f32 * segment_dist,
0.5, Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a: prev_id,
body_b: seg_id,
distance: segment_dist,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
segment_ids.push(seg_id);
prev_id = seg_id;
}
for _ in 0..180 {
world.step(1.0 / 60.0);
}
let anchor = world.get_body(anchor_id).unwrap();
let mut prev_pos = (anchor.x, anchor.y);
for (i, &seg_id) in segment_ids.iter().enumerate() {
let seg = world.get_body(seg_id).unwrap();
let curr_pos = (seg.x, seg.y);
let dist = ((curr_pos.0 - prev_pos.0).powi(2) + (curr_pos.1 - prev_pos.1).powi(2)).sqrt();
let max_stretch = segment_dist * 1.10;
assert!(
dist <= max_stretch,
"Rope segment {} stretched too much: {} (max {})",
i, dist, max_stretch
);
prev_pos = curr_pos;
}
}
#[test]
fn test_rope_collision_does_not_launch_body() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let anchor_id = world.add_body(
BodyType::Static,
Shape::Circle { radius: 3.0 },
150.0, 50.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let segment_dist = 15.0;
let mut prev_id = anchor_id;
for i in 0..4 {
let seg_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 4.0 },
150.0, 50.0 + (i + 1) as f32 * segment_dist,
0.5,
Material { restitution: 0.3, friction: 0.5 },
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a: prev_id,
body_b: seg_id,
distance: segment_dist,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
prev_id = seg_id;
}
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 8.0 },
50.0, 90.0, 1.0,
Material { restitution: 0.5, friction: 0.3 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball_id, 100.0, 0.0);
let mut max_speed = 0.0f32;
for _ in 0..180 {
world.step(1.0 / 60.0);
let ball = world.get_body(ball_id).unwrap();
let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
max_speed = max_speed.max(speed);
}
assert!(
max_speed < 250.0,
"Ball gained too much speed after rope collision: {} (started at 100)",
max_speed
);
}
#[test]
fn test_ball_chain_collision_no_energy_gain() {
let mut world = PhysicsWorld::new(0.0, 0.0);
for i in 0..4 {
world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 4.0 },
150.0, 50.0 + (i + 1) as f32 * 15.0,
0.5,
Material { restitution: 0.3, friction: 0.5 },
0xFFFF, 0xFFFF,
);
}
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 8.0 },
50.0, 80.0, 1.0,
Material { restitution: 0.5, friction: 0.3 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball_id, 100.0, 0.0);
let mut max_speed = 0.0f32;
for _ in 0..180 {
world.step(1.0 / 60.0);
let ball = world.get_body(ball_id).unwrap();
let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
max_speed = max_speed.max(speed);
}
assert!(
max_speed < 150.0,
"Ball gained energy from simple collisions: {} (started at 100)",
max_speed
);
}
#[test]
fn test_seesaw_rotates_when_weight_lands() {
let mut world = PhysicsWorld::new(0.0, 300.0);
let pivot_id = world.add_body(
BodyType::Static,
Shape::Circle { radius: 3.0 },
200.0, 150.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let plank_id = world.add_body(
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![
(-60.0, -4.0), (60.0, -4.0), (60.0, 4.0), (-60.0, 4.0),
],
},
200.0, 150.0, 3.0,
Material { restitution: 0.0, friction: 0.8 },
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Revolute { soft: None, accumulated_impulse: (0.0, 0.0),
id: 0,
body_a: plank_id,
body_b: pivot_id,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
let ball_id = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 10.0 },
250.0, 50.0, 5.0, Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let initial_angle = world.get_body(plank_id).unwrap().angle;
for _ in 0..120 {
world.step(1.0 / 60.0);
}
let plank = world.get_body(plank_id).unwrap();
let _ball = world.get_body(ball_id).unwrap();
let angle_change = (plank.angle - initial_angle).abs();
assert!(
angle_change > 0.1, "Seesaw should rotate when weight lands on one side. Angle change: {} rad ({} deg)",
angle_change, angle_change.to_degrees()
);
let dist_from_pivot = ((plank.x - 200.0).powi(2) + (plank.y - 150.0).powi(2)).sqrt();
assert!(
dist_from_pivot < 10.0,
"Plank should stay centered on pivot. Distance: {}",
dist_from_pivot
);
}
#[test]
fn test_distance_joint_dampens_velocity() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let body_a = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
0.0, 0.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let body_b = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
50.0, 0.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Distance { soft: None, accumulated_impulse: 0.0,
id: 0,
body_a,
body_b,
distance: 50.0,
anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
world.set_velocity(body_a, -100.0, 0.0);
world.set_velocity(body_b, 100.0, 0.0);
world.step(1.0 / 60.0);
let a = world.get_body(body_a).unwrap();
let b = world.get_body(body_b).unwrap();
let rel_vx = b.vx - a.vx;
assert!(
rel_vx.abs() < 100.0,
"Distance constraint should dampen relative velocity. Got rel_vx: {} (bodies: {}, {})",
rel_vx, a.vx, b.vx
);
}
#[test]
fn test_polygon_inertia_is_nonzero() {
let vertices = vec![(-20.0, -6.0), (20.0, -6.0), (20.0, 6.0), (-20.0, 6.0)];
let shape = Shape::Polygon { vertices };
let (inv_mass, inertia, inv_inertia) =
compute_mass_and_inertia(&shape, 3.0, BodyType::Dynamic);
assert!(inv_mass > 0.0, "Polygon should have positive inverse mass");
assert!(inertia > 0.0, "Polygon should have positive inertia (unlike AABB)");
assert!(inv_inertia > 0.0, "Polygon should have positive inverse inertia");
}
#[test]
fn test_polygon_seesaw_rotates() {
let mut world = PhysicsWorld::new(0.0, 200.0);
let plank_vertices = vec![(-40.0, -4.0), (40.0, -4.0), (40.0, 4.0), (-40.0, 4.0)];
let plank = world.add_body(
BodyType::Dynamic,
Shape::Polygon { vertices: plank_vertices },
200.0, 200.0, 3.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let pivot = world.add_body(
BodyType::Static,
Shape::Circle { radius: 3.0 },
200.0, 210.0, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Revolute { soft: None, accumulated_impulse: (0.0, 0.0),
id: 0,
body_a: plank,
body_b: pivot,
anchor_a: (0.0, 10.0), anchor_b: (0.0, 0.0),
});
let _ball = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 8.0 },
235.0, 150.0, 2.0, Material::default(),
0xFFFF, 0xFFFF,
);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let plank_body = world.get_body(plank).unwrap();
let angle_deg = plank_body.angle.to_degrees();
assert!(
angle_deg.abs() > 5.0,
"Polygon plank should rotate significantly when weight lands on end. Angle: {:.2}°",
angle_deg
);
}
#[test]
fn test_polygon_stack_reaches_sleep() {
let mut world = PhysicsWorld::new(0.0, 200.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 10.0 },
200.0, 300.0, 1.0,
Material { restitution: 0.1, friction: 0.8 },
0xFFFF, 0xFFFF,
);
let box_vertices = vec![(-15.0, -15.0), (15.0, -15.0), (15.0, 15.0), (-15.0, 15.0)];
let mut bodies = vec![];
for i in 0..3 {
let y = 280.0 - 35.0 * (i as f32); let id = world.add_body(
BodyType::Dynamic,
Shape::Polygon { vertices: box_vertices.clone() },
200.0, y, 1.0,
Material { restitution: 0.1, friction: 0.8 },
0xFFFF, 0xFFFF,
);
bodies.push(id);
}
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let bottom_box = world.get_body(bodies[0]).unwrap();
assert!(
bottom_box.sleeping,
"Bottom polygon box should reach sleep after 5 seconds of settling"
);
}
#[test]
fn test_polygon_manifold_generates_two_contacts() {
use arcane_core::physics::narrowphase::test_collision_manifold;
let box_verts = vec![(-10.0, -10.0), (10.0, -10.0), (10.0, 10.0), (-10.0, 10.0)];
let bottom = make_body(0, BodyType::Static, Shape::Polygon { vertices: box_verts.clone() }, 0.0, 0.0, 0.0);
let top = make_body(1, BodyType::Dynamic, Shape::Polygon { vertices: box_verts }, 0.0, -18.0, 1.0);
let manifold = test_collision_manifold(&bottom, &top);
assert!(manifold.is_some(), "Should detect collision");
let m = manifold.unwrap();
assert_eq!(m.points.len(), 2, "Edge-on-face should generate 2 contact points");
assert!(m.normal.1 < 0.0, "Normal should point upward (negative Y)");
assert!((m.points[0].penetration - m.points[1].penetration).abs() < 0.1);
}
#[test]
fn test_aabb_manifold_generates_two_contacts() {
use arcane_core::physics::narrowphase::test_collision_manifold;
let bottom = make_body(0, BodyType::Static, Shape::AABB { half_w: 50.0, half_h: 10.0 }, 0.0, 0.0, 0.0);
let top = make_body(1, BodyType::Dynamic, Shape::AABB { half_w: 15.0, half_h: 15.0 }, 0.0, -22.0, 1.0);
let manifold = test_collision_manifold(&bottom, &top);
assert!(manifold.is_some(), "Should detect collision");
let m = manifold.unwrap();
assert_eq!(m.points.len(), 2, "AABB edge-on-face should generate 2 contact points");
}
#[test]
fn test_manifold_warm_start_uses_contact_id() {
use arcane_core::physics::narrowphase::test_collision_manifold;
let box_verts = vec![(-10.0, -10.0), (10.0, -10.0), (10.0, 10.0), (-10.0, 10.0)];
let a = make_body(0, BodyType::Static, Shape::Polygon { vertices: box_verts.clone() }, 0.0, 0.0, 0.0);
let b = make_body(1, BodyType::Dynamic, Shape::Polygon { vertices: box_verts }, 0.0, -18.0, 1.0);
let manifold = test_collision_manifold(&a, &b).unwrap();
if manifold.points.len() >= 2 {
assert_ne!(manifold.points[0].id, manifold.points[1].id,
"Different contact points should have different ContactIDs");
}
}
#[test]
fn test_world_manifold_solver_enabled() {
let mut world = PhysicsWorld::new(0.0, 200.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 10.0 },
0.0, 300.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let mut ids = Vec::new();
for i in 0..5 {
let y = 280.0 - (i as f32 * 22.0);
ids.push(world.add_body(
BodyType::Dynamic,
Shape::AABB { half_w: 10.0, half_h: 10.0 },
0.0, y, 1.0,
Material { restitution: 0.1, friction: 0.5 },
0xFFFF, 0xFFFF,
));
}
for _ in 0..120 {
world.step(1.0 / 60.0);
}
let manifolds = world.get_manifolds();
assert!(!manifolds.is_empty(), "Manifold solver should generate manifolds");
let multi_point_count = manifolds.iter().filter(|m| m.points.len() >= 2).count();
assert!(multi_point_count > 0, "Some manifolds should have 2 contact points for edge-on-face");
}
#[test]
fn test_soft_distance_joint_oscillates() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let anchor = world.add_body(
BodyType::Static,
Shape::Circle { radius: 5.0 },
0.0, 0.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let mass = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: 5.0 },
100.0, 0.0, 1.0, Material::default(),
0xFFFF, 0xFFFF,
);
world.add_constraint(Constraint::Distance {
soft: Some(SoftConstraintParams::soft(2.0, 0.3)), accumulated_impulse: 0.0,
id: 0,
body_a: anchor,
body_b: mass,
distance: 50.0, anchor_a: (0.0, 0.0),
anchor_b: (0.0, 0.0),
});
let mut crossings = 0;
let mut prev_x = 100.0f32;
let rest_x = 50.0;
for _ in 0..60 {
world.step(1.0 / 60.0);
let body = world.get_body(mass).unwrap();
let curr_x = body.x;
if (prev_x - rest_x) * (curr_x - rest_x) < 0.0 {
crossings += 1;
}
prev_x = curr_x;
}
assert!(crossings >= 2, "Soft spring should oscillate, got {} crossings", crossings);
}
#[test]
fn test_rigid_vs_soft_constraint_behavior() {
let mut world_rigid = PhysicsWorld::new(0.0, 100.0);
let mut world_soft = PhysicsWorld::new(0.0, 100.0);
for world in [&mut world_rigid, &mut world_soft] {
world.add_body(BodyType::Static, Shape::Circle { radius: 5.0 }, 0.0, 0.0, 0.0, Material::default(), 0xFFFF, 0xFFFF);
world.add_body(BodyType::Dynamic, Shape::Circle { radius: 5.0 }, 0.0, 50.0, 1.0, Material::default(), 0xFFFF, 0xFFFF);
}
world_rigid.add_constraint(Constraint::Distance {
soft: None, accumulated_impulse: 0.0,
id: 0, body_a: 0, body_b: 1, distance: 50.0,
anchor_a: (0.0, 0.0), anchor_b: (0.0, 0.0),
});
world_soft.add_constraint(Constraint::Distance {
soft: Some(SoftConstraintParams::soft(5.0, 1.0)), accumulated_impulse: 0.0,
id: 0, body_a: 0, body_b: 1, distance: 50.0,
anchor_a: (0.0, 0.0), anchor_b: (0.0, 0.0),
});
for _ in 0..30 {
world_rigid.step(1.0 / 60.0);
world_soft.step(1.0 / 60.0);
}
let rigid_y = world_rigid.get_body(1).unwrap().y;
let soft_y = world_soft.get_body(1).unwrap().y;
let rigid_dist = rigid_y.abs();
let soft_dist = soft_y.abs();
assert!(rigid_dist > 45.0 && rigid_dist < 55.0, "Rigid should maintain distance: {}", rigid_dist);
assert!(soft_dist > 40.0 && soft_dist < 60.0, "Soft should be near target: {}", soft_dist);
}
#[test]
fn test_fast_ball_no_tunneling() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let wall_thickness = 2.0;
let _wall = world.add_body(
BodyType::Static,
Shape::AABB { half_w: wall_thickness / 2.0, half_h: 50.0 },
100.0, 0.0, 0.0,
Material { restitution: 0.0, friction: 0.5 },
0xFFFF, 0xFFFF,
);
let ball_radius = 5.0;
let ball = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: ball_radius },
50.0, 0.0, 1.0,
Material { restitution: 0.5, friction: 0.5 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball, 600.0, 0.0);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let ball_state = world.get_body(ball).unwrap();
let wall_surface = 100.0 - wall_thickness / 2.0;
let max_allowed_x = wall_surface - ball_radius + 0.5;
assert!(
ball_state.x <= max_allowed_x,
"Ball tunneled through wall! Ball x = {:.2}, wall surface at {:.2}. Ball should have stopped.",
ball_state.x, wall_surface,
);
assert!(
ball_state.vx <= 0.0,
"Ball should have bounced or stopped, but vx = {:.2}",
ball_state.vx,
);
}
#[test]
fn test_speculative_contact_allows_collision() {
let mut world = PhysicsWorld::new(0.0, 0.0);
let _ground = world.add_body(
BodyType::Static,
Shape::AABB { half_w: 100.0, half_h: 10.0 },
0.0, 100.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let ball_radius = 5.0;
let ball = world.add_body(
BodyType::Dynamic,
Shape::Circle { radius: ball_radius },
0.0, 80.0, 1.0,
Material { restitution: 0.3, friction: 0.5 },
0xFFFF, 0xFFFF,
);
world.set_velocity(ball, 0.0, 100.0);
for _ in 0..10 {
world.step(1.0 / 60.0);
}
let state = world.get_body(ball).unwrap();
let ground_top = 100.0 - 10.0; let expected_rest_y = ground_top - ball_radius;
assert!(
(state.y - expected_rest_y).abs() < 5.0,
"Ball should have collided with ground and settled. y = {:.2}, expected near {:.2}",
state.y, expected_rest_y,
);
assert!(
state.vy.abs() < 50.0,
"Ball should have slowed after hitting ground, vy = {:.2}",
state.vy,
);
}
#[test]
fn test_box_on_ground_has_two_contacts() {
let mut world = PhysicsWorld::new(0.0, 500.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 20.0 },
0.0, 100.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let box_half = 20.0;
let box_y = 100.0 - 20.0 - box_half; world.add_body(
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![
(-box_half, -box_half),
(box_half, -box_half),
(box_half, box_half),
(-box_half, box_half),
],
},
0.0, box_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let manifolds = world.get_manifolds();
assert!(!manifolds.is_empty(), "Should have at least one manifold");
let total_points: usize = manifolds.iter().map(|m| m.points.len()).sum();
assert!(
total_points >= 2,
"Box resting on ground should have 2 contact points, got {}",
total_points,
);
}
#[test]
fn test_stacked_boxes_have_two_contacts_each() {
let mut world = PhysicsWorld::new(0.0, 500.0);
world.add_body(
BodyType::Static,
Shape::AABB { half_w: 200.0, half_h: 20.0 },
0.0, 100.0, 0.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let box_half = 20.0;
let bottom_y = 100.0 - 20.0 - box_half;
let _bottom = world.add_body(
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![
(-box_half, -box_half),
(box_half, -box_half),
(box_half, box_half),
(-box_half, box_half),
],
},
0.0, bottom_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
let top_y = bottom_y - box_half * 2.0;
let _top = world.add_body(
BodyType::Dynamic,
Shape::Polygon {
vertices: vec![
(-box_half, -box_half),
(box_half, -box_half),
(box_half, box_half),
(-box_half, box_half),
],
},
0.0, top_y, 1.0,
Material::default(),
0xFFFF, 0xFFFF,
);
for _ in 0..30 {
world.step(1.0 / 60.0);
}
let manifolds = world.get_manifolds();
let mut ground_contacts = 0;
let mut stack_contacts = 0;
for m in manifolds {
let points = m.points.len();
if m.body_a == 0 || m.body_b == 0 {
ground_contacts += points;
} else {
stack_contacts += points;
}
}
assert!(
ground_contacts >= 2,
"Bottom box should have 2 contacts with ground, got {}",
ground_contacts,
);
assert!(
stack_contacts >= 2,
"Stacked boxes should have 2 contacts between them, got {}",
stack_contacts,
);
}