Skip to main content

oxiphysics_collision/
types.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core collision detection types.
5//!
6//! Provides the fundamental value types used throughout the collision pipeline:
7//! contact points, contact manifolds, collision pairs, feature IDs, friction /
8//! restitution material properties, collision filters, and island/group helpers.
9
10use oxiphysics_core::math::{Real, Vec3};
11
12// ── CollisionPair ─────────────────────────────────────────────────────────────
13
14/// A pair of colliding object indices.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct CollisionPair {
17    /// Index of the first object.
18    pub a: usize,
19    /// Index of the second object.
20    pub b: usize,
21}
22
23impl CollisionPair {
24    /// Create a new collision pair.
25    pub fn new(a: usize, b: usize) -> Self {
26        Self { a, b }
27    }
28
29    /// Return a canonicalized pair with `a <= b`.
30    pub fn canonical(a: usize, b: usize) -> Self {
31        if a <= b {
32            Self { a, b }
33        } else {
34            Self { a: b, b: a }
35        }
36    }
37
38    /// Returns `true` if the given index is one of the pair's members.
39    pub fn contains(&self, idx: usize) -> bool {
40        self.a == idx || self.b == idx
41    }
42
43    /// Returns the other member of the pair (panics if `idx` is not in the pair).
44    pub fn other(&self, idx: usize) -> usize {
45        if self.a == idx {
46            self.b
47        } else if self.b == idx {
48            self.a
49        } else {
50            panic!(
51                "CollisionPair::other: index {idx} is not in pair ({}, {})",
52                self.a, self.b
53            )
54        }
55    }
56}
57
58// ── FeatureId ─────────────────────────────────────────────────────────────────
59
60/// Identifies a geometric feature (vertex, edge, or face) on a shape.
61///
62/// Used by the contact manifold generator to determine which features are in
63/// contact, enabling warm-starting and persistent manifold tracking.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum FeatureId {
66    /// A vertex feature with the given local index.
67    Vertex(u32),
68    /// An edge feature with the given local index.
69    Edge(u32),
70    /// A face feature with the given local index.
71    Face(u32),
72    /// Feature identity is unknown or not applicable.
73    Unknown,
74}
75
76impl FeatureId {
77    /// Returns `true` if this is a vertex feature.
78    pub fn is_vertex(self) -> bool {
79        matches!(self, FeatureId::Vertex(_))
80    }
81
82    /// Returns `true` if this is an edge feature.
83    pub fn is_edge(self) -> bool {
84        matches!(self, FeatureId::Edge(_))
85    }
86
87    /// Returns `true` if this is a face feature.
88    pub fn is_face(self) -> bool {
89        matches!(self, FeatureId::Face(_))
90    }
91
92    /// Extract the raw index, or `None` if `Unknown`.
93    pub fn index(self) -> Option<u32> {
94        match self {
95            FeatureId::Vertex(i) | FeatureId::Edge(i) | FeatureId::Face(i) => Some(i),
96            FeatureId::Unknown => None,
97        }
98    }
99}
100
101// ── Contact ───────────────────────────────────────────────────────────────────
102
103/// A single contact point between two objects.
104#[derive(Debug, Clone)]
105pub struct Contact {
106    /// Contact point on body A in world space.
107    pub point_a: Vec3,
108    /// Contact point on body B in world space.
109    pub point_b: Vec3,
110    /// Contact normal (from B towards A).
111    pub normal: Vec3,
112    /// Penetration depth (positive means overlapping).
113    pub depth: Real,
114}
115
116impl Contact {
117    /// Create a new contact point.
118    pub fn new(point_a: Vec3, point_b: Vec3, normal: Vec3, depth: Real) -> Self {
119        Self {
120            point_a,
121            point_b,
122            normal,
123            depth,
124        }
125    }
126
127    /// The midpoint of the two contact points.
128    pub fn point(&self) -> Vec3 {
129        (self.point_a + self.point_b) * 0.5
130    }
131}
132
133/// An extended contact point that also carries feature IDs and solver impulses.
134///
135/// This is the richer variant used by the persistent manifold and warm-starting
136/// system.  The simpler [`Contact`] is kept for backward compatibility.
137#[derive(Debug, Clone)]
138pub struct RichContact {
139    /// Contact point on body A in world space.
140    pub point_a: Vec3,
141    /// Contact point on body B in world space.
142    pub point_b: Vec3,
143    /// Contact normal (from B towards A).
144    pub normal: Vec3,
145    /// Penetration depth (positive means overlapping).
146    pub depth: Real,
147    /// Feature on shape A that generated this contact.
148    pub feature_a: FeatureId,
149    /// Feature on shape B that generated this contact.
150    pub feature_b: FeatureId,
151    /// Accumulated normal impulse (used for warm-starting the constraint solver).
152    pub impulse_n: Real,
153    /// Accumulated tangent impulse along the first friction direction.
154    pub impulse_t1: Real,
155    /// Accumulated tangent impulse along the second friction direction.
156    pub impulse_t2: Real,
157}
158
159impl RichContact {
160    /// Create a `RichContact` with feature and impulse fields zeroed.
161    pub fn new(point_a: Vec3, point_b: Vec3, normal: Vec3, depth: Real) -> Self {
162        Self {
163            point_a,
164            point_b,
165            normal,
166            depth,
167            feature_a: FeatureId::Unknown,
168            feature_b: FeatureId::Unknown,
169            impulse_n: 0.0,
170            impulse_t1: 0.0,
171            impulse_t2: 0.0,
172        }
173    }
174
175    /// Create from a plain [`Contact`], zeroing feature IDs and impulses.
176    pub fn from_contact(c: &Contact) -> Self {
177        Self::new(c.point_a, c.point_b, c.normal, c.depth)
178    }
179
180    /// Downgrade to a plain [`Contact`] (drops feature IDs and impulses).
181    pub fn to_contact(&self) -> Contact {
182        Contact {
183            point_a: self.point_a,
184            point_b: self.point_b,
185            normal: self.normal,
186            depth: self.depth,
187        }
188    }
189
190    /// The midpoint of the two contact points.
191    pub fn point(&self) -> Vec3 {
192        (self.point_a + self.point_b) * 0.5
193    }
194
195    /// Build a pair of orthogonal tangent vectors from the contact normal.
196    ///
197    /// Returns `(t1, t2)` forming a right-handed frame with `self.normal`.
198    pub fn tangent_basis(&self) -> (Vec3, Vec3) {
199        let n = self.normal;
200        // Choose a reference vector not parallel to n
201        let ref_vec = if n.x.abs() < 0.9 {
202            Vec3::new(1.0, 0.0, 0.0)
203        } else {
204            Vec3::new(0.0, 1.0, 0.0)
205        };
206        let t1 = n.cross(&ref_vec).normalize();
207        let t2 = n.cross(&t1);
208        (t1, t2)
209    }
210
211    /// Returns `true` if the contact is a penetrating contact (depth > 0).
212    pub fn is_penetrating(&self) -> bool {
213        self.depth > 0.0
214    }
215}
216
217// ── ContactManifold ───────────────────────────────────────────────────────────
218
219/// Maximum number of contact points stored per manifold.
220pub const MAX_CONTACTS: usize = 4;
221
222/// A manifold holding multiple contact points.
223#[derive(Debug, Clone)]
224pub struct ContactManifold {
225    /// The collision pair.
226    pub pair: CollisionPair,
227    /// Contact points in this manifold.
228    pub contacts: Vec<Contact>,
229    /// Number of frames this manifold has been alive.
230    pub age: u32,
231}
232
233impl ContactManifold {
234    /// Create a new empty contact manifold for the given pair.
235    pub fn new(pair: CollisionPair) -> Self {
236        Self {
237            pair,
238            contacts: Vec::new(),
239            age: 0,
240        }
241    }
242
243    /// Add a contact to the manifold, capping at [`MAX_CONTACTS`].
244    ///
245    /// When the manifold is full the contact with the shallowest penetration
246    /// depth is replaced if the new contact is deeper.
247    pub fn add_contact(&mut self, contact: Contact) {
248        if self.contacts.len() < MAX_CONTACTS {
249            self.contacts.push(contact);
250        } else {
251            // Replace shallowest contact if new one is deeper
252            if let Some(min_idx) = self
253                .contacts
254                .iter()
255                .enumerate()
256                .min_by(|(_, a), (_, b)| {
257                    a.depth
258                        .partial_cmp(&b.depth)
259                        .unwrap_or(std::cmp::Ordering::Equal)
260                })
261                .map(|(i, _)| i)
262                && contact.depth > self.contacts[min_idx].depth
263            {
264                self.contacts[min_idx] = contact;
265            }
266        }
267    }
268
269    /// Returns `true` if there are no contacts.
270    pub fn is_empty(&self) -> bool {
271        self.contacts.is_empty()
272    }
273
274    /// Number of contact points.
275    pub fn len(&self) -> usize {
276        self.contacts.len()
277    }
278
279    /// Maximum penetration depth across all contact points.
280    pub fn max_depth(&self) -> Real {
281        self.contacts
282            .iter()
283            .map(|c| c.depth)
284            .fold(f64::NEG_INFINITY, f64::max)
285    }
286
287    /// Average contact normal. Returns the zero vector if no contacts.
288    pub fn average_normal(&self) -> Vec3 {
289        if self.contacts.is_empty() {
290            return Vec3::zeros();
291        }
292        let sum: Vec3 = self
293            .contacts
294            .iter()
295            .map(|c| c.normal)
296            .fold(Vec3::zeros(), |a, b| a + b);
297        let n = sum / (self.contacts.len() as Real);
298        let norm = n.norm();
299        if norm > 1e-12 {
300            n / norm
301        } else {
302            Vec3::zeros()
303        }
304    }
305
306    /// Remove contacts whose depth is below `threshold` (e.g., bodies separated).
307    pub fn prune_shallow(&mut self, threshold: Real) {
308        self.contacts.retain(|c| c.depth >= threshold);
309    }
310
311    /// Increment the manifold age counter.
312    pub fn tick(&mut self) {
313        self.age = self.age.saturating_add(1);
314    }
315}
316
317// ── RichContactManifold ───────────────────────────────────────────────────────
318
319/// A manifold holding [`RichContact`] points that carry feature IDs and
320/// solver impulses.  Used by the warm-starting / persistent manifold system.
321#[derive(Debug, Clone)]
322pub struct RichContactManifold {
323    /// The collision pair.
324    pub pair: CollisionPair,
325    /// Contact points.
326    pub contacts: Vec<RichContact>,
327    /// Number of frames this manifold has been alive.
328    pub age: u32,
329}
330
331impl RichContactManifold {
332    /// Create a new empty manifold.
333    pub fn new(pair: CollisionPair) -> Self {
334        Self {
335            pair,
336            contacts: Vec::new(),
337            age: 0,
338        }
339    }
340
341    /// Add a contact, capping at [`MAX_CONTACTS`].
342    pub fn add_contact(&mut self, contact: RichContact) {
343        if self.contacts.len() < MAX_CONTACTS {
344            self.contacts.push(contact);
345        } else {
346            if let Some(min_idx) = self
347                .contacts
348                .iter()
349                .enumerate()
350                .min_by(|(_, a), (_, b)| {
351                    a.depth
352                        .partial_cmp(&b.depth)
353                        .unwrap_or(std::cmp::Ordering::Equal)
354                })
355                .map(|(i, _)| i)
356                && contact.depth > self.contacts[min_idx].depth
357            {
358                self.contacts[min_idx] = contact;
359            }
360        }
361    }
362
363    /// Transfer accumulated impulses from `old` matching on feature IDs (warm-starting).
364    pub fn warm_start_from(&mut self, old: &RichContactManifold) {
365        for c in &mut self.contacts {
366            if let Some(prev) = old
367                .contacts
368                .iter()
369                .find(|p| p.feature_a == c.feature_a && p.feature_b == c.feature_b)
370            {
371                c.impulse_n = prev.impulse_n;
372                c.impulse_t1 = prev.impulse_t1;
373                c.impulse_t2 = prev.impulse_t2;
374            }
375        }
376    }
377
378    /// Returns `true` if there are no contacts.
379    pub fn is_empty(&self) -> bool {
380        self.contacts.is_empty()
381    }
382
383    /// Number of contact points.
384    pub fn len(&self) -> usize {
385        self.contacts.len()
386    }
387
388    /// Maximum penetration depth.
389    pub fn max_depth(&self) -> Real {
390        self.contacts
391            .iter()
392            .map(|c| c.depth)
393            .fold(f64::NEG_INFINITY, f64::max)
394    }
395
396    /// Increment the age counter.
397    pub fn tick(&mut self) {
398        self.age = self.age.saturating_add(1);
399    }
400}
401
402// ── PhysicsMaterial ───────────────────────────────────────────────────────────
403
404/// Physical surface properties used by the constraint solver.
405#[derive(Debug, Clone, Copy)]
406pub struct PhysicsMaterial {
407    /// Coefficient of restitution in \[0, 1\]; 0 = perfectly inelastic.
408    pub restitution: Real,
409    /// Static friction coefficient.
410    pub friction_static: Real,
411    /// Dynamic (kinetic) friction coefficient.
412    pub friction_dynamic: Real,
413}
414
415impl Default for PhysicsMaterial {
416    fn default() -> Self {
417        Self {
418            restitution: 0.3,
419            friction_static: 0.6,
420            friction_dynamic: 0.4,
421        }
422    }
423}
424
425impl PhysicsMaterial {
426    /// Create a material with explicit parameters.
427    pub fn new(restitution: Real, friction_static: Real, friction_dynamic: Real) -> Self {
428        Self {
429            restitution,
430            friction_static,
431            friction_dynamic,
432        }
433    }
434
435    /// Combine two materials using geometric-mean mixing for friction and
436    /// the minimum for restitution (conservative).
437    pub fn combine(a: &Self, b: &Self) -> Self {
438        Self {
439            restitution: a.restitution.min(b.restitution),
440            friction_static: (a.friction_static * b.friction_static).sqrt(),
441            friction_dynamic: (a.friction_dynamic * b.friction_dynamic).sqrt(),
442        }
443    }
444}
445
446// ── CollisionFilter ───────────────────────────────────────────────────────────
447
448/// Bitmask-based collision filter.
449///
450/// Body A collides with body B if and only if:
451/// `A.mask & B.category != 0`.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
453pub struct CollisionFilter {
454    /// Categories this body belongs to (bit flags).
455    pub category: u32,
456    /// Which categories this body should collide with (bit flags).
457    pub mask: u32,
458}
459
460impl Default for CollisionFilter {
461    fn default() -> Self {
462        Self {
463            category: 0xFFFF_FFFF,
464            mask: 0xFFFF_FFFF,
465        }
466    }
467}
468
469impl CollisionFilter {
470    /// Create a filter with explicit category and mask.
471    pub fn new(category: u32, mask: u32) -> Self {
472        Self { category, mask }
473    }
474
475    /// Returns `true` if `self` and `other` should collide.
476    pub fn should_collide(&self, other: &CollisionFilter) -> bool {
477        (self.mask & other.category) != 0 && (other.mask & self.category) != 0
478    }
479
480    /// A filter that collides with everything (default).
481    pub fn all() -> Self {
482        Self::default()
483    }
484
485    /// A filter that collides with nothing.
486    pub fn none() -> Self {
487        Self {
488            category: 0,
489            mask: 0,
490        }
491    }
492}
493
494// ── CollisionEvent ────────────────────────────────────────────────────────────
495
496/// High-level event emitted by the collision pipeline for game logic.
497#[derive(Debug, Clone)]
498pub enum CollisionEvent {
499    /// Two bodies began touching this frame.
500    ContactStarted(CollisionPair),
501    /// Two bodies that were touching are now separated.
502    ContactEnded(CollisionPair),
503    /// A trigger (sensor) was entered.
504    TriggerEntered {
505        /// Index of the sensor body.
506        sensor: usize,
507        /// Index of the body that entered the sensor.
508        body: usize,
509    },
510    /// A trigger (sensor) was exited.
511    TriggerExited {
512        /// Index of the sensor body.
513        sensor: usize,
514        /// Index of the body that exited the sensor.
515        body: usize,
516    },
517}
518
519impl CollisionEvent {
520    /// Return the pair of indices involved in the event.
521    pub fn pair(&self) -> (usize, usize) {
522        match self {
523            CollisionEvent::ContactStarted(p) | CollisionEvent::ContactEnded(p) => (p.a, p.b),
524            CollisionEvent::TriggerEntered { sensor, body }
525            | CollisionEvent::TriggerExited { sensor, body } => (*sensor, *body),
526        }
527    }
528
529    /// Returns `true` if this event starts a contact or trigger.
530    pub fn is_enter(&self) -> bool {
531        matches!(
532            self,
533            CollisionEvent::ContactStarted(_) | CollisionEvent::TriggerEntered { .. }
534        )
535    }
536
537    /// Returns `true` if this event ends a contact or trigger.
538    pub fn is_exit(&self) -> bool {
539        !self.is_enter()
540    }
541}
542
543// ── IslandId ──────────────────────────────────────────────────────────────────
544
545/// Identifier for a rigid-body island (connected component in the contact graph).
546///
547/// Bodies in the same island share constraints and are solved together.  The
548/// sentinel value [`IslandId::NONE`] marks a body that has not been assigned to
549/// any island.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
551pub struct IslandId(pub u32);
552
553impl IslandId {
554    /// Sentinel: body has not been assigned to any island.
555    pub const NONE: Self = IslandId(u32::MAX);
556
557    /// Returns `true` if this is the sentinel value.
558    pub fn is_none(self) -> bool {
559        self == Self::NONE
560    }
561
562    /// Returns `true` if this is a valid island id.
563    pub fn is_some(self) -> bool {
564        self != Self::NONE
565    }
566
567    /// Raw integer index.
568    pub fn index(self) -> usize {
569        self.0 as usize
570    }
571}
572
573impl Default for IslandId {
574    fn default() -> Self {
575        Self::NONE
576    }
577}
578
579impl std::fmt::Display for IslandId {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        if self.is_none() {
582            write!(f, "IslandId::NONE")
583        } else {
584            write!(f, "IslandId({})", self.0)
585        }
586    }
587}
588
589// ── RigidBodyKind ─────────────────────────────────────────────────────────────
590
591/// Classifies how a rigid body participates in dynamics.
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
593pub enum RigidBodyKind {
594    /// Fully simulated: responds to forces, collisions, and constraints.
595    Dynamic,
596    /// Has infinite mass; cannot be moved by forces or impulses.
597    Static,
598    /// Kinematically driven: moves under scripted velocity but pushes dynamic bodies.
599    Kinematic,
600    /// Sensor (trigger): generates overlap events but no contact impulses.
601    Sensor,
602}
603
604impl RigidBodyKind {
605    /// Returns `true` if the body can be moved by the solver.
606    pub fn is_movable(self) -> bool {
607        matches!(self, RigidBodyKind::Dynamic | RigidBodyKind::Kinematic)
608    }
609
610    /// Returns `true` if the body generates contact forces.
611    pub fn generates_contacts(self) -> bool {
612        !matches!(self, RigidBodyKind::Sensor)
613    }
614
615    /// Returns `true` if the body participates in island building.
616    pub fn is_island_member(self) -> bool {
617        matches!(self, RigidBodyKind::Dynamic)
618    }
619}
620
621// ── BodyGroup ─────────────────────────────────────────────────────────────────
622
623/// A strongly-connected group of rigid bodies linked by persistent contacts.
624///
625/// Groups are computed from the contact graph each frame to allow the solver to
626/// partition work and detect sleeping candidates.
627#[derive(Debug, Clone)]
628pub struct BodyGroup {
629    /// Island this group belongs to (may span multiple groups).
630    pub island: IslandId,
631    /// Indices of bodies in this group.
632    pub bodies: Vec<usize>,
633    /// Whether every body in the group is at rest (can be put to sleep).
634    pub all_sleeping: bool,
635}
636
637impl BodyGroup {
638    /// Create a new group from a list of body indices.
639    pub fn new(island: IslandId, bodies: Vec<usize>) -> Self {
640        Self {
641            island,
642            bodies,
643            all_sleeping: false,
644        }
645    }
646
647    /// Number of bodies in the group.
648    pub fn len(&self) -> usize {
649        self.bodies.len()
650    }
651
652    /// Returns `true` if the group has no members.
653    pub fn is_empty(&self) -> bool {
654        self.bodies.is_empty()
655    }
656
657    /// Returns `true` if `body_idx` is a member of this group.
658    pub fn contains(&self, body_idx: usize) -> bool {
659        self.bodies.contains(&body_idx)
660    }
661
662    /// Mark all bodies as sleeping/not-sleeping.
663    pub fn set_sleeping(&mut self, sleeping: bool) {
664        self.all_sleeping = sleeping;
665    }
666}
667
668// ── ContactConstraint ─────────────────────────────────────────────────────────
669
670/// A position-level contact constraint ready to be fed to a constraint solver.
671///
672/// Carries the pair, penetration depth, normal, and warm-start impulse.
673#[derive(Debug, Clone)]
674pub struct ContactConstraint {
675    /// The colliding pair.
676    pub pair: CollisionPair,
677    /// Contact normal (from B toward A, unit length).
678    pub normal: Vec3,
679    /// Penetration depth (positive = overlapping).
680    pub depth: Real,
681    /// World-space contact point (midpoint of the two witness points).
682    pub point: Vec3,
683    /// Accumulated normal impulse from the previous frame (warm-start).
684    pub impulse_n: Real,
685    /// Accumulated tangent impulse 1 from the previous frame.
686    pub impulse_t1: Real,
687    /// Accumulated tangent impulse 2 from the previous frame.
688    pub impulse_t2: Real,
689    /// Effective restitution coefficient for this contact.
690    pub restitution: Real,
691    /// Effective friction coefficient for this contact.
692    pub friction: Real,
693}
694
695impl ContactConstraint {
696    /// Create a constraint with zero warm-start impulses and default material.
697    pub fn new(pair: CollisionPair, normal: Vec3, depth: Real, point: Vec3) -> Self {
698        Self {
699            pair,
700            normal,
701            depth,
702            point,
703            impulse_n: 0.0,
704            impulse_t1: 0.0,
705            impulse_t2: 0.0,
706            restitution: 0.3,
707            friction: 0.5,
708        }
709    }
710
711    /// Build a pair of tangent vectors for friction from the contact normal.
712    pub fn tangent_basis(&self) -> (Vec3, Vec3) {
713        let n = self.normal;
714        let ref_vec = if n.x.abs() < 0.9 {
715            Vec3::new(1.0, 0.0, 0.0)
716        } else {
717            Vec3::new(0.0, 1.0, 0.0)
718        };
719        let t1 = n.cross(&ref_vec).normalize();
720        let t2 = n.cross(&t1);
721        (t1, t2)
722    }
723
724    /// Returns `true` if the contact is currently penetrating.
725    pub fn is_penetrating(&self) -> bool {
726        self.depth > 0.0
727    }
728
729    /// Zero all accumulated impulses (invalidates warm-start data).
730    pub fn clear_impulses(&mut self) {
731        self.impulse_n = 0.0;
732        self.impulse_t1 = 0.0;
733        self.impulse_t2 = 0.0;
734    }
735}
736
737// ── ContactVelocityConstraint ─────────────────────────────────────────────────
738
739/// A velocity-level (impulse-based) contact constraint.
740///
741/// Pre-computed quantities that remain constant during the solver iteration.
742#[derive(Debug, Clone)]
743pub struct ContactVelocityConstraint {
744    /// The colliding pair.
745    pub pair: CollisionPair,
746    /// Contact normal.
747    pub normal: Vec3,
748    /// Target relative velocity along the normal (bias + restitution).
749    pub velocity_bias: Real,
750    /// Effective mass along the normal direction.
751    pub effective_mass_n: Real,
752    /// Effective mass along tangent 1.
753    pub effective_mass_t1: Real,
754    /// Effective mass along tangent 2.
755    pub effective_mass_t2: Real,
756    /// Friction coefficient.
757    pub friction: Real,
758    /// Accumulated normal impulse (clamped to >= 0 during solve).
759    pub impulse_n: Real,
760    /// Accumulated tangent impulse 1.
761    pub impulse_t1: Real,
762    /// Accumulated tangent impulse 2.
763    pub impulse_t2: Real,
764}
765
766impl ContactVelocityConstraint {
767    /// Create a velocity constraint with zeroed impulses.
768    pub fn new(pair: CollisionPair, normal: Vec3) -> Self {
769        Self {
770            pair,
771            normal,
772            velocity_bias: 0.0,
773            effective_mass_n: 1.0,
774            effective_mass_t1: 1.0,
775            effective_mass_t2: 1.0,
776            friction: 0.5,
777            impulse_n: 0.0,
778            impulse_t1: 0.0,
779            impulse_t2: 0.0,
780        }
781    }
782
783    /// Returns `true` if there is any accumulated impulse.
784    pub fn has_impulse(&self) -> bool {
785        self.impulse_n.abs() > 1e-12
786            || self.impulse_t1.abs() > 1e-12
787            || self.impulse_t2.abs() > 1e-12
788    }
789
790    /// The total impulse magnitude (for debugging/telemetry).
791    pub fn impulse_magnitude(&self) -> Real {
792        (self.impulse_n * self.impulse_n
793            + self.impulse_t1 * self.impulse_t1
794            + self.impulse_t2 * self.impulse_t2)
795            .sqrt()
796    }
797
798    /// Warm-start: copy impulses from a previous-frame constraint.
799    pub fn warm_start_from(&mut self, prev: &ContactVelocityConstraint) {
800        self.impulse_n = prev.impulse_n;
801        self.impulse_t1 = prev.impulse_t1;
802        self.impulse_t2 = prev.impulse_t2;
803    }
804}
805
806// ── ContactBatch ──────────────────────────────────────────────────────────────
807
808/// A batch of velocity constraints to be solved together (same island).
809#[derive(Debug, Default)]
810pub struct ContactBatch {
811    /// All velocity constraints in the batch.
812    pub constraints: Vec<ContactVelocityConstraint>,
813    /// The island this batch belongs to.
814    pub island: IslandId,
815}
816
817impl ContactBatch {
818    /// Create an empty batch for the given island.
819    pub fn new(island: IslandId) -> Self {
820        Self {
821            constraints: Vec::new(),
822            island,
823        }
824    }
825
826    /// Add a constraint to the batch.
827    pub fn push(&mut self, c: ContactVelocityConstraint) {
828        self.constraints.push(c);
829    }
830
831    /// Number of constraints.
832    pub fn len(&self) -> usize {
833        self.constraints.len()
834    }
835
836    /// Returns `true` if no constraints are present.
837    pub fn is_empty(&self) -> bool {
838        self.constraints.is_empty()
839    }
840
841    /// Total accumulated normal impulse across all constraints.
842    pub fn total_normal_impulse(&self) -> Real {
843        self.constraints.iter().map(|c| c.impulse_n).sum()
844    }
845
846    /// Clear all impulses (invalidates warm-start data for this batch).
847    pub fn clear_impulses(&mut self) {
848        for c in &mut self.constraints {
849            c.impulse_n = 0.0;
850            c.impulse_t1 = 0.0;
851            c.impulse_t2 = 0.0;
852        }
853    }
854}
855
856// ── CollisionMatrix ───────────────────────────────────────────────────────────
857
858/// Dense bitmatrix of which category bits collide with which others.
859///
860/// Up to 32 categories are supported.  The matrix is symmetric: if `(i, j)` is
861/// set then `(j, i)` is also set.
862#[derive(Debug, Clone)]
863pub struct CollisionMatrix {
864    pub(super) rows: [u32; 32],
865}
866
867impl Default for CollisionMatrix {
868    fn default() -> Self {
869        Self::all_collide()
870    }
871}
872
873impl CollisionMatrix {
874    /// Create a matrix where every category collides with every other.
875    pub fn all_collide() -> Self {
876        Self {
877            rows: [u32::MAX; 32],
878        }
879    }
880
881    /// Create a matrix where no categories collide.
882    pub fn none_collide() -> Self {
883        Self { rows: [0u32; 32] }
884    }
885
886    /// Enable collision between categories `a` and `b` (symmetric).
887    pub fn enable(&mut self, a: u8, b: u8) {
888        let a = (a & 31) as usize;
889        let b = (b & 31) as usize;
890        self.rows[a] |= 1 << b;
891        self.rows[b] |= 1 << a;
892    }
893
894    /// Disable collision between categories `a` and `b` (symmetric).
895    pub fn disable(&mut self, a: u8, b: u8) {
896        let a = (a & 31) as usize;
897        let b = (b & 31) as usize;
898        self.rows[a] &= !(1 << b);
899        self.rows[b] &= !(1 << a);
900    }
901
902    /// Returns `true` if categories `a` and `b` should collide.
903    pub fn should_collide(&self, a: u8, b: u8) -> bool {
904        let a = (a & 31) as usize;
905        let b = (b & 31) as usize;
906        (self.rows[a] >> b) & 1 == 1
907    }
908}
909
910// ── ContactStats ──────────────────────────────────────────────────────────────
911
912/// Per-frame contact statistics collected by the pipeline.
913#[derive(Debug, Default, Clone, Copy)]
914pub struct ContactStats {
915    /// Total number of broadphase pairs tested.
916    pub broadphase_pairs: u32,
917    /// Number of pairs that passed to narrowphase.
918    pub narrowphase_pairs: u32,
919    /// Number of contacts generated by narrowphase.
920    pub contacts_generated: u32,
921    /// Number of contacts removed due to manifold aging.
922    pub contacts_expired: u32,
923    /// Number of islands built.
924    pub islands: u32,
925}
926
927impl ContactStats {
928    /// Create a zeroed stats block.
929    pub fn new() -> Self {
930        Self::default()
931    }
932
933    /// Returns the ratio of narrowphase work to broadphase pairs.
934    ///
935    /// A value close to 1 means the broadphase is poor at rejection.
936    pub fn narrowphase_ratio(&self) -> f64 {
937        if self.broadphase_pairs == 0 {
938            return 0.0;
939        }
940        self.narrowphase_pairs as f64 / self.broadphase_pairs as f64
941    }
942
943    /// Reset all counters.
944    pub fn reset(&mut self) {
945        *self = Self::default();
946    }
947}
948
949// ── tests ─────────────────────────────────────────────────────────────────────
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn test_collision_pair_basic() {
957        let pair = CollisionPair::new(0, 1);
958        assert_eq!(pair.a, 0);
959        assert_eq!(pair.b, 1);
960    }
961
962    #[test]
963    fn test_collision_pair_canonical() {
964        let p1 = CollisionPair::canonical(3, 1);
965        assert!(p1.a <= p1.b);
966        let p2 = CollisionPair::canonical(1, 3);
967        assert_eq!(p1, p2);
968    }
969
970    #[test]
971    fn test_collision_pair_contains() {
972        let p = CollisionPair::new(2, 7);
973        assert!(p.contains(2));
974        assert!(p.contains(7));
975        assert!(!p.contains(5));
976    }
977
978    #[test]
979    fn test_collision_pair_other() {
980        let p = CollisionPair::new(2, 7);
981        assert_eq!(p.other(2), 7);
982        assert_eq!(p.other(7), 2);
983    }
984
985    #[test]
986    #[should_panic]
987    fn test_collision_pair_other_panics() {
988        let p = CollisionPair::new(2, 7);
989        let _ = p.other(99);
990    }
991
992    #[test]
993    fn test_feature_id() {
994        assert!(FeatureId::Vertex(0).is_vertex());
995        assert!(FeatureId::Edge(2).is_edge());
996        assert!(FeatureId::Face(5).is_face());
997        assert_eq!(FeatureId::Face(5).index(), Some(5));
998        assert_eq!(FeatureId::Unknown.index(), None);
999    }
1000
1001    #[test]
1002    fn test_contact_manifold_basic() {
1003        let pair = CollisionPair::new(0, 1);
1004        let mut manifold = ContactManifold::new(pair);
1005        manifold.add_contact(Contact {
1006            point_a: Vec3::zeros(),
1007            point_b: Vec3::new(0.0, -0.1, 0.0),
1008            normal: Vec3::new(0.0, 1.0, 0.0),
1009            depth: 0.1,
1010        });
1011        assert_eq!(manifold.contacts.len(), 1);
1012    }
1013
1014    fn make_contact(depth: f64) -> Contact {
1015        Contact {
1016            point_a: Vec3::zeros(),
1017            point_b: Vec3::zeros(),
1018            normal: Vec3::new(0.0, 1.0, 0.0),
1019            depth,
1020        }
1021    }
1022
1023    #[test]
1024    fn test_manifold_max_contacts_cap() {
1025        let pair = CollisionPair::new(0, 1);
1026        let mut manifold = ContactManifold::new(pair);
1027        for i in 0..8 {
1028            manifold.add_contact(make_contact(i as f64 * 0.01));
1029        }
1030        assert!(manifold.contacts.len() <= MAX_CONTACTS);
1031    }
1032
1033    #[test]
1034    fn test_manifold_max_depth() {
1035        let pair = CollisionPair::new(0, 1);
1036        let mut m = ContactManifold::new(pair);
1037        m.add_contact(make_contact(0.1));
1038        m.add_contact(make_contact(0.5));
1039        m.add_contact(make_contact(0.3));
1040        assert!((m.max_depth() - 0.5).abs() < 1e-12);
1041    }
1042
1043    #[test]
1044    fn test_manifold_prune_shallow() {
1045        let pair = CollisionPair::new(0, 1);
1046        let mut m = ContactManifold::new(pair);
1047        m.add_contact(make_contact(0.1));
1048        m.add_contact(make_contact(-0.05));
1049        m.prune_shallow(0.0);
1050        assert_eq!(m.contacts.len(), 1);
1051        assert!((m.contacts[0].depth - 0.1).abs() < 1e-12);
1052    }
1053
1054    #[test]
1055    fn test_manifold_average_normal() {
1056        let pair = CollisionPair::new(0, 1);
1057        let mut m = ContactManifold::new(pair);
1058        m.add_contact(make_contact(0.1));
1059        m.add_contact(make_contact(0.2));
1060        let avg = m.average_normal();
1061        assert!((avg.norm() - 1.0).abs() < 1e-10);
1062    }
1063
1064    #[test]
1065    fn test_manifold_empty_average_normal() {
1066        let m = ContactManifold::new(CollisionPair::new(0, 1));
1067        let avg = m.average_normal();
1068        assert_eq!(avg.norm(), 0.0);
1069    }
1070
1071    #[test]
1072    fn test_rich_contact_tangent_basis_orthogonal() {
1073        let c = RichContact::new(Vec3::zeros(), Vec3::zeros(), Vec3::new(0.0, 1.0, 0.0), 0.1);
1074        let (t1, t2) = c.tangent_basis();
1075        assert!(c.normal.dot(&t1).abs() < 1e-10, "t1 not perp to normal");
1076        assert!(c.normal.dot(&t2).abs() < 1e-10, "t2 not perp to normal");
1077        assert!(t1.dot(&t2).abs() < 1e-10, "t1 not perp to t2");
1078    }
1079
1080    #[test]
1081    fn test_physics_material_combine() {
1082        let a = PhysicsMaterial::new(0.8, 0.4, 0.3);
1083        let b = PhysicsMaterial::new(0.5, 0.9, 0.6);
1084        let c = PhysicsMaterial::combine(&a, &b);
1085        assert!((c.restitution - 0.5).abs() < 1e-12, "min restitution");
1086        let expected_fs = (0.4_f64 * 0.9).sqrt();
1087        assert!((c.friction_static - expected_fs).abs() < 1e-12);
1088    }
1089
1090    #[test]
1091    fn test_collision_filter_default_collides() {
1092        let f1 = CollisionFilter::default();
1093        let f2 = CollisionFilter::default();
1094        assert!(f1.should_collide(&f2));
1095    }
1096
1097    #[test]
1098    fn test_collision_filter_none_no_collide() {
1099        let f1 = CollisionFilter::none();
1100        let f2 = CollisionFilter::default();
1101        assert!(!f1.should_collide(&f2));
1102    }
1103
1104    #[test]
1105    fn test_collision_filter_category_mask() {
1106        // player (cat 1) collides with wall (cat 2) if its mask includes cat 2
1107        let player = CollisionFilter::new(0b0001, 0b0010);
1108        let wall = CollisionFilter::new(0b0010, 0b0001);
1109        assert!(player.should_collide(&wall));
1110        // two players: cat 1, mask 0b0010 → they should NOT collide with each other
1111        let player2 = CollisionFilter::new(0b0001, 0b0010);
1112        assert!(!player.should_collide(&player2)); // mask 0b0010 & cat 0b0001 == 0
1113    }
1114
1115    #[test]
1116    fn test_collision_event_pair() {
1117        let ev = CollisionEvent::ContactStarted(CollisionPair::new(3, 7));
1118        assert_eq!(ev.pair(), (3, 7));
1119        assert!(ev.is_enter());
1120        assert!(!ev.is_exit());
1121    }
1122
1123    #[test]
1124    fn test_collision_event_trigger() {
1125        let ev = CollisionEvent::TriggerEntered {
1126            sensor: 10,
1127            body: 2,
1128        };
1129        assert_eq!(ev.pair(), (10, 2));
1130        assert!(ev.is_enter());
1131        let ev2 = CollisionEvent::TriggerExited {
1132            sensor: 10,
1133            body: 2,
1134        };
1135        assert!(ev2.is_exit());
1136    }
1137
1138    #[test]
1139    fn test_rich_manifold_warm_start_from() {
1140        let pair = CollisionPair::new(0, 1);
1141        let mut old = RichContactManifold::new(pair);
1142        let mut c = RichContact::new(Vec3::zeros(), Vec3::zeros(), Vec3::new(0.0, 1.0, 0.0), 0.1);
1143        c.feature_a = FeatureId::Face(0);
1144        c.feature_b = FeatureId::Face(1);
1145        c.impulse_n = 5.0;
1146        old.add_contact(c);
1147
1148        let mut new_m = RichContactManifold::new(pair);
1149        let mut c2 = RichContact::new(Vec3::zeros(), Vec3::zeros(), Vec3::new(0.0, 1.0, 0.0), 0.12);
1150        c2.feature_a = FeatureId::Face(0);
1151        c2.feature_b = FeatureId::Face(1);
1152        new_m.add_contact(c2);
1153
1154        new_m.warm_start_from(&old);
1155        assert!((new_m.contacts[0].impulse_n - 5.0).abs() < 1e-12);
1156    }
1157
1158    #[test]
1159    fn test_manifold_tick_age() {
1160        let mut m = ContactManifold::new(CollisionPair::new(0, 1));
1161        assert_eq!(m.age, 0);
1162        m.tick();
1163        m.tick();
1164        assert_eq!(m.age, 2);
1165    }
1166
1167    #[test]
1168    fn test_rich_contact_from_contact_roundtrip() {
1169        let c = Contact {
1170            point_a: Vec3::new(1.0, 0.0, 0.0),
1171            point_b: Vec3::new(2.0, 0.0, 0.0),
1172            normal: Vec3::new(0.0, 1.0, 0.0),
1173            depth: 0.3,
1174        };
1175        let rc = RichContact::from_contact(&c);
1176        assert_eq!(rc.depth, 0.3);
1177        assert_eq!(rc.feature_a, FeatureId::Unknown);
1178        let back = rc.to_contact();
1179        assert!((back.depth - 0.3).abs() < 1e-12);
1180    }
1181
1182    #[test]
1183    fn test_rich_contact_is_penetrating() {
1184        let c_pen = RichContact::new(Vec3::zeros(), Vec3::zeros(), Vec3::new(0.0, 1.0, 0.0), 0.1);
1185        let c_sep = RichContact::new(Vec3::zeros(), Vec3::zeros(), Vec3::new(0.0, 1.0, 0.0), -0.1);
1186        assert!(c_pen.is_penetrating());
1187        assert!(!c_sep.is_penetrating());
1188    }
1189
1190    // ── IslandId tests ───────────────────────────────────────────────────────
1191
1192    #[test]
1193    fn island_id_none_is_sentinel() {
1194        let id = IslandId::NONE;
1195        assert!(id.is_none());
1196        assert!(!id.is_some());
1197    }
1198
1199    #[test]
1200    fn island_id_valid() {
1201        let id = IslandId(3);
1202        assert!(!id.is_none());
1203        assert!(id.is_some());
1204        assert_eq!(id.index(), 3);
1205    }
1206
1207    #[test]
1208    fn island_id_default_is_none() {
1209        let id = IslandId::default();
1210        assert!(id.is_none());
1211    }
1212
1213    #[test]
1214    fn island_id_ordering() {
1215        assert!(IslandId(0) < IslandId(1));
1216        assert!(IslandId(0) < IslandId::NONE);
1217    }
1218
1219    #[test]
1220    fn island_id_display_none() {
1221        let s = format!("{}", IslandId::NONE);
1222        assert!(s.contains("NONE"), "display: {s}");
1223    }
1224
1225    #[test]
1226    fn island_id_display_value() {
1227        let s = format!("{}", IslandId(42));
1228        assert!(s.contains("42"), "display: {s}");
1229    }
1230
1231    // ── RigidBodyKind tests ──────────────────────────────────────────────────
1232
1233    #[test]
1234    fn rigid_body_kind_movable() {
1235        assert!(RigidBodyKind::Dynamic.is_movable());
1236        assert!(RigidBodyKind::Kinematic.is_movable());
1237        assert!(!RigidBodyKind::Static.is_movable());
1238        assert!(!RigidBodyKind::Sensor.is_movable());
1239    }
1240
1241    #[test]
1242    fn rigid_body_kind_generates_contacts() {
1243        assert!(RigidBodyKind::Dynamic.generates_contacts());
1244        assert!(RigidBodyKind::Static.generates_contacts());
1245        assert!(RigidBodyKind::Kinematic.generates_contacts());
1246        assert!(!RigidBodyKind::Sensor.generates_contacts());
1247    }
1248
1249    #[test]
1250    fn rigid_body_kind_island_member() {
1251        assert!(RigidBodyKind::Dynamic.is_island_member());
1252        assert!(!RigidBodyKind::Static.is_island_member());
1253        assert!(!RigidBodyKind::Kinematic.is_island_member());
1254        assert!(!RigidBodyKind::Sensor.is_island_member());
1255    }
1256
1257    // ── BodyGroup tests ──────────────────────────────────────────────────────
1258
1259    #[test]
1260    fn body_group_basic() {
1261        let g = BodyGroup::new(IslandId(0), vec![1, 2, 3]);
1262        assert_eq!(g.len(), 3);
1263        assert!(g.contains(2));
1264        assert!(!g.contains(99));
1265        assert!(!g.is_empty());
1266    }
1267
1268    #[test]
1269    fn body_group_empty() {
1270        let g = BodyGroup::new(IslandId(0), vec![]);
1271        assert!(g.is_empty());
1272        assert_eq!(g.len(), 0);
1273    }
1274
1275    #[test]
1276    fn body_group_set_sleeping() {
1277        let mut g = BodyGroup::new(IslandId(0), vec![1]);
1278        assert!(!g.all_sleeping);
1279        g.set_sleeping(true);
1280        assert!(g.all_sleeping);
1281        g.set_sleeping(false);
1282        assert!(!g.all_sleeping);
1283    }
1284
1285    // ── ContactConstraint tests ──────────────────────────────────────────────
1286
1287    #[test]
1288    fn contact_constraint_new() {
1289        let pair = CollisionPair::new(0, 1);
1290        let c = ContactConstraint::new(pair, Vec3::new(0.0, 1.0, 0.0), 0.05, Vec3::zeros());
1291        assert!(c.is_penetrating());
1292        assert_eq!(c.impulse_n, 0.0);
1293        assert_eq!(c.impulse_t1, 0.0);
1294        assert_eq!(c.impulse_t2, 0.0);
1295    }
1296
1297    #[test]
1298    fn contact_constraint_not_penetrating() {
1299        let pair = CollisionPair::new(0, 1);
1300        let c = ContactConstraint::new(pair, Vec3::new(0.0, 1.0, 0.0), -0.01, Vec3::zeros());
1301        assert!(!c.is_penetrating());
1302    }
1303
1304    #[test]
1305    fn contact_constraint_tangent_basis_orthogonal() {
1306        let pair = CollisionPair::new(0, 1);
1307        let c = ContactConstraint::new(pair, Vec3::new(0.0, 1.0, 0.0), 0.1, Vec3::zeros());
1308        let (t1, t2) = c.tangent_basis();
1309        assert!(c.normal.dot(&t1).abs() < 1e-10, "t1 not perp to normal");
1310        assert!(c.normal.dot(&t2).abs() < 1e-10, "t2 not perp to normal");
1311        assert!(t1.dot(&t2).abs() < 1e-10, "t1 not perp to t2");
1312    }
1313
1314    #[test]
1315    fn contact_constraint_clear_impulses() {
1316        let pair = CollisionPair::new(0, 1);
1317        let mut c = ContactConstraint::new(pair, Vec3::new(0.0, 1.0, 0.0), 0.1, Vec3::zeros());
1318        c.impulse_n = 5.0;
1319        c.impulse_t1 = 2.0;
1320        c.impulse_t2 = -1.0;
1321        c.clear_impulses();
1322        assert_eq!(c.impulse_n, 0.0);
1323        assert_eq!(c.impulse_t1, 0.0);
1324        assert_eq!(c.impulse_t2, 0.0);
1325    }
1326
1327    // ── ContactVelocityConstraint tests ──────────────────────────────────────
1328
1329    #[test]
1330    fn velocity_constraint_new_zeroed() {
1331        let c = ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1332        assert!(!c.has_impulse());
1333        assert_eq!(c.impulse_magnitude(), 0.0);
1334    }
1335
1336    #[test]
1337    fn velocity_constraint_has_impulse_after_set() {
1338        let mut c =
1339            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1340        c.impulse_n = 3.0;
1341        assert!(c.has_impulse());
1342    }
1343
1344    #[test]
1345    fn velocity_constraint_impulse_magnitude() {
1346        let mut c =
1347            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1348        c.impulse_n = 3.0;
1349        c.impulse_t1 = 4.0;
1350        let mag = c.impulse_magnitude();
1351        assert!((mag - 5.0).abs() < 1e-10, "magnitude={mag}");
1352    }
1353
1354    #[test]
1355    fn velocity_constraint_warm_start_from() {
1356        let mut prev =
1357            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1358        prev.impulse_n = 7.0;
1359        prev.impulse_t1 = -2.0;
1360        prev.impulse_t2 = 1.5;
1361
1362        let mut curr =
1363            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1364        curr.warm_start_from(&prev);
1365        assert!((curr.impulse_n - 7.0).abs() < 1e-12);
1366        assert!((curr.impulse_t1 - (-2.0)).abs() < 1e-12);
1367        assert!((curr.impulse_t2 - 1.5).abs() < 1e-12);
1368    }
1369
1370    // ── ContactBatch tests ────────────────────────────────────────────────────
1371
1372    #[test]
1373    fn contact_batch_empty() {
1374        let batch = ContactBatch::new(IslandId(0));
1375        assert!(batch.is_empty());
1376        assert_eq!(batch.len(), 0);
1377        assert_eq!(batch.total_normal_impulse(), 0.0);
1378    }
1379
1380    #[test]
1381    fn contact_batch_push_and_len() {
1382        let mut batch = ContactBatch::new(IslandId(1));
1383        let c = ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1384        batch.push(c);
1385        assert_eq!(batch.len(), 1);
1386        assert!(!batch.is_empty());
1387    }
1388
1389    #[test]
1390    fn contact_batch_total_normal_impulse() {
1391        let mut batch = ContactBatch::new(IslandId(0));
1392        let mut c1 =
1393            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1394        c1.impulse_n = 3.0;
1395        let mut c2 =
1396            ContactVelocityConstraint::new(CollisionPair::new(1, 2), Vec3::new(0.0, 1.0, 0.0));
1397        c2.impulse_n = 5.0;
1398        batch.push(c1);
1399        batch.push(c2);
1400        assert!((batch.total_normal_impulse() - 8.0).abs() < 1e-12);
1401    }
1402
1403    #[test]
1404    fn contact_batch_clear_impulses() {
1405        let mut batch = ContactBatch::new(IslandId(0));
1406        let mut c =
1407            ContactVelocityConstraint::new(CollisionPair::new(0, 1), Vec3::new(0.0, 1.0, 0.0));
1408        c.impulse_n = 9.0;
1409        batch.push(c);
1410        batch.clear_impulses();
1411        assert_eq!(batch.total_normal_impulse(), 0.0);
1412    }
1413
1414    // ── CollisionMatrix tests ─────────────────────────────────────────────────
1415
1416    #[test]
1417    fn collision_matrix_all_collide() {
1418        let m = CollisionMatrix::all_collide();
1419        for a in 0..32u8 {
1420            for b in 0..32u8 {
1421                assert!(
1422                    m.should_collide(a, b),
1423                    "all_collide: ({a},{b}) should collide"
1424                );
1425            }
1426        }
1427    }
1428
1429    #[test]
1430    fn collision_matrix_none_collide() {
1431        let m = CollisionMatrix::none_collide();
1432        for a in 0..32u8 {
1433            for b in 0..32u8 {
1434                assert!(
1435                    !m.should_collide(a, b),
1436                    "none_collide: ({a},{b}) should not collide"
1437                );
1438            }
1439        }
1440    }
1441
1442    #[test]
1443    fn collision_matrix_enable_symmetric() {
1444        let mut m = CollisionMatrix::none_collide();
1445        m.enable(2, 5);
1446        assert!(m.should_collide(2, 5));
1447        assert!(m.should_collide(5, 2), "must be symmetric");
1448        assert!(!m.should_collide(2, 3));
1449    }
1450
1451    #[test]
1452    fn collision_matrix_disable_symmetric() {
1453        let mut m = CollisionMatrix::all_collide();
1454        m.disable(3, 7);
1455        assert!(!m.should_collide(3, 7));
1456        assert!(!m.should_collide(7, 3), "must be symmetric");
1457        assert!(m.should_collide(3, 8), "other pairs unaffected");
1458    }
1459
1460    #[test]
1461    fn collision_matrix_self_collision() {
1462        let mut m = CollisionMatrix::none_collide();
1463        m.enable(4, 4);
1464        assert!(m.should_collide(4, 4));
1465    }
1466
1467    // ── ContactStats tests ────────────────────────────────────────────────────
1468
1469    #[test]
1470    fn contact_stats_default_zeroed() {
1471        let s = ContactStats::new();
1472        assert_eq!(s.broadphase_pairs, 0);
1473        assert_eq!(s.narrowphase_pairs, 0);
1474        assert_eq!(s.contacts_generated, 0);
1475    }
1476
1477    #[test]
1478    fn contact_stats_narrowphase_ratio_zero_broadphase() {
1479        let s = ContactStats::new();
1480        assert_eq!(s.narrowphase_ratio(), 0.0);
1481    }
1482
1483    #[test]
1484    fn contact_stats_narrowphase_ratio() {
1485        let s = ContactStats {
1486            broadphase_pairs: 100,
1487            narrowphase_pairs: 25,
1488            ..ContactStats::default()
1489        };
1490        assert!((s.narrowphase_ratio() - 0.25).abs() < 1e-12);
1491    }
1492
1493    #[test]
1494    fn contact_stats_reset() {
1495        let mut s = ContactStats {
1496            broadphase_pairs: 50,
1497            contacts_generated: 10,
1498            islands: 3,
1499            ..ContactStats::default()
1500        };
1501        s.reset();
1502        assert_eq!(s.broadphase_pairs, 0);
1503        assert_eq!(s.contacts_generated, 0);
1504        assert_eq!(s.islands, 0);
1505    }
1506}