Skip to main content

gizmo_physics_core/
collision.rs

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