use approx::assert_relative_eq;
use phyz::{
ContactMaterial, Geometry, Mat3, ModelBuilder, SpatialInertia, SpatialTransform, SpatialVec,
Vec3,
collision::sweep_and_prune,
contact::{contact_forces, find_contacts, find_ground_contacts},
};
#[test]
fn body_drop_on_fixed_body_with_contacts() {
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(0.001)
.add_fixed_body(
"host",
-1,
SpatialTransform::identity(),
SpatialInertia::new(
1.0,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(0.1, 0.1, 0.1)),
),
)
.add_free_body(
"accessory",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(0.1, 0.1),
)
.build();
model.bodies[0].geometry = Some(Geometry::Box {
half_extents: Vec3::new(0.5, 0.5, 0.1),
});
model.bodies[1].geometry = Some(Geometry::Sphere { radius: 0.1 });
let mut state = model.default_state();
let q_offset = model.q_offsets[model.bodies[1].joint_idx];
state.q[q_offset + 2] = 0.05;
state.body_xform[0] = SpatialTransform::identity();
state.body_xform[1] = SpatialTransform::new(Mat3::identity(), Vec3::new(0.0, 0.0, 0.05));
let geometries: Vec<Option<Geometry>> =
model.bodies.iter().map(|b| b.geometry.clone()).collect();
let contacts = find_contacts(&model, &state, &geometries);
assert_eq!(
contacts.len(),
1,
"expected exactly one body-body contact, got {}",
contacts.len()
);
let contact = &contacts[0];
assert_eq!(contact.body_i, 0);
assert_eq!(contact.body_j, 1);
assert_relative_eq!(contact.contact_normal.z, 1.0, epsilon = 1e-10);
let ground = find_ground_contacts(&state, &geometries, -10.0);
assert!(ground.is_empty());
let materials = vec![ContactMaterial::default()];
let forces = contact_forces(&contacts, &state, &materials, None);
let fz_free = forces[1].linear.z;
assert!(
fz_free > 0.0,
"contact force on accessory should push UP (+z), got fz = {:.4}",
fz_free,
);
let fz_host = forces[0].linear.z;
assert!(
fz_host < 0.0,
"reaction force on host should push DOWN (-z), got fz = {:.4}",
fz_host,
);
assert_relative_eq!(fz_free, -fz_host, epsilon = 1e-12);
assert!(forces[1].linear.norm() > 0.0);
let _ = SpatialVec::zero();
}
#[test]
fn nan_body_xform_does_not_panic_broad_phase() {
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(0.001)
.add_free_body(
"a",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.add_free_body(
"b",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.build();
model.bodies[0].geometry = Some(Geometry::Sphere { radius: 0.1 });
model.bodies[1].geometry = Some(Geometry::Sphere { radius: 0.1 });
let mut state = model.default_state();
state.body_xform[0] = SpatialTransform::identity();
state.body_xform[1] = SpatialTransform::new(
Mat3::identity(),
Vec3::new(f64::NAN, f64::NAN, f64::NAN),
);
let geometries: Vec<Option<Geometry>> =
model.bodies.iter().map(|b| b.geometry.clone()).collect();
let contacts = find_contacts(&model, &state, &geometries);
for c in &contacts {
assert!(
c.contact_normal.x.is_finite()
&& c.contact_normal.y.is_finite()
&& c.contact_normal.z.is_finite(),
"NaN snuck into a contact normal: {:?}",
c.contact_normal,
);
}
}
#[test]
fn coincident_bodies_produce_finite_contact_normal() {
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(0.001)
.add_free_body(
"a",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.add_free_body(
"b",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.build();
model.bodies[0].geometry = Some(Geometry::Sphere { radius: 0.1 });
model.bodies[1].geometry = Some(Geometry::Sphere { radius: 0.1 });
let mut state = model.default_state();
state.body_xform[0] = SpatialTransform::identity();
state.body_xform[1] = SpatialTransform::identity();
let geometries: Vec<Option<Geometry>> =
model.bodies.iter().map(|b| b.geometry.clone()).collect();
let contacts = find_contacts(&model, &state, &geometries);
for c in &contacts {
assert!(
c.contact_normal.x.is_finite()
&& c.contact_normal.y.is_finite()
&& c.contact_normal.z.is_finite(),
"got non-finite normal {:?} for coincident bodies",
c.contact_normal,
);
assert!(
(c.contact_normal.norm() - 1.0).abs() < 1e-6,
"contact normal should be unit length, got |n|={}",
c.contact_normal.norm(),
);
}
}
#[test]
fn low_mass_cube_settles_on_plate() {
use phyz::collision::Collision;
let m = 0.032_f64; let half = 0.015_f64; let dt = 1.0 / 2000.0;
let g = 9.81_f64;
let plate_top = 0.0_f64;
let material = ContactMaterial::default();
let k = material.stiffness;
let mut z = half + 0.005;
let mut vz = 0.0_f64;
let mut max_penetration: f64 = 0.0;
let total_steps = (1.0 / dt) as usize;
for _ in 0..total_steps {
let depth = (plate_top - (z - half)).max(0.0);
let fz_contact = if depth > 0.0 {
max_penetration = max_penetration.max(depth);
let collision = Collision {
body_i: 0,
body_j: 1,
contact_point: Vec3::new(0.0, 0.0, plate_top),
contact_normal: Vec3::z(),
penetration_depth: depth,
};
let wrench = phyz::compute_contact_force_implicit(
&collision,
&material,
&Vec3::zeros(),
&Vec3::new(0.0, 0.0, vz),
f64::INFINITY,
m,
dt,
);
wrench.linear.z
} else {
0.0
};
let az = fz_contact / m - g;
vz += az * dt;
z += vz * dt;
assert!(z.is_finite(), "cube z became non-finite: {z}");
assert!(vz.is_finite(), "cube vz became non-finite: {vz}");
assert!(
z < 1.0,
"cube launched: z={z}, vz={vz}; implicit damping is broken",
);
}
let denom = m + dt * material.damping + dt * dt * k;
let x_eq_discrete = g * denom / k;
let z_eq = half - x_eq_discrete;
let drift = (z - z_eq).abs();
assert!(
drift < 5e-3,
"cube drifted {:.4}mm after 1s, want < 5mm; z={z}, z_eq={z_eq}",
drift * 1000.0,
);
assert!(
max_penetration < 5e-4,
"max penetration {:.4}mm exceeded 0.5mm",
max_penetration * 1000.0,
);
assert!(
vz.abs() < 1e-2,
"cube still moving at v_z = {vz} after 1s",
);
}
#[test]
fn implicit_force_is_smaller_than_explicit_at_impact() {
use phyz::collision::Collision;
use phyz::{compute_contact_force_implicit, contact::compute_contact_force};
let material = ContactMaterial::default();
let collision = Collision {
body_i: 0,
body_j: 1,
contact_point: Vec3::zeros(),
contact_normal: Vec3::z(),
penetration_depth: 1e-5,
};
let vel_i = Vec3::zeros();
let vel_j = Vec3::new(0.0, 0.0, -1.0);
let dt = 1.0 / 2000.0;
let m = 0.032;
let f_exp = compute_contact_force(&collision, &material, &vel_i, &vel_j).linear.z;
let f_imp =
compute_contact_force_implicit(&collision, &material, &vel_i, &vel_j, f64::INFINITY, m, dt)
.linear
.z;
assert!(f_exp > 0.0);
assert!(f_imp > 0.0);
assert!(
f_imp < f_exp,
"implicit force {f_imp} should be < explicit {f_exp}",
);
let expected_ratio_upper_bound = 0.5_f64;
assert!(
f_imp / f_exp < expected_ratio_upper_bound,
"implicit/explicit ratio = {}, want < {}",
f_imp / f_exp,
expected_ratio_upper_bound,
);
}
#[test]
fn contact_force_torque_at_contact_point() {
use phyz::collision::Collision;
use phyz::contact::contact_forces_implicit;
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -9.81))
.dt(0.001)
.add_fixed_body(
"support",
-1,
SpatialTransform::from_translation(Vec3::new(-0.04, 0.0, -0.005)),
SpatialInertia::new(
1.0,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(0.001, 0.001, 0.001)),
),
)
.add_free_body(
"rod",
-1,
SpatialTransform::identity(),
SpatialInertia::new(
0.01,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(1e-5, 1e-5, 1e-5)),
),
)
.build();
model.bodies[0].geometry = Some(Geometry::Box {
half_extents: Vec3::new(0.005, 0.005, 0.005),
});
model.bodies[1].geometry = Some(Geometry::Capsule {
radius: 0.005,
length: 0.1,
});
let mut state = model.default_state();
state.body_xform[0] =
SpatialTransform::new(Mat3::identity(), Vec3::new(-0.04, 0.0, -0.005));
state.body_xform[1] = SpatialTransform::new(Mat3::identity(), Vec3::zeros());
let contact_point_world = Vec3::new(-0.04, 0.0, 0.0);
let collision = Collision {
body_i: 0,
body_j: 1,
contact_point: contact_point_world,
contact_normal: Vec3::z(),
penetration_depth: 1e-3,
};
let materials = vec![ContactMaterial::default()];
let masses = vec![f64::INFINITY, 0.01];
let vels = vec![SpatialVec::zero(), SpatialVec::zero()];
let forces = contact_forces_implicit(
std::slice::from_ref(&collision),
&state,
&materials,
Some(&vels),
&masses,
0.001,
);
assert!(
forces[1].linear.z > 0.0,
"rod should be pushed up, got fz = {}",
forces[1].linear.z,
);
assert!(forces[0].linear.z < 0.0);
let expected_torque_y = 0.04 * forces[1].linear.z;
assert!(
forces[1].angular.y.abs() > 0.0,
"rod should receive a non-zero y-torque from offset contact",
);
assert_relative_eq!(forces[1].angular.y, expected_torque_y, epsilon = 1e-9);
assert!(forces[1].angular.x.abs() < 1e-12);
assert!(forces[1].angular.z.abs() < 1e-12);
}
#[test]
fn rod_tips_off_support_when_contact_is_offset() {
use phyz::collision::Collision;
let m = 0.01_f64; let length = 0.1_f64;
let half_l = length / 2.0;
let radius = 0.005_f64;
let i_y = m * length * length / 12.0; let dt = 1.0 / 2000.0;
let g = 9.81_f64;
let material = ContactMaterial::default();
let support_x = -half_l + 0.01;
let support_top_z = 0.0;
let mut x = 0.0_f64;
let mut z = radius + 1e-4;
let mut theta = 0.0_f64; let mut vx = 0.0_f64;
let mut vz = 0.0_f64;
let mut omega = 0.0_f64;
let initial_tip_z = z;
for _ in 0..((0.5_f64 / dt) as usize) {
let cos_t = theta.cos();
let sin_t = theta.sin();
let s = ((support_x - x) / cos_t.max(0.1)).clamp(-half_l, half_l);
let p_axis_x = x + s * cos_t;
let p_axis_z = z - s * sin_t;
let p_bot_z = p_axis_z - radius;
let depth = (support_top_z - p_bot_z).max(0.0);
let (fx, fz, torque_y) = if depth > 0.0 {
let cp = Vec3::new(p_axis_x, 0.0, support_top_z);
let r_x = cp.x - x;
let r_z = cp.z - z;
let v_pt = Vec3::new(vx + omega * r_z, 0.0, vz - omega * r_x);
let collision = Collision {
body_i: 0,
body_j: 1,
contact_point: cp,
contact_normal: Vec3::z(),
penetration_depth: depth,
};
let wrench = phyz::compute_contact_force_implicit(
&collision,
&material,
&Vec3::zeros(),
&v_pt,
f64::INFINITY,
m,
dt,
);
let f = wrench.linear;
let torque_y = r_z * f.x - r_x * f.z;
(f.x, f.z, torque_y)
} else {
(0.0, 0.0, 0.0)
};
let ax = fx / m;
let az = fz / m - g;
let alpha = torque_y / i_y;
vx += ax * dt;
vz += az * dt;
omega += alpha * dt;
x += vx * dt;
z += vz * dt;
theta += omega * dt;
assert!(theta.is_finite() && z.is_finite() && omega.is_finite());
}
let tip_z_world = z - half_l * theta.sin();
let tip_drop = initial_tip_z - tip_z_world;
assert!(
omega.abs() > 0.0,
"rod should be rotating; got ω = {omega}",
);
assert!(
tip_drop > 0.01,
"far end of rod should drop > 1cm; got {:.4}mm (θ={:.3} rad, z={:.4} m)",
tip_drop * 1000.0,
theta,
z,
);
}
#[test]
fn broad_phase_nan_endpoints_do_not_panic() {
use phyz::collision::AABB;
let nan = f64::NAN;
let aabbs = vec![
AABB::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0)),
AABB::new(Vec3::new(nan, 0.0, 0.0), Vec3::new(2.0, 1.0, 1.0)),
AABB::new(Vec3::new(0.5, 0.5, 0.5), Vec3::new(1.5, 1.5, 1.5)),
];
let pairs = sweep_and_prune(&aabbs);
assert!(
pairs.iter().all(|&(a, b)| a != 1 && b != 1),
"expected NaN body to be excluded, got pairs {pairs:?}",
);
}