Skip to main content

symtropy_physics/
contact.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
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 { position: point, depth, lambda: 0.0 });
73        Self {
74            body_a,
75            body_b,
76            normal,
77            points,
78        }
79    }
80
81    /// Primary contact point (deepest penetration).
82    pub fn primary_point(&self) -> &ContactPoint<D> {
83        self.points
84            .iter()
85            .max_by(|a, b| a.depth.total_cmp(&b.depth))
86            .unwrap_or(&self.points[0])
87    }
88
89    /// Primary penetration depth (deepest point).
90    pub fn depth(&self) -> f64 {
91        self.primary_point().depth
92    }
93
94    /// Primary contact position (deepest point).
95    pub fn point(&self) -> SVector<f64, D> {
96        self.primary_point().position
97    }
98
99    /// Impulse magnitude for elastic collision with given restitution.
100    ///
101    /// j = -(1 + e) * v_rel · n / (1/m_a + 1/m_b)
102    pub fn impulse_magnitude(
103        &self,
104        relative_velocity: &SVector<f64, D>,
105        inv_mass_a: f64,
106        inv_mass_b: f64,
107        restitution: f64,
108    ) -> f64 {
109        let v_rel_n = relative_velocity.dot(&self.normal);
110
111        // Separating — no impulse needed
112        if v_rel_n > 0.0 {
113            return 0.0;
114        }
115
116        let denom = inv_mass_a + inv_mass_b;
117        if denom < 1e-15 {
118            return 0.0;
119        }
120
121        -(1.0 + restitution) * v_rel_n / denom
122    }
123}
124
125/// Cached impulse from a previous frame for warm-starting.
126#[derive(Clone, Debug)]
127pub struct CachedImpulse<const D: usize> {
128    /// Normal impulse magnitude from previous resolution.
129    pub normal_impulse: f64,
130    /// Tangent impulse magnitude from previous resolution.
131    pub tangent_impulse: f64,
132    /// Contact point from previous frame (for proximity matching).
133    pub point: SVector<f64, D>,
134}
135
136/// Cache of previous-frame impulses for warm-starting the contact solver.
137///
138/// Uses `BTreeMap` for deterministic iteration order (critical for replay).
139/// Maps `(body_a, body_b)` → cached impulse data.
140pub struct ContactCache<const D: usize> {
141    entries: std::collections::BTreeMap<(BodyHandle, BodyHandle), Vec<CachedImpulse<D>>>,
142    /// Proximity threshold for matching contacts across frames.
143    pub match_threshold: f64,
144}
145
146impl<const D: usize> ContactCache<D> {
147    pub fn new() -> Self {
148        Self {
149            entries: std::collections::BTreeMap::new(),
150            match_threshold: 2.0,
151        }
152    }
153
154    /// Store impulses from this frame's contact resolution.
155    pub fn store(
156        &mut self,
157        body_a: BodyHandle,
158        body_b: BodyHandle,
159        point: SVector<f64, D>,
160        normal_impulse: f64,
161        tangent_impulse: f64,
162    ) {
163        let key = if body_a < body_b {
164            (body_a, body_b)
165        } else {
166            (body_b, body_a)
167        };
168        let entry = self.entries.entry(key).or_insert_with(Vec::new);
169        entry.push(CachedImpulse {
170            normal_impulse,
171            tangent_impulse,
172            point,
173        });
174    }
175
176    /// Look up the cached impulse for a contact point from the previous frame.
177    ///
178    /// Matches by body pair + proximity of contact point.
179    pub fn lookup(
180        &self,
181        body_a: BodyHandle,
182        body_b: BodyHandle,
183        point: &SVector<f64, D>,
184    ) -> Option<&CachedImpulse<D>> {
185        let key = if body_a < body_b {
186            (body_a, body_b)
187        } else {
188            (body_b, body_a)
189        };
190        let entries = self.entries.get(&key)?;
191        // Find the closest cached point within threshold
192        entries
193            .iter()
194            .filter(|c| (c.point - point).norm() < self.match_threshold)
195            .min_by(|a, b| {
196                let da = (a.point - point).norm();
197                let db = (b.point - point).norm();
198                da.total_cmp(&db)
199            })
200    }
201
202    /// Clear all cached data (call at the start of each frame before resolving).
203    pub fn begin_frame(&mut self) {
204        self.entries.clear();
205    }
206
207    /// Number of cached contact pairs.
208    pub fn pair_count(&self) -> usize {
209        self.entries.len()
210    }
211}
212
213impl<const D: usize> Default for ContactCache<D> {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn single_point_manifold() {
225        let m = ContactManifold::<3>::single(
226            BodyHandle(0), BodyHandle(1),
227            SVector::from([0.0, 1.0, 0.0]),
228            SVector::from([1.0, 0.0, 0.0]),
229            0.5,
230        );
231        assert_eq!(m.points.len(), 1);
232        assert!((m.depth() - 0.5).abs() < 1e-12);
233    }
234
235    #[test]
236    fn multi_point_primary_is_deepest() {
237        let mut m = ContactManifold::<3>::single(
238            BodyHandle(0), BodyHandle(1),
239            SVector::from([0.0, 1.0, 0.0]),
240            SVector::from([1.0, 0.0, 0.0]),
241            0.3,
242        );
243        m.points.push(ContactPoint {
244            position: SVector::from([2.0, 0.0, 0.0]),
245            depth: 0.7,
246            lambda: 0.0,
247        });
248        assert_eq!(m.points.len(), 2);
249        assert!((m.depth() - 0.7).abs() < 1e-12, "primary should be deepest");
250    }
251
252    #[test]
253    fn contact_cache_store_and_lookup() {
254        let mut cache = ContactCache::<3>::new();
255        let pt = SVector::from([1.0, 0.0, 0.0]);
256        cache.store(BodyHandle(0), BodyHandle(1), pt, 5.0, 1.0);
257
258        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &pt);
259        assert!(hit.is_some());
260        assert!((hit.unwrap().normal_impulse - 5.0).abs() < 1e-12);
261    }
262
263    #[test]
264    fn contact_cache_proximity_match() {
265        let mut cache = ContactCache::<3>::new();
266        cache.store(BodyHandle(0), BodyHandle(1), SVector::from([1.0, 0.0, 0.0]), 5.0, 1.0);
267
268        // Slightly displaced point should still match
269        let nearby = SVector::from([1.5, 0.0, 0.0]);
270        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &nearby);
271        assert!(hit.is_some(), "nearby point should match cached contact");
272
273        // Far point should not match
274        let far = SVector::from([100.0, 0.0, 0.0]);
275        let miss = cache.lookup(BodyHandle(0), BodyHandle(1), &far);
276        assert!(miss.is_none(), "far point should not match");
277    }
278
279    #[test]
280    fn contact_cache_symmetric_keys() {
281        let mut cache = ContactCache::<3>::new();
282        let pt = SVector::from([0.0, 0.0, 0.0]);
283        cache.store(BodyHandle(1), BodyHandle(0), pt, 3.0, 0.5);
284
285        // Lookup with reversed body order should still find it
286        let hit = cache.lookup(BodyHandle(0), BodyHandle(1), &pt);
287        assert!(hit.is_some(), "cache should be symmetric on body order");
288    }
289
290    #[test]
291    fn contact_cache_begin_frame_clears() {
292        let mut cache = ContactCache::<3>::new();
293        cache.store(BodyHandle(0), BodyHandle(1), SVector::zeros(), 1.0, 0.0);
294        assert_eq!(cache.pair_count(), 1);
295
296        cache.begin_frame();
297        assert_eq!(cache.pair_count(), 0);
298    }
299}