use alice_physics::collider::AABB;
use alice_physics::joint::BallJoint;
use alice_physics::{
bvh::BvhPrimitive, Cloth, CollisionFilter, ContactEventType, DistanceConstraint, Fix128,
ForceField, ForceFieldInstance, Joint, LinearBvh, PhysicsConfig, PhysicsWorld, QuatFix,
RigidBody, Rope, SleepConfig, Vec3Fix, Vehicle, VehicleConfig,
};
fn run_world(world: &mut PhysicsWorld, steps: usize, dt: Fix128) {
for _ in 0..steps {
world.step(dt);
}
}
#[test]
fn test_free_fall_determinism() {
fn simulate() -> Vec3Fix {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 100, 0), Fix128::ONE);
world.add_body(body);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 60, dt);
world.bodies[0].position
}
let pos1 = simulate();
let pos2 = simulate();
assert_eq!(pos1.x.hi, pos2.x.hi, "x.hi diverged");
assert_eq!(pos1.x.lo, pos2.x.lo, "x.lo diverged");
assert_eq!(pos1.y.hi, pos2.y.hi, "y.hi diverged");
assert_eq!(pos1.y.lo, pos2.y.lo, "y.lo diverged");
assert_eq!(pos1.z.hi, pos2.z.hi, "z.hi diverged");
assert_eq!(pos1.z.lo, pos2.z.lo, "z.lo diverged");
assert!(
pos1.y < Fix128::from_int(100),
"Body did not fall: y = {:?}",
pos1.y
);
}
#[test]
fn test_multi_step_bit_exact() {
fn make_world() -> PhysicsWorld {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(3, 50, -7), Fix128::from_ratio(3, 2));
world.add_body(body);
world
}
let dt = Fix128::from_ratio(1, 60);
let mut world_a = make_world();
run_world(&mut world_a, 120, dt);
let pos_a = world_a.bodies[0].position;
let mut world_b = make_world();
run_world(&mut world_b, 120, dt);
let pos_b = world_b.bodies[0].position;
assert_eq!(pos_a.x.hi, pos_b.x.hi);
assert_eq!(pos_a.x.lo, pos_b.x.lo);
assert_eq!(pos_a.y.hi, pos_b.y.hi);
assert_eq!(pos_a.y.lo, pos_b.y.lo);
assert_eq!(pos_a.z.hi, pos_b.z.hi);
assert_eq!(pos_a.z.lo, pos_b.z.lo);
}
#[test]
fn test_state_serialization_rollback() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(10, 20, 30), Fix128::ONE);
world.add_body(body);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 30, dt);
let snapshot = world.serialize_state();
run_world(&mut world, 30, dt);
let pos_branch_a = world.bodies[0].position;
world.deserialize_state(&snapshot);
run_world(&mut world, 30, dt);
let pos_branch_b = world.bodies[0].position;
assert_eq!(
pos_branch_a.x.hi, pos_branch_b.x.hi,
"rollback diverged at x.hi"
);
assert_eq!(
pos_branch_a.y.hi, pos_branch_b.y.hi,
"rollback diverged at y.hi"
);
assert_eq!(
pos_branch_a.z.hi, pos_branch_b.z.hi,
"rollback diverged at z.hi"
);
}
#[test]
fn test_constraint_stability() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let target_dist = Fix128::from_int(5);
let id_a = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 10, 0),
Fix128::ONE,
));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 10, 0),
Fix128::ONE,
));
let constraint = DistanceConstraint {
body_a: id_a,
body_b: id_b,
local_anchor_a: Vec3Fix::ZERO,
local_anchor_b: Vec3Fix::ZERO,
target_distance: target_dist,
compliance: Fix128::ZERO,
cached_lambda: Fix128::ZERO,
};
world.add_distance_constraint(constraint);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 120, dt);
let pa = world.bodies[id_a].position;
let pb = world.bodies[id_b].position;
let actual_dist = (pb - pa).length();
let error = if actual_dist > target_dist {
actual_dist - target_dist
} else {
target_dist - actual_dist
};
let tolerance = Fix128::from_ratio(5, 10); assert!(
error < tolerance,
"Constraint violated: dist error = hi={} lo={}",
error.hi,
error.lo
);
}
#[test]
fn test_bvh_query_correctness() {
let prims: Vec<BvhPrimitive> = (0u32..5)
.map(|i| {
let x = Fix128::from_int(i64::from(i) * 3); BvhPrimitive {
aabb: AABB::new(
Vec3Fix::new(x, Fix128::ZERO, Fix128::ZERO),
Vec3Fix::new(x + Fix128::ONE, Fix128::ONE, Fix128::ONE),
),
index: i,
morton: 0,
}
})
.collect();
let bvh = LinearBvh::build(prims);
let q = AABB::new(Vec3Fix::from_int(0, 0, 0), Vec3Fix::from_int(1, 1, 1));
let hits = bvh.query(&q);
assert!(
hits.contains(&0),
"Expected primitive 0 in hit set, got {hits:?}"
);
let gap = AABB::new(Vec3Fix::from_int(1, 0, 0), Vec3Fix::from_int(2, 1, 1));
let gap_hits = bvh.query(&gap);
assert!(
!gap_hits.contains(&2) && !gap_hits.contains(&3) && !gap_hits.contains(&4),
"Gap query should not return far primitives, got {gap_hits:?}"
);
let empty_bvh = LinearBvh::build(vec![]);
assert!(empty_bvh.query(&q).is_empty());
}
#[test]
fn test_cloth_grid_creation() {
let res_x = 4usize;
let res_y = 4usize;
let mass = Fix128::from_ratio(1, 2);
let cloth = Cloth::new_grid(
Vec3Fix::ZERO,
Fix128::from_int(3),
Fix128::from_int(3),
res_x,
res_y,
mass,
);
assert_eq!(cloth.positions.len(), res_x * res_y, "particle count");
assert_eq!(cloth.inv_masses.len(), res_x * res_y, "inv_mass count");
let expected_tris = (res_x - 1) * (res_y - 1) * 2;
assert_eq!(cloth.triangles.len(), expected_tris, "triangle count");
let expected_inv_mass = Fix128::from_int(2);
for &im in &cloth.inv_masses {
assert_eq!(im.hi, expected_inv_mass.hi, "inv_mass.hi mismatch");
}
assert!(cloth.pinned.is_empty(), "no pins expected");
}
#[test]
fn test_static_body_does_not_move() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let initial = Vec3Fix::from_int(7, -3, 11);
let ground = RigidBody::new_static(initial);
let id = world.add_body(ground);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 120, dt);
let final_pos = world.bodies[id].position;
assert_eq!(final_pos.x.hi, initial.x.hi, "static body x changed");
assert_eq!(final_pos.y.hi, initial.y.hi, "static body y changed");
assert_eq!(final_pos.z.hi, initial.z.hi, "static body z changed");
}
#[test]
fn test_vehicle_ground_contact() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let chassis_id = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 1, 0),
Fix128::from_int(1500),
));
let mut vehicle = Vehicle::new(VehicleConfig::default());
let dt = Fix128::from_ratio(1, 60);
for _ in 0..120 {
vehicle.update(&mut world.bodies[chassis_id], dt);
world.step(dt);
}
assert!(
vehicle.grounded_wheels() > 0,
"Expected grounded wheels after settling, got {}",
vehicle.grounded_wheels()
);
assert!(
world.bodies[chassis_id].position.y > Fix128::ZERO,
"Chassis should be above ground after suspension settling"
);
}
#[test]
fn test_rope_sag_under_gravity() {
let start = Vec3Fix::from_int(0, 10, 0);
let end = Vec3Fix::from_int(10, 10, 0);
let mut rope = Rope::new(start, end, 10, Fix128::from_ratio(1, 10));
rope.pin_start();
rope.pin_end();
let dt = Fix128::from_ratio(1, 60);
for _ in 0..120 {
rope.step(dt);
}
assert_eq!(
rope.positions[0].y.hi, 10,
"Start pin should remain at y=10"
);
assert_eq!(rope.positions[10].y.hi, 10, "End pin should remain at y=10");
let mid_y = rope.positions[5].y;
assert!(
mid_y < Fix128::from_int(10),
"Rope midpoint should sag below y=10, got y.hi={}",
mid_y.hi
);
}
#[test]
fn test_determinism_golden_hash() {
fn simulate_and_hash() -> u64 {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 50, 0),
Fix128::ONE,
));
world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 50, 0),
Fix128::from_ratio(3, 2),
));
world.add_body(RigidBody::new_static(Vec3Fix::ZERO));
let dt = Fix128::from_ratio(1, 60);
for _ in 0..180 {
world.step(dt);
}
let mut bytes = Vec::new();
for body in &world.bodies {
bytes.extend_from_slice(&body.position.x.hi.to_le_bytes());
bytes.extend_from_slice(&body.position.x.lo.to_le_bytes());
bytes.extend_from_slice(&body.position.y.hi.to_le_bytes());
bytes.extend_from_slice(&body.position.y.lo.to_le_bytes());
bytes.extend_from_slice(&body.position.z.hi.to_le_bytes());
bytes.extend_from_slice(&body.position.z.lo.to_le_bytes());
}
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in &bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash
}
let hash1 = simulate_and_hash();
let hash2 = simulate_and_hash();
assert_eq!(
hash1, hash2,
"Determinism broken: hash1={hash1:#018x} hash2={hash2:#018x}"
);
assert_ne!(
hash1, 0xcbf2_9ce4_8422_2325_u64,
"Hash should differ from FNV basis"
);
}
#[test]
fn test_auto_collision_detection() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO, ..Default::default()
};
let mut world = PhysicsWorld::new(config);
let mut body_a = RigidBody::new_dynamic(Vec3Fix::from_int(-1, 0, 0), Fix128::ONE);
body_a.velocity = Vec3Fix::from_int(10, 0, 0); let id_a = world.add_body_with_radius(body_a, Fix128::ONE);
let mut body_b = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
body_b.velocity = Vec3Fix::from_int(-10, 0, 0); let id_b = world.add_body_with_radius(body_b, Fix128::ONE);
let dt = Fix128::from_ratio(1, 60);
let mut had_contact = false;
for _ in 0..10 {
world.step(dt);
if !world.contact_events().is_empty() {
had_contact = true;
}
}
assert!(had_contact, "Expected contact events from auto collision");
let pa = world.bodies[id_a].position;
let pb = world.bodies[id_b].position;
let dist = (pb - pa).length();
assert!(
dist > Fix128::ZERO,
"Bodies should be separated after collision, dist.hi={}",
dist.hi
);
}
#[test]
fn test_collision_filtering() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let body_a = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
let id_a = world.add_body_with_radius(body_a, Fix128::from_int(2));
let body_b = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
let id_b = world.add_body_with_radius(body_b, Fix128::from_int(2));
world.set_body_filter(
id_a,
CollisionFilter {
layer: 1,
mask: 1,
group: 0,
},
);
world.set_body_filter(
id_b,
CollisionFilter {
layer: 2,
mask: 2,
group: 0,
},
);
let dt = Fix128::from_ratio(1, 60);
world.step(dt);
let events = world.contact_events();
assert!(
events.is_empty(),
"Expected no events when filters prevent collision, got {}",
events.len()
);
}
#[test]
fn test_joint_in_step() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let id_a = world.add_body(RigidBody::new_static(Vec3Fix::ZERO));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(3, 0, 0),
Fix128::ONE,
));
let joint = Joint::Ball(BallJoint::new(id_a, id_b, Vec3Fix::ZERO, Vec3Fix::ZERO));
world.add_joint(joint);
assert_eq!(world.joint_count(), 1);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 60, dt);
let pb = world.bodies[id_b].position;
let dist = pb.length();
assert!(
dist < Fix128::from_int(3),
"Joint should pull body closer, dist.hi={}",
dist.hi
);
}
#[test]
fn test_force_field_in_step() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
world.add_body(body);
let wind = ForceFieldInstance::new(ForceField::Directional {
direction: Vec3Fix::UNIT_X,
strength: Fix128::from_int(50),
});
world.add_force_field(wind);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 60, dt);
let pos = world.bodies[0].position;
assert!(
pos.x > Fix128::ZERO,
"Wind should push body in +X direction, got x.hi={}",
pos.x.hi
);
}
#[test]
fn test_sleeping_in_step() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
world.set_sleep_config(SleepConfig {
linear_threshold: Fix128::from_ratio(1, 100),
angular_threshold: Fix128::from_ratio(1, 100),
frames_to_sleep: 5,
});
let body = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
let id = world.add_body(body);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 10, dt);
assert!(
world.is_sleeping(id),
"Still body should be sleeping after threshold"
);
world.wake_body(id);
assert!(
!world.is_sleeping(id),
"Body should be awake after wake_body()"
);
}
#[test]
fn test_event_lifecycle() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let body_a = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
let _id_a = world.add_body_with_radius(body_a, Fix128::from_int(2));
let body_b = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
let _id_b = world.add_body_with_radius(body_b, Fix128::from_int(2));
let dt = Fix128::from_ratio(1, 60);
world.step(dt);
let events = world.contact_events();
assert!(events
.iter()
.any(|e| e.event_type == ContactEventType::Begin));
world.step(dt);
let events2 = world.contact_events();
assert!(
!events2.is_empty(),
"Should have Persist or End events in frame 2"
);
}
#[test]
fn test_remove_body() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let id_a = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 0, 0),
Fix128::ONE,
));
let id_c = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(10, 0, 0),
Fix128::ONE,
));
world.add_distance_constraint(DistanceConstraint {
body_a: id_b,
body_b: id_c,
local_anchor_a: Vec3Fix::ZERO,
local_anchor_b: Vec3Fix::ZERO,
target_distance: Fix128::from_int(5),
compliance: Fix128::ZERO,
cached_lambda: Fix128::ZERO,
});
assert_eq!(world.body_count(), 3);
let removed = world.remove_body(id_a);
assert!(removed.is_some());
assert_eq!(world.body_count(), 2);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 10, dt);
}
#[test]
fn test_sensor_trigger_events() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let mut sensor = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
sensor.is_sensor = true;
let id_sensor = world.add_body_with_radius(sensor, Fix128::from_int(2));
let moving = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
let _id_moving = world.add_body_with_radius(moving, Fix128::from_int(2));
let dt = Fix128::from_ratio(1, 60);
world.step(dt);
let triggers = world.trigger_events();
assert!(
!triggers.is_empty(),
"Expected trigger events for sensor overlap"
);
assert!(triggers[0].entered, "First trigger should be an enter");
let sensor_pos = world.bodies[id_sensor].position;
assert_eq!(
sensor_pos.x.hi, 0,
"Sensor should not move from physics response"
);
}
#[test]
fn test_world_raycast() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body_near = RigidBody::new_static(Vec3Fix::from_int(5, 0, 0));
let id_near = world.add_body_with_radius(body_near, Fix128::ONE);
let body_far = RigidBody::new_static(Vec3Fix::from_int(20, 0, 0));
let _id_far = world.add_body_with_radius(body_far, Fix128::ONE);
let result = world.raycast(Vec3Fix::ZERO, Vec3Fix::UNIT_X, Fix128::from_int(100));
assert!(result.is_some(), "Raycast should hit a body");
let (hit_body, hit_dist) = result.unwrap();
assert_eq!(hit_body, id_near, "Should hit nearest body");
assert!(
hit_dist > Fix128::from_int(3) && hit_dist < Fix128::from_int(6),
"Hit distance should be ~4 (center=5, radius=1), got hi={}",
hit_dist.hi
);
}
#[test]
fn test_add_body_with_radius() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let plain = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
world.add_body(plain);
let sphere = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
let id = world.add_body_with_radius(sphere, Fix128::ONE);
let hit = world.raycast(
Vec3Fix::from_int(-5, 0, 0),
Vec3Fix::UNIT_X,
Fix128::from_int(100),
);
assert!(hit.is_some());
assert_eq!(hit.unwrap().0, id);
}
#[test]
fn test_body_count_and_active() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
world.set_sleep_config(SleepConfig {
linear_threshold: Fix128::from_ratio(1, 100),
angular_threshold: Fix128::from_ratio(1, 100),
frames_to_sleep: 3,
});
for i in 0..3 {
let body = RigidBody::new_dynamic(Vec3Fix::from_int(i * 5, 0, 0), Fix128::ONE);
world.add_body(body);
}
assert_eq!(world.body_count(), 3);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 5, dt);
assert_eq!(world.body_count(), 3);
assert_eq!(
world.active_body_count(),
0,
"All still bodies should sleep"
);
}
#[test]
fn test_rigid_body_convenience_methods() {
let mut body = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::from_int(2));
assert_eq!(body.mass().hi, 2, "mass should be 2");
body.set_velocity(Vec3Fix::from_int(3, 4, 0));
assert_eq!(body.velocity.x.hi, 3);
assert_eq!(body.velocity.y.hi, 4);
let spd = body.speed();
assert!(
spd > Fix128::from_int(4),
"speed should be 5 (3-4-5 triangle)"
);
assert!(spd < Fix128::from_int(6));
body.set_velocity(Vec3Fix::ZERO);
body.add_force(Vec3Fix::from_int(4, 0, 0), Fix128::ONE);
assert_eq!(body.velocity.x.hi, 2, "add_force should change velocity");
}
#[test]
fn test_angular_velocity_serialization_roundtrip() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let mut body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 10, 0), Fix128::ONE);
body.angular_velocity = Vec3Fix::from_int(3, 7, 11);
world.add_body(body);
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 10, dt);
let av_before = world.bodies[0].angular_velocity;
let state = world.serialize_state();
world.bodies[0].angular_velocity = Vec3Fix::ZERO;
assert!(world.deserialize_state(&state));
let av_after = world.bodies[0].angular_velocity;
assert_eq!(av_before.x.hi, av_after.x.hi);
assert_eq!(av_before.x.lo, av_after.x.lo);
assert_eq!(av_before.y.hi, av_after.y.hi);
assert_eq!(av_before.y.lo, av_after.y.lo);
assert_eq!(av_before.z.hi, av_after.z.hi);
assert_eq!(av_before.z.lo, av_after.z.lo);
}
#[test]
fn test_all_subsystems_combined() {
let config = PhysicsConfig {
gravity: Vec3Fix::from_int(0, -10, 0),
substeps: 4,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
world.set_sleep_config(SleepConfig {
linear_threshold: Fix128::from_ratio(1, 100),
angular_threshold: Fix128::from_ratio(1, 100),
frames_to_sleep: 120,
});
let id_a = world.add_body_with_radius(
RigidBody::new_dynamic(Vec3Fix::from_int(-1, 3, 0), Fix128::ONE),
Fix128::from_int(2),
);
let id_b = world.add_body_with_radius(
RigidBody::new_dynamic(Vec3Fix::from_int(1, 3, 0), Fix128::ONE),
Fix128::from_int(2),
);
let _id_c = world.add_body_with_radius(
RigidBody::new_static(Vec3Fix::from_int(0, -2, 0)),
Fix128::from_int(3),
);
let ball = BallJoint::new(id_a, id_b, Vec3Fix::ZERO, Vec3Fix::ZERO);
world.add_joint(Joint::Ball(ball));
world.add_force_field(ForceFieldInstance::new(ForceField::Directional {
direction: Vec3Fix::UNIT_X,
strength: Fix128::from_int(5),
}));
let dt = Fix128::from_ratio(1, 64);
let mut had_contacts = false;
for _ in 0..240 {
world.step(dt);
if !world.contact_events().is_empty() {
had_contacts = true;
}
}
let pos_a = world.bodies[id_a].position;
assert!(pos_a.y < Fix128::from_int(3), "Gravity should pull A down");
assert!(pos_a.x > Fix128::from_int(-1), "Wind should push A right");
assert!(had_contacts, "Collisions should generate contact events");
}
#[test]
fn test_raycast_zero_direction() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body_with_radius(
RigidBody::new_static(Vec3Fix::from_int(5, 0, 0)),
Fix128::ONE,
);
let result = world.raycast(Vec3Fix::ZERO, Vec3Fix::ZERO, Fix128::from_int(100));
assert!(result.is_none(), "Zero direction should return None");
}
#[test]
#[should_panic(expected = "out of bounds")]
fn test_add_joint_out_of_bounds() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let ball = BallJoint::new(0, 999, Vec3Fix::ZERO, Vec3Fix::ZERO);
world.add_joint(Joint::Ball(ball));
}
#[test]
fn test_remove_body_with_active_joint() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id_a = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 0, 0),
Fix128::ONE,
));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 0, 0),
Fix128::ONE,
));
let id_c = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(10, 0, 0),
Fix128::ONE,
));
let ball_ab = BallJoint::new(id_a, id_b, Vec3Fix::ZERO, Vec3Fix::ZERO);
let ball_bc = BallJoint::new(id_b, id_c, Vec3Fix::ZERO, Vec3Fix::ZERO);
world.add_joint(Joint::Ball(ball_ab));
world.add_joint(Joint::Ball(ball_bc));
assert_eq!(world.joint_count(), 2);
world.remove_body(id_a);
assert_eq!(world.body_count(), 2, "Should have 2 bodies left");
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 10, dt);
}
#[test]
fn test_empty_world_step() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
assert_eq!(world.body_count(), 0);
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 10, dt);
assert_eq!(world.body_count(), 0);
}
#[test]
fn test_empty_world_after_remove_all() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 0, 0),
Fix128::ONE,
));
world.remove_body(0);
world.remove_body(0);
assert_eq!(world.body_count(), 0);
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 10, dt);
}
#[test]
fn test_sleep_island_propagation() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO, ..Default::default()
};
let mut world = PhysicsWorld::new(config);
world.set_sleep_config(SleepConfig {
linear_threshold: Fix128::from_ratio(1, 100),
angular_threshold: Fix128::from_ratio(1, 100),
frames_to_sleep: 3,
});
let id_a = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 0, 0),
Fix128::ONE,
));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 0, 0),
Fix128::ONE,
));
let id_c = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(10, 0, 0),
Fix128::ONE,
));
let ball_ab = BallJoint::new(id_a, id_b, Vec3Fix::ZERO, Vec3Fix::ZERO);
let ball_bc = BallJoint::new(id_b, id_c, Vec3Fix::ZERO, Vec3Fix::ZERO);
world.add_joint(Joint::Ball(ball_ab));
world.add_joint(Joint::Ball(ball_bc));
let id_d = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(20, 0, 0),
Fix128::ONE,
));
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 5, dt);
assert_eq!(
world.active_body_count(),
0,
"All still bodies should sleep"
);
world.wake_body(id_c);
assert!(!world.is_sleeping(id_c), "C should be awake");
assert!(
world.is_sleeping(id_d),
"D should still be sleeping (different island)"
);
}
#[test]
fn test_event_persist_explicit() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
substeps: 1,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let mut body_a = RigidBody::new_dynamic(Vec3Fix::from_int(-1, 0, 0), Fix128::ONE);
body_a.velocity = Vec3Fix::from_int(10, 0, 0); let mut body_b = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
body_b.velocity = Vec3Fix::from_int(-10, 0, 0);
world.add_body_with_radius(body_a, Fix128::from_int(2));
world.add_body_with_radius(body_b, Fix128::from_int(2));
let dt = Fix128::from_ratio(1, 64);
world.step(dt);
let has_begin = world
.contact_events()
.iter()
.any(|e| e.event_type == ContactEventType::Begin);
world.step(dt);
let has_persist = world
.contact_events()
.iter()
.any(|e| e.event_type == ContactEventType::Persist);
assert!(
has_begin || has_persist,
"Should have Begin or Persist events from sustained contact"
);
}
#[test]
fn test_step_zero_and_negative_dt() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 10, 0), Fix128::ONE);
world.add_body(body);
let pos_before = world.bodies[0].position;
world.step(Fix128::ZERO);
assert_eq!(
world.bodies[0].position.y.hi, pos_before.y.hi,
"dt=0 should not move body"
);
world.step(Fix128::from_int(-1));
assert_eq!(
world.bodies[0].position.y.hi, pos_before.y.hi,
"dt<0 should not move body"
);
}
#[test]
fn test_kinematic_pushes_dynamic() {
use alice_physics::QuatFix;
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
substeps: 4,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let kin = RigidBody::new_kinematic(Vec3Fix::from_int(-5, 0, 0));
let id_kin = world.add_body_with_radius(kin, Fix128::from_int(2));
let dyn_body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 0, 0), Fix128::ONE);
let _id_dyn = world.add_body_with_radius(dyn_body, Fix128::from_int(2));
let dt = Fix128::from_ratio(1, 64);
for i in 0..30 {
let target_x = Fix128::from_int(-5) + Fix128::from_int(i + 1) * dt * Fix128::from_int(10);
world.bodies[id_kin].set_kinematic_target(
Vec3Fix::new(target_x, Fix128::ZERO, Fix128::ZERO),
QuatFix::IDENTITY,
);
world.step(dt);
}
assert!(
world.bodies[id_kin].position.x > Fix128::from_int(-5),
"Kinematic should move"
);
}
#[test]
fn test_collision_detection_mixed_radii() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let mut body_a = RigidBody::new_dynamic(Vec3Fix::from_int(-1, 0, 0), Fix128::ONE);
body_a.velocity = Vec3Fix::from_int(5, 0, 0);
world.add_body_with_radius(body_a, Fix128::from_int(2));
let body_b = RigidBody::new_dynamic(Vec3Fix::from_int(1, 0, 0), Fix128::ONE);
world.add_body(body_b);
let dt = Fix128::from_ratio(1, 64);
let mut had_contacts = false;
for _ in 0..20 {
world.step(dt);
if !world.contact_events().is_empty() {
had_contacts = true;
}
}
assert!(
!had_contacts,
"Body without radius should not generate auto-collisions"
);
}
#[test]
fn test_get_body_accessors() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(7, 0, 0),
Fix128::ONE,
));
assert!(world.get_body(id).is_some());
assert_eq!(world.get_body(id).unwrap().position.x.hi, 7);
assert!(world.get_body(999).is_none());
{
let body = world.get_body_mut(id).unwrap();
body.position.x = Fix128::from_int(42);
}
assert_eq!(world.get_body(id).unwrap().position.x.hi, 42);
assert!(world.get_body_mut(999).is_none());
}
#[test]
fn test_layers_reexport() {
use alice_physics::layers;
let player = CollisionFilter::new(layers::PLAYER, layers::ENEMY | layers::STATIC);
let enemy = CollisionFilter::new(layers::ENEMY, layers::PLAYER | layers::STATIC);
assert!(CollisionFilter::can_collide(&player, &enemy));
assert!(!CollisionFilter::can_collide(
&CollisionFilter::new(layers::PLAYER, layers::PLAYER),
&CollisionFilter::new(layers::ENEMY, layers::ENEMY),
));
}
#[test]
fn test_rebuild_batches_constraint_chain() {
let config = PhysicsConfig {
gravity: Vec3Fix::ZERO,
substeps: 8,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
for i in 0..5 {
world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(i64::from(i), 0, 0),
Fix128::ONE,
));
}
for i in 0..4 {
world.add_distance_constraint(DistanceConstraint {
body_a: i,
body_b: i + 1,
local_anchor_a: Vec3Fix::ZERO,
local_anchor_b: Vec3Fix::ZERO,
target_distance: Fix128::ONE,
compliance: Fix128::ZERO,
cached_lambda: Fix128::ZERO,
});
}
world.rebuild_batches();
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 60, dt);
for i in 0..4 {
let a = world.bodies[i].position;
let b = world.bodies[i + 1].position;
let dist = (b - a).length();
let error = if dist > Fix128::ONE {
dist - Fix128::ONE
} else {
Fix128::ONE - dist
};
assert!(
error < Fix128::from_int(2),
"Chain link {}-{} distance error too large: {:?}",
i,
i + 1,
error,
);
}
}
#[cfg(feature = "parallel")]
#[test]
fn test_step_parallel_determinism() {
fn run_sequential() -> Vec3Fix {
let config = PhysicsConfig {
gravity: Vec3Fix::from_int(0, -10, 0),
substeps: 4,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(5, 50, 3), Fix128::ONE);
world.add_body(body);
let dt = Fix128::from_ratio(1, 64);
for _ in 0..120 {
world.step(dt);
}
world.bodies[0].position
}
fn run_parallel() -> Vec3Fix {
let config = PhysicsConfig {
gravity: Vec3Fix::from_int(0, -10, 0),
substeps: 4,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(5, 50, 3), Fix128::ONE);
world.add_body(body);
let dt = Fix128::from_ratio(1, 64);
for _ in 0..120 {
world.step_parallel(dt);
}
world.bodies[0].position
}
let seq = run_sequential();
let par = run_parallel();
assert_eq!(seq.x.hi, par.x.hi, "x.hi mismatch");
assert_eq!(seq.x.lo, par.x.lo, "x.lo mismatch");
assert_eq!(seq.y.hi, par.y.hi, "y.hi mismatch");
assert_eq!(seq.y.lo, par.y.lo, "y.lo mismatch");
assert_eq!(seq.z.hi, par.z.hi, "z.hi mismatch");
assert_eq!(seq.z.lo, par.z.lo, "z.lo mismatch");
}
#[cfg(feature = "parallel")]
#[test]
fn test_step_parallel_with_constraints() {
let config = PhysicsConfig {
gravity: Vec3Fix::from_int(0, -10, 0),
substeps: 4,
..Default::default()
};
let mut world = PhysicsWorld::new(config);
let id_a = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 10, 0),
Fix128::ONE,
));
let id_b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(5, 10, 0),
Fix128::ONE,
));
world.add_distance_constraint(DistanceConstraint {
body_a: id_a,
body_b: id_b,
local_anchor_a: Vec3Fix::ZERO,
local_anchor_b: Vec3Fix::ZERO,
target_distance: Fix128::from_int(5),
compliance: Fix128::ZERO,
cached_lambda: Fix128::ZERO,
});
let dt = Fix128::from_ratio(1, 64);
for _ in 0..60 {
world.step_parallel(dt);
}
let pos_a = world.bodies[id_a].position;
let pos_b = world.bodies[id_b].position;
let dist = (pos_b - pos_a).length();
let target = Fix128::from_int(5);
let error = if dist > target {
dist - target
} else {
target - dist
};
assert!(
error < Fix128::from_int(2),
"Parallel constraint stability failed: error={:?}",
error
);
}
#[cfg(feature = "parallel")]
#[test]
fn test_step_parallel_self_determinism() {
fn run() -> Vec3Fix {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 100, 0), Fix128::ONE);
world.add_body(body);
let dt = Fix128::from_ratio(1, 64);
for _ in 0..200 {
world.step_parallel(dt);
}
world.bodies[0].position
}
let r1 = run();
let r2 = run();
assert_eq!(r1.x.hi, r2.x.hi);
assert_eq!(r1.x.lo, r2.x.lo);
assert_eq!(r1.y.hi, r2.y.hi);
assert_eq!(r1.y.lo, r2.y.lo);
assert_eq!(r1.z.hi, r2.z.hi);
assert_eq!(r1.z.lo, r2.z.lo);
}
#[test]
fn test_batch_raycast_results() {
use alice_physics::query::{batch_raycast, BatchRayQuery};
let bodies = [
RigidBody::new_static(Vec3Fix::from_int(5, 0, 0)),
RigidBody::new_static(Vec3Fix::from_int(10, 0, 0)),
];
let queries = vec![
BatchRayQuery {
origin: Vec3Fix::ZERO,
direction: Vec3Fix::UNIT_X,
max_distance: Fix128::from_int(100),
},
BatchRayQuery {
origin: Vec3Fix::ZERO,
direction: Vec3Fix::from_int(0, 1, 0), max_distance: Fix128::from_int(100),
},
];
let results = batch_raycast(&queries, &bodies, Fix128::ONE);
assert_eq!(results.len(), 2);
assert!(results[0].is_some(), "First ray should hit body at x=5");
assert!(
results[1].is_none(),
"Second ray should miss (no body along Y)"
);
}
#[test]
fn test_batch_sphere_cast_results() {
use alice_physics::query::batch_sphere_cast;
let bodies = [RigidBody::new_static(Vec3Fix::from_int(5, 0, 0))];
let origins = vec![Vec3Fix::ZERO, Vec3Fix::from_int(0, 100, 0)];
let directions = vec![Vec3Fix::UNIT_X, Vec3Fix::UNIT_X];
let results = batch_sphere_cast(
&origins,
Fix128::ONE,
&directions,
Fix128::from_int(100),
&bodies,
Fix128::ONE,
);
assert_eq!(results.len(), 2);
assert!(results[0].is_some(), "Close ray should hit");
assert!(results[1].is_none(), "Far-off ray should miss");
}
#[test]
fn test_serialization_exact_roundtrip() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let mut body_a = RigidBody::new_dynamic(Vec3Fix::from_int(1, 20, 3), Fix128::ONE);
body_a.angular_velocity = Vec3Fix::from_int(5, -3, 7);
world.add_body(body_a);
let mut body_b = RigidBody::new_dynamic(Vec3Fix::from_int(-4, 0, 8), Fix128::from_int(2));
body_b.velocity = Vec3Fix::from_int(10, -5, 2);
world.add_body(body_b);
let dt = Fix128::from_ratio(1, 64);
run_world(&mut world, 30, dt);
let state1 = world.serialize_state();
assert!(world.deserialize_state(&state1));
let state2 = world.serialize_state();
assert_eq!(state1.len(), state2.len(), "Round-trip size mismatch");
assert_eq!(state1, state2, "Round-trip bytes not identical");
}
#[test]
fn test_serialization_rejects_mismatch() {
let config = PhysicsConfig::default();
let mut world_2 = PhysicsWorld::new(config);
world_2.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
world_2.add_body(RigidBody::new_dynamic(Vec3Fix::UNIT_X, Fix128::ONE));
let state_2 = world_2.serialize_state();
let mut world_3 = PhysicsWorld::new(config);
for _ in 0..3 {
world_3.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
}
assert!(
!world_3.deserialize_state(&state_2),
"Mismatched body count should return false"
);
}
#[test]
fn test_apply_impulse() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let impulse = Vec3Fix::new(Fix128::from_int(5), Fix128::ZERO, Fix128::ZERO);
world.bodies[id].apply_impulse(impulse);
assert_eq!(world.bodies[id].velocity.x, Fix128::from_int(5));
assert_eq!(world.bodies[id].velocity.y, Fix128::ZERO);
let static_id = world.add_body(RigidBody::new_static(Vec3Fix::ZERO));
world.bodies[static_id].apply_impulse(impulse);
assert_eq!(world.bodies[static_id].velocity.x, Fix128::ZERO);
}
#[test]
fn test_apply_impulse_at() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let impulse = Vec3Fix::new(Fix128::ZERO, Fix128::from_int(10), Fix128::ZERO);
let point = Vec3Fix::new(Fix128::ONE, Fix128::ZERO, Fix128::ZERO);
world.bodies[id].apply_impulse_at(impulse, point);
assert_eq!(world.bodies[id].velocity.y, Fix128::from_int(10));
assert!(
world.bodies[id].angular_velocity.z != Fix128::ZERO,
"apply_impulse_at should generate angular velocity"
);
}
#[test]
fn test_add_torque() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let dt = Fix128::from_ratio(1, 60);
let torque = Vec3Fix::new(Fix128::ZERO, Fix128::from_int(60), Fix128::ZERO);
world.bodies[id].add_torque(torque, dt);
assert!(
world.bodies[id].angular_velocity.y != Fix128::ZERO,
"add_torque should modify angular velocity"
);
let static_id = world.add_body(RigidBody::new_static(Vec3Fix::ZERO));
world.bodies[static_id].add_torque(torque, dt);
assert_eq!(world.bodies[static_id].angular_velocity.y, Fix128::ZERO);
}
#[test]
fn test_set_position_resets_prev() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let dt = Fix128::from_ratio(1, 60);
world.step(dt);
assert!(
world.bodies[id].position != world.bodies[id].prev_position
|| world.bodies[id].position == Vec3Fix::ZERO,
"After step, position or prev should differ (gravity)"
);
let new_pos = Vec3Fix::from_int(100, 200, 300);
world.bodies[id].set_position(new_pos);
assert_eq!(world.bodies[id].position, new_pos);
assert_eq!(
world.bodies[id].prev_position, new_pos,
"set_position must reset prev_position"
);
}
#[test]
fn test_set_rotation_resets_prev() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let new_rot = QuatFix::from_axis_angle(Vec3Fix::UNIT_Z, Fix128::HALF_PI);
world.bodies[id].set_rotation(new_rot);
assert_eq!(world.bodies[id].rotation, new_rot);
assert_eq!(
world.bodies[id].prev_rotation, new_rot,
"set_rotation must reset prev_rotation"
);
}
#[test]
fn test_new_sensor() {
let pos = Vec3Fix::from_int(5, 5, 5);
let sensor = RigidBody::new_sensor(pos);
assert!(sensor.is_static(), "Sensor should be static");
assert!(sensor.is_sensor, "Sensor flag must be true");
assert_eq!(sensor.position, pos);
assert_eq!(sensor.inv_mass, Fix128::ZERO, "Sensor has zero inv_mass");
assert_eq!(sensor.gravity_scale, Fix128::ZERO, "Sensor ignores gravity");
}
#[test]
fn test_remove_joint() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let a = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let b = world.add_body(RigidBody::new_dynamic(Vec3Fix::UNIT_X, Fix128::ONE));
let joint = Joint::Ball(BallJoint::new(a, b, Vec3Fix::ZERO, Vec3Fix::ZERO));
let joint_idx = world.add_joint(joint);
assert_eq!(world.joint_count(), 1);
let removed = world.remove_joint(joint_idx);
assert!(removed.is_some(), "Should return the removed joint");
assert_eq!(world.joint_count(), 0);
assert!(world.remove_joint(999).is_none());
}
#[test]
fn test_remove_force_field() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let field = ForceFieldInstance::new(ForceField::Directional {
direction: Vec3Fix::UNIT_Y,
strength: Fix128::ONE,
});
let idx = world.add_force_field(field);
let removed = world.remove_force_field(idx);
assert!(removed.is_some());
assert!(world.remove_force_field(999).is_none());
}
#[test]
fn test_set_clear_body_collision_radius() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
world.set_body_collision_radius(id, Fix128::from_int(2));
let origin = Vec3Fix::new(Fix128::from_int(-10), Fix128::ZERO, Fix128::ZERO);
let direction = Vec3Fix::UNIT_X;
let hit = world.raycast(origin, direction, Fix128::from_int(100));
assert!(hit.is_some(), "Should hit after set_body_collision_radius");
world.clear_body_collision_radius(id);
let hit2 = world.raycast(origin, direction, Fix128::from_int(100));
assert!(
hit2.is_none(),
"Should not hit after clear_body_collision_radius"
);
}
#[test]
fn test_body_filter_getter() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let id = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let default_filter = world.body_filter(id);
assert_eq!(default_filter, CollisionFilter::DEFAULT);
let custom = CollisionFilter::new(0x02, 0x04);
world.set_body_filter(id, custom);
let got = world.body_filter(id);
assert_eq!(got, custom);
let oob = world.body_filter(9999);
assert_eq!(oob, CollisionFilter::DEFAULT);
}
#[test]
fn test_drain_events() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let a = world.add_body_with_radius(
RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE),
Fix128::from_int(2),
);
let b = world.add_body_with_radius(
RigidBody::new_static(Vec3Fix::new(Fix128::ONE, Fix128::ZERO, Fix128::ZERO)),
Fix128::from_int(2),
);
let _ = (a, b);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 5, dt);
let contacts = world.drain_contact_events();
assert!(
world.contact_events().is_empty(),
"Events should be empty after drain"
);
let triggers = world.drain_trigger_events();
let _ = (contacts, triggers);
}
#[test]
fn test_soft_constraint_with_compliance() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let a = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let b = world.add_body(RigidBody::new_dynamic(
Vec3Fix::new(Fix128::from_int(5), Fix128::ZERO, Fix128::ZERO),
Fix128::ONE,
));
let stiff = DistanceConstraint::new(a, b, Vec3Fix::ZERO, Vec3Fix::ZERO, Fix128::from_int(5))
.with_compliance(Fix128::ZERO);
world.add_distance_constraint(stiff);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 120, dt);
let dist_stiff = (world.bodies[a].position - world.bodies[b].position).length();
let mut world2 = PhysicsWorld::new(config);
let a2 = world2.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let b2 = world2.add_body(RigidBody::new_dynamic(
Vec3Fix::new(Fix128::from_int(5), Fix128::ZERO, Fix128::ZERO),
Fix128::ONE,
));
let soft = DistanceConstraint::new(a2, b2, Vec3Fix::ZERO, Vec3Fix::ZERO, Fix128::from_int(5))
.with_compliance(Fix128::from_int(10));
world2.add_distance_constraint(soft);
run_world(&mut world2, 120, dt);
let dist_soft = (world2.bodies[a2].position - world2.bodies[b2].position).length();
let _ = (dist_stiff, dist_soft);
}
#[test]
fn test_num_batches() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.rebuild_batches();
assert_eq!(world.num_batches(), 0);
let a = world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
let b = world.add_body(RigidBody::new_dynamic(Vec3Fix::UNIT_X, Fix128::ONE));
let c = world.add_body(RigidBody::new_dynamic(
Vec3Fix::new(Fix128::from_int(2), Fix128::ZERO, Fix128::ZERO),
Fix128::ONE,
));
world.add_distance_constraint(DistanceConstraint::new(
a,
b,
Vec3Fix::ZERO,
Vec3Fix::ZERO,
Fix128::ONE,
));
world.add_distance_constraint(DistanceConstraint::new(
b,
c,
Vec3Fix::ZERO,
Vec3Fix::ZERO,
Fix128::ONE,
));
world.rebuild_batches();
assert!(
world.num_batches() >= 2,
"Chain sharing a body needs >= 2 batches"
);
}
#[test]
fn test_zero_mass_body() {
let body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 10, 0), Fix128::ZERO);
assert_eq!(body.inv_mass, Fix128::ZERO, "mass=0 → inv_mass=0");
assert!(body.is_static(), "mass=0 body is_static() returns true");
let mut body2 = body;
body2.apply_impulse(Vec3Fix::new(
Fix128::from_int(100),
Fix128::ZERO,
Fix128::ZERO,
));
assert_eq!(
body2.velocity.x,
Fix128::ZERO,
"mass=0 body ignores impulse"
);
body2.add_torque(
Vec3Fix::new(Fix128::ZERO, Fix128::from_int(100), Fix128::ZERO),
Fix128::from_ratio(1, 60),
);
assert_eq!(
body2.angular_velocity.y,
Fix128::ZERO,
"mass=0 body ignores torque"
);
}
#[test]
fn test_deserialize_empty_and_truncated() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
assert!(!world.deserialize_state(&[]), "Empty data should fail");
assert!(!world.deserialize_state(&[0, 0]), "Short data should fail");
let mut truncated = vec![0u8; 4];
truncated[0] = 1; assert!(
!world.deserialize_state(&truncated),
"Truncated body data should fail"
);
}
#[test]
fn test_self_referential_joint() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let a = world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 5, 0),
Fix128::ONE,
));
let joint = Joint::Ball(BallJoint::new(a, a, Vec3Fix::ZERO, Vec3Fix::ZERO));
world.add_joint(joint);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 60, dt);
}
#[test]
fn test_large_dt() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(
Vec3Fix::from_int(0, 10, 0),
Fix128::ONE,
));
let large_dt = Fix128::from_int(100);
world.step(large_dt);
assert!(
world.bodies[0].position.y < Fix128::from_int(10),
"Large dt should still produce downward motion"
);
}
#[test]
fn test_raycast_inside_sphere() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body_with_radius(RigidBody::new_static(Vec3Fix::ZERO), Fix128::from_int(10));
let origin = Vec3Fix::new(Fix128::ONE, Fix128::ZERO, Fix128::ZERO);
let direction = Vec3Fix::UNIT_X;
let hit = world.raycast(origin, direction, Fix128::from_int(100));
assert!(
hit.is_some(),
"Ray inside sphere should hit far intersection"
);
let (idx, t) = hit.unwrap();
assert_eq!(idx, 0);
assert!(
t > Fix128::ZERO,
"Hit distance should be positive (far side)"
);
}
#[test]
fn test_remove_sdf_collider() {
use alice_physics::sdf_collider::{ClosureSdf, SdfCollider};
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let ground = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
let collider = SdfCollider::new_static(Box::new(ground), Vec3Fix::ZERO, QuatFix::IDENTITY);
let idx = world.add_sdf_collider(collider);
let removed = world.remove_sdf_collider(idx);
assert!(removed.is_some());
assert!(world.remove_sdf_collider(999).is_none());
}
#[test]
fn test_remove_only_body() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
world.add_body(RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE));
assert_eq!(world.bodies.len(), 1);
world.remove_body(0);
assert_eq!(world.bodies.len(), 0);
let dt = Fix128::from_ratio(1, 60);
world.step(dt);
}
#[test]
fn test_raycast_bvh_correctness() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
for i in 0..5 {
world.add_body_with_radius(
RigidBody::new_static(Vec3Fix::new(
Fix128::from_int(i * 10),
Fix128::ZERO,
Fix128::ZERO,
)),
Fix128::from_int(2),
);
}
let origin = Vec3Fix::new(Fix128::from_int(-20), Fix128::ZERO, Fix128::ZERO);
let direction = Vec3Fix::UNIT_X;
let hit = world.raycast(origin, direction, Fix128::from_int(200));
assert!(hit.is_some());
let (idx, _) = hit.unwrap();
assert_eq!(idx, 0, "Nearest sphere should be body 0");
let origin2 = Vec3Fix::new(Fix128::from_int(60), Fix128::ZERO, Fix128::ZERO);
let dir2 = Vec3Fix::new(-Fix128::ONE, Fix128::ZERO, Fix128::ZERO);
let hit2 = world.raycast(origin2, dir2, Fix128::from_int(200));
assert!(hit2.is_some());
let (idx2, _) = hit2.unwrap();
assert_eq!(idx2, 4, "Nearest sphere from right should be body 4");
}
#[test]
fn test_stress_10_bodies_with_constraints() {
let config = PhysicsConfig {
substeps: 2,
iterations: 2,
..PhysicsConfig::default()
};
let mut world = PhysicsWorld::new(config);
for i in 0..10 {
let x = i64::from(i) * 3;
let body = RigidBody::new_dynamic(Vec3Fix::from_int(x, 20, 0), Fix128::ONE);
world.add_body_with_radius(body, Fix128::ONE);
}
assert_eq!(world.bodies.len(), 10);
for i in 0..9 {
let constraint =
DistanceConstraint::new(i, i + 1, Vec3Fix::ZERO, Vec3Fix::ZERO, Fix128::from_int(3));
world.distance_constraints.push(constraint);
}
world
.force_fields
.push(ForceFieldInstance::new(ForceField::Directional {
direction: Vec3Fix::UNIT_X,
strength: Fix128::from_int(2),
}));
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 10, dt);
for (i, body) in world.bodies.iter().enumerate() {
let pos_magnitude = body.position.length();
assert!(
pos_magnitude < Fix128::from_int(10000),
"Body {i} diverged: position magnitude too large"
);
}
}
#[test]
fn test_per_body_damping() {
let config = PhysicsConfig::default();
let mut world = PhysicsWorld::new(config);
let body_a = RigidBody::new_dynamic(Vec3Fix::from_int(0, 10, 0), Fix128::ONE)
.with_velocity(Vec3Fix::from_int(10, 0, 0));
world.add_body(body_a);
let body_b = RigidBody::new_dynamic(Vec3Fix::from_int(0, 10, 10), Fix128::ONE)
.with_velocity(Vec3Fix::from_int(10, 0, 0))
.with_linear_damping(Fix128::from_ratio(5, 10));
world.add_body(body_b);
let dt = Fix128::from_ratio(1, 60);
run_world(&mut world, 60, dt);
let x_a = world.bodies[0].position.x;
let x_b = world.bodies[1].position.x;
assert!(x_b < x_a, "Heavily damped body should travel less distance");
}
#[test]
fn test_rigid_body_builder() {
let body = RigidBody::new_dynamic(Vec3Fix::from_int(0, 5, 0), Fix128::ONE)
.with_restitution(Fix128::from_ratio(8, 10))
.with_friction(Fix128::from_ratio(5, 10))
.with_gravity_scale(Fix128::from_int(2))
.with_velocity(Vec3Fix::from_int(1, 0, 0))
.with_rotation(QuatFix::IDENTITY);
assert_eq!(body.restitution, Fix128::from_ratio(8, 10));
assert_eq!(body.friction, Fix128::from_ratio(5, 10));
assert_eq!(body.gravity_scale, Fix128::from_int(2));
assert_eq!(body.velocity, Vec3Fix::from_int(1, 0, 0));
}
#[test]
fn test_display_traits() {
let v = Vec3Fix::from_int(1, 2, 3);
let s = format!("{v}");
assert!(s.contains('1'), "Vec3Fix display should contain components");
let q = QuatFix::IDENTITY;
let s = format!("{q}");
assert!(s.contains('1'), "QuatFix display should contain w=1");
let f = Fix128::from_ratio(3, 2);
let s = format!("{f}");
assert!(s.contains("1.5"), "Fix128(1.5) display should contain 1.5");
}
#[test]
fn test_from_into_conversions() {
let f: Fix128 = 42i64.into();
assert_eq!(f, Fix128::from_int(42));
let f32: Fix128 = 7i32.into();
assert_eq!(f32, Fix128::from_int(7));
let arr: [Fix128; 3] = Vec3Fix::from_int(1, 2, 3).into();
assert_eq!(arr[0], Fix128::from_int(1));
assert_eq!(arr[1], Fix128::from_int(2));
assert_eq!(arr[2], Fix128::from_int(3));
let v: Vec3Fix = arr.into();
assert_eq!(v, Vec3Fix::from_int(1, 2, 3));
}
#[test]
fn test_physics_world_debug() {
let world = PhysicsWorld::new(PhysicsConfig::default());
let s = format!("{world:?}");
assert!(s.contains("PhysicsWorld"));
assert!(s.contains("bodies"));
}
#[test]
fn test_rigid_body_default() {
let body = RigidBody::default();
assert_eq!(body.position, Vec3Fix::ZERO);
assert!(body.is_dynamic());
assert!(!body.is_static());
}
#[test]
fn test_new_ccd_functions() {
use alice_physics::ccd::{aabb_plane_toi, capsule_plane_toi, sphere_capsule_toi};
let toi = capsule_plane_toi(
Vec3Fix::from_int(0, 10, -1),
Vec3Fix::from_int(0, 10, 1),
Fix128::ONE,
Vec3Fix::from_int(0, -20, 0),
Vec3Fix::UNIT_Y,
Fix128::ZERO,
);
assert!(toi.is_some(), "Capsule should hit ground plane");
let toi2 = sphere_capsule_toi(
Vec3Fix::from_int(-10, 0, 0),
Fix128::ONE,
Vec3Fix::from_int(20, 0, 0),
Vec3Fix::from_int(5, -2, 0),
Vec3Fix::from_int(5, 2, 0),
Fix128::ONE,
);
assert!(toi2.is_some(), "Sphere should hit capsule");
let aabb = AABB::new(Vec3Fix::from_int(-1, 8, -1), Vec3Fix::from_int(1, 10, 1));
let toi3 = aabb_plane_toi(
&aabb,
Vec3Fix::from_int(0, -20, 0),
Vec3Fix::UNIT_Y,
Fix128::ZERO,
);
assert!(toi3.is_some(), "AABB should hit ground plane");
}
#[test]
fn test_error_new_variants() {
use alice_physics::PhysicsError;
let e1 = PhysicsError::CapacityExceeded {
resource: "bodies",
limit: 10000,
};
assert!(format!("{e1}").contains("10000"));
let e2 = PhysicsError::InvalidConfiguration {
reason: "substeps must be > 0",
};
assert!(format!("{e2}").contains("substeps"));
}
#[test]
fn test_heightfield_derives() {
use alice_physics::HeightField;
let hf = HeightField::flat(4, 4, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
let hf2 = hf.clone();
assert_eq!(hf, hf2, "HeightField should be cloneable and comparable");
let s = format!("{hf:?}");
assert!(s.contains("HeightField"));
}