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}