Skip to main content

oxiphysics_collision/
contact_graph.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Contact graph persistence and speculative contacts.
5//!
6//! Provides persistent contact tracking across frames with warm-starting support,
7//! speculative contact prediction for continuous collision avoidance, and a compact
8//! LRU contact cache.
9
10use std::collections::HashMap;
11
12// ── ContactKey ───────────────────────────────────────────────────────────────
13
14/// Canonical key for a contact pair. Always stores `body_a < body_b`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ContactKey {
17    /// Index of the lower-indexed body.
18    pub body_a: usize,
19    /// Index of the higher-indexed body.
20    pub body_b: usize,
21}
22
23impl ContactKey {
24    /// Create a canonical `ContactKey`; swaps indices so `body_a < body_b`.
25    pub fn new(a: usize, b: usize) -> Self {
26        if a <= b {
27            Self {
28                body_a: a,
29                body_b: b,
30            }
31        } else {
32            Self {
33                body_a: b,
34                body_b: a,
35            }
36        }
37    }
38}
39
40// ── PersistedContact ─────────────────────────────────────────────────────────
41
42/// A contact that persists across simulation frames for warm-starting.
43#[derive(Debug, Clone)]
44pub struct PersistedContact {
45    /// The pair of bodies involved.
46    pub key: ContactKey,
47    /// Contact normal pointing from body_a to body_b.
48    pub normal: [f64; 3],
49    /// Penetration depth (positive = overlapping).
50    pub depth: f64,
51    /// World-space contact point.
52    pub contact_point: [f64; 3],
53    /// Number of consecutive frames this contact has been active.
54    pub age: u32,
55    /// Accumulated normal impulse for warm starting.
56    pub cached_impulse: f64,
57    /// Whether this contact was seen in the current frame.
58    pub is_active: bool,
59}
60
61// ── ContactGraph ─────────────────────────────────────────────────────────────
62
63/// Persistent contact graph: maps body pairs to their latest contact data.
64#[derive(Debug, Default)]
65pub struct ContactGraph {
66    contacts: HashMap<ContactKey, PersistedContact>,
67}
68
69impl ContactGraph {
70    /// Create an empty `ContactGraph`.
71    pub fn new() -> Self {
72        Self {
73            contacts: HashMap::new(),
74        }
75    }
76
77    /// Insert or update a contact. Increments `age` on update and marks it active.
78    pub fn update(&mut self, key: ContactKey, normal: [f64; 3], depth: f64, point: [f64; 3]) {
79        let entry = self.contacts.entry(key).or_insert(PersistedContact {
80            key,
81            normal,
82            depth,
83            contact_point: point,
84            age: 0,
85            cached_impulse: 0.0,
86            is_active: true,
87        });
88        entry.normal = normal;
89        entry.depth = depth;
90        entry.contact_point = point;
91        entry.age += 1;
92        entry.is_active = true;
93    }
94
95    /// Remove a contact by key.
96    pub fn remove(&mut self, key: &ContactKey) {
97        self.contacts.remove(key);
98    }
99
100    /// Look up a contact.
101    pub fn get(&self, key: &ContactKey) -> Option<&PersistedContact> {
102        self.contacts.get(key)
103    }
104
105    /// Look up a contact mutably.
106    pub fn get_mut(&mut self, key: &ContactKey) -> Option<&mut PersistedContact> {
107        self.contacts.get_mut(key)
108    }
109
110    /// Mark every contact inactive before processing a new frame.
111    pub fn mark_inactive_all(&mut self) {
112        for c in self.contacts.values_mut() {
113            c.is_active = false;
114        }
115    }
116
117    /// Remove all contacts that are still inactive (not seen this frame).
118    pub fn purge_inactive(&mut self) {
119        self.contacts.retain(|_, v| v.is_active);
120    }
121
122    /// Iterate over active contacts.
123    pub fn active_contacts(&self) -> Vec<&PersistedContact> {
124        self.contacts.values().filter(|c| c.is_active).collect()
125    }
126
127    /// All contacts that involve `body` (active or not).
128    pub fn contacts_for_body(&self, body: usize) -> Vec<&PersistedContact> {
129        self.contacts
130            .values()
131            .filter(|c| c.key.body_a == body || c.key.body_b == body)
132            .collect()
133    }
134
135    /// Total number of contacts stored (active + inactive).
136    pub fn len(&self) -> usize {
137        self.contacts.len()
138    }
139
140    /// Returns `true` if there are no contacts stored.
141    pub fn is_empty(&self) -> bool {
142        self.contacts.is_empty()
143    }
144}
145
146// ── SpeculativeContact ────────────────────────────────────────────────────────
147
148/// A predicted contact used for speculative collision response.
149#[derive(Debug, Clone)]
150pub struct SpeculativeContact {
151    /// Index of body A.
152    pub body_a: usize,
153    /// Index of body B.
154    pub body_b: usize,
155    /// Predicted contact normal (from A toward B).
156    pub normal: [f64; 3],
157    /// Current gap (positive = separated, negative = penetrating).
158    pub separation: f64,
159    /// Relative closing velocity along the normal (positive = approaching).
160    pub closing_velocity: f64,
161}
162
163/// Predict a speculative contact between two spheres within the next `dt` seconds.
164///
165/// Returns `Some` if the bodies will touch or are already penetrating,
166/// or if they are approaching fast enough to close the gap within `dt`.
167pub fn speculative_contact(
168    pos_a: [f64; 3],
169    vel_a: [f64; 3],
170    pos_b: [f64; 3],
171    vel_b: [f64; 3],
172    radius_a: f64,
173    radius_b: f64,
174    dt: f64,
175) -> Option<SpeculativeContact> {
176    let dx = pos_b[0] - pos_a[0];
177    let dy = pos_b[1] - pos_a[1];
178    let dz = pos_b[2] - pos_a[2];
179    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
180
181    let (nx, ny, nz) = if dist > 1e-12 {
182        (dx / dist, dy / dist, dz / dist)
183    } else {
184        (0.0, 1.0, 0.0)
185    };
186
187    let separation = dist - (radius_a + radius_b);
188
189    // Relative velocity of B with respect to A, projected onto normal
190    let rel_vx = vel_b[0] - vel_a[0];
191    let rel_vy = vel_b[1] - vel_a[1];
192    let rel_vz = vel_b[2] - vel_a[2];
193    // Negative dot = approaching (B moving toward A along normal)
194    let rel_v_along_n = rel_vx * nx + rel_vy * ny + rel_vz * nz;
195    // closing_velocity > 0 means the gap is shrinking
196    let closing_velocity = -rel_v_along_n;
197
198    let will_contact =
199        separation <= 0.0 || (closing_velocity > 0.0 && separation < closing_velocity * dt);
200
201    if will_contact {
202        Some(SpeculativeContact {
203            body_a: 0,
204            body_b: 1,
205            normal: [nx, ny, nz],
206            separation,
207            closing_velocity,
208        })
209    } else {
210        None
211    }
212}
213
214/// Compute the speculative impulse magnitude needed to prevent interpenetration.
215///
216/// Returns the scalar impulse along the contact normal.
217pub fn speculative_impulse(
218    contact: &SpeculativeContact,
219    inv_mass_a: f64,
220    inv_mass_b: f64,
221    restitution: f64,
222) -> f64 {
223    let denom = inv_mass_a + inv_mass_b;
224    if denom < 1e-30 {
225        return 0.0;
226    }
227    // closing_velocity > 0 means approaching; impulse must be positive
228    let numerator = (1.0 + restitution) * contact.closing_velocity;
229    (numerator / denom).max(0.0)
230}
231
232// ── ContactCache ──────────────────────────────────────────────────────────────
233
234/// Compact fixed-capacity contact cache with LRU eviction.
235///
236/// Stores `(ContactKey, cached_normal_impulse)` pairs in insertion order.
237/// When full, the oldest entry (front) is evicted.
238#[derive(Debug)]
239pub struct ContactCache {
240    entries: Vec<(ContactKey, f64)>,
241    capacity: usize,
242}
243
244impl ContactCache {
245    /// Create a new cache with the given maximum capacity.
246    pub fn new(capacity: usize) -> Self {
247        Self {
248            entries: Vec::with_capacity(capacity),
249            capacity,
250        }
251    }
252
253    /// Insert or update a `(key, impulse)` pair. Evicts the oldest entry when full.
254    pub fn insert(&mut self, key: ContactKey, impulse: f64) {
255        // Update in place if already present
256        if let Some(e) = self.entries.iter_mut().find(|e| e.0 == key) {
257            e.1 = impulse;
258            return;
259        }
260        // Do nothing if capacity is zero
261        if self.capacity == 0 {
262            return;
263        }
264        // Evict oldest (front) if at capacity
265        if self.entries.len() >= self.capacity {
266            self.entries.remove(0);
267        }
268        self.entries.push((key, impulse));
269    }
270
271    /// Look up a cached impulse by key.
272    pub fn lookup(&self, key: &ContactKey) -> Option<f64> {
273        self.entries.iter().find(|e| &e.0 == key).map(|e| e.1)
274    }
275
276    /// Number of entries currently stored.
277    pub fn len(&self) -> usize {
278        self.entries.len()
279    }
280
281    /// Returns `true` when no entries are stored.
282    pub fn is_empty(&self) -> bool {
283        self.entries.is_empty()
284    }
285}
286
287// ── Tests ─────────────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::ContactGraph;
293    use crate::ContactKey;
294
295    // ── ContactKey tests ──────────────────────────────────────────────────────
296
297    #[test]
298    fn contact_key_canonical_order() {
299        let k1 = ContactKey::new(3, 1);
300        let k2 = ContactKey::new(1, 3);
301        assert_eq!(k1, k2);
302        assert_eq!(k1.body_a, 1);
303        assert_eq!(k1.body_b, 3);
304    }
305
306    #[test]
307    fn contact_key_equal_indices() {
308        let k = ContactKey::new(5, 5);
309        assert_eq!(k.body_a, 5);
310        assert_eq!(k.body_b, 5);
311    }
312
313    #[test]
314    fn contact_key_hash_consistent() {
315        use std::collections::HashSet;
316        let mut s = HashSet::new();
317        s.insert(ContactKey::new(0, 1));
318        s.insert(ContactKey::new(1, 0));
319        assert_eq!(s.len(), 1);
320    }
321
322    #[test]
323    fn contact_key_different_pairs_not_equal() {
324        let k1 = ContactKey::new(0, 1);
325        let k2 = ContactKey::new(0, 2);
326        assert_ne!(k1, k2);
327    }
328
329    #[test]
330    fn contact_key_zero_indices() {
331        let k = ContactKey::new(0, 0);
332        assert_eq!(k.body_a, 0);
333        assert_eq!(k.body_b, 0);
334    }
335
336    // ── ContactGraph tests ────────────────────────────────────────────────────
337
338    #[test]
339    fn contact_graph_new_is_empty() {
340        let g = ContactGraph::new();
341        assert!(g.is_empty());
342        assert_eq!(g.len(), 0);
343    }
344
345    #[test]
346    fn contact_graph_update_inserts() {
347        let mut g = ContactGraph::new();
348        let key = ContactKey::new(0, 1);
349        g.update(key, [0.0, 1.0, 0.0], 0.1, [0.0, 0.0, 0.0]);
350        assert_eq!(g.len(), 1);
351    }
352
353    #[test]
354    fn contact_graph_update_increments_age() {
355        let mut g = ContactGraph::new();
356        let key = ContactKey::new(0, 1);
357        g.update(key, [0.0, 1.0, 0.0], 0.1, [0.0, 0.0, 0.0]);
358        g.update(key, [0.0, 1.0, 0.0], 0.2, [0.0, 0.0, 0.0]);
359        let c = g.get(&key).unwrap();
360        assert_eq!(c.age, 2);
361    }
362
363    #[test]
364    fn contact_graph_update_overwrites_depth() {
365        let mut g = ContactGraph::new();
366        let key = ContactKey::new(0, 1);
367        g.update(key, [0.0, 1.0, 0.0], 0.5, [0.0, 0.0, 0.0]);
368        g.update(key, [0.0, 1.0, 0.0], 1.5, [0.0, 0.0, 0.0]);
369        assert!((g.get(&key).unwrap().depth - 1.5).abs() < 1e-12);
370    }
371
372    #[test]
373    fn contact_graph_remove() {
374        let mut g = ContactGraph::new();
375        let key = ContactKey::new(0, 1);
376        g.update(key, [0.0, 1.0, 0.0], 0.1, [0.0, 0.0, 0.0]);
377        g.remove(&key);
378        assert!(g.is_empty());
379    }
380
381    #[test]
382    fn contact_graph_get_missing_returns_none() {
383        let g = ContactGraph::new();
384        let key = ContactKey::new(7, 8);
385        assert!(g.get(&key).is_none());
386    }
387
388    #[test]
389    fn contact_graph_get_mut_modifies() {
390        let mut g = ContactGraph::new();
391        let key = ContactKey::new(0, 1);
392        g.update(key, [0.0, 1.0, 0.0], 0.1, [0.0, 0.0, 0.0]);
393        g.get_mut(&key).unwrap().cached_impulse = 3.125;
394        assert!((g.get(&key).unwrap().cached_impulse - 3.125).abs() < 1e-12);
395    }
396
397    #[test]
398    fn contact_graph_mark_inactive_all() {
399        let mut g = ContactGraph::new();
400        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
401        g.update(ContactKey::new(1, 2), [0.0, 1.0, 0.0], 0.2, [0.0; 3]);
402        g.mark_inactive_all();
403        assert_eq!(g.active_contacts().len(), 0);
404    }
405
406    #[test]
407    fn contact_graph_purge_inactive_removes_stale() {
408        let mut g = ContactGraph::new();
409        let k1 = ContactKey::new(0, 1);
410        let k2 = ContactKey::new(1, 2);
411        g.update(k1, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
412        g.update(k2, [0.0, 1.0, 0.0], 0.2, [0.0; 3]);
413        g.mark_inactive_all();
414        // Re-activate only k1
415        g.update(k1, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
416        g.purge_inactive();
417        assert_eq!(g.len(), 1);
418        assert!(g.get(&k1).is_some());
419        assert!(g.get(&k2).is_none());
420    }
421
422    #[test]
423    fn contact_graph_active_contacts_count() {
424        let mut g = ContactGraph::new();
425        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
426        g.update(ContactKey::new(2, 3), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
427        g.mark_inactive_all();
428        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
429        assert_eq!(g.active_contacts().len(), 1);
430    }
431
432    #[test]
433    fn contact_graph_contacts_for_body() {
434        let mut g = ContactGraph::new();
435        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
436        g.update(ContactKey::new(0, 2), [0.0, 1.0, 0.0], 0.2, [0.0; 3]);
437        g.update(ContactKey::new(3, 4), [0.0, 1.0, 0.0], 0.3, [0.0; 3]);
438        let contacts = g.contacts_for_body(0);
439        assert_eq!(contacts.len(), 2);
440        let contacts_4 = g.contacts_for_body(4);
441        assert_eq!(contacts_4.len(), 1);
442    }
443
444    #[test]
445    fn contact_graph_is_active_on_update() {
446        let mut g = ContactGraph::new();
447        let key = ContactKey::new(0, 1);
448        g.update(key, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
449        assert!(g.get(&key).unwrap().is_active);
450    }
451
452    // ── SpeculativeContact tests ──────────────────────────────────────────────
453
454    #[test]
455    fn speculative_contact_approaching_predicts_contact() {
456        // Two spheres 1 unit apart (radii 0.4 each → gap = 0.2), approaching at 1 m/s
457        let pos_a = [0.0, 0.0, 0.0];
458        let vel_a = [0.5, 0.0, 0.0];
459        let pos_b = [1.0, 0.0, 0.0];
460        let vel_b = [-0.5, 0.0, 0.0];
461        let result = speculative_contact(pos_a, vel_a, pos_b, vel_b, 0.4, 0.4, 1.0 / 60.0);
462        // closing velocity = 1.0, gap = 0.2, 1.0 * (1/60) ≈ 0.0167 < 0.2 → no prediction this frame
463        assert!(result.is_none());
464    }
465
466    #[test]
467    fn speculative_contact_fast_approach_within_dt() {
468        // Gap = 0.01, closing at 10 m/s, dt = 1/60 → 10 * 1/60 ≈ 0.167 > 0.01
469        let pos_a = [0.0, 0.0, 0.0];
470        let vel_a = [5.0, 0.0, 0.0];
471        let pos_b = [1.0, 0.0, 0.0];
472        let vel_b = [-5.0, 0.0, 0.0];
473        // radii sum = 0.99 → separation = 1.0 - 0.99 = 0.01
474        let result = speculative_contact(pos_a, vel_a, pos_b, vel_b, 0.495, 0.495, 1.0 / 60.0);
475        assert!(result.is_some());
476        let c = result.unwrap();
477        assert!(c.closing_velocity > 0.0);
478    }
479
480    #[test]
481    fn speculative_contact_penetrating_always_some() {
482        // Already overlapping (dist < r_a + r_b)
483        let pos_a = [0.0, 0.0, 0.0];
484        let pos_b = [0.5, 0.0, 0.0];
485        let result = speculative_contact(pos_a, [0.0; 3], pos_b, [0.0; 3], 0.4, 0.4, 1.0 / 60.0);
486        assert!(result.is_some());
487        assert!(result.unwrap().separation < 0.0);
488    }
489
490    #[test]
491    fn speculative_contact_separating_returns_none() {
492        let pos_a = [0.0, 0.0, 0.0];
493        let vel_a = [-5.0, 0.0, 0.0];
494        let pos_b = [2.0, 0.0, 0.0];
495        let vel_b = [5.0, 0.0, 0.0];
496        let result = speculative_contact(pos_a, vel_a, pos_b, vel_b, 0.1, 0.1, 1.0 / 60.0);
497        assert!(result.is_none());
498    }
499
500    #[test]
501    fn speculative_impulse_zero_for_static_bodies() {
502        let c = SpeculativeContact {
503            body_a: 0,
504            body_b: 1,
505            normal: [0.0, 1.0, 0.0],
506            separation: -0.05,
507            closing_velocity: 1.0,
508        };
509        let j = speculative_impulse(&c, 0.0, 0.0, 0.5);
510        assert!((j).abs() < 1e-12);
511    }
512
513    #[test]
514    fn speculative_impulse_positive_for_approaching() {
515        let c = SpeculativeContact {
516            body_a: 0,
517            body_b: 1,
518            normal: [0.0, 1.0, 0.0],
519            separation: -0.05,
520            closing_velocity: 2.0,
521        };
522        let j = speculative_impulse(&c, 1.0, 1.0, 0.0);
523        assert!(j > 0.0);
524    }
525
526    #[test]
527    fn speculative_impulse_no_negative() {
528        // Separating contact → impulse clamped to 0
529        let c = SpeculativeContact {
530            body_a: 0,
531            body_b: 1,
532            normal: [0.0, 1.0, 0.0],
533            separation: 0.5,
534            closing_velocity: -1.0,
535        };
536        let j = speculative_impulse(&c, 1.0, 1.0, 0.5);
537        assert!(j >= 0.0);
538    }
539
540    // ── ContactCache tests ────────────────────────────────────────────────────
541
542    #[test]
543    fn contact_cache_insert_and_lookup() {
544        let mut cache = ContactCache::new(8);
545        let key = ContactKey::new(0, 1);
546        cache.insert(key, 1.5);
547        assert_eq!(cache.lookup(&key), Some(1.5));
548    }
549
550    #[test]
551    fn contact_cache_lookup_missing_returns_none() {
552        let cache = ContactCache::new(8);
553        assert_eq!(cache.lookup(&ContactKey::new(9, 10)), None);
554    }
555
556    #[test]
557    fn contact_cache_update_existing() {
558        let mut cache = ContactCache::new(8);
559        let key = ContactKey::new(0, 1);
560        cache.insert(key, 1.0);
561        cache.insert(key, 2.0);
562        assert_eq!(cache.lookup(&key), Some(2.0));
563        assert_eq!(cache.len(), 1);
564    }
565
566    #[test]
567    fn contact_cache_lru_eviction() {
568        let mut cache = ContactCache::new(3);
569        let k0 = ContactKey::new(0, 1);
570        let k1 = ContactKey::new(1, 2);
571        let k2 = ContactKey::new(2, 3);
572        let k3 = ContactKey::new(3, 4);
573        cache.insert(k0, 0.0);
574        cache.insert(k1, 1.0);
575        cache.insert(k2, 2.0);
576        // k0 should be evicted
577        cache.insert(k3, 3.0);
578        assert_eq!(cache.len(), 3);
579        assert!(cache.lookup(&k0).is_none());
580        assert_eq!(cache.lookup(&k3), Some(3.0));
581    }
582
583    #[test]
584    fn contact_cache_is_empty() {
585        let cache = ContactCache::new(4);
586        assert!(cache.is_empty());
587    }
588
589    #[test]
590    fn contact_cache_zero_capacity_does_not_panic() {
591        let mut cache = ContactCache::new(0);
592        let key = ContactKey::new(0, 1);
593        cache.insert(key, 1.0); // should not panic
594        assert!(cache.is_empty());
595    }
596}
597
598// ── ContactManifold (up to 4 contact points) ─────────────────────────────────
599
600/// A single contact point within a manifold.
601#[derive(Debug, Clone, Copy)]
602pub struct ContactPoint {
603    /// World-space position of the contact point.
604    pub position: [f64; 3],
605    /// Penetration depth at this contact point.
606    pub depth: f64,
607    /// Accumulated normal impulse for warm starting.
608    pub impulse: f64,
609    /// Whether this point was matched to a previous frame's point.
610    pub warm_started: bool,
611}
612
613impl ContactPoint {
614    /// Create a new contact point.
615    pub fn new(position: [f64; 3], depth: f64) -> Self {
616        Self {
617            position,
618            depth,
619            impulse: 0.0,
620            warm_started: false,
621        }
622    }
623}
624
625/// A persistent contact manifold holding up to 4 contact points.
626///
627/// The manifold is associated with a body pair and stores the contact
628/// normal and up to 4 contact points for stable simulation.
629#[derive(Debug, Clone)]
630pub struct ContactManifold {
631    /// Unique key for the body pair.
632    pub key: ContactKey,
633    /// Contact normal (from body_b toward body_a).
634    pub normal: [f64; 3],
635    /// Contact points (max 4).
636    pub points: Vec<ContactPoint>,
637    /// Number of frames this manifold has been active.
638    pub age: u32,
639    /// Whether this manifold was refreshed this frame.
640    pub active: bool,
641}
642
643impl ContactManifold {
644    /// Create a new empty manifold.
645    pub fn new(key: ContactKey, normal: [f64; 3]) -> Self {
646        Self {
647            key,
648            normal,
649            points: Vec::with_capacity(4),
650            age: 0,
651            active: true,
652        }
653    }
654
655    /// Add a contact point. If already at 4, reduce by removing the point
656    /// that minimises the manifold area (contact reduction).
657    pub fn add_point(&mut self, point: ContactPoint) {
658        if self.points.len() < 4 {
659            self.points.push(point);
660        } else {
661            // Contact reduction: keep the deepest + 3 that maximise area
662            self.reduce_contacts(point);
663        }
664    }
665
666    /// Contact reduction heuristic: replace the point that minimises the
667    /// area of the remaining 4-point set.
668    fn reduce_contacts(&mut self, new_point: ContactPoint) {
669        // Candidate set: existing 4 + new_point = 5 points.
670        // For each removal candidate, compute the triangle area of the remaining 4.
671        let mut candidates = self.points.clone();
672        candidates.push(new_point);
673
674        let mut max_area = -1.0_f64;
675        let mut best_set = self.points.clone();
676
677        // Deepest point is always kept.
678        let deepest_idx = candidates
679            .iter()
680            .enumerate()
681            .max_by(|(_, a), (_, b)| {
682                a.depth
683                    .partial_cmp(&b.depth)
684                    .unwrap_or(std::cmp::Ordering::Equal)
685            })
686            .map(|(i, _)| i)
687            .unwrap_or(0);
688
689        // Try all combinations of 3 from the remaining 4.
690        let remaining: Vec<usize> = (0..candidates.len())
691            .filter(|&i| i != deepest_idx)
692            .collect();
693        for skip in &remaining {
694            let subset: Vec<&ContactPoint> = candidates
695                .iter()
696                .enumerate()
697                .filter(|(i, _)| *i != deepest_idx && i != skip)
698                .map(|(_, p)| p)
699                .collect();
700            if subset.len() < 3 {
701                continue;
702            }
703            let area = triangle_area(subset[0].position, subset[1].position, subset[2].position);
704            if area > max_area {
705                max_area = area;
706                let mut new_set = vec![candidates[deepest_idx]];
707                new_set.extend(subset.iter().copied().copied());
708                best_set = new_set;
709            }
710        }
711
712        self.points = best_set;
713    }
714
715    /// Mark the manifold as inactive (to be purged if not refreshed).
716    pub fn mark_inactive(&mut self) {
717        self.active = false;
718    }
719
720    /// Clear all contact points.
721    pub fn clear_points(&mut self) {
722        self.points.clear();
723    }
724
725    /// Increment the age counter.
726    pub fn tick(&mut self) {
727        self.age += 1;
728    }
729
730    /// Number of contact points.
731    pub fn n_points(&self) -> usize {
732        self.points.len()
733    }
734}
735
736/// Compute the area of a triangle from 3 world-space points.
737fn triangle_area(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
738    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
739    let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
740    let cross = [
741        ab[1] * ac[2] - ab[2] * ac[1],
742        ab[2] * ac[0] - ab[0] * ac[2],
743        ab[0] * ac[1] - ab[1] * ac[0],
744    ];
745    0.5 * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt()
746}
747
748// ── ManifoldStore ──────────────────────────────────────────────────────────────
749
750/// Stores all persistent contact manifolds.
751#[derive(Debug, Default)]
752pub struct ManifoldStore {
753    manifolds: HashMap<ContactKey, ContactManifold>,
754}
755
756impl ManifoldStore {
757    /// Create an empty store.
758    pub fn new() -> Self {
759        Self::default()
760    }
761
762    /// Get or create a manifold for the given body pair.
763    pub fn get_or_create(&mut self, key: ContactKey, normal: [f64; 3]) -> &mut ContactManifold {
764        self.manifolds
765            .entry(key)
766            .or_insert_with(|| ContactManifold::new(key, normal))
767    }
768
769    /// Get a manifold immutably.
770    pub fn get(&self, key: &ContactKey) -> Option<&ContactManifold> {
771        self.manifolds.get(key)
772    }
773
774    /// Mark all manifolds inactive at the start of a frame.
775    pub fn mark_all_inactive(&mut self) {
776        for m in self.manifolds.values_mut() {
777            m.mark_inactive();
778        }
779    }
780
781    /// Remove all inactive manifolds.
782    pub fn purge_inactive(&mut self) {
783        self.manifolds.retain(|_, m| m.active);
784    }
785
786    /// Tick all manifolds (increment age).
787    pub fn tick_all(&mut self) {
788        for m in self.manifolds.values_mut() {
789            m.tick();
790        }
791    }
792
793    /// Total number of manifolds.
794    pub fn len(&self) -> usize {
795        self.manifolds.len()
796    }
797
798    /// Whether the store is empty.
799    pub fn is_empty(&self) -> bool {
800        self.manifolds.is_empty()
801    }
802
803    /// Collect all active manifolds.
804    pub fn active_manifolds(&self) -> Vec<&ContactManifold> {
805        self.manifolds.values().filter(|m| m.active).collect()
806    }
807}
808
809// ── Contact normal/tangent frame ──────────────────────────────────────────────
810
811/// An orthogonal contact frame: normal + two tangents.
812#[derive(Debug, Clone, Copy)]
813pub struct ContactFrame {
814    /// Contact normal (from B toward A).
815    pub normal: [f64; 3],
816    /// First tangent (perpendicular to normal).
817    pub tangent1: [f64; 3],
818    /// Second tangent (perpendicular to both normal and tangent1).
819    pub tangent2: [f64; 3],
820}
821
822impl ContactFrame {
823    /// Build a contact frame from a normal vector.
824    ///
825    /// Uses the Gram-Schmidt process to find two perpendicular tangents.
826    pub fn from_normal(n: [f64; 3]) -> Self {
827        // Normalize
828        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
829        let normal = if len > 1e-12 {
830            [n[0] / len, n[1] / len, n[2] / len]
831        } else {
832            [0.0, 1.0, 0.0]
833        };
834
835        // Choose a reference vector not parallel to normal
836        let ref_vec = if normal[0].abs() < 0.9 {
837            [1.0, 0.0, 0.0]
838        } else {
839            [0.0, 1.0, 0.0]
840        };
841
842        // Gram-Schmidt: t1 = ref - (ref·n)*n
843        let dot_rn = ref_vec[0] * normal[0] + ref_vec[1] * normal[1] + ref_vec[2] * normal[2];
844        let t1_raw = [
845            ref_vec[0] - dot_rn * normal[0],
846            ref_vec[1] - dot_rn * normal[1],
847            ref_vec[2] - dot_rn * normal[2],
848        ];
849        let t1_len = (t1_raw[0] * t1_raw[0] + t1_raw[1] * t1_raw[1] + t1_raw[2] * t1_raw[2]).sqrt();
850        let tangent1 = if t1_len > 1e-12 {
851            [t1_raw[0] / t1_len, t1_raw[1] / t1_len, t1_raw[2] / t1_len]
852        } else {
853            [0.0, 0.0, 1.0]
854        };
855
856        // t2 = n × t1
857        let tangent2 = [
858            normal[1] * tangent1[2] - normal[2] * tangent1[1],
859            normal[2] * tangent1[0] - normal[0] * tangent1[2],
860            normal[0] * tangent1[1] - normal[1] * tangent1[0],
861        ];
862
863        Self {
864            normal,
865            tangent1,
866            tangent2,
867        }
868    }
869
870    /// Project a vector onto the contact frame.
871    pub fn project(&self, v: [f64; 3]) -> [f64; 3] {
872        let vn = v[0] * self.normal[0] + v[1] * self.normal[1] + v[2] * self.normal[2];
873        let vt1 = v[0] * self.tangent1[0] + v[1] * self.tangent1[1] + v[2] * self.tangent1[2];
874        let vt2 = v[0] * self.tangent2[0] + v[1] * self.tangent2[1] + v[2] * self.tangent2[2];
875        [vn, vt1, vt2]
876    }
877
878    /// Unproject from the contact frame back to world space.
879    pub fn unproject(&self, v: [f64; 3]) -> [f64; 3] {
880        [
881            v[0] * self.normal[0] + v[1] * self.tangent1[0] + v[2] * self.tangent2[0],
882            v[0] * self.normal[1] + v[1] * self.tangent1[1] + v[2] * self.tangent2[1],
883            v[0] * self.normal[2] + v[1] * self.tangent1[2] + v[2] * self.tangent2[2],
884        ]
885    }
886}
887
888// ── Cache invalidation by AABB movement ───────────────────────────────────────
889
890/// Threshold for contact cache invalidation.
891///
892/// When a body moves by more than `threshold * contact_radius`, cached contacts
893/// for that body are invalidated.
894pub struct ContactCacheInvalidator {
895    /// Previous positions of each body.
896    pub prev_positions: Vec<[f64; 3]>,
897    /// Movement threshold.
898    pub threshold: f64,
899}
900
901impl ContactCacheInvalidator {
902    /// Create a new invalidator.
903    pub fn new(n_bodies: usize, threshold: f64) -> Self {
904        Self {
905            prev_positions: vec![[0.0; 3]; n_bodies],
906            threshold,
907        }
908    }
909
910    /// Update positions and return list of bodies that moved beyond threshold.
911    pub fn update_and_check(
912        &mut self,
913        new_positions: &[[f64; 3]],
914        contact_radius: f64,
915    ) -> Vec<usize> {
916        let limit = self.threshold * contact_radius;
917        let moved: Vec<usize> = new_positions
918            .iter()
919            .enumerate()
920            .filter(|(i, np)| {
921                let pp = self.prev_positions[*i];
922                let d = [np[0] - pp[0], np[1] - pp[1], np[2] - pp[2]];
923                (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() > limit
924            })
925            .map(|(i, _)| i)
926            .collect();
927        let copy_len = new_positions.len().min(self.prev_positions.len());
928        self.prev_positions[..copy_len].copy_from_slice(&new_positions[..copy_len]);
929        moved
930    }
931
932    /// Invalidate contacts in a graph for all moved bodies.
933    pub fn invalidate_contacts(&self, graph: &mut ContactGraph, moved_bodies: &[usize]) {
934        for &body in moved_bodies {
935            let keys_to_remove: Vec<ContactKey> = graph
936                .contacts_for_body(body)
937                .iter()
938                .map(|c| c.key)
939                .collect();
940            for key in keys_to_remove {
941                graph.remove(&key);
942            }
943        }
944    }
945}
946
947// ── Island Detection (BFS-based connected components) ─────────────────────────
948
949/// A simulation island: a group of bodies connected by active contacts.
950///
951/// Islands are used to split the constraint solver into independent groups,
952/// enabling sleeping and parallel solving.
953#[derive(Debug, Clone)]
954pub struct ContactIsland {
955    /// Indices of bodies belonging to this island.
956    pub bodies: Vec<usize>,
957    /// Contact keys that form the internal edges of the island.
958    pub edges: Vec<ContactKey>,
959    /// Whether every body in the island could be put to sleep.
960    pub can_sleep: bool,
961}
962
963impl ContactIsland {
964    /// Number of bodies in the island.
965    pub fn body_count(&self) -> usize {
966        self.bodies.len()
967    }
968
969    /// Number of contact edges in the island.
970    pub fn edge_count(&self) -> usize {
971        self.edges.len()
972    }
973}
974
975/// Statistics about the current island decomposition.
976#[derive(Debug, Clone, Default)]
977pub struct IslandStats {
978    /// Total number of islands.
979    pub island_count: usize,
980    /// Average island size (bodies per island).
981    pub avg_size: f64,
982    /// Maximum island size.
983    pub max_size: usize,
984    /// Number of islands marked as sleeping.
985    pub sleeping_islands: usize,
986    /// Total number of bodies assigned to an island.
987    pub total_bodies: usize,
988}
989
990impl IslandStats {
991    /// Compute statistics from a slice of islands.
992    pub fn from_islands(islands: &[ContactIsland]) -> Self {
993        if islands.is_empty() {
994            return Self::default();
995        }
996        let total: usize = islands.iter().map(|i| i.bodies.len()).sum();
997        let max_size = islands.iter().map(|i| i.bodies.len()).max().unwrap_or(0);
998        let sleeping = islands.iter().filter(|i| i.can_sleep).count();
999        Self {
1000            island_count: islands.len(),
1001            avg_size: total as f64 / islands.len() as f64,
1002            max_size,
1003            sleeping_islands: sleeping,
1004            total_bodies: total,
1005        }
1006    }
1007}
1008
1009/// Detect connected components in the contact graph using BFS.
1010///
1011/// Only **active** contacts are traversed.  Each returned [`ContactIsland`]
1012/// contains the body indices and contact keys of one connected component.
1013///
1014/// `n_bodies` is the total number of bodies; body indices must be in
1015/// `0..n_bodies`.
1016pub fn detect_islands(graph: &ContactGraph, n_bodies: usize) -> Vec<ContactIsland> {
1017    // Build adjacency: body → list of (neighbour, key)
1018    let mut adj: Vec<Vec<(usize, ContactKey)>> = vec![Vec::new(); n_bodies];
1019    for c in graph.active_contacts() {
1020        let a = c.key.body_a;
1021        let b = c.key.body_b;
1022        if a < n_bodies && b < n_bodies {
1023            adj[a].push((b, c.key));
1024            adj[b].push((a, c.key));
1025        }
1026    }
1027
1028    let mut visited = vec![false; n_bodies];
1029    let mut islands: Vec<ContactIsland> = Vec::new();
1030
1031    for start in 0..n_bodies {
1032        if visited[start] || adj[start].is_empty() {
1033            continue;
1034        }
1035        // BFS
1036        let mut queue = std::collections::VecDeque::new();
1037        queue.push_back(start);
1038        visited[start] = true;
1039        let mut bodies = Vec::new();
1040        let mut edges: std::collections::HashSet<ContactKey> = std::collections::HashSet::new();
1041
1042        while let Some(cur) = queue.pop_front() {
1043            bodies.push(cur);
1044            for &(neighbour, key) in &adj[cur] {
1045                edges.insert(key);
1046                if !visited[neighbour] {
1047                    visited[neighbour] = true;
1048                    queue.push_back(neighbour);
1049                }
1050            }
1051        }
1052
1053        islands.push(ContactIsland {
1054            bodies,
1055            edges: edges.into_iter().collect(),
1056            can_sleep: false,
1057        });
1058    }
1059
1060    islands
1061}
1062
1063/// Propagate sleep state across islands.
1064///
1065/// An island may sleep if *all* bodies in it have a velocity magnitude below
1066/// `sleep_threshold` for at least `min_sleep_frames` consecutive frames.
1067///
1068/// `velocities[i]` is the squared speed of body `i`.
1069/// `sleep_counters[i]` tracks how many frames body `i` has been slow.
1070///
1071/// Returns a list of body indices that should be put to sleep this frame.
1072pub fn propagate_sleep(
1073    islands: &mut [ContactIsland],
1074    velocities_sq: &[f64],
1075    sleep_counters: &mut [u32],
1076    sleep_threshold_sq: f64,
1077    min_sleep_frames: u32,
1078) -> Vec<usize> {
1079    let mut bodies_to_sleep = Vec::new();
1080
1081    for island in islands.iter_mut() {
1082        // Check if all bodies are slow
1083        let all_slow = island
1084            .bodies
1085            .iter()
1086            .all(|&b| velocities_sq.get(b).copied().unwrap_or(f64::INFINITY) < sleep_threshold_sq);
1087
1088        if all_slow {
1089            // First increment all counters, then check if all have reached the threshold
1090            for &b in &island.bodies {
1091                if let Some(cnt) = sleep_counters.get_mut(b) {
1092                    *cnt += 1;
1093                }
1094            }
1095            let ready = island
1096                .bodies
1097                .iter()
1098                .all(|&b| sleep_counters.get(b).copied().unwrap_or(0) >= min_sleep_frames);
1099            if ready {
1100                island.can_sleep = true;
1101                bodies_to_sleep.extend(island.bodies.iter().copied());
1102            }
1103        } else {
1104            // Reset counters for any fast body, wake island
1105            for &b in &island.bodies {
1106                if let Some(cnt) = sleep_counters.get_mut(b) {
1107                    *cnt = 0;
1108                }
1109            }
1110            island.can_sleep = false;
1111        }
1112    }
1113
1114    bodies_to_sleep
1115}
1116
1117// ── Contact frequency tracking ────────────────────────────────────────────────
1118
1119/// Category of a contact based on how long it has been active.
1120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1121pub enum ContactFrequency {
1122    /// Contact exists for only 1 frame (just started).
1123    Transient,
1124    /// Contact has been active for 2–4 frames.
1125    ShortLived,
1126    /// Contact has been active for 5+ frames (persistent warm contact).
1127    Persistent,
1128}
1129
1130impl ContactFrequency {
1131    /// Classify a contact by its `age` field.
1132    pub fn classify(age: u32) -> Self {
1133        match age {
1134            0..=1 => ContactFrequency::Transient,
1135            2..=4 => ContactFrequency::ShortLived,
1136            _ => ContactFrequency::Persistent,
1137        }
1138    }
1139}
1140
1141impl ContactGraph {
1142    /// Classify every active contact by frequency and return counts.
1143    ///
1144    /// Returns `(transient, short_lived, persistent)`.
1145    pub fn frequency_counts(&self) -> (usize, usize, usize) {
1146        let mut t = 0usize;
1147        let mut s = 0usize;
1148        let mut p = 0usize;
1149        for c in self.active_contacts() {
1150            match ContactFrequency::classify(c.age) {
1151                ContactFrequency::Transient => t += 1,
1152                ContactFrequency::ShortLived => s += 1,
1153                ContactFrequency::Persistent => p += 1,
1154            }
1155        }
1156        (t, s, p)
1157    }
1158
1159    /// Return all active contacts classified as persistent (age ≥ 5).
1160    pub fn persistent_contacts(&self) -> Vec<&PersistedContact> {
1161        self.active_contacts()
1162            .into_iter()
1163            .filter(|c| {
1164                matches!(
1165                    ContactFrequency::classify(c.age),
1166                    ContactFrequency::Persistent
1167                )
1168            })
1169            .collect()
1170    }
1171}
1172
1173// ── Manifold quality scoring ──────────────────────────────────────────────────
1174
1175/// A quality score for a contact manifold in `[0.0, 1.0]`.
1176///
1177/// Higher is better.  The score is based on the depth and age of the contact.
1178pub fn manifold_quality_score(contact: &PersistedContact) -> f64 {
1179    // Depth contribution: clamp to [0, 1] (deeper = better, up to 0.5 units)
1180    let depth_score = (contact.depth / 0.5).clamp(0.0, 1.0);
1181    // Age contribution: older contacts are more reliable (saturates at age 10)
1182    let age_score = (contact.age as f64 / 10.0).min(1.0);
1183    // Warm-start contribution: non-zero impulse indicates solver has converged
1184    let impulse_score = if contact.cached_impulse.abs() > 1e-6 {
1185        0.2
1186    } else {
1187        0.0
1188    };
1189    (0.5 * depth_score + 0.3 * age_score + 0.2 * impulse_score).min(1.0)
1190}
1191
1192// ── Bipartite static-dynamic boundary check ───────────────────────────────────
1193
1194/// Test whether the contact graph is bipartite across a static/dynamic boundary.
1195///
1196/// A contact graph is "bipartite-safe" if no two static bodies share a direct
1197/// contact edge (static–static contacts are usually filtered by the broadphase
1198/// but this function can verify that invariant).
1199///
1200/// `is_static[i]` is `true` when body `i` is a static (infinite mass) body.
1201///
1202/// Returns `true` if no static–static edge exists.
1203pub fn is_static_dynamic_bipartite(graph: &ContactGraph, is_static: &[bool]) -> bool {
1204    for c in graph.active_contacts() {
1205        let a_static = is_static.get(c.key.body_a).copied().unwrap_or(false);
1206        let b_static = is_static.get(c.key.body_b).copied().unwrap_or(false);
1207        if a_static && b_static {
1208            return false;
1209        }
1210    }
1211    true
1212}
1213
1214// ── Tests for new functionality ────────────────────────────────────────────────
1215
1216#[cfg(test)]
1217mod tests_extended {
1218    use super::*;
1219
1220    // ── ContactPoint tests ────────────────────────────────────────────────────
1221
1222    #[test]
1223    fn contact_point_new() {
1224        let cp = ContactPoint::new([1.0, 2.0, 3.0], 0.05);
1225        assert!((cp.depth - 0.05).abs() < 1e-12);
1226        assert!(!cp.warm_started);
1227        assert_eq!(cp.impulse, 0.0);
1228    }
1229
1230    // ── ContactManifold tests ─────────────────────────────────────────────────
1231
1232    #[test]
1233    fn contact_manifold_add_up_to_four() {
1234        let key = ContactKey::new(0, 1);
1235        let mut m = ContactManifold::new(key, [0.0, 1.0, 0.0]);
1236        for i in 0..4 {
1237            m.add_point(ContactPoint::new([i as f64, 0.0, 0.0], 0.1));
1238        }
1239        assert_eq!(m.n_points(), 4);
1240    }
1241
1242    #[test]
1243    fn contact_manifold_reduction_keeps_four() {
1244        let key = ContactKey::new(0, 1);
1245        let mut m = ContactManifold::new(key, [0.0, 1.0, 0.0]);
1246        for i in 0..5 {
1247            m.add_point(ContactPoint::new(
1248                [i as f64 * 0.1, 0.0, 0.0],
1249                (i as f64 + 1.0) * 0.01,
1250            ));
1251        }
1252        assert_eq!(m.n_points(), 4, "Should keep exactly 4 after reduction");
1253    }
1254
1255    #[test]
1256    fn contact_manifold_tick_increments_age() {
1257        let key = ContactKey::new(0, 1);
1258        let mut m = ContactManifold::new(key, [0.0, 1.0, 0.0]);
1259        m.tick();
1260        m.tick();
1261        assert_eq!(m.age, 2);
1262    }
1263
1264    #[test]
1265    fn contact_manifold_mark_inactive() {
1266        let key = ContactKey::new(0, 1);
1267        let mut m = ContactManifold::new(key, [0.0, 1.0, 0.0]);
1268        assert!(m.active);
1269        m.mark_inactive();
1270        assert!(!m.active);
1271    }
1272
1273    // ── ManifoldStore tests ───────────────────────────────────────────────────
1274
1275    #[test]
1276    fn manifold_store_get_or_create() {
1277        let mut store = ManifoldStore::new();
1278        let key = ContactKey::new(0, 1);
1279        let m = store.get_or_create(key, [0.0, 1.0, 0.0]);
1280        m.add_point(ContactPoint::new([0.0; 3], 0.1));
1281        assert_eq!(store.get(&key).unwrap().n_points(), 1);
1282    }
1283
1284    #[test]
1285    fn manifold_store_purge_inactive() {
1286        let mut store = ManifoldStore::new();
1287        let k1 = ContactKey::new(0, 1);
1288        let k2 = ContactKey::new(2, 3);
1289        store.get_or_create(k1, [0.0, 1.0, 0.0]);
1290        store.get_or_create(k2, [0.0, 1.0, 0.0]);
1291        store.mark_all_inactive();
1292        // Reactivate k1
1293        store.get_or_create(k1, [0.0, 1.0, 0.0]).active = true;
1294        store.purge_inactive();
1295        assert_eq!(store.len(), 1);
1296        assert!(store.get(&k1).is_some());
1297        assert!(store.get(&k2).is_none());
1298    }
1299
1300    #[test]
1301    fn manifold_store_active_count() {
1302        let mut store = ManifoldStore::new();
1303        store
1304            .get_or_create(ContactKey::new(0, 1), [0.0, 1.0, 0.0])
1305            .active = true;
1306        store
1307            .get_or_create(ContactKey::new(2, 3), [0.0, 1.0, 0.0])
1308            .active = false;
1309        assert_eq!(store.active_manifolds().len(), 1);
1310    }
1311
1312    // ── ContactFrame tests ────────────────────────────────────────────────────
1313
1314    #[test]
1315    fn contact_frame_normal_preserved() {
1316        let f = ContactFrame::from_normal([0.0, 1.0, 0.0]);
1317        let len =
1318            (f.normal[0] * f.normal[0] + f.normal[1] * f.normal[1] + f.normal[2] * f.normal[2])
1319                .sqrt();
1320        assert!((len - 1.0).abs() < 1e-10);
1321        assert!((f.normal[1] - 1.0).abs() < 1e-10);
1322    }
1323
1324    #[test]
1325    fn contact_frame_tangents_perpendicular_to_normal() {
1326        let f = ContactFrame::from_normal([1.0, 0.0, 0.0]);
1327        let dt1n =
1328            f.tangent1[0] * f.normal[0] + f.tangent1[1] * f.normal[1] + f.tangent1[2] * f.normal[2];
1329        let dt2n =
1330            f.tangent2[0] * f.normal[0] + f.tangent2[1] * f.normal[1] + f.tangent2[2] * f.normal[2];
1331        assert!(
1332            dt1n.abs() < 1e-10,
1333            "tangent1 not perpendicular to normal: {dt1n}"
1334        );
1335        assert!(
1336            dt2n.abs() < 1e-10,
1337            "tangent2 not perpendicular to normal: {dt2n}"
1338        );
1339    }
1340
1341    #[test]
1342    fn contact_frame_project_unproject_roundtrip() {
1343        let f = ContactFrame::from_normal([0.0, 0.0, 1.0]);
1344        let v = [1.0, 2.0, 3.0];
1345        let projected = f.project(v);
1346        let recovered = f.unproject(projected);
1347        for k in 0..3 {
1348            assert!(
1349                (recovered[k] - v[k]).abs() < 1e-10,
1350                "component {k}: {} vs {}",
1351                recovered[k],
1352                v[k]
1353            );
1354        }
1355    }
1356
1357    // ── ContactCacheInvalidator tests ─────────────────────────────────────────
1358
1359    #[test]
1360    fn invalidator_detects_moved_body() {
1361        let mut inv = ContactCacheInvalidator::new(3, 0.5);
1362        let new_pos = [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
1363        let moved = inv.update_and_check(&new_pos, 1.0);
1364        // Body 0 moved by 1.0 > 0.5 * 1.0 = 0.5
1365        assert!(moved.contains(&0), "body 0 should be in moved list");
1366    }
1367
1368    #[test]
1369    fn invalidator_static_body_not_moved() {
1370        let mut inv = ContactCacheInvalidator::new(2, 0.5);
1371        inv.prev_positions[0] = [0.1, 0.0, 0.0];
1372        let new_pos = [[0.1, 0.0, 0.0], [0.0, 0.0, 0.0]];
1373        let moved = inv.update_and_check(&new_pos, 1.0);
1374        assert!(
1375            !moved.contains(&0),
1376            "static body should not be in moved list"
1377        );
1378    }
1379
1380    #[test]
1381    fn invalidator_removes_contacts_for_moved_body() {
1382        let mut inv = ContactCacheInvalidator::new(3, 0.1);
1383        let mut graph = ContactGraph::new();
1384        let k01 = ContactKey::new(0, 1);
1385        let k12 = ContactKey::new(1, 2);
1386        graph.update(k01, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1387        graph.update(k12, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1388
1389        // Move body 0 beyond threshold
1390        let new_pos = [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
1391        let moved = inv.update_and_check(&new_pos, 1.0);
1392        inv.invalidate_contacts(&mut graph, &moved);
1393
1394        // Contact (0,1) should be removed; (1,2) stays
1395        assert!(graph.get(&k01).is_none(), "contact 0-1 should be removed");
1396        assert!(graph.get(&k12).is_some(), "contact 1-2 should stay");
1397    }
1398
1399    // ── Triangle area helper ──────────────────────────────────────────────────
1400
1401    #[test]
1402    fn triangle_area_unit() {
1403        // Right triangle with legs of length 1
1404        let area = triangle_area([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1405        assert!((area - 0.5).abs() < 1e-12, "area={area}");
1406    }
1407
1408    #[test]
1409    fn triangle_area_degenerate_zero() {
1410        // Three collinear points → zero area
1411        let area = triangle_area([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
1412        assert!(area < 1e-12, "degenerate triangle area={area}");
1413    }
1414}
1415
1416// ── Tests for island detection and related features ───────────────────────────
1417
1418#[cfg(test)]
1419mod tests_islands {
1420    use super::*;
1421
1422    fn make_graph_chain(n: usize) -> ContactGraph {
1423        let mut g = ContactGraph::new();
1424        for i in 0..(n - 1) {
1425            g.update(ContactKey::new(i, i + 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1426        }
1427        g
1428    }
1429
1430    // ── detect_islands ────────────────────────────────────────────────────────
1431
1432    #[test]
1433    fn island_chain_of_four_is_one_island() {
1434        let g = make_graph_chain(4);
1435        let islands = detect_islands(&g, 4);
1436        assert_eq!(islands.len(), 1, "chain of 4 should be 1 island");
1437        assert_eq!(islands[0].bodies.len(), 4);
1438    }
1439
1440    #[test]
1441    fn island_two_separate_pairs() {
1442        let mut g = ContactGraph::new();
1443        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1444        g.update(ContactKey::new(2, 3), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1445        let islands = detect_islands(&g, 4);
1446        assert_eq!(
1447            islands.len(),
1448            2,
1449            "two disconnected pairs should give 2 islands"
1450        );
1451    }
1452
1453    #[test]
1454    fn island_empty_graph_gives_no_islands() {
1455        let g = ContactGraph::new();
1456        let islands = detect_islands(&g, 5);
1457        assert_eq!(islands.len(), 0);
1458    }
1459
1460    #[test]
1461    fn island_single_contact() {
1462        let mut g = ContactGraph::new();
1463        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.05, [0.0; 3]);
1464        let islands = detect_islands(&g, 2);
1465        assert_eq!(islands.len(), 1);
1466        assert_eq!(islands[0].edges.len(), 1);
1467    }
1468
1469    #[test]
1470    fn island_star_topology() {
1471        // Body 0 touches bodies 1, 2, 3 → all in one island
1472        let mut g = ContactGraph::new();
1473        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1474        g.update(ContactKey::new(0, 2), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1475        g.update(ContactKey::new(0, 3), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1476        let islands = detect_islands(&g, 4);
1477        assert_eq!(islands.len(), 1);
1478        assert_eq!(islands[0].bodies.len(), 4);
1479        assert_eq!(islands[0].edges.len(), 3);
1480    }
1481
1482    #[test]
1483    fn island_inactive_contacts_ignored() {
1484        let mut g = ContactGraph::new();
1485        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1486        g.update(ContactKey::new(2, 3), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1487        g.mark_inactive_all();
1488        // Re-activate only (0,1)
1489        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1490        let islands = detect_islands(&g, 4);
1491        assert_eq!(islands.len(), 1, "only 1 active island");
1492    }
1493
1494    // ── IslandStats ───────────────────────────────────────────────────────────
1495
1496    #[test]
1497    fn island_stats_empty() {
1498        let stats = IslandStats::from_islands(&[]);
1499        assert_eq!(stats.island_count, 0);
1500        assert_eq!(stats.max_size, 0);
1501    }
1502
1503    #[test]
1504    fn island_stats_two_islands() {
1505        let mut g = ContactGraph::new();
1506        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1507        g.update(ContactKey::new(2, 3), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1508        g.update(ContactKey::new(3, 4), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1509        let islands = detect_islands(&g, 5);
1510        let stats = IslandStats::from_islands(&islands);
1511        assert_eq!(stats.island_count, 2);
1512        assert_eq!(stats.max_size, 3); // island containing 2,3,4
1513        assert_eq!(stats.total_bodies, 5);
1514    }
1515
1516    // ── propagate_sleep ───────────────────────────────────────────────────────
1517
1518    #[test]
1519    fn sleep_propagation_slow_bodies_sleep() {
1520        let mut g = ContactGraph::new();
1521        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1522        let mut islands = detect_islands(&g, 2);
1523
1524        // Velocities below threshold
1525        let vel_sq = [0.0001, 0.0001];
1526        let mut sleep_counters = [0u32; 2];
1527
1528        // Run for min_sleep_frames = 3 frames
1529        let mut to_sleep = Vec::new();
1530        for _ in 0..3 {
1531            to_sleep = propagate_sleep(&mut islands, &vel_sq, &mut sleep_counters, 0.01, 3);
1532        }
1533        assert!(
1534            !to_sleep.is_empty(),
1535            "bodies should be put to sleep after 3 slow frames"
1536        );
1537    }
1538
1539    #[test]
1540    fn sleep_propagation_fast_body_resets_counter() {
1541        let mut g = ContactGraph::new();
1542        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1543        let mut islands = detect_islands(&g, 2);
1544
1545        let mut sleep_counters = [5u32; 2]; // already counted
1546        // Body 0 is fast
1547        let vel_sq = [1.0, 0.0001];
1548        let to_sleep = propagate_sleep(&mut islands, &vel_sq, &mut sleep_counters, 0.01, 3);
1549        // Island won't sleep because body 0 is fast
1550        assert!(to_sleep.is_empty(), "fast body should prevent sleep");
1551        assert_eq!(sleep_counters[0], 0, "counter for fast body should reset");
1552    }
1553
1554    // ── ContactFrequency ──────────────────────────────────────────────────────
1555
1556    #[test]
1557    fn contact_frequency_classify() {
1558        assert_eq!(ContactFrequency::classify(0), ContactFrequency::Transient);
1559        assert_eq!(ContactFrequency::classify(1), ContactFrequency::Transient);
1560        assert_eq!(ContactFrequency::classify(2), ContactFrequency::ShortLived);
1561        assert_eq!(ContactFrequency::classify(4), ContactFrequency::ShortLived);
1562        assert_eq!(ContactFrequency::classify(5), ContactFrequency::Persistent);
1563        assert_eq!(
1564            ContactFrequency::classify(100),
1565            ContactFrequency::Persistent
1566        );
1567    }
1568
1569    #[test]
1570    fn contact_frequency_counts() {
1571        let mut g = ContactGraph::new();
1572        let k0 = ContactKey::new(0, 1);
1573        let k1 = ContactKey::new(1, 2);
1574        let k2 = ContactKey::new(2, 3);
1575        // Insert and age k2 manually by calling update repeatedly
1576        for _ in 0..6 {
1577            g.update(k2, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1578        }
1579        g.update(k0, [0.0, 1.0, 0.0], 0.1, [0.0; 3]); // age=1 → transient
1580        g.update(k1, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1581        g.update(k1, [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1582        g.update(k1, [0.0, 1.0, 0.0], 0.1, [0.0; 3]); // age=3 → short_lived
1583
1584        let (t, s, p) = g.frequency_counts();
1585        assert_eq!(t, 1, "one transient contact");
1586        assert_eq!(s, 1, "one short-lived contact");
1587        assert!(p >= 1, "at least one persistent contact");
1588    }
1589
1590    #[test]
1591    fn persistent_contacts_only_old() {
1592        let mut g = ContactGraph::new();
1593        let k_new = ContactKey::new(0, 1);
1594        let k_old = ContactKey::new(1, 2);
1595        g.update(k_new, [0.0, 1.0, 0.0], 0.1, [0.0; 3]); // age=1
1596        for _ in 0..6 {
1597            g.update(k_old, [0.0, 1.0, 0.0], 0.1, [0.0; 3]); // age=6
1598        }
1599        let persistent = g.persistent_contacts();
1600        assert_eq!(persistent.len(), 1);
1601        assert_eq!(persistent[0].key, k_old);
1602    }
1603
1604    // ── manifold_quality_score ────────────────────────────────────────────────
1605
1606    #[test]
1607    fn quality_score_deep_old_warm_is_high() {
1608        let key = ContactKey::new(0, 1);
1609        let c = PersistedContact {
1610            key,
1611            normal: [0.0, 1.0, 0.0],
1612            depth: 0.5,
1613            contact_point: [0.0; 3],
1614            age: 20,
1615            cached_impulse: 1.0,
1616            is_active: true,
1617        };
1618        let score = manifold_quality_score(&c);
1619        assert!(
1620            score > 0.8,
1621            "deep, old, warm contact should score > 0.8, got {score}"
1622        );
1623        assert!(score <= 1.0);
1624    }
1625
1626    #[test]
1627    fn quality_score_shallow_new_is_low() {
1628        let key = ContactKey::new(0, 1);
1629        let c = PersistedContact {
1630            key,
1631            normal: [0.0, 1.0, 0.0],
1632            depth: 0.001,
1633            contact_point: [0.0; 3],
1634            age: 0,
1635            cached_impulse: 0.0,
1636            is_active: true,
1637        };
1638        let score = manifold_quality_score(&c);
1639        assert!(
1640            score < 0.2,
1641            "shallow, new, cold contact should score < 0.2, got {score}"
1642        );
1643    }
1644
1645    #[test]
1646    fn quality_score_in_unit_range() {
1647        let key = ContactKey::new(0, 1);
1648        for depth in [0.0, 0.1, 0.5, 1.0, 10.0] {
1649            for age in [0u32, 5, 20] {
1650                let c = PersistedContact {
1651                    key,
1652                    normal: [0.0, 1.0, 0.0],
1653                    depth,
1654                    contact_point: [0.0; 3],
1655                    age,
1656                    cached_impulse: 0.0,
1657                    is_active: true,
1658                };
1659                let score = manifold_quality_score(&c);
1660                assert!((0.0..=1.0).contains(&score), "score out of range: {score}");
1661            }
1662        }
1663    }
1664
1665    // ── is_static_dynamic_bipartite ───────────────────────────────────────────
1666
1667    #[test]
1668    fn bipartite_check_all_dynamic() {
1669        let mut g = ContactGraph::new();
1670        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1671        g.update(ContactKey::new(1, 2), [0.0, 1.0, 0.0], 0.1, [0.0; 3]);
1672        let is_static = [false, false, false];
1673        assert!(is_static_dynamic_bipartite(&g, &is_static));
1674    }
1675
1676    #[test]
1677    fn bipartite_check_dynamic_on_static() {
1678        let mut g = ContactGraph::new();
1679        // Dynamic body 1 rests on static body 0
1680        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.05, [0.0; 3]);
1681        let is_static = [true, false];
1682        assert!(is_static_dynamic_bipartite(&g, &is_static));
1683    }
1684
1685    #[test]
1686    fn bipartite_check_static_static_contact_fails() {
1687        let mut g = ContactGraph::new();
1688        g.update(ContactKey::new(0, 1), [0.0, 1.0, 0.0], 0.01, [0.0; 3]);
1689        let is_static = [true, true]; // both static → should fail
1690        assert!(!is_static_dynamic_bipartite(&g, &is_static));
1691    }
1692
1693    #[test]
1694    fn bipartite_check_empty_graph_is_bipartite() {
1695        let g = ContactGraph::new();
1696        let is_static: Vec<bool> = vec![];
1697        assert!(is_static_dynamic_bipartite(&g, &is_static));
1698    }
1699}