use crate::convex::{ContactProblem, ContactRow, ContactSolverConfig};
use crate::material::ContactMaterial;
use phyz_collision::Collision;
use phyz_math::{DMat, DVec, SpatialVec, Vec3};
use phyz_model::{Model, State};
pub fn assemble(
model: &Model,
state: &State,
contacts: &[Collision],
materials: &[ContactMaterial],
free_qd: &DVec,
dt: f64,
config: &ContactSolverConfig,
) -> ContactAssembly {
let n = contacts.len();
let nv = model.nv;
let (xforms, _) = phyz_rigid::forward_kinematics(model, state);
let mass = phyz_rigid::crba(model, state);
let inv_mass = invert_symmetric(&mass);
let mut jacobians: Vec<DMat> = Vec::with_capacity(n);
for c in contacts {
let point_j = phyz_rigid::relative_point_jacobian(
model,
&xforms,
c.body_i,
c.attachment_j(),
c.contact_point,
);
let (nrm, u, w) = crate::cone::contact_frame(&c.contact_normal);
let mut rows = DMat::zeros(3, nv);
for col in 0..nv {
let v = Vec3::new(point_j[(0, col)], point_j[(1, col)], point_j[(2, col)]);
rows[(0, col)] = v.dot(nrm);
rows[(1, col)] = v.dot(u);
rows[(2, col)] = v.dot(w);
}
jacobians.push(rows);
}
let dim = 3 * n;
let mut delassus = vec![0.0; dim * dim];
let mut minv_jt: Vec<DMat> = Vec::with_capacity(n);
for jc in &jacobians {
let mut m = DMat::zeros(nv, 3);
for r in 0..nv {
for k in 0..3 {
let mut acc = 0.0;
for col in 0..nv {
acc += inv_mass[(r, col)] * jc[(k, col)];
}
m[(r, k)] = acc;
}
}
minv_jt.push(m);
}
for a in 0..n {
for b in 0..n {
for r in 0..3 {
for k in 0..3 {
let mut acc = 0.0;
for col in 0..nv {
acc += jacobians[a][(r, col)] * minv_jt[b][(col, k)];
}
delassus[(3 * a + r) * dim + 3 * b + k] = acc;
}
}
}
}
let mut free_velocity = vec![0.0; dim];
let mut rows = Vec::with_capacity(n);
for (ci, c) in contacts.iter().enumerate() {
for r in 0..3 {
let mut acc = 0.0;
for col in 0..nv {
acc += jacobians[ci][(r, col)] * free_qd[col];
}
free_velocity[3 * ci + r] = acc;
}
let material = material_for(materials, c.body_i, c.body_j);
let mut normal_pre = 0.0;
for col in 0..nv {
normal_pre += jacobians[ci][(0, col)] * state.v[col];
}
let approach = (-normal_pre).max(0.0);
let e = ContactProblem::effective_restitution(
material.restitution,
approach,
config.restitution_threshold,
);
free_velocity[3 * ci] -= e * approach;
let impact = ContactProblem::impact_weight(approach, config.restitution_threshold);
rows.push(
ContactRow::from_material(&material, c.penetration_depth, dt, e).with_impact(impact),
);
}
ContactAssembly {
problem: ContactProblem {
n,
delassus,
free_velocity,
rows,
bodies: contacts.iter().map(|c| (c.body_i, c.body_j)).collect(),
},
jacobians,
inv_mass,
}
}
pub struct ContactAssembly {
pub problem: ContactProblem,
pub jacobians: Vec<DMat>,
pub inv_mass: DMat,
}
impl ContactAssembly {
pub fn velocity_delta(&self, impulses: &[Vec3]) -> DVec {
let nv = self.inv_mass.nrows();
let tau = generalized_impulse_inner(nv, &self.jacobians, impulses);
let mut out = DVec::zeros(nv);
for r in 0..nv {
let mut acc = 0.0;
for c in 0..nv {
acc += self.inv_mass[(r, c)] * tau[c];
}
out[r] = acc;
}
out
}
}
fn generalized_impulse_inner(nv: usize, jacobians: &[DMat], impulses: &[Vec3]) -> DVec {
let mut out = DVec::zeros(nv);
for (j, f) in jacobians.iter().zip(impulses) {
let fv = [f.x, f.y, f.z];
for col in 0..nv {
let mut acc = 0.0;
for (r, fr) in fv.iter().enumerate() {
acc += j[(r, col)] * fr;
}
out[col] += acc;
}
}
out
}
pub fn generalized_impulse(model: &Model, jacobians: &[DMat], impulses: &[Vec3]) -> DVec {
generalized_impulse_inner(model.nv, jacobians, impulses)
}
pub fn contact_wrenches(
state: &State,
contacts: &[Collision],
impulses: &[Vec3],
dt: f64,
) -> Vec<SpatialVec> {
let nbodies = state.body_xform.len();
let mut out = vec![SpatialVec::zero(); nbodies];
if dt <= 0.0 {
return out;
}
for (c, f) in contacts.iter().zip(impulses) {
let (nrm, u, w) = crate::cone::contact_frame(&c.contact_normal);
let force = (nrm * f.x + u * f.y + w * f.z) / dt;
let i = c.body_i;
let r_i = c.contact_point - state.body_xform[i].pos;
out[i] = out[i] + SpatialVec::new(r_i.cross(force), force);
if !c.is_world_j() {
let j = c.body_j;
let r_j = c.contact_point - state.body_xform[j].pos;
out[j] = out[j] + SpatialVec::new(r_j.cross(-force), -force);
}
}
out
}
fn material_for(materials: &[ContactMaterial], body_i: usize, body_j: usize) -> ContactMaterial {
if materials.is_empty() {
return ContactMaterial::default();
}
let pick = |b: usize| materials[b.min(materials.len() - 1)].clone();
let a = pick(body_i);
if body_j == Collision::WORLD {
return a;
}
ContactMaterial::combine(&a, &pick(body_j))
}
fn invert_symmetric(m: &DMat) -> DMat {
let n = m.nrows();
let mut a = vec![0.0; n * n];
let mut inv = vec![0.0; n * n];
for r in 0..n {
for c in 0..n {
a[r * n + c] = m[(r, c)];
}
inv[r * n + r] = 1.0;
}
for col in 0..n {
let mut pivot = col;
for r in col + 1..n {
if a[r * n + col].abs() > a[pivot * n + col].abs() {
pivot = r;
}
}
if a[pivot * n + col].abs() < 1e-14 {
continue;
}
if pivot != col {
for k in 0..n {
a.swap(col * n + k, pivot * n + k);
inv.swap(col * n + k, pivot * n + k);
}
}
let d = a[col * n + col];
for k in 0..n {
a[col * n + k] /= d;
inv[col * n + k] /= d;
}
for r in 0..n {
if r == col {
continue;
}
let factor = a[r * n + col];
if factor == 0.0 {
continue;
}
for k in 0..n {
a[r * n + k] -= factor * a[col * n + k];
inv[r * n + k] -= factor * inv[col * n + k];
}
}
}
DMat::from_fn(n, n, |r, c| inv[r * n + c])
}
#[cfg(test)]
mod tests {
use super::*;
use phyz_math::{GRAVITY, Mat3, SpatialInertia, SpatialTransform};
use phyz_model::{Geometry, ModelBuilder};
#[test]
fn inverse_mass_round_trips() {
let model = ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"b",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(2.0, 0.3),
)
.build();
let state = model.default_state();
let m = phyz_rigid::crba(&model, &state);
let inv = invert_symmetric(&m);
for r in 0..model.nv {
for c in 0..model.nv {
let mut acc = 0.0;
for k in 0..model.nv {
acc += m[(r, k)] * inv[(k, c)];
}
let want = if r == c { 1.0 } else { 0.0 };
assert!((acc - want).abs() < 1e-9, "M*M^-1 [{r},{c}] = {acc}");
}
}
}
#[test]
fn free_body_on_ground_cancels_gravity() {
let mass = 2.0;
let dt = 1e-3;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -GRAVITY))
.dt(dt)
.add_free_body(
"ball",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(mass, 0.1),
)
.build();
let mut state = model.default_state();
state.body_xform[0] = SpatialTransform::new(Mat3::identity(), Vec3::zeros());
let contacts = vec![Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: Vec3::new(0.0, 0.0, -0.1),
contact_normal: Vec3::z(),
penetration_depth: 0.0,
}];
let mut free_qd = DVec::zeros(model.nv);
free_qd[5] = -GRAVITY * dt;
let cfg = ContactSolverConfig::simulation();
let asm = assemble(
&model,
&state,
&contacts,
&[ContactMaterial::default()],
&free_qd,
dt,
&cfg,
);
let sol = crate::solve_contacts(&asm.problem, &cfg);
assert!(sol.converged);
let d = ContactMaterial::default().solimp.impedance(0.0);
let expected = d * mass * GRAVITY * dt;
assert!(
(sol.impulses[0].x - expected).abs() / expected < 1e-3,
"normal impulse {} vs d*m*g*dt {expected}",
sol.impulses[0].x
);
let gen_impulse = generalized_impulse(&model, &asm.jacobians, &sol.impulses);
assert!(
(gen_impulse[5] - expected).abs() / expected < 1e-3,
"generalized z impulse {}",
gen_impulse[5]
);
}
#[test]
fn penetration_adds_a_recovery_impulse() {
let mass = 2.0;
let dt = 1e-3;
let depth = 1e-4;
let model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -GRAVITY))
.dt(dt)
.add_free_body(
"ball",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(mass, 0.1),
)
.build();
let mut state = model.default_state();
state.body_xform[0] = SpatialTransform::new(Mat3::identity(), Vec3::zeros());
let contacts = vec![Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: Vec3::new(0.0, 0.0, -0.1),
contact_normal: Vec3::z(),
penetration_depth: depth,
}];
let mut free_qd = DVec::zeros(model.nv);
free_qd[5] = -GRAVITY * dt;
let cfg = ContactSolverConfig::simulation();
let material = ContactMaterial::default();
let asm = assemble(
&model,
&state,
&contacts,
std::slice::from_ref(&material),
&free_qd,
dt,
&cfg,
);
let sol = crate::solve_contacts(&asm.problem, &cfg);
assert!(sol.converged);
let weight = mass * GRAVITY * dt;
assert!(
sol.impulses[0].x > weight,
"penetrating contact must push harder than the weight: {} vs {weight}",
sol.impulses[0].x
);
let row = asm.problem.rows[0];
assert!(row.bias > 0.0, "a penetrating row must carry a bias");
let dv = asm.velocity_delta(&sol.impulses);
let v_after = free_qd[5] + dv[5];
assert!(
v_after > 0.0 && v_after < row.bias,
"normal velocity after solve {v_after} should separate, under the \
bias {}",
row.bias
);
assert!(row.bias * dt <= depth + 1e-15);
}
#[test]
fn two_contacts_on_one_body_are_coupled() {
let model = ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"box",
-1,
SpatialTransform::identity(),
SpatialInertia::new(
1.0,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(0.1, 0.1, 0.1)),
),
)
.build();
let mut state = model.default_state();
state.body_xform[0] = SpatialTransform::identity();
let _ = Geometry::Box {
half_extents: Vec3::new(0.5, 0.5, 0.5),
};
let contacts = vec![
Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: Vec3::new(0.5, 0.0, -0.5),
contact_normal: Vec3::z(),
penetration_depth: 1e-4,
},
Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: Vec3::new(-0.5, 0.0, -0.5),
contact_normal: Vec3::z(),
penetration_depth: 1e-4,
},
];
let cfg = ContactSolverConfig::simulation();
let asm = assemble(
&model,
&state,
&contacts,
&[ContactMaterial::default()],
&DVec::zeros(model.nv),
1e-3,
&cfg,
);
let _dim = 6;
let off = asm.problem.delassus[3];
assert!(
off.abs() > 1e-9,
"contacts on the same body must couple; A[0,3] = {off}"
);
}
}