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