Skip to main content

gizmo_physics_core/
collision.rs

1use gizmo_core::entity::Entity;
2use gizmo_math::Vec3;
3
4// ============================================================================
5//  ContactPoint
6// ============================================================================
7
8/// A single contact point between two colliding bodies.
9///
10/// `normal` always points **from body A toward body B** (the separating
11/// direction for body A).  Both `local_point_*` fields are populated by the
12/// dispatcher for warm-starting the constraint solver across frames.
13#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)]
14pub struct ContactPoint {
15    /// World-space contact position (midpoint on the contact surface).
16    pub point: Vec3,
17    /// Contact normal, pointing from A to B (unit vector).
18    pub normal: Vec3,
19    /// Penetration depth (always ≥ 0).
20    pub penetration: f32,
21    /// `point` expressed in body A's local space (set by the dispatcher).
22    pub local_point_a: Vec3,
23    /// `point` expressed in body B's local space (set by the dispatcher).
24    pub local_point_b: Vec3,
25    /// Accumulated normal impulse — reused for warm-starting.
26    pub normal_impulse: f32,
27    /// Accumulated tangential impulse — reused for warm-starting.
28    pub tangent_impulse: Vec3,
29}
30
31// ============================================================================
32//  ContactManifold
33// ============================================================================
34
35/// Up to four contact points between a pair of bodies, along with the
36/// combined material properties needed by the constraint solver.
37///
38/// # Contact limit & point selection
39///
40/// Physics engines conventionally cap manifolds at **4 points** because that
41/// is the minimum required to fully constrain a convex face-face contact.
42/// When a 5th point would be added we keep the configuration that maximises
43/// the contact area while retaining the deepest point:
44///
45/// 1. Always keep the deepest point (most important for penetration resolution).
46/// 2. Fill the remaining 3 slots by greedily maximising the minimum distance
47///    to any already-selected point (farthest-point heuristic — O(n) per slot).
48///
49/// This gives a good approximation of the convex hull of the contact patch
50/// without an expensive full hull computation.
51#[derive(Debug, Clone)]
52pub struct ContactManifold {
53    pub entity_a: Entity,
54    pub entity_b: Entity,
55    /// At most 4 contact points.
56    pub contacts: Vec<ContactPoint>,
57    /// Combined dynamic friction coefficient (geometric mean of both materials).
58    pub friction: f32,
59    /// Combined static friction coefficient.
60    pub static_friction: f32,
61    /// Combined coefficient of restitution (max of both materials).
62    pub restitution: f32,
63    /// Number of consecutive physics frames this manifold has been alive.
64    /// Incremented by the pipeline each frame; reset when the collision ends.
65    pub lifetime: u32,
66}
67
68impl ContactManifold {
69    /// Create a new manifold.  Entity order is normalised (lower id → entity_a)
70    /// so that cache lookups with either ordering always hit.
71    pub fn new(entity_a: Entity, entity_b: Entity) -> Self {
72        let (entity_a, entity_b) = if entity_a.id() <= entity_b.id() {
73            (entity_a, entity_b)
74        } else {
75            (entity_b, entity_a)
76        };
77        Self {
78            entity_a,
79            entity_b,
80            contacts: Vec::with_capacity(4),
81            // Sensible defaults; overwritten by the pipeline using
82            // PhysicsMaterial::combine before the solver runs.
83            friction: 0.5,
84            static_friction: 0.5,
85            restitution: 0.3,
86            lifetime: 0,
87        }
88    }
89
90    /// Add `contact` to the manifold, warm-starting from any existing point
91    /// that is within `MERGE_RADIUS` in world space.
92    ///
93    /// If the manifold is already at capacity (4 points) and no merge occurs,
94    /// the 5-point set is reduced back to 4 using the area-maximisation
95    /// heuristic described in the type-level docs.
96    pub fn add_contact(&mut self, contact: ContactPoint) {
97        const MERGE_RADIUS_SQ: f32 = 0.02 * 0.02;
98
99        // ── Warm-start merge ─────────────────────────────────────────────
100        for existing in &mut self.contacts {
101            if (existing.point - contact.point).length_squared() < MERGE_RADIUS_SQ {
102                // Update geometry but preserve accumulated impulses.
103                let saved_normal = existing.normal_impulse;
104                let saved_tangent = existing.tangent_impulse;
105                *existing = contact;
106                existing.normal_impulse = saved_normal;
107                existing.tangent_impulse = saved_tangent;
108                return;
109            }
110        }
111
112        // ── Fast path: still room ────────────────────────────────────────
113        if self.contacts.len() < 4 {
114            self.contacts.push(contact);
115            return;
116        }
117
118        // ── Reduce 5 → 4 with area-maximisation heuristic ────────────────
119        // Build a temporary 5-element array on the stack.
120        let mut pool = [ContactPoint::default(); 5];
121        pool[..4].copy_from_slice(&self.contacts);
122        pool[4] = contact;
123
124        self.contacts.clear();
125        self.contacts.extend_from_slice(&select_4_contacts(&pool));
126    }
127
128    /// Remove all contact points (does **not** reset `lifetime`).
129    pub fn clear(&mut self) {
130        self.contacts.clear();
131    }
132
133    /// Returns `true` if the manifold has not been refreshed within
134    /// `max_lifetime` frames — i.e. the collision pair has separated.
135    pub fn is_stale(&self, max_lifetime: u32) -> bool {
136        self.lifetime > max_lifetime
137    }
138}
139
140// ============================================================================
141//  4-point selection
142// ============================================================================
143
144/// Reduce `pool` (exactly 5 elements) to the 4 points that maximise the
145/// contact area:
146///
147/// 1. Pick the deepest point (index of maximum `penetration`).
148/// 2. Pick the point farthest from #1.
149/// 3. Pick the point farthest from the line #1–#2.
150/// 4. Pick the point that maximises the triangle area of the remaining set.
151///
152/// This is equivalent to a greedy farthest-point sampling and runs in O(1)
153/// (fixed pool size of 5).
154fn select_4_contacts(pool: &[ContactPoint; 5]) -> [ContactPoint; 4] {
155    // Step 1 — deepest point.
156    let i0 = (0..5)
157        .max_by(|&a, &b| pool[a].penetration.total_cmp(&pool[b].penetration))
158        .unwrap();
159
160    // Step 2 — farthest from i0.
161    let p0 = pool[i0].point;
162    let i1 = (0..5)
163        .filter(|&i| i != i0)
164        .max_by(|&a, &b| {
165            (pool[a].point - p0)
166                .length_squared()
167                .total_cmp(&(pool[b].point - p0).length_squared())
168        })
169        .unwrap();
170
171    // Step 3 — farthest from the line p0–p1.
172    let p1 = pool[i1].point;
173    let seg = (p1 - p0).normalize_or_zero();
174    let i2 = (0..5)
175        .filter(|&i| i != i0 && i != i1)
176        .max_by(|&a, &b| {
177            dist_sq_to_line(pool[a].point, p0, seg).total_cmp(&dist_sq_to_line(
178                pool[b].point,
179                p0,
180                seg,
181            ))
182        })
183        .unwrap();
184
185    // Step 4 — the remaining point that maximises the area of the
186    // quadrilateral formed by the 4 selected points.
187    //
188    // When the contact patch is coplanar (common case: face-on-face) the
189    // volume-based heuristic degenerates to zero.  Instead, compute the
190    // sum of triangle areas from the candidate to every pair of
191    // already-selected points.  This always picks the point that keeps
192    // the contact patch as spread-out as possible.
193    let p2 = pool[i2].point;
194    let i3 = (0..5)
195        .filter(|&i| i != i0 && i != i1 && i != i2)
196        .max_by(|&a, &b| {
197            let score = |idx: usize| -> f32 {
198                let q = pool[idx].point;
199                // Sum of cross-product magnitudes gives a good proxy for
200                // how much area the candidate adds to the patch.
201                (q - p0).cross(q - p1).length_squared()
202                    + (q - p1).cross(q - p2).length_squared()
203                    + (q - p2).cross(q - p0).length_squared()
204            };
205            score(a).total_cmp(&score(b))
206        })
207        .unwrap();
208
209    [pool[i0], pool[i1], pool[i2], pool[i3]]
210}
211
212/// Squared distance from `point` to the infinite line through `origin` along
213/// unit direction `dir`.
214#[inline]
215fn dist_sq_to_line(point: Vec3, origin: Vec3, dir: Vec3) -> f32 {
216    let d = point - origin;
217    let along = dir * d.dot(dir);
218    (d - along).length_squared()
219}
220
221// ============================================================================
222//  Event types
223// ============================================================================
224
225/// Whether a collision pair has just begun, is ongoing, or has ended.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227pub enum CollisionEventType {
228    /// First frame the pair is in contact.
229    Started,
230    /// Pair was already in contact last frame.
231    Persisting,
232    /// Pair is no longer in contact.
233    Ended,
234}
235
236/// Emitted every physics step for each solid collision pair.
237#[derive(Debug, Clone)]
238pub struct CollisionEvent {
239    pub entity_a: Entity,
240    pub entity_b: Entity,
241    pub event_type: CollisionEventType,
242    /// Solved contact points (populated after constraint resolution).
243    pub contact_points: arrayvec::ArrayVec<ContactPoint, 4>,
244}
245
246/// Emitted for trigger (non-solid) collider overlaps.
247#[derive(Debug, Clone)]
248pub struct TriggerEvent {
249    /// The entity whose collider has `is_trigger = true`.
250    pub trigger_entity: Entity,
251    pub other_entity: Entity,
252    pub event_type: CollisionEventType,
253}
254
255/// Emitted when a rigid body's fracture threshold is exceeded.
256#[derive(Debug, Clone, Copy)]
257pub struct FractureEvent {
258    pub entity: Entity,
259    pub impact_point: Vec3,
260    pub impact_force: f32,
261}
262
263// ============================================================================
264//  Tests
265// ============================================================================
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn make_entity(id: u32) -> Entity {
272        Entity::new(id, 0)
273    }
274
275    fn pt(x: f32, y: f32, pen: f32) -> ContactPoint {
276        ContactPoint {
277            point: Vec3::new(x, y, 0.0),
278            normal: Vec3::Y,
279            penetration: pen,
280            ..Default::default()
281        }
282    }
283
284    // ── Entity ordering ───────────────────────────────────────────────────
285
286    #[test]
287    fn manifold_normalises_entity_order() {
288        let e_high = make_entity(10);
289        let e_low = make_entity(5);
290        let m = ContactManifold::new(e_high, e_low);
291        assert_eq!(m.entity_a.id(), 5);
292        assert_eq!(m.entity_b.id(), 10);
293    }
294
295    #[test]
296    fn manifold_same_order_when_already_sorted() {
297        let e1 = make_entity(1);
298        let e2 = make_entity(2);
299        let m = ContactManifold::new(e1, e2);
300        assert_eq!(m.entity_a.id(), 1);
301        assert_eq!(m.entity_b.id(), 2);
302    }
303
304    // ── Warm-start merge ──────────────────────────────────────────────────
305
306    #[test]
307    fn warm_start_preserves_impulses_on_merge() {
308        let mut m = ContactManifold::new(make_entity(1), make_entity(2));
309
310        let mut first = pt(1.0, 0.0, 0.1);
311        first.normal_impulse = 5.0;
312        first.tangent_impulse = Vec3::new(1.0, 0.0, 0.0);
313        m.add_contact(first);
314
315        // New contact is within the merge radius with updated geometry.
316        let updated = pt(1.001, 0.0, 0.2);
317        m.add_contact(updated);
318
319        assert_eq!(m.contacts.len(), 1, "near-duplicate should merge, not add");
320        assert_eq!(
321            m.contacts[0].normal_impulse, 5.0,
322            "accumulated normal impulse must be preserved"
323        );
324        assert_eq!(
325            m.contacts[0].tangent_impulse,
326            Vec3::new(1.0, 0.0, 0.0),
327            "accumulated tangent impulse must be preserved"
328        );
329        assert!(
330            (m.contacts[0].penetration - 0.2).abs() < 1e-6,
331            "geometry (penetration) must be updated"
332        );
333    }
334
335    // ── Contact capacity & area maximisation ──────────────────────────────
336
337    #[test]
338    fn contact_limit_enforced_at_4() {
339        let mut m = ContactManifold::new(make_entity(1), make_entity(2));
340        // 4 well-separated, equal-depth contacts.
341        m.add_contact(pt(0.0, 0.0, 1.0));
342        m.add_contact(pt(10.0, 0.0, 1.0));
343        m.add_contact(pt(0.0, 10.0, 1.0));
344        m.add_contact(pt(10.0, 10.0, 1.0));
345        assert_eq!(m.contacts.len(), 4);
346
347        // 5th point — shallow, near point #0; should be the one dropped.
348        m.add_contact(pt(0.5, 0.5, 0.1));
349        assert_eq!(m.contacts.len(), 4, "must stay at 4 contacts");
350
351        // The shallow interloper should not survive.
352        assert!(
353            !m.contacts
354                .iter()
355                .any(|c| (c.penetration - 0.1).abs() < 1e-6),
356            "shallowest near-duplicate contact should be dropped"
357        );
358    }
359
360    #[test]
361    fn deepest_contact_always_retained() {
362        let mut m = ContactManifold::new(make_entity(1), make_entity(2));
363        m.add_contact(pt(0.0, 0.0, 0.5));
364        m.add_contact(pt(1.0, 0.0, 0.5));
365        m.add_contact(pt(0.0, 1.0, 0.5));
366        m.add_contact(pt(1.0, 1.0, 0.5));
367
368        // Add a new point with extreme penetration.
369        m.add_contact(pt(0.5, 0.5, 99.0));
370
371        assert!(
372            m.contacts
373                .iter()
374                .any(|c| (c.penetration - 99.0).abs() < 1e-6),
375            "deepest contact must always be retained"
376        );
377    }
378
379    // ── Staleness ─────────────────────────────────────────────────────────
380
381    #[test]
382    fn is_stale_respects_lifetime() {
383        let mut m = ContactManifold::new(make_entity(1), make_entity(2));
384        assert!(!m.is_stale(3));
385        m.lifetime = 4;
386        assert!(m.is_stale(3));
387        m.lifetime = 3;
388        assert!(!m.is_stale(3));
389    }
390
391    // ── Clear ─────────────────────────────────────────────────────────────
392
393    #[test]
394    fn clear_removes_contacts_but_not_lifetime() {
395        let mut m = ContactManifold::new(make_entity(1), make_entity(2));
396        m.add_contact(pt(0.0, 0.0, 1.0));
397        m.lifetime = 7;
398        m.clear();
399        assert!(m.contacts.is_empty(), "contacts should be cleared");
400        assert_eq!(m.lifetime, 7, "lifetime must not be touched by clear()");
401    }
402
403    // ── select_4_contacts ─────────────────────────────────────────────────
404
405    #[test]
406    fn select_4_keeps_deepest_and_maximises_spread() {
407        // Arrange 5 points: 4 at corners of a 10×10 square (depth 1.0)
408        // and one very deep point at the centre.
409        let pool = [
410            pt(0.0, 0.0, 1.0),
411            pt(10.0, 0.0, 1.0),
412            pt(0.0, 10.0, 1.0),
413            pt(10.0, 10.0, 1.0),
414            pt(5.0, 5.0, 5.0), // deepest, at centre
415        ];
416        let result = select_4_contacts(&pool);
417
418        // The deepest point (centre, pen=5.0) must be in the result.
419        assert!(
420            result.iter().any(|c| (c.penetration - 5.0).abs() < 1e-6),
421            "deepest point must be selected"
422        );
423        // All 4 must be distinct (no duplicates).
424        for i in 0..4 {
425            for j in (i + 1)..4 {
426                assert_ne!(
427                    result[i].point, result[j].point,
428                    "selected contacts must be distinct"
429                );
430            }
431        }
432    }
433}