phyz_contact/cache.rs
1//! Contact persistence across timesteps, for warm starting the solve.
2//!
3//! The narrow phase hands the solver a fresh `Vec<Collision>` every step, in
4//! whatever order the broadphase happened to produce, with no identity
5//! attached. So the convex solve started from zero impulses every step even
6//! though a standing body's stance contacts are solving *almost the same
7//! problem* as the step before. Projected Gauss-Seidel converges linearly:
8//! from zero it spends its whole iteration budget re-discovering that the feet
9//! carry `m g dt`, and on a big stack it often does not get there at all
10//! before `max_iterations` — which means [`crate::gradient`] refuses to
11//! differentiate the step, because an unconverged iterate is not a KKT point.
12//!
13//! This module gives contacts a stable identity so last step's impulses can be
14//! carried forward.
15//!
16//! # What makes a key stable
17//!
18//! Not the world-space contact point: a foot that is perfectly still in
19//! world space is the *easy* case, and a rolling wheel or a walking foot moves
20//! its world contact point every step even where the contact is physically the
21//! same feature. The key is the contact point expressed in the **local frame
22//! of the reference body**, quantized to a grid. That is invariant to the
23//! body's own rigid motion, so a box sliding across the floor keeps its four
24//! corner contacts identified across steps, and a body rotating under a static
25//! contact correctly reports a *changing* feature — which it is.
26//!
27//! Quantization is what makes the key an equality-comparable hash key at all,
28//! and the grid must be coarse relative to per-step contact-point jitter
29//! (millimetres) and fine relative to the distance between genuinely distinct
30//! features (centimetres). [`ContactCache::DEFAULT_CELL`] sits at 2 mm.
31//!
32//! A mis-keyed contact costs nothing but a bad initial guess: the problem is
33//! strongly convex, so every start converges to the same unique minimizer.
34//! Warm starting can only change how many iterations that takes, never the
35//! answer. That is the property that makes this safe to do at all.
36
37use phyz_collision::Collision;
38use phyz_math::{SpatialTransformExt, Vec3};
39use phyz_model::State;
40use std::collections::HashMap;
41
42/// A stable identifier for a contact across timesteps.
43///
44/// Ordered so that the pair `(i, j)` and `(j, i)` cannot collide, and so the
45/// quantized point is read in the frame named by `reference`.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub struct ContactKey {
48 /// The lower-numbered body of the pair ([`Collision::WORLD`] = world).
49 pub body_i: usize,
50 /// The higher-numbered body of the pair ([`Collision::WORLD`] = world).
51 pub body_j: usize,
52 /// Contact point in `body_i`'s local frame, quantized to the cache's grid.
53 pub cell: [i64; 3],
54}
55
56/// Per-step contact impulses, keyed by feature, for warm starting.
57///
58/// Usage is two calls per step around the solve: [`ContactCache::warm_start`]
59/// before it to build the seed vector, [`ContactCache::store`] after it to
60/// record the result. `store` replaces the whole table rather than merging, so
61/// contacts that ended are dropped and the cache cannot grow without bound.
62#[derive(Debug, Clone)]
63pub struct ContactCache {
64 cell: f64,
65 prev: HashMap<ContactKey, Vec3>,
66 next: HashMap<ContactKey, Vec3>,
67 hits: usize,
68 misses: usize,
69}
70
71impl Default for ContactCache {
72 fn default() -> Self {
73 Self::new(Self::DEFAULT_CELL)
74 }
75}
76
77impl ContactCache {
78 /// Default quantization cell, in metres.
79 ///
80 /// Coarse enough to absorb the sub-millimetre wander of a narrow-phase
81 /// contact point on a resting face, fine enough to keep the corners of a
82 /// centimetre-scale box distinct.
83 pub const DEFAULT_CELL: f64 = 2e-3;
84
85 /// Create a cache with a given quantization cell size, in metres.
86 pub fn new(cell: f64) -> Self {
87 Self {
88 cell: if cell > 0.0 { cell } else { Self::DEFAULT_CELL },
89 prev: HashMap::new(),
90 next: HashMap::new(),
91 hits: 0,
92 misses: 0,
93 }
94 }
95
96 /// The feature key for one contact at the current state.
97 pub fn key(&self, state: &State, c: &Collision) -> ContactKey {
98 // Order the pair so the key does not depend on narrow-phase ordering.
99 let (lo, hi) = if c.body_i <= c.body_j {
100 (c.body_i, c.body_j)
101 } else {
102 (c.body_j, c.body_i)
103 };
104 // Express the point in the reference body's frame — invariant to that
105 // body's own rigid motion, which is what a world-space key would lose.
106 // `rot` is already world→body here, so the old `rot.transpose() *`
107 // rotated the offset *out* of the body frame instead of into it: the
108 // key silently varied under rotation and a rolling or pivoting contact
109 // lost its warm start exactly when warm starts pay.
110 let local = match state.body_xform.get(lo) {
111 Some(x) => x.world_to_body_point(c.contact_point),
112 None => c.contact_point,
113 };
114 let q = |v: f64| (v / self.cell).round() as i64;
115 ContactKey {
116 body_i: lo,
117 body_j: hi,
118 cell: [q(local.x), q(local.y), q(local.z)],
119 }
120 }
121
122 /// Seed impulses for `contacts`, in the same order, from the previous step.
123 ///
124 /// Contacts with no cached match seed at zero, which is exactly the cold
125 /// start. The returned vector is what [`crate::solve_contacts_warm`] takes.
126 pub fn warm_start(&mut self, state: &State, contacts: &[Collision]) -> Vec<Vec3> {
127 contacts
128 .iter()
129 .map(|c| match self.prev.get(&self.key(state, c)) {
130 Some(f) => {
131 self.hits += 1;
132 *f
133 }
134 None => {
135 self.misses += 1;
136 Vec3::zeros()
137 }
138 })
139 .collect()
140 }
141
142 /// Record the solved impulses for the next step's warm start.
143 pub fn store(&mut self, state: &State, contacts: &[Collision], impulses: &[Vec3]) {
144 self.next.clear();
145 for (c, f) in contacts.iter().zip(impulses) {
146 self.next.insert(self.key(state, c), *f);
147 }
148 std::mem::swap(&mut self.prev, &mut self.next);
149 }
150
151 /// Forget every cached contact. Call after teleporting or resetting a
152 /// state, where last step's impulses are not a guess about this step.
153 pub fn clear(&mut self) {
154 self.prev.clear();
155 self.next.clear();
156 self.hits = 0;
157 self.misses = 0;
158 }
159
160 /// `(hits, misses)` since construction or the last [`Self::clear`].
161 ///
162 /// A persistently low hit rate on a body that is visibly resting means the
163 /// key is not identifying the feature — the cell size is likely wrong for
164 /// the scale of the model.
165 pub fn stats(&self) -> (usize, usize) {
166 (self.hits, self.misses)
167 }
168
169 /// Number of contacts currently remembered.
170 pub fn len(&self) -> usize {
171 self.prev.len()
172 }
173
174 /// Whether anything is remembered.
175 pub fn is_empty(&self) -> bool {
176 self.prev.is_empty()
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use phyz_math::{Mat3, SpatialTransform};
184 use phyz_model::ModelBuilder;
185
186 fn state_with(xform: SpatialTransform) -> State {
187 let model = ModelBuilder::new()
188 .dt(1e-3)
189 .add_free_body(
190 "b",
191 -1,
192 SpatialTransform::identity(),
193 phyz_math::SpatialInertia::sphere(1.0, 0.1),
194 )
195 .build();
196 let mut s = model.default_state();
197 s.body_xform[0] = xform;
198 s
199 }
200
201 fn ground_contact(p: Vec3) -> Collision {
202 Collision {
203 body_i: 0,
204 body_j: Collision::WORLD,
205 contact_point: p,
206 contact_normal: Vec3::z(),
207 penetration_depth: 1e-4,
208 }
209 }
210
211 #[test]
212 fn a_resting_contact_keeps_its_key_and_warm_starts() {
213 let state = state_with(SpatialTransform::identity());
214 let contacts = vec![ground_contact(Vec3::new(0.0, 0.0, -0.1))];
215 let mut cache = ContactCache::default();
216
217 assert_eq!(cache.warm_start(&state, &contacts)[0], Vec3::zeros());
218 let solved = vec![Vec3::new(9.81e-3, 0.0, 0.0)];
219 cache.store(&state, &contacts, &solved);
220
221 // Next step, sub-cell jitter in the contact point.
222 let jittered = vec![ground_contact(Vec3::new(1e-5, -2e-5, -0.1))];
223 let seed = cache.warm_start(&state, &jittered);
224 assert_eq!(seed[0], solved[0], "jitter within a cell must still hit");
225 }
226
227 /// The point of keying in the body's local frame: a body that translates
228 /// keeps the identity of the feature it is resting on.
229 #[test]
230 fn a_sliding_body_keeps_its_contact_identity() {
231 let mut cache = ContactCache::default();
232 let s0 = state_with(SpatialTransform::identity());
233 let c0 = vec![ground_contact(Vec3::new(0.0, 0.0, -0.1))];
234 cache.store(&s0, &c0, &[Vec3::new(1.0, 0.2, 0.0)]);
235
236 // Body has slid 5 cm in +x; the contact point under it moved with it.
237 let s1 = state_with(SpatialTransform::new(
238 Mat3::identity(),
239 Vec3::new(0.05, 0.0, 0.0),
240 ));
241 let c1 = vec![ground_contact(Vec3::new(0.05, 0.0, -0.1))];
242 let seed = cache.warm_start(&s1, &c1);
243 assert_eq!(seed[0], Vec3::new(1.0, 0.2, 0.0));
244 }
245
246 #[test]
247 fn distinct_features_do_not_collide_and_stale_ones_are_evicted() {
248 let state = state_with(SpatialTransform::identity());
249 let corners = vec![
250 ground_contact(Vec3::new(0.05, 0.05, -0.1)),
251 ground_contact(Vec3::new(-0.05, 0.05, -0.1)),
252 ];
253 let mut cache = ContactCache::default();
254 cache.store(
255 &state,
256 &corners,
257 &[Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)],
258 );
259 assert_eq!(cache.len(), 2);
260 let seed = cache.warm_start(&state, &corners);
261 assert_eq!(seed[0].x, 1.0);
262 assert_eq!(seed[1].x, 2.0);
263
264 // Only one corner survives the next step: the other is forgotten.
265 cache.store(&state, &corners[..1], &[Vec3::new(3.0, 0.0, 0.0)]);
266 assert_eq!(cache.len(), 1);
267 let seed = cache.warm_start(&state, &corners);
268 assert_eq!(seed[0].x, 3.0);
269 assert_eq!(seed[1], Vec3::zeros());
270 }
271}
272
273#[cfg(test)]
274mod rotation_tests {
275 use super::*;
276 use phyz_math::{Quat, SpatialTransform, SpatialTransformExt, Vec3};
277
278 /// A body pivoting on a fixed feature keeps that feature's key.
279 ///
280 /// This is the case the old `rot.transpose()` broke: it rotated the offset
281 /// *out* of the body frame instead of into it, so the key varied with the
282 /// body's orientation and a rolling or pivoting contact lost its warm start
283 /// exactly when warm starts pay. Every prior test used an identity or
284 /// translation-only transform, which is why it survived.
285 #[test]
286 fn a_pivoting_body_keeps_the_key_of_the_feature_it_pivots_on() {
287 let model = phyz_model::ModelBuilder::new()
288 .dt(1e-3)
289 .add_free_body(
290 "b",
291 -1,
292 SpatialTransform::identity(),
293 phyz_math::SpatialInertia::sphere(1.0, 0.1),
294 )
295 .build();
296
297 // The body-frame feature: a corner at (0.1, 0, -0.1) in the body.
298 let feature_local = Vec3::new(0.1, 0.0, -0.1);
299 let impulse = Vec3::new(3.0, 0.5, 0.0);
300 let mut cache = ContactCache::default();
301
302 // Step 0: unrotated. World point = local point.
303 let mut s0 = model.default_state();
304 s0.body_xform[0] = SpatialTransform::identity();
305 let c0 = Collision {
306 body_i: 0,
307 body_j: Collision::WORLD,
308 contact_point: feature_local,
309 contact_normal: Vec3::z(),
310 penetration_depth: 1e-4,
311 };
312 cache.store(&s0, std::slice::from_ref(&c0), &[impulse]);
313
314 // Step 1: the body has pitched 0.1 rad about the feature. Same
315 // body-frame feature, new world position — computed with the named
316 // body→world method so the test cannot itself have the convention bug.
317 let mut s1 = model.default_state();
318 let xf = SpatialTransform {
319 rot: Quat::from_axis_angle(Vec3::y(), -0.1).to_matrix(),
320 pos: Vec3::new(0.003, 0.0, 0.001),
321 };
322 s1.body_xform[0] = xf;
323 let c1 = Collision {
324 contact_point: xf.body_to_world_point(feature_local),
325 ..c0
326 };
327
328 let seed = cache.warm_start(&s1, &[c1]);
329 assert_eq!(
330 seed[0], impulse,
331 "the same body-frame feature under rotation lost its warm start — \
332 the key is not rotation-invariant"
333 );
334 }
335}