use phyz_collision::Collision;
use phyz_math::{SpatialTransformExt, Vec3};
use phyz_model::State;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContactKey {
pub body_i: usize,
pub body_j: usize,
pub cell: [i64; 3],
}
#[derive(Debug, Clone)]
pub struct ContactCache {
cell: f64,
prev: HashMap<ContactKey, Vec3>,
next: HashMap<ContactKey, Vec3>,
hits: usize,
misses: usize,
}
impl Default for ContactCache {
fn default() -> Self {
Self::new(Self::DEFAULT_CELL)
}
}
impl ContactCache {
pub const DEFAULT_CELL: f64 = 2e-3;
pub fn new(cell: f64) -> Self {
Self {
cell: if cell > 0.0 { cell } else { Self::DEFAULT_CELL },
prev: HashMap::new(),
next: HashMap::new(),
hits: 0,
misses: 0,
}
}
pub fn key(&self, state: &State, c: &Collision) -> ContactKey {
let (lo, hi) = if c.body_i <= c.body_j {
(c.body_i, c.body_j)
} else {
(c.body_j, c.body_i)
};
let local = match state.body_xform.get(lo) {
Some(x) => x.world_to_body_point(c.contact_point),
None => c.contact_point,
};
let q = |v: f64| (v / self.cell).round() as i64;
ContactKey {
body_i: lo,
body_j: hi,
cell: [q(local.x), q(local.y), q(local.z)],
}
}
pub fn warm_start(&mut self, state: &State, contacts: &[Collision]) -> Vec<Vec3> {
contacts
.iter()
.map(|c| match self.prev.get(&self.key(state, c)) {
Some(f) => {
self.hits += 1;
*f
}
None => {
self.misses += 1;
Vec3::zeros()
}
})
.collect()
}
pub fn store(&mut self, state: &State, contacts: &[Collision], impulses: &[Vec3]) {
self.next.clear();
for (c, f) in contacts.iter().zip(impulses) {
self.next.insert(self.key(state, c), *f);
}
std::mem::swap(&mut self.prev, &mut self.next);
}
pub fn clear(&mut self) {
self.prev.clear();
self.next.clear();
self.hits = 0;
self.misses = 0;
}
pub fn stats(&self) -> (usize, usize) {
(self.hits, self.misses)
}
pub fn len(&self) -> usize {
self.prev.len()
}
pub fn is_empty(&self) -> bool {
self.prev.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use phyz_math::{Mat3, SpatialTransform};
use phyz_model::ModelBuilder;
fn state_with(xform: SpatialTransform) -> State {
let model = ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"b",
-1,
SpatialTransform::identity(),
phyz_math::SpatialInertia::sphere(1.0, 0.1),
)
.build();
let mut s = model.default_state();
s.body_xform[0] = xform;
s
}
fn ground_contact(p: Vec3) -> Collision {
Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: p,
contact_normal: Vec3::z(),
penetration_depth: 1e-4,
}
}
#[test]
fn a_resting_contact_keeps_its_key_and_warm_starts() {
let state = state_with(SpatialTransform::identity());
let contacts = vec![ground_contact(Vec3::new(0.0, 0.0, -0.1))];
let mut cache = ContactCache::default();
assert_eq!(cache.warm_start(&state, &contacts)[0], Vec3::zeros());
let solved = vec![Vec3::new(9.81e-3, 0.0, 0.0)];
cache.store(&state, &contacts, &solved);
let jittered = vec![ground_contact(Vec3::new(1e-5, -2e-5, -0.1))];
let seed = cache.warm_start(&state, &jittered);
assert_eq!(seed[0], solved[0], "jitter within a cell must still hit");
}
#[test]
fn a_sliding_body_keeps_its_contact_identity() {
let mut cache = ContactCache::default();
let s0 = state_with(SpatialTransform::identity());
let c0 = vec![ground_contact(Vec3::new(0.0, 0.0, -0.1))];
cache.store(&s0, &c0, &[Vec3::new(1.0, 0.2, 0.0)]);
let s1 = state_with(SpatialTransform::new(
Mat3::identity(),
Vec3::new(0.05, 0.0, 0.0),
));
let c1 = vec![ground_contact(Vec3::new(0.05, 0.0, -0.1))];
let seed = cache.warm_start(&s1, &c1);
assert_eq!(seed[0], Vec3::new(1.0, 0.2, 0.0));
}
#[test]
fn distinct_features_do_not_collide_and_stale_ones_are_evicted() {
let state = state_with(SpatialTransform::identity());
let corners = vec![
ground_contact(Vec3::new(0.05, 0.05, -0.1)),
ground_contact(Vec3::new(-0.05, 0.05, -0.1)),
];
let mut cache = ContactCache::default();
cache.store(
&state,
&corners,
&[Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)],
);
assert_eq!(cache.len(), 2);
let seed = cache.warm_start(&state, &corners);
assert_eq!(seed[0].x, 1.0);
assert_eq!(seed[1].x, 2.0);
cache.store(&state, &corners[..1], &[Vec3::new(3.0, 0.0, 0.0)]);
assert_eq!(cache.len(), 1);
let seed = cache.warm_start(&state, &corners);
assert_eq!(seed[0].x, 3.0);
assert_eq!(seed[1], Vec3::zeros());
}
}
#[cfg(test)]
mod rotation_tests {
use super::*;
use phyz_math::{Quat, SpatialTransform, SpatialTransformExt, Vec3};
#[test]
fn a_pivoting_body_keeps_the_key_of_the_feature_it_pivots_on() {
let model = phyz_model::ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"b",
-1,
SpatialTransform::identity(),
phyz_math::SpatialInertia::sphere(1.0, 0.1),
)
.build();
let feature_local = Vec3::new(0.1, 0.0, -0.1);
let impulse = Vec3::new(3.0, 0.5, 0.0);
let mut cache = ContactCache::default();
let mut s0 = model.default_state();
s0.body_xform[0] = SpatialTransform::identity();
let c0 = Collision {
body_i: 0,
body_j: Collision::WORLD,
contact_point: feature_local,
contact_normal: Vec3::z(),
penetration_depth: 1e-4,
};
cache.store(&s0, std::slice::from_ref(&c0), &[impulse]);
let mut s1 = model.default_state();
let xf = SpatialTransform {
rot: Quat::from_axis_angle(Vec3::y(), -0.1).to_matrix(),
pos: Vec3::new(0.003, 0.0, 0.001),
};
s1.body_xform[0] = xf;
let c1 = Collision {
contact_point: xf.body_to_world_point(feature_local),
..c0
};
let seed = cache.warm_start(&s1, &[c1]);
assert_eq!(
seed[0], impulse,
"the same body-frame feature under rotation lost its warm start — \
the key is not rotation-invariant"
);
}
}