Skip to main content

symtropy_physics/
contact.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4use arrayvec::ArrayVec;
5use nalgebra::SVector;
6
7use crate::body::BodyHandle;
8
9/// Maximum contact points per manifold. D+1 covers all common cases up to 4D.
10pub const MAX_CONTACTS: usize = 8;
11
12/// A single contact point within a manifold.
13#[derive(Clone, Debug)]
14pub struct ContactPoint<const D: usize> {
15    /// Contact position in world space.
16    pub position: SVector<f64, D>,
17    /// Penetration depth at this point (positive = overlapping).
18    pub depth: f64,
19    /// Accumulated normal impulse this frame (TGS Soft solver state).
20    /// Initialised to 0.0 each frame; warm-started from the previous frame's cache.
21    pub lambda: f64,
22}
23
24/// Collision event emitted when two bodies collide.
25#[derive(Clone, Debug)]
26pub struct CollisionEvent<const D: usize> {
27    pub body_a: BodyHandle,
28    pub body_b: BodyHandle,
29    /// Impulse magnitude applied during resolution.
30    pub impulse: f64,
31    /// Contact normal.
32    pub normal: SVector<f64, D>,
33    /// Penetration depth.
34    pub depth: f64,
35}
36
37/// Sensor overlap event (no collision resolution, just notification).
38#[derive(Clone, Debug)]
39pub struct SensorEvent {
40    /// The sensor body.
41    pub sensor: BodyHandle,
42    /// The other body overlapping the sensor.
43    pub other: BodyHandle,
44}
45
46/// Contact information from a collision between two bodies.
47///
48/// Holds one or more contact points. Multi-point manifolds arise from
49/// flat-on-flat contacts (e.g., box on plane = 4 points in 3D).
50#[derive(Clone, Debug)]
51pub struct ContactManifold<const D: usize> {
52    /// Handle of body A.
53    pub body_a: BodyHandle,
54    /// Handle of body B.
55    pub body_b: BodyHandle,
56    /// Contact normal pointing from A to B.
57    pub normal: SVector<f64, D>,
58    /// Contact points (at least 1). Zero-heap via ArrayVec.
59    pub points: ArrayVec<ContactPoint<D>, MAX_CONTACTS>,
60}
61
62impl<const D: usize> ContactManifold<D> {
63    /// Create a single-point manifold (backward compatible).
64    pub fn single(
65        body_a: BodyHandle,
66        body_b: BodyHandle,
67        normal: SVector<f64, D>,
68        point: SVector<f64, D>,
69        depth: f64,
70    ) -> Self {
71        let mut points = ArrayVec::new();
72        points.push(ContactPoint {
73            position: point,
74            depth,
75            lambda: 0.0,
76        });
77        Self {
78            body_a,
79            body_b,
80            normal,
81            points,
82        }
83    }
84
85    /// Primary contact point (deepest penetration).
86    pub fn primary_point(&self) -> &ContactPoint<D> {
87        self.points
88            .iter()
89            .max_by(|a, b| a.depth.total_cmp(&b.depth))
90            .unwrap_or(&self.points[0])
91    }
92
93    /// Primary penetration depth (deepest point).
94    pub fn depth(&self) -> f64 {
95        self.primary_point().depth
96    }
97
98    /// Primary contact position (deepest point).
99    pub fn point(&self) -> SVector<f64, D> {
100        self.primary_point().position
101    }
102
103    /// Impulse magnitude for elastic collision with given restitution.
104    ///
105    /// j = -(1 + e) * v_rel · n / (1/m_a + 1/m_b)
106    pub fn impulse_magnitude(
107        &self,
108        relative_velocity: &SVector<f64, D>,
109        inv_mass_a: f64,
110        inv_mass_b: f64,
111        restitution: f64,
112    ) -> f64 {
113        let v_rel_n = relative_velocity.dot(&self.normal);
114
115        // Separating — no impulse needed
116        if v_rel_n > 0.0 {
117            return 0.0;
118        }
119
120        let denom = inv_mass_a + inv_mass_b;
121        if denom < 1e-15 {
122            return 0.0;
123        }
124
125        -(1.0 + restitution) * v_rel_n / denom
126    }
127}
128
129/// Cached impulse from a previous frame for warm-starting.
130#[derive(Clone, Debug)]
131pub struct CachedImpulse<const D: usize> {
132    /// Normal impulse magnitude from previous resolution.
133    pub normal_impulse: f64,
134    /// Tangent impulse magnitude from previous resolution.
135    pub tangent_impulse: f64,
136    /// Contact point from previous frame (for proximity matching).
137    pub point: SVector<f64, D>,
138}
139
140/// Cache of previous-frame impulses for warm-starting the contact solver.
141///
142/// Uses `BTreeMap` for deterministic iteration order (critical for replay).
143/// Maps `(body_a, body_b)` → cached impulse data.
144pub struct ContactCache<const D: usize> {
145    entries: std::collections::BTreeMap<(BodyHandle, BodyHandle), Vec<CachedImpulse<D>>>,
146    /// Proximity threshold for matching contacts across frames.
147    pub match_threshold: f64,
148}
149
150impl<const D: usize> ContactCache<D> {
151    pub fn new() -> Self {
152        Self {
153            entries: std::collections::BTreeMap::new(),
154            match_threshold: 2.0,
155        }
156    }
157
158    /// Store impulses from this frame's contact resolution.
159    pub fn store(
160        &mut self,
161        body_a: BodyHandle,
162        body_b: BodyHandle,
163        point: SVector<f64, D>,
164        normal_impulse: f64,
165        tangent_impulse: f64,
166    ) {
167        let key = if body_a < body_b {
168            (body_a, body_b)
169        } else {
170            (body_b, body_a)
171        };
172        let entry = self.entries.entry(key).or_default();
173        entry.push(CachedImpulse {
174            normal_impulse,
175            tangent_impulse,
176            point,
177        });
178    }
179
180    /// Look up the cached impulse for a contact point from the previous frame.
181    ///
182    /// Matches by body pair + proximity of contact point.
183    pub fn lookup(
184        &self,
185        body_a: BodyHandle,
186        body_b: BodyHandle,
187        point: &SVector<f64, D>,
188    ) -> Option<&CachedImpulse<D>> {
189        let key = if body_a < body_b {
190            (body_a, body_b)
191        } else {
192            (body_b, body_a)
193        };
194        let entries = self.entries.get(&key)?;
195        // Find the closest cached point within threshold
196        entries
197            .iter()
198            .filter(|c| (c.point - point).norm() < self.match_threshold)
199            .min_by(|a, b| {
200                let da = (a.point - point).norm();
201                let db = (b.point - point).norm();
202                da.total_cmp(&db)
203            })
204    }
205
206    /// Clear all cached data (call at the start of each frame before resolving).
207    pub fn begin_frame(&mut self) {
208        self.entries.clear();
209    }
210
211    /// Number of cached contact pairs.
212    pub fn pair_count(&self) -> usize {
213        self.entries.len()
214    }
215}
216
217impl<const D: usize> Default for ContactCache<D> {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn single_point_manifold() {
229        let m = ContactManifold::<3>::single(
230            BodyHandle(0),
231            BodyHandle(1),
232            SVector::from([0.0, 1.0, 0.0]),
233            SVector::from([1.0, 0.0, 0.0]),
234            0.5,
235        );
236        assert_eq!(m.points.len(), 1);
237        assert!((m.depth() - 0.5).abs() < 1e-12);
238    }
239
240    #[test]
241    fn multi_point_primary_is_deepest() {
242        let mut m = ContactManifold::<3>::single(
243            BodyHandle(0),
244            BodyHandle(1),
245            SVector::from([0.0, 1.0, 0.0]),
246            SVector::from([1.0, 0.0, 0.0]),
247            0.3,
248        );
249        m.points.push(ContactPoint {
250            position: SVector::from([2.0, 0.0, 0.0]),
251            depth: 0.7,
252            lambda: 0.0,
253        });
254        assert_eq!(m.points.len(), 2);
255        assert!((m.depth() - 0.7).abs() < 1e-12, "primary should be deepest");
256    }
257
258    #[test]
259    fn contact_cache_store_and_lookup() {
260        let mut cache = ContactCache::<3>::new();
261        let pt = SVector::from([1.0, 0.0, 0.0]);
262        cache.store(BodyHandle(0), BodyHandle(1), pt, 5.0, 1.0);
263
264        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &pt);
265        assert!(hit.is_some());
266        assert!((hit.unwrap().normal_impulse - 5.0).abs() < 1e-12);
267    }
268
269    #[test]
270    fn contact_cache_proximity_match() {
271        let mut cache = ContactCache::<3>::new();
272        cache.store(
273            BodyHandle(0),
274            BodyHandle(1),
275            SVector::from([1.0, 0.0, 0.0]),
276            5.0,
277            1.0,
278        );
279
280        // Slightly displaced point should still match
281        let nearby = SVector::from([1.5, 0.0, 0.0]);
282        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &nearby);
283        assert!(hit.is_some(), "nearby point should match cached contact");
284
285        // Far point should not match
286        let far = SVector::from([100.0, 0.0, 0.0]);
287        let miss = cache.lookup(BodyHandle(0), BodyHandle(1), &far);
288        assert!(miss.is_none(), "far point should not match");
289    }
290
291    #[test]
292    fn contact_cache_symmetric_keys() {
293        let mut cache = ContactCache::<3>::new();
294        let pt = SVector::from([0.0, 0.0, 0.0]);
295        cache.store(BodyHandle(1), BodyHandle(0), pt, 3.0, 0.5);
296
297        // Lookup with reversed body order should still find it
298        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &pt);
299        assert!(hit.is_some(), "cache should be symmetric on body order");
300    }
301
302    #[test]
303    fn contact_cache_begin_frame_clears() {
304        let mut cache = ContactCache::<3>::new();
305        cache.store(BodyHandle(0), BodyHandle(1), SVector::zeros(), 1.0, 0.0);
306        assert_eq!(cache.pair_count(), 1);
307
308        cache.begin_frame();
309        assert_eq!(cache.pair_count(), 0);
310    }
311}