Skip to main content

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}