Skip to main content

oxiphysics_collision/
manifold_cache.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Persistent contact manifold caching for stable simulation.
5//!
6//! Provides warm-starting of contact impulses across frames by caching
7//! contact points and matching them between simulation steps.
8
9use std::collections::HashMap;
10
11// ─── Math helpers ─────────────────────────────────────────────────────────────
12
13#[inline]
14fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
15    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
16}
17
18#[inline]
19fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
20    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
21}
22
23#[inline]
24fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
25    [a[0] * s, a[1] * s, a[2] * s]
26}
27
28#[inline]
29fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
30    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
31}
32
33#[inline]
34fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
35    [
36        a[1] * b[2] - a[2] * b[1],
37        a[2] * b[0] - a[0] * b[2],
38        a[0] * b[1] - a[1] * b[0],
39    ]
40}
41
42#[inline]
43fn len_sq3(a: [f64; 3]) -> f64 {
44    dot3(a, a)
45}
46
47#[inline]
48fn len3(a: [f64; 3]) -> f64 {
49    len_sq3(a).sqrt()
50}
51
52#[inline]
53fn normalize3(a: [f64; 3]) -> [f64; 3] {
54    let l = len3(a);
55    if l > 1e-10 {
56        scale3(a, 1.0 / l)
57    } else {
58        [0.0, 0.0, 0.0]
59    }
60}
61
62/// Apply rotation matrix (row-major 3×3) to a vector.
63#[inline]
64fn mat3_mul_vec(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
65    [
66        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
67        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
68        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
69    ]
70}
71
72/// Transform a local-space point to world space.
73/// `transform` = (translation, rotation_matrix)
74#[inline]
75fn transform_point(transform: ([f64; 3], [[f64; 3]; 3]), local: [f64; 3]) -> [f64; 3] {
76    add3(transform.0, mat3_mul_vec(transform.1, local))
77}
78
79// ─── ContactPointId ───────────────────────────────────────────────────────────
80
81/// Utility for computing a hash-based ID for contact point matching.
82pub struct ContactPointId;
83
84impl ContactPointId {
85    /// Compute a stable hash ID from quantized local positions.
86    ///
87    /// Positions are quantized to a 1 cm grid before hashing to allow
88    /// small numerical drift without changing the contact identity.
89    pub fn compute(local_a: [f64; 3], local_b: [f64; 3]) -> u64 {
90        const QUANT: f64 = 100.0; // 1 cm grid
91        let qa = [
92            (local_a[0] * QUANT).round() as i64,
93            (local_a[1] * QUANT).round() as i64,
94            (local_a[2] * QUANT).round() as i64,
95        ];
96        let qb = [
97            (local_b[0] * QUANT).round() as i64,
98            (local_b[1] * QUANT).round() as i64,
99            (local_b[2] * QUANT).round() as i64,
100        ];
101        // FNV-1a style hash over the six integers
102        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
103        for &v in qa.iter().chain(qb.iter()) {
104            for b in v.to_le_bytes() {
105                h ^= b as u64;
106                h = h.wrapping_mul(0x0000_0100_0000_01B3);
107            }
108        }
109        h
110    }
111}
112
113// ─── ContactPoint ─────────────────────────────────────────────────────────────
114
115/// A single contact point with warm-start impulse data.
116#[derive(Debug, Clone)]
117pub struct ContactPoint {
118    /// Contact point on body A in world space.
119    pub world_pos_a: [f64; 3],
120    /// Contact point on body B in world space.
121    pub world_pos_b: [f64; 3],
122    /// Contact point in body A local space.
123    pub local_pos_a: [f64; 3],
124    /// Contact point in body B local space.
125    pub local_pos_b: [f64; 3],
126    /// Contact normal pointing from B to A.
127    pub normal: [f64; 3],
128    /// Penetration depth (positive = overlapping).
129    pub depth: f64,
130    /// Accumulated tangential impulse for warm starting (2 tangent directions).
131    pub tangent_impulse: [f64; 2],
132    /// Accumulated normal impulse for warm starting.
133    pub normal_impulse: f64,
134    /// Number of frames this contact has persisted.
135    pub lifetime: u32,
136    /// Hash-based ID for matching across frames.
137    pub id: u64,
138}
139
140impl ContactPoint {
141    /// Create a new contact point, computing its ID automatically.
142    pub fn new(
143        world_pos_a: [f64; 3],
144        world_pos_b: [f64; 3],
145        local_pos_a: [f64; 3],
146        local_pos_b: [f64; 3],
147        normal: [f64; 3],
148        depth: f64,
149    ) -> Self {
150        let id = ContactPointId::compute(local_pos_a, local_pos_b);
151        Self {
152            world_pos_a,
153            world_pos_b,
154            local_pos_a,
155            local_pos_b,
156            normal,
157            depth,
158            tangent_impulse: [0.0, 0.0],
159            normal_impulse: 0.0,
160            lifetime: 0,
161            id,
162        }
163    }
164}
165
166// ─── PersistentManifold ───────────────────────────────────────────────────────
167
168/// Persistent contact manifold holding up to 4 contact points with warm-start data.
169///
170/// Named `PersistentManifold` to distinguish from the simpler `ContactManifold`
171/// used in narrow-phase output.
172#[derive(Debug, Clone)]
173pub struct PersistentManifold {
174    /// Handle ID of body A.
175    pub body_a: u32,
176    /// Handle ID of body B.
177    pub body_b: u32,
178    /// Contact points (max 4).
179    pub points: Vec<ContactPoint>,
180    /// Average contact normal.
181    pub normal: [f64; 3],
182    /// Friction coefficient.
183    pub friction: f64,
184    /// Restitution coefficient.
185    pub restitution: f64,
186    /// Whether this manifold was touched this frame.
187    pub is_active: bool,
188}
189
190/// Distance threshold (squared) below which two contact points are considered
191/// the same contact.
192const MATCH_DIST_SQ: f64 = 0.01 * 0.01; // 1 cm
193
194/// Separation threshold beyond which a cached contact is considered broken.
195const SEPARATION_THRESHOLD: f64 = 0.02; // 2 cm
196
197impl PersistentManifold {
198    /// Create a new empty persistent manifold.
199    pub fn new(body_a: u32, body_b: u32, friction: f64, restitution: f64) -> Self {
200        Self {
201            body_a,
202            body_b,
203            points: Vec::with_capacity(4),
204            normal: [0.0, 1.0, 0.0],
205            friction,
206            restitution,
207            is_active: true,
208        }
209    }
210
211    /// Add a new contact point or update an existing cached one.
212    ///
213    /// If a point with the same ID (or within the distance threshold) already
214    /// exists, the cached warm-start impulses are transferred to the new point.
215    /// The manifold is pruned to at most 4 points using area-maximising selection.
216    pub fn add_or_update(&mut self, mut new_point: ContactPoint) {
217        // Try to find a matching existing point by ID or proximity.
218        let match_idx = self.points.iter().position(|p| {
219            if p.id == new_point.id {
220                return true;
221            }
222            let d = len_sq3(sub3(p.local_pos_a, new_point.local_pos_a));
223            d < MATCH_DIST_SQ
224        });
225
226        if let Some(idx) = match_idx {
227            // Transfer warm-start impulses.
228            new_point.normal_impulse = self.points[idx].normal_impulse;
229            new_point.tangent_impulse = self.points[idx].tangent_impulse;
230            new_point.lifetime = self.points[idx].lifetime + 1;
231            self.points[idx] = new_point;
232        } else {
233            self.points.push(new_point);
234            if self.points.len() > 4 {
235                self.points = ManifoldReduction::reduce_to_4_points(&self.points);
236            }
237        }
238
239        self.recompute_normal();
240    }
241
242    /// Remove stale contact points based on world-space re-projection.
243    ///
244    /// For each cached point the local positions are transformed back to world
245    /// space using the current body transforms. The point is removed if the
246    /// bodies have separated or if the projected position has drifted too far
247    /// from the cached world position.
248    pub fn remove_stale(
249        &mut self,
250        transform_a: ([f64; 3], [[f64; 3]; 3]),
251        transform_b: ([f64; 3], [[f64; 3]; 3]),
252    ) {
253        self.points.retain(|p| {
254            let wa = transform_point(transform_a, p.local_pos_a);
255            let wb = transform_point(transform_b, p.local_pos_b);
256
257            // Check separation along normal.
258            let sep = dot3(sub3(wa, wb), p.normal);
259            if sep > SEPARATION_THRESHOLD {
260                return false;
261            }
262
263            // Check positional drift of world_pos_a.
264            if len_sq3(sub3(wa, p.world_pos_a)) > SEPARATION_THRESHOLD * SEPARATION_THRESHOLD {
265                return false;
266            }
267
268            // Check positional drift of world_pos_b.
269            if len_sq3(sub3(wb, p.world_pos_b)) > SEPARATION_THRESHOLD * SEPARATION_THRESHOLD {
270                return false;
271            }
272
273            true
274        });
275
276        self.recompute_normal();
277    }
278
279    fn recompute_normal(&mut self) {
280        if self.points.is_empty() {
281            return;
282        }
283        let mut avg = [0.0f64; 3];
284        for p in &self.points {
285            avg = add3(avg, p.normal);
286        }
287        let n = self.points.len() as f64;
288        self.normal = normalize3(scale3(avg, 1.0 / n));
289    }
290}
291
292// ─── ManifoldReduction ────────────────────────────────────────────────────────
293
294/// Algorithms for reducing a contact set to at most 4 representative points.
295pub struct ManifoldReduction;
296
297impl ManifoldReduction {
298    /// Reduce a slice of contact points to at most 4 points.
299    ///
300    /// Selection strategy:
301    /// 1. Keep the deepest penetrating point.
302    /// 2. Keep the point furthest from point 1.
303    /// 3. Keep the point that maximises triangle area with points 1 and 2.
304    /// 4. Keep the point that maximises quadrilateral area with points 1–3.
305    pub fn reduce_to_4_points(points: &[ContactPoint]) -> Vec<ContactPoint> {
306        if points.len() <= 4 {
307            return points.to_vec();
308        }
309
310        // 1. Deepest point.
311        let idx0 = points
312            .iter()
313            .enumerate()
314            .max_by(|(_, a), (_, b)| {
315                a.depth
316                    .partial_cmp(&b.depth)
317                    .unwrap_or(std::cmp::Ordering::Equal)
318            })
319            .map(|(i, _)| i)
320            .unwrap_or(0);
321
322        // 2. Furthest from point 0.
323        let p0 = points[idx0].world_pos_a;
324        let idx1 = points
325            .iter()
326            .enumerate()
327            .filter(|(i, _)| *i != idx0)
328            .max_by(|(_, a), (_, b)| {
329                len_sq3(sub3(a.world_pos_a, p0))
330                    .partial_cmp(&len_sq3(sub3(b.world_pos_a, p0)))
331                    .unwrap_or(std::cmp::Ordering::Equal)
332            })
333            .map(|(i, _)| i)
334            .unwrap_or(0);
335
336        // 3. Maximise triangle area with p0 and p1.
337        let p1 = points[idx1].world_pos_a;
338        let idx2 = points
339            .iter()
340            .enumerate()
341            .filter(|(i, _)| *i != idx0 && *i != idx1)
342            .max_by(|(_, a), (_, b)| {
343                Self::tri_area_sq(p0, p1, a.world_pos_a)
344                    .partial_cmp(&Self::tri_area_sq(p0, p1, b.world_pos_a))
345                    .unwrap_or(std::cmp::Ordering::Equal)
346            })
347            .map(|(i, _)| i)
348            .unwrap_or(0);
349
350        // 4. Maximise quad area.
351        let p2 = points[idx2].world_pos_a;
352        let maybe_idx3 = points
353            .iter()
354            .enumerate()
355            .filter(|(i, _)| *i != idx0 && *i != idx1 && *i != idx2)
356            .max_by(|(_, a), (_, b)| {
357                Self::quad_area_sq(p0, p1, p2, a.world_pos_a)
358                    .partial_cmp(&Self::quad_area_sq(p0, p1, p2, b.world_pos_a))
359                    .unwrap_or(std::cmp::Ordering::Equal)
360            })
361            .map(|(i, _)| i);
362
363        let mut result = vec![
364            points[idx0].clone(),
365            points[idx1].clone(),
366            points[idx2].clone(),
367        ];
368        if let Some(idx3) = maybe_idx3 {
369            result.push(points[idx3].clone());
370        }
371        result
372    }
373
374    /// Squared magnitude of cross product, proportional to triangle area squared.
375    fn tri_area_sq(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
376        len_sq3(cross3(sub3(b, a), sub3(c, a)))
377    }
378
379    /// Approximate quad area: sum of two triangle areas (squared, for comparison only).
380    fn quad_area_sq(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> f64 {
381        Self::tri_area_sq(a, b, c) + Self::tri_area_sq(a, c, d)
382    }
383
384    /// Compute the approximate area of the convex hull of the contact points
385    /// projected onto the contact plane defined by the first point's normal.
386    ///
387    /// Uses the shoelace formula on the projected 2-D coordinates.
388    pub fn contact_area(points: &[ContactPoint]) -> f64 {
389        if points.len() < 3 {
390            return 0.0;
391        }
392
393        let n = points[0].normal;
394        let t1 = Self::make_tangent(n);
395        let t2 = cross3(n, t1);
396
397        let origin = points[0].world_pos_a;
398        let projected: Vec<[f64; 2]> = points
399            .iter()
400            .map(|p| {
401                let d = sub3(p.world_pos_a, origin);
402                [dot3(d, t1), dot3(d, t2)]
403            })
404            .collect();
405
406        shoelace_area(&projected)
407    }
408
409    fn make_tangent(n: [f64; 3]) -> [f64; 3] {
410        let candidate = if n[0].abs() < 0.9 {
411            [1.0, 0.0, 0.0]
412        } else {
413            [0.0, 1.0, 0.0]
414        };
415        normalize3(cross3(n, candidate))
416    }
417}
418
419// ─── Shoelace helper ──────────────────────────────────────────────────────────
420
421fn shoelace_area(pts: &[[f64; 2]]) -> f64 {
422    let n = pts.len();
423    if n < 3 {
424        return 0.0;
425    }
426    let mut area = 0.0f64;
427    for i in 0..n {
428        let j = (i + 1) % n;
429        area += pts[i][0] * pts[j][1];
430        area -= pts[j][0] * pts[i][1];
431    }
432    area.abs() * 0.5
433}
434
435// ─── ContactManifoldCache ─────────────────────────────────────────────────────
436
437/// World-level cache of persistent contact manifolds.
438///
439/// Stores one `PersistentManifold` per body pair, keyed by ordered `(min, max)`
440/// body-handle pair.
441pub struct ContactManifoldCache {
442    /// All active manifolds, keyed by ordered body-handle pair.
443    pub manifolds: HashMap<(u32, u32), PersistentManifold>,
444    /// Maximum number of frames a manifold can persist without being touched.
445    pub max_lifetime: u32,
446}
447
448impl ContactManifoldCache {
449    /// Create a new cache.
450    pub fn new(max_lifetime: u32) -> Self {
451        Self {
452            manifolds: HashMap::new(),
453            max_lifetime,
454        }
455    }
456
457    /// Return the canonical key for a body pair (always min, max).
458    #[inline]
459    fn key(body_a: u32, body_b: u32) -> (u32, u32) {
460        (body_a.min(body_b), body_a.max(body_b))
461    }
462
463    /// Get or create the persistent manifold for the given body pair.
464    pub fn get_or_create(
465        &mut self,
466        body_a: u32,
467        body_b: u32,
468        friction: f64,
469        restitution: f64,
470    ) -> &mut PersistentManifold {
471        let k = Self::key(body_a, body_b);
472        self.manifolds
473            .entry(k)
474            .or_insert_with(|| PersistentManifold::new(body_a, body_b, friction, restitution))
475    }
476
477    /// Merge a set of newly detected contact points into the cached manifold.
478    ///
479    /// Each new point is added-or-updated; existing point lifetimes are
480    /// incremented automatically inside `add_or_update`.
481    pub fn update_manifold(&mut self, body_a: u32, body_b: u32, new_points: Vec<ContactPoint>) {
482        let k = Self::key(body_a, body_b);
483        if let Some(manifold) = self.manifolds.get_mut(&k) {
484            manifold.is_active = true;
485            for pt in new_points {
486                manifold.add_or_update(pt);
487            }
488            for p in &mut manifold.points {
489                p.lifetime = p.lifetime.saturating_add(1);
490            }
491        }
492    }
493
494    /// Remove manifolds that are no longer active or have no points.
495    pub fn remove_inactive(&mut self) {
496        self.manifolds
497            .retain(|_, m| m.is_active && !m.points.is_empty());
498    }
499
500    /// Mark all manifolds as inactive at the start of a frame.
501    ///
502    /// Manifolds that are still colliding will be reactivated when
503    /// `update_manifold` is called.
504    pub fn begin_frame(&mut self) {
505        for m in self.manifolds.values_mut() {
506            m.is_active = false;
507        }
508    }
509}
510
511// ─── ManifoldPointMatcher ────────────────────────────────────────────────────
512
513/// Utility for matching new contact points to existing cached points.
514pub struct ManifoldPointMatcher;
515
516impl ManifoldPointMatcher {
517    /// Find the best matching point in `existing` for `new_point`.
518    ///
519    /// Matching is done by:
520    /// 1. Exact ID match (hash equality).
521    /// 2. Proximity in local-space A (distance² < threshold).
522    ///
523    /// Returns the index into `existing` or `None` if no match.
524    pub fn find_match(
525        existing: &[ContactPoint],
526        new_point: &ContactPoint,
527        dist_sq_threshold: f64,
528    ) -> Option<usize> {
529        for (i, p) in existing.iter().enumerate() {
530            if p.id == new_point.id {
531                return Some(i);
532            }
533            let d = len_sq3(sub3(p.local_pos_a, new_point.local_pos_a));
534            if d < dist_sq_threshold {
535                return Some(i);
536            }
537        }
538        None
539    }
540
541    /// Match all points in `new_points` against `existing`, returning
542    /// pairs `(new_idx, existing_idx)` for each successful match.
543    pub fn match_all(
544        existing: &[ContactPoint],
545        new_points: &[ContactPoint],
546        dist_sq_threshold: f64,
547    ) -> Vec<(usize, usize)> {
548        let mut matches = Vec::new();
549        for (ni, np) in new_points.iter().enumerate() {
550            if let Some(ei) = Self::find_match(existing, np, dist_sq_threshold) {
551                matches.push((ni, ei));
552            }
553        }
554        matches
555    }
556}
557
558// ─── ManifoldLifetimeManager ─────────────────────────────────────────────────
559
560/// Manages the lifetime of manifolds in a cache: marks active ones,
561/// ages inactive ones, and removes stale ones.
562pub struct ManifoldLifetimeManager {
563    /// Number of frames a manifold can be inactive before removal.
564    pub max_inactive_frames: u32,
565}
566
567impl ManifoldLifetimeManager {
568    /// Create a new manager.
569    pub fn new(max_inactive_frames: u32) -> Self {
570        Self {
571            max_inactive_frames,
572        }
573    }
574
575    /// Increment lifetime counters for active manifold points; drop ones that
576    /// have exceeded `max_lifetime`.
577    pub fn age_manifold(&self, manifold: &mut PersistentManifold) {
578        manifold
579            .points
580            .retain(|p| p.lifetime <= self.max_inactive_frames);
581    }
582
583    /// Process all manifolds in a cache: age each and remove empty ones.
584    pub fn process_cache(&self, cache: &mut ContactManifoldCache) {
585        for manifold in cache.manifolds.values_mut() {
586            self.age_manifold(manifold);
587        }
588        cache.manifolds.retain(|_, m| !m.points.is_empty());
589    }
590
591    /// Return how many manifolds are active.
592    pub fn active_count(&self, cache: &ContactManifoldCache) -> usize {
593        cache.manifolds.values().filter(|m| m.is_active).count()
594    }
595}
596
597// ─── ManifoldCompressor ───────────────────────────────────────────────────────
598
599/// Compresses a manifold by removing redundant contact points while
600/// preserving the most physically significant ones.
601pub struct ManifoldCompressor;
602
603impl ManifoldCompressor {
604    /// Compress `points` to at most `max_count` points.
605    ///
606    /// Keeps the point with the greatest penetration depth, then selects
607    /// remaining points to maximise the covered area (same strategy as
608    /// `ManifoldReduction::reduce_to_4_points`).
609    pub fn compress(points: &[ContactPoint], max_count: usize) -> Vec<ContactPoint> {
610        if points.len() <= max_count {
611            return points.to_vec();
612        }
613        if max_count == 0 {
614            return vec![];
615        }
616        if max_count == 1 {
617            // Just the deepest point
618            let idx = points
619                .iter()
620                .enumerate()
621                .max_by(|(_, a), (_, b)| {
622                    a.depth
623                        .partial_cmp(&b.depth)
624                        .unwrap_or(std::cmp::Ordering::Equal)
625                })
626                .map(|(i, _)| i)
627                .unwrap_or(0);
628            return vec![points[idx].clone()];
629        }
630        // For max_count >= 4 fall through to existing reduction
631        ManifoldReduction::reduce_to_4_points(points)
632    }
633
634    /// Remove duplicate contact points (same ID).
635    pub fn deduplicate(points: &mut Vec<ContactPoint>) {
636        let mut seen = std::collections::HashSet::new();
637        points.retain(|p| seen.insert(p.id));
638    }
639
640    /// Merge two contact sets, preferring warm-start data from the existing set.
641    pub fn merge(
642        existing: &[ContactPoint],
643        incoming: &[ContactPoint],
644        max_count: usize,
645    ) -> Vec<ContactPoint> {
646        let mut merged: Vec<ContactPoint> = existing.to_vec();
647        for inc in incoming {
648            if let Some(idx) = ManifoldPointMatcher::find_match(&merged, inc, MATCH_DIST_SQ) {
649                // Transfer warm-start data
650                let mut updated = inc.clone();
651                updated.normal_impulse = merged[idx].normal_impulse;
652                updated.tangent_impulse = merged[idx].tangent_impulse;
653                updated.lifetime = merged[idx].lifetime + 1;
654                merged[idx] = updated;
655            } else {
656                merged.push(inc.clone());
657            }
658        }
659        Self::compress(&merged, max_count)
660    }
661}
662
663// ─── PersistentManifold extensions ───────────────────────────────────────────
664
665impl PersistentManifold {
666    /// Update the manifold with a completely new set of contact points.
667    ///
668    /// Points are matched against the existing cache to preserve warm-start
669    /// impulses, then compressed to at most 4.
670    pub fn update_from_new_contacts(&mut self, new_contacts: Vec<ContactPoint>) {
671        let merged = ManifoldCompressor::merge(&self.points, &new_contacts, 4);
672        self.points = merged;
673        self.recompute_normal();
674        self.is_active = true;
675    }
676
677    /// Extract warm-start data for the solver: returns (normal_impulse, tangent_impulse) per point.
678    pub fn warmstart_data(&self) -> Vec<(f64, [f64; 2])> {
679        self.points
680            .iter()
681            .map(|p| (p.normal_impulse, p.tangent_impulse))
682            .collect()
683    }
684
685    /// Apply solver results back to the warm-start cache.
686    pub fn store_solver_impulses(&mut self, impulses: &[(f64, [f64; 2])]) {
687        for (p, &(ni, ti)) in self.points.iter_mut().zip(impulses.iter()) {
688            p.normal_impulse = ni;
689            p.tangent_impulse = ti;
690        }
691    }
692
693    /// Number of active contact points.
694    pub fn contact_count(&self) -> usize {
695        self.points.len()
696    }
697
698    /// Maximum penetration depth across all contact points.
699    pub fn max_depth(&self) -> f64 {
700        self.points.iter().map(|p| p.depth).fold(0.0f64, f64::max)
701    }
702
703    /// Clamp all accumulated impulses to non-negative (velocity-based solvers).
704    pub fn clamp_impulses(&mut self) {
705        for p in &mut self.points {
706            p.normal_impulse = p.normal_impulse.max(0.0);
707        }
708    }
709}
710
711// ─── ContactManifoldCache extensions ─────────────────────────────────────────
712
713impl ContactManifoldCache {
714    /// Total number of contact points across all active manifolds.
715    pub fn total_contact_points(&self) -> usize {
716        self.manifolds.values().map(|m| m.points.len()).sum()
717    }
718
719    /// Number of manifolds in the cache.
720    pub fn manifold_count(&self) -> usize {
721        self.manifolds.len()
722    }
723
724    /// Clear all manifolds.
725    pub fn clear(&mut self) {
726        self.manifolds.clear();
727    }
728
729    /// Collect all warm-start data for a given body pair (if present).
730    pub fn get_warmstart(&self, body_a: u32, body_b: u32) -> Option<Vec<(f64, [f64; 2])>> {
731        let k = Self::key(body_a, body_b);
732        self.manifolds.get(&k).map(|m| m.warmstart_data())
733    }
734
735    /// Update all manifolds that belong to a single body (e.g., on body removal).
736    pub fn remove_body(&mut self, body_id: u32) {
737        self.manifolds
738            .retain(|_, m| m.body_a != body_id && m.body_b != body_id);
739    }
740}
741
742// ─── Contact caching strategies ──────────────────────────────────────────────
743
744/// Strategy for matching contact points across frames.
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub enum CachingStrategy {
747    /// Match by exact hash ID only.
748    IdOnly,
749    /// Match by spatial proximity only.
750    ProximityOnly,
751    /// Match by ID first, then fall back to proximity.
752    IdThenProximity,
753}
754
755/// Apply a caching strategy to find the best match for a new contact in existing contacts.
756pub fn find_match_with_strategy(
757    existing: &[ContactPoint],
758    new_point: &ContactPoint,
759    strategy: CachingStrategy,
760    dist_sq_threshold: f64,
761) -> Option<usize> {
762    match strategy {
763        CachingStrategy::IdOnly => existing.iter().position(|p| p.id == new_point.id),
764        CachingStrategy::ProximityOnly => existing.iter().enumerate().find_map(|(i, p)| {
765            if len_sq3(sub3(p.local_pos_a, new_point.local_pos_a)) < dist_sq_threshold {
766                Some(i)
767            } else {
768                None
769            }
770        }),
771        CachingStrategy::IdThenProximity => {
772            ManifoldPointMatcher::find_match(existing, new_point, dist_sq_threshold)
773        }
774    }
775}
776
777// ─── Warm-start data aging ─────────────────────────────────────────────────────
778
779/// Scale warm-start impulses by an age factor.
780///
781/// Reduces the effective warm-start contribution of older contacts.
782/// `age_factor` should be in `(0, 1]` — e.g. `0.95` per frame.
783pub fn age_warm_start(point: &mut ContactPoint, age_factor: f64) {
784    point.normal_impulse *= age_factor;
785    point.tangent_impulse[0] *= age_factor;
786    point.tangent_impulse[1] *= age_factor;
787}
788
789/// Apply aging to all contact points in a persistent manifold.
790pub fn age_manifold_warm_start(manifold: &mut PersistentManifold, age_factor: f64) {
791    for p in &mut manifold.points {
792        age_warm_start(p, age_factor);
793    }
794}
795
796/// Apply aging to all manifolds in a cache.
797pub fn age_cache_warm_start(cache: &mut ContactManifoldCache, age_factor: f64) {
798    for manifold in cache.manifolds.values_mut() {
799        age_manifold_warm_start(manifold, age_factor);
800    }
801}
802
803// ─── Position correction impulses ────────────────────────────────────────────
804
805/// Compute a Baumgarte position correction impulse magnitude.
806///
807/// `depth` is penetration depth, `beta` is the correction factor (0.1–0.3 typical),
808/// `dt` is the time step.  Returns the correction impulse to apply along the normal.
809pub fn baumgarte_correction(depth: f64, beta: f64, dt: f64) -> f64 {
810    if dt > 1e-12 {
811        (beta * depth / dt).max(0.0)
812    } else {
813        0.0
814    }
815}
816
817/// Compute a slop-clamped Baumgarte correction.
818///
819/// `slop` is a small penetration allowance (e.g. 0.005 m) that is not corrected.
820pub fn baumgarte_correction_slop(depth: f64, beta: f64, dt: f64, slop: f64) -> f64 {
821    baumgarte_correction((depth - slop).max(0.0), beta, dt)
822}
823
824/// Apply position correction impulses to all contact points in a manifold.
825///
826/// Returns the per-point correction magnitudes.
827pub fn apply_position_corrections(
828    manifold: &PersistentManifold,
829    beta: f64,
830    dt: f64,
831    slop: f64,
832) -> Vec<f64> {
833    manifold
834        .points
835        .iter()
836        .map(|p| baumgarte_correction_slop(p.depth, beta, dt, slop))
837        .collect()
838}
839
840// ─── Island-level manifold batching ──────────────────────────────────────────
841
842/// A simulation island: a group of bodies connected by contacts.
843#[derive(Debug, Clone)]
844pub struct ContactIsland {
845    /// Body IDs in this island.
846    pub bodies: Vec<u32>,
847    /// Manifold keys `(min_id, max_id)` belonging to this island.
848    pub manifold_keys: Vec<(u32, u32)>,
849}
850
851impl ContactIsland {
852    /// Create an empty island.
853    pub fn new() -> Self {
854        Self {
855            bodies: Vec::new(),
856            manifold_keys: Vec::new(),
857        }
858    }
859
860    /// Number of bodies in the island.
861    pub fn body_count(&self) -> usize {
862        self.bodies.len()
863    }
864
865    /// Number of contacts in the island.
866    pub fn contact_count(&self) -> usize {
867        self.manifold_keys.len()
868    }
869}
870
871impl Default for ContactIsland {
872    fn default() -> Self {
873        Self::new()
874    }
875}
876
877/// Build contact islands from a manifold cache using union-find.
878///
879/// Returns a list of islands, each containing body IDs and manifold keys.
880pub fn build_contact_islands(cache: &ContactManifoldCache) -> Vec<ContactIsland> {
881    // Collect all body IDs
882    let mut all_bodies: Vec<u32> = Vec::new();
883    for (a, b) in cache.manifolds.keys() {
884        if !all_bodies.contains(a) {
885            all_bodies.push(*a);
886        }
887        if !all_bodies.contains(b) {
888            all_bodies.push(*b);
889        }
890    }
891
892    let n = all_bodies.len();
893    if n == 0 {
894        return Vec::new();
895    }
896
897    // Union-Find
898    let mut parent: Vec<usize> = (0..n).collect();
899
900    let find = |parent: &mut Vec<usize>, mut x: usize| -> usize {
901        while parent[x] != x {
902            parent[x] = parent[parent[x]]; // path compression
903            x = parent[x];
904        }
905        x
906    };
907
908    for (a, b) in cache.manifolds.keys() {
909        let Some(ia) = all_bodies.iter().position(|&id| id == *a) else {
910            continue;
911        };
912        let Some(ib) = all_bodies.iter().position(|&id| id == *b) else {
913            continue;
914        };
915        let ra = find(&mut parent, ia);
916        let rb = find(&mut parent, ib);
917        if ra != rb {
918            parent[ra] = rb;
919        }
920    }
921
922    // Group bodies by root
923    let mut island_map: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
924    let mut islands: Vec<ContactIsland> = Vec::new();
925
926    for (i, &body_id) in all_bodies.iter().enumerate() {
927        let root = find(&mut parent, i);
928        let island_idx = *island_map.entry(root).or_insert_with(|| {
929            islands.push(ContactIsland::new());
930            islands.len() - 1
931        });
932        islands[island_idx].bodies.push(body_id);
933    }
934
935    // Assign manifold keys to islands
936    for key in cache.manifolds.keys() {
937        let Some(ia) = all_bodies.iter().position(|&id| id == key.0) else {
938            continue;
939        };
940        let root = find(&mut parent, ia);
941        let island_idx = island_map[&root];
942        islands[island_idx].manifold_keys.push(*key);
943    }
944
945    islands
946}
947
948// ─── Manifold quality metrics ─────────────────────────────────────────────────
949
950/// Compute quality metrics for a manifold.
951#[derive(Debug, Clone)]
952pub struct ManifoldMetrics {
953    /// Number of contact points.
954    pub contact_count: usize,
955    /// Maximum penetration depth.
956    pub max_depth: f64,
957    /// Average penetration depth.
958    pub avg_depth: f64,
959    /// Maximum pairwise distance between contact points (contact area measure).
960    pub spread: f64,
961    /// Whether all contact points have valid warm-start data.
962    pub is_warm: bool,
963}
964
965/// Compute quality metrics for a persistent manifold.
966pub fn compute_manifold_metrics(manifold: &PersistentManifold) -> ManifoldMetrics {
967    let n = manifold.points.len();
968    if n == 0 {
969        return ManifoldMetrics {
970            contact_count: 0,
971            max_depth: 0.0,
972            avg_depth: 0.0,
973            spread: 0.0,
974            is_warm: false,
975        };
976    }
977
978    let max_depth = manifold
979        .points
980        .iter()
981        .map(|p| p.depth)
982        .fold(0.0f64, f64::max);
983    let avg_depth = manifold.points.iter().map(|p| p.depth).sum::<f64>() / n as f64;
984    let is_warm = manifold
985        .points
986        .iter()
987        .all(|p| p.normal_impulse.abs() > 0.0 || p.lifetime > 0);
988
989    // Max pairwise distance
990    let spread = manifold
991        .points
992        .iter()
993        .enumerate()
994        .flat_map(|(i, a)| {
995            manifold.points[i + 1..]
996                .iter()
997                .map(move |b| len3(sub3(a.world_pos_a, b.world_pos_a)))
998        })
999        .fold(0.0f64, f64::max);
1000
1001    ManifoldMetrics {
1002        contact_count: n,
1003        max_depth,
1004        avg_depth,
1005        spread,
1006        is_warm,
1007    }
1008}
1009
1010// ─── Tests ────────────────────────────────────────────────────────────────────
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    fn make_contact(
1017        pos_a: [f64; 3],
1018        pos_b: [f64; 3],
1019        depth: f64,
1020        normal_impulse: f64,
1021    ) -> ContactPoint {
1022        let mut cp = ContactPoint::new(pos_a, pos_b, pos_a, pos_b, [0.0, 1.0, 0.0], depth);
1023        cp.normal_impulse = normal_impulse;
1024        cp
1025    }
1026
1027    // ------------------------------------------------------------------
1028    // PersistentManifold::add_or_update
1029    // ------------------------------------------------------------------
1030
1031    #[test]
1032    fn test_add_same_point_twice_no_overflow() {
1033        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1034        let pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 1.0);
1035        m.add_or_update(pt.clone());
1036        m.add_or_update(pt.clone());
1037        assert_eq!(m.points.len(), 1, "same point added twice should stay as 1");
1038    }
1039
1040    #[test]
1041    fn test_add_or_update_max_4_points() {
1042        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1043        let positions: [[f64; 3]; 6] = [
1044            [1.0, 0.0, 0.0],
1045            [-1.0, 0.0, 0.0],
1046            [0.0, 0.0, 1.0],
1047            [0.0, 0.0, -1.0],
1048            [0.5, 0.0, 0.5],
1049            [-0.5, 0.0, -0.5],
1050        ];
1051        for &p in &positions {
1052            let neg_p = scale3(p, -1.0);
1053            let cp = make_contact(p, neg_p, 0.01, 0.0);
1054            m.add_or_update(cp);
1055        }
1056        assert!(
1057            m.points.len() <= 4,
1058            "manifold must not exceed 4 points, got {}",
1059            m.points.len()
1060        );
1061    }
1062
1063    // ------------------------------------------------------------------
1064    // ManifoldReduction
1065    // ------------------------------------------------------------------
1066
1067    #[test]
1068    fn test_reduce_to_4_points_with_6_inputs() {
1069        let positions: [[f64; 3]; 6] = [
1070            [1.0, 0.0, 0.0],
1071            [-1.0, 0.0, 0.0],
1072            [0.0, 0.0, 1.0],
1073            [0.0, 0.0, -1.0],
1074            [0.5, 0.0, 0.5],
1075            [-0.5, 0.0, -0.5],
1076        ];
1077        let points: Vec<ContactPoint> = positions
1078            .iter()
1079            .map(|&p| ContactPoint::new(p, p, p, p, [0.0, 1.0, 0.0], 0.01))
1080            .collect();
1081        let reduced = ManifoldReduction::reduce_to_4_points(&points);
1082        assert!(
1083            reduced.len() <= 4,
1084            "reduce_to_4_points must return <=4 points, got {}",
1085            reduced.len()
1086        );
1087    }
1088
1089    // ------------------------------------------------------------------
1090    // ContactManifoldCache::get_or_create
1091    // ------------------------------------------------------------------
1092
1093    #[test]
1094    fn test_get_or_create_new_pair() {
1095        let mut cache = ContactManifoldCache::new(5);
1096        let m = cache.get_or_create(1, 2, 0.5, 0.3);
1097        assert_eq!(m.friction, 0.5);
1098        assert_eq!(m.restitution, 0.3);
1099        assert!(m.is_active);
1100    }
1101
1102    #[test]
1103    fn test_get_or_create_ordered_key() {
1104        let mut cache = ContactManifoldCache::new(5);
1105        let _ = cache.get_or_create(2, 1, 0.4, 0.2);
1106        assert!(
1107            cache.manifolds.contains_key(&(1, 2)),
1108            "key should be canonicalized to (1,2)"
1109        );
1110    }
1111
1112    // ------------------------------------------------------------------
1113    // ContactManifoldCache::begin_frame
1114    // ------------------------------------------------------------------
1115
1116    #[test]
1117    fn test_begin_frame_marks_all_inactive() {
1118        let mut cache = ContactManifoldCache::new(5);
1119        let _ = cache.get_or_create(0, 1, 0.5, 0.3);
1120        let _ = cache.get_or_create(2, 3, 0.5, 0.3);
1121        cache.begin_frame();
1122        for m in cache.manifolds.values() {
1123            assert!(
1124                !m.is_active,
1125                "manifold should be inactive after begin_frame"
1126            );
1127        }
1128    }
1129
1130    // ------------------------------------------------------------------
1131    // Warm start: normal_impulse preserved on update
1132    // ------------------------------------------------------------------
1133
1134    #[test]
1135    fn test_warm_start_impulse_preserved() {
1136        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1137        let mut pt = ContactPoint::new(
1138            [0.0, 0.0, 0.0],
1139            [0.0, -0.01, 0.0],
1140            [0.0, 0.0, 0.0],
1141            [0.0, -0.01, 0.0],
1142            [0.0, 1.0, 0.0],
1143            0.01,
1144        );
1145        pt.normal_impulse = 42.0;
1146        m.add_or_update(pt);
1147
1148        // Second frame: new point at the same location.
1149        let pt2 = ContactPoint::new(
1150            [0.0, 0.0, 0.0],
1151            [0.0, -0.01, 0.0],
1152            [0.0, 0.0, 0.0],
1153            [0.0, -0.01, 0.0],
1154            [0.0, 1.0, 0.0],
1155            0.01,
1156        );
1157        m.add_or_update(pt2);
1158
1159        assert_eq!(
1160            m.points[0].normal_impulse, 42.0,
1161            "normal_impulse should be warm-started from previous frame"
1162        );
1163    }
1164
1165    // ------------------------------------------------------------------
1166    // contact_area > 0 for 3+ non-collinear points
1167    // ------------------------------------------------------------------
1168
1169    #[test]
1170    fn test_contact_area_nonzero_for_triangle() {
1171        let pts = vec![
1172            ContactPoint::new(
1173                [0.0, 0.0, 0.0],
1174                [0.0, 0.0, 0.0],
1175                [0.0, 0.0, 0.0],
1176                [0.0, 0.0, 0.0],
1177                [0.0, 1.0, 0.0],
1178                0.01,
1179            ),
1180            ContactPoint::new(
1181                [1.0, 0.0, 0.0],
1182                [1.0, 0.0, 0.0],
1183                [1.0, 0.0, 0.0],
1184                [1.0, 0.0, 0.0],
1185                [0.0, 1.0, 0.0],
1186                0.01,
1187            ),
1188            ContactPoint::new(
1189                [0.0, 0.0, 1.0],
1190                [0.0, 0.0, 1.0],
1191                [0.0, 0.0, 1.0],
1192                [0.0, 0.0, 1.0],
1193                [0.0, 1.0, 0.0],
1194                0.01,
1195            ),
1196        ];
1197        let area = ManifoldReduction::contact_area(&pts);
1198        assert!(
1199            area > 0.0,
1200            "contact_area of non-collinear triangle must be > 0, got {}",
1201            area
1202        );
1203    }
1204
1205    // ------------------------------------------------------------------
1206    // ManifoldPointMatcher
1207    // ------------------------------------------------------------------
1208
1209    #[test]
1210    fn test_matcher_finds_by_id() {
1211        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 5.0)];
1212        // Build a point with the same local positions → same ID
1213        let new_pt = ContactPoint::new(
1214            [0.0, 0.0, 0.0],
1215            [0.0, -0.01, 0.0],
1216            [0.0, 0.0, 0.0],
1217            [0.0, -0.01, 0.0],
1218            [0.0, 1.0, 0.0],
1219            0.01,
1220        );
1221        let idx = ManifoldPointMatcher::find_match(&existing, &new_pt, MATCH_DIST_SQ);
1222        assert_eq!(idx, Some(0), "should match by ID");
1223    }
1224
1225    #[test]
1226    fn test_matcher_finds_by_proximity() {
1227        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 1.0)];
1228        // Slightly different position, but within MATCH_DIST_SQ
1229        let new_pt = ContactPoint::new(
1230            [0.001, 0.0, 0.0],
1231            [0.001, -0.01, 0.0],
1232            [0.001, 0.0, 0.0], // close to [0,0,0]
1233            [0.001, -0.01, 0.0],
1234            [0.0, 1.0, 0.0],
1235            0.01,
1236        );
1237        let idx = ManifoldPointMatcher::find_match(&existing, &new_pt, MATCH_DIST_SQ);
1238        assert!(idx.is_some(), "should match by proximity");
1239    }
1240
1241    #[test]
1242    fn test_matcher_no_match_far() {
1243        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 1.0)];
1244        let new_pt = ContactPoint::new(
1245            [5.0, 0.0, 0.0],
1246            [5.0, -0.01, 0.0],
1247            [5.0, 0.0, 0.0],
1248            [5.0, -0.01, 0.0],
1249            [0.0, 1.0, 0.0],
1250            0.01,
1251        );
1252        let idx = ManifoldPointMatcher::find_match(&existing, &new_pt, MATCH_DIST_SQ);
1253        assert!(idx.is_none(), "far point should not match");
1254    }
1255
1256    // ------------------------------------------------------------------
1257    // ManifoldLifetimeManager
1258    // ------------------------------------------------------------------
1259
1260    #[test]
1261    fn test_lifetime_manager_ages_out_old_points() {
1262        let mgr = ManifoldLifetimeManager::new(2);
1263        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1264        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 1.0);
1265        pt.lifetime = 100; // Very old
1266        m.points.push(pt);
1267        mgr.age_manifold(&mut m);
1268        assert!(m.points.is_empty(), "old point should be removed");
1269    }
1270
1271    #[test]
1272    fn test_lifetime_manager_keeps_young_points() {
1273        let mgr = ManifoldLifetimeManager::new(10);
1274        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1275        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 1.0);
1276        pt.lifetime = 3;
1277        m.points.push(pt);
1278        mgr.age_manifold(&mut m);
1279        assert_eq!(m.points.len(), 1, "young point should be kept");
1280    }
1281
1282    // ------------------------------------------------------------------
1283    // ManifoldCompressor
1284    // ------------------------------------------------------------------
1285
1286    #[test]
1287    fn test_compressor_no_change_small_set() {
1288        let pts: Vec<ContactPoint> = (0..3)
1289            .map(|i| make_contact([i as f64, 0.0, 0.0], [i as f64, -0.01, 0.0], 0.01, 0.0))
1290            .collect();
1291        let out = ManifoldCompressor::compress(&pts, 4);
1292        assert_eq!(out.len(), 3);
1293    }
1294
1295    #[test]
1296    fn test_compressor_single_point() {
1297        let pts: Vec<ContactPoint> = vec![
1298            make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.05, 1.0),
1299            make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.01, 0.0),
1300        ];
1301        let out = ManifoldCompressor::compress(&pts, 1);
1302        assert_eq!(out.len(), 1);
1303        // Should keep the deepest
1304        assert!((out[0].depth - 0.05).abs() < 1e-10);
1305    }
1306
1307    #[test]
1308    fn test_compressor_merge_preserves_warmstart() {
1309        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 7.0)];
1310        let incoming = vec![ContactPoint::new(
1311            [0.0, 0.0, 0.0],
1312            [0.0, -0.01, 0.0],
1313            [0.0, 0.0, 0.0],
1314            [0.0, -0.01, 0.0],
1315            [0.0, 1.0, 0.0],
1316            0.01,
1317        )];
1318        let merged = ManifoldCompressor::merge(&existing, &incoming, 4);
1319        assert_eq!(merged.len(), 1);
1320        assert!(
1321            (merged[0].normal_impulse - 7.0).abs() < 1e-10,
1322            "warm-start should be 7.0"
1323        );
1324    }
1325
1326    // ------------------------------------------------------------------
1327    // PersistentManifold extensions
1328    // ------------------------------------------------------------------
1329
1330    #[test]
1331    fn test_update_from_new_contacts() {
1332        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1333        let contacts = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0)];
1334        m.update_from_new_contacts(contacts);
1335        assert_eq!(m.contact_count(), 1);
1336        assert!(m.is_active);
1337    }
1338
1339    #[test]
1340    fn test_max_depth() {
1341        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1342        m.add_or_update(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.02, 0.0));
1343        m.add_or_update(make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.05, 0.0));
1344        assert!((m.max_depth() - 0.05).abs() < 1e-10);
1345    }
1346
1347    #[test]
1348    fn test_warmstart_data_roundtrip() {
1349        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1350        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0);
1351        pt.normal_impulse = 3.0;
1352        pt.tangent_impulse = [1.0, 2.0];
1353        m.points.push(pt);
1354        let ws = m.warmstart_data();
1355        assert_eq!(ws.len(), 1);
1356        assert!((ws[0].0 - 3.0).abs() < 1e-12);
1357        // Store different values
1358        m.store_solver_impulses(&[(10.0, [5.0, 6.0])]);
1359        assert!((m.points[0].normal_impulse - 10.0).abs() < 1e-12);
1360    }
1361
1362    #[test]
1363    fn test_clamp_impulses() {
1364        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1365        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0);
1366        pt.normal_impulse = -3.0;
1367        m.points.push(pt);
1368        m.clamp_impulses();
1369        assert_eq!(m.points[0].normal_impulse, 0.0);
1370    }
1371
1372    // ------------------------------------------------------------------
1373    // ContactManifoldCache extensions
1374    // ------------------------------------------------------------------
1375
1376    #[test]
1377    fn test_cache_total_contact_points() {
1378        let mut cache = ContactManifoldCache::new(5);
1379        let m = cache.get_or_create(0, 1, 0.5, 0.3);
1380        m.add_or_update(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0));
1381        m.add_or_update(make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.01, 0.0));
1382        assert_eq!(cache.total_contact_points(), 2);
1383    }
1384
1385    #[test]
1386    fn test_cache_remove_body() {
1387        let mut cache = ContactManifoldCache::new(5);
1388        let _ = cache.get_or_create(0, 1, 0.5, 0.3);
1389        let _ = cache.get_or_create(2, 3, 0.5, 0.3);
1390        cache.remove_body(0);
1391        assert_eq!(cache.manifold_count(), 1);
1392    }
1393
1394    #[test]
1395    fn test_cache_clear() {
1396        let mut cache = ContactManifoldCache::new(5);
1397        let _ = cache.get_or_create(0, 1, 0.5, 0.3);
1398        cache.clear();
1399        assert_eq!(cache.manifold_count(), 0);
1400    }
1401
1402    #[test]
1403    fn test_cache_get_warmstart_none() {
1404        let cache = ContactManifoldCache::new(5);
1405        assert!(cache.get_warmstart(0, 1).is_none());
1406    }
1407
1408    // ─── CachingStrategy ──────────────────────────────────────────────────────
1409
1410    #[test]
1411    fn test_caching_strategy_id_only_finds_match() {
1412        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0)];
1413        let new_pt = ContactPoint::new(
1414            [0.0, 0.0, 0.0],
1415            [0.0, -0.01, 0.0],
1416            [0.0, 0.0, 0.0],
1417            [0.0, -0.01, 0.0],
1418            [0.0, 1.0, 0.0],
1419            0.01,
1420        );
1421        let result =
1422            find_match_with_strategy(&existing, &new_pt, CachingStrategy::IdOnly, MATCH_DIST_SQ);
1423        assert_eq!(result, Some(0), "ID-only strategy should match by hash ID");
1424    }
1425
1426    #[test]
1427    fn test_caching_strategy_proximity_only_finds_match() {
1428        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0)];
1429        // Slightly different position but within threshold
1430        let new_pt = ContactPoint::new(
1431            [0.001, 0.0, 0.0],
1432            [0.001, -0.01, 0.0],
1433            [0.001, 0.0, 0.0],
1434            [0.001, -0.01, 0.0],
1435            [0.0, 1.0, 0.0],
1436            0.01,
1437        );
1438        let result = find_match_with_strategy(
1439            &existing,
1440            &new_pt,
1441            CachingStrategy::ProximityOnly,
1442            MATCH_DIST_SQ,
1443        );
1444        assert!(
1445            result.is_some(),
1446            "Proximity strategy should find nearby match"
1447        );
1448    }
1449
1450    #[test]
1451    fn test_caching_strategy_proximity_only_no_match_far() {
1452        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0)];
1453        let new_pt = ContactPoint::new(
1454            [10.0, 0.0, 0.0],
1455            [10.0, -0.01, 0.0],
1456            [10.0, 0.0, 0.0],
1457            [10.0, -0.01, 0.0],
1458            [0.0, 1.0, 0.0],
1459            0.01,
1460        );
1461        let result = find_match_with_strategy(
1462            &existing,
1463            &new_pt,
1464            CachingStrategy::ProximityOnly,
1465            MATCH_DIST_SQ,
1466        );
1467        assert!(
1468            result.is_none(),
1469            "Far point should not match with proximity strategy"
1470        );
1471    }
1472
1473    #[test]
1474    fn test_caching_strategy_id_then_proximity_finds_by_id() {
1475        let existing = vec![make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 3.0)];
1476        let new_pt = ContactPoint::new(
1477            [0.0, 0.0, 0.0],
1478            [0.0, -0.01, 0.0],
1479            [0.0, 0.0, 0.0],
1480            [0.0, -0.01, 0.0],
1481            [0.0, 1.0, 0.0],
1482            0.01,
1483        );
1484        let result = find_match_with_strategy(
1485            &existing,
1486            &new_pt,
1487            CachingStrategy::IdThenProximity,
1488            MATCH_DIST_SQ,
1489        );
1490        assert_eq!(result, Some(0), "IdThenProximity should find by ID");
1491    }
1492
1493    // ─── age_warm_start ────────────────────────────────────────────────────────
1494
1495    #[test]
1496    fn test_age_warm_start_scales_impulses() {
1497        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 10.0);
1498        pt.tangent_impulse = [4.0, 2.0];
1499        age_warm_start(&mut pt, 0.9);
1500        assert!(
1501            (pt.normal_impulse - 9.0).abs() < 1e-10,
1502            "normal_impulse should be 9.0"
1503        );
1504        assert!(
1505            (pt.tangent_impulse[0] - 3.6).abs() < 1e-10,
1506            "tangent[0] should be 3.6"
1507        );
1508        assert!(
1509            (pt.tangent_impulse[1] - 1.8).abs() < 1e-10,
1510            "tangent[1] should be 1.8"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_age_manifold_warm_start() {
1516        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1517        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 5.0);
1518        pt.tangent_impulse = [2.0, 0.0];
1519        m.points.push(pt);
1520        age_manifold_warm_start(&mut m, 0.5);
1521        assert!(
1522            (m.points[0].normal_impulse - 2.5).abs() < 1e-10,
1523            "normal_impulse should be 2.5"
1524        );
1525        assert!(
1526            (m.points[0].tangent_impulse[0] - 1.0).abs() < 1e-10,
1527            "tangent[0] should be 1.0"
1528        );
1529    }
1530
1531    #[test]
1532    fn test_age_cache_warm_start() {
1533        let mut cache = ContactManifoldCache::new(5);
1534        let m = cache.get_or_create(0, 1, 0.5, 0.3);
1535        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 8.0);
1536        pt.tangent_impulse = [4.0, 0.0];
1537        m.points.push(pt);
1538        age_cache_warm_start(&mut cache, 0.5);
1539        let m2 = cache.manifolds.get(&(0, 1)).unwrap();
1540        assert!(
1541            (m2.points[0].normal_impulse - 4.0).abs() < 1e-10,
1542            "normal_impulse should be 4.0"
1543        );
1544    }
1545
1546    // ─── baumgarte_correction ──────────────────────────────────────────────────
1547
1548    #[test]
1549    fn test_baumgarte_correction_basic() {
1550        let corr = baumgarte_correction(0.1, 0.2, 0.016);
1551        let expected = 0.2 * 0.1 / 0.016;
1552        assert!(
1553            (corr - expected).abs() < 1e-10,
1554            "Expected {expected}, got {corr}"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_baumgarte_correction_zero_depth() {
1560        let corr = baumgarte_correction(0.0, 0.2, 0.016);
1561        assert_eq!(corr, 0.0, "Zero depth should produce no correction");
1562    }
1563
1564    #[test]
1565    fn test_baumgarte_correction_slop_no_correction_below_slop() {
1566        let corr = baumgarte_correction_slop(0.003, 0.2, 0.016, 0.005);
1567        assert_eq!(corr, 0.0, "Depth below slop should produce no correction");
1568    }
1569
1570    #[test]
1571    fn test_baumgarte_correction_slop_correction_above_slop() {
1572        let corr = baumgarte_correction_slop(0.01, 0.2, 0.016, 0.005);
1573        let expected = baumgarte_correction(0.005, 0.2, 0.016);
1574        assert!(
1575            (corr - expected).abs() < 1e-10,
1576            "Expected {expected}, got {corr}"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_apply_position_corrections_returns_per_point() {
1582        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1583        m.points
1584            .push(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0));
1585        m.points
1586            .push(make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.02, 0.0));
1587        let corrections = apply_position_corrections(&m, 0.2, 0.016, 0.005);
1588        assert_eq!(
1589            corrections.len(),
1590            2,
1591            "Should return one correction per point"
1592        );
1593        assert!(
1594            corrections[0] >= 0.0 && corrections[1] >= 0.0,
1595            "Corrections should be non-negative"
1596        );
1597    }
1598
1599    // ─── ContactIsland ─────────────────────────────────────────────────────────
1600
1601    #[test]
1602    fn test_contact_island_default() {
1603        let island = ContactIsland::default();
1604        assert_eq!(island.body_count(), 0);
1605        assert_eq!(island.contact_count(), 0);
1606    }
1607
1608    #[test]
1609    fn test_build_contact_islands_single_pair() {
1610        let mut cache = ContactManifoldCache::new(5);
1611        let m = cache.get_or_create(0, 1, 0.5, 0.3);
1612        m.add_or_update(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0));
1613        let islands = build_contact_islands(&cache);
1614        assert_eq!(islands.len(), 1, "One pair should form one island");
1615        assert_eq!(islands[0].body_count(), 2, "Island should have 2 bodies");
1616    }
1617
1618    #[test]
1619    fn test_build_contact_islands_two_separate_pairs() {
1620        let mut cache = ContactManifoldCache::new(5);
1621        let m1 = cache.get_or_create(0, 1, 0.5, 0.3);
1622        m1.add_or_update(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0));
1623        let m2 = cache.get_or_create(2, 3, 0.5, 0.3);
1624        m2.add_or_update(make_contact([5.0, 0.0, 0.0], [5.0, -0.01, 0.0], 0.01, 0.0));
1625        let islands = build_contact_islands(&cache);
1626        // Two disconnected pairs → two islands
1627        assert_eq!(
1628            islands.len(),
1629            2,
1630            "Two separate pairs should form two islands"
1631        );
1632    }
1633
1634    #[test]
1635    fn test_build_contact_islands_connected_chain() {
1636        // Bodies 0-1-2 form a chain: should be one island
1637        let mut cache = ContactManifoldCache::new(5);
1638        let m1 = cache.get_or_create(0, 1, 0.5, 0.3);
1639        m1.add_or_update(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 0.0));
1640        let m2 = cache.get_or_create(1, 2, 0.5, 0.3);
1641        m2.add_or_update(make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.01, 0.0));
1642        let islands = build_contact_islands(&cache);
1643        assert_eq!(islands.len(), 1, "Connected chain should form one island");
1644        assert_eq!(
1645            islands[0].body_count(),
1646            3,
1647            "Chain of 3 bodies should have 3 bodies in island"
1648        );
1649    }
1650
1651    // ─── ManifoldMetrics ───────────────────────────────────────────────────────
1652
1653    #[test]
1654    fn test_manifold_metrics_empty() {
1655        let m = PersistentManifold::new(0, 1, 0.5, 0.3);
1656        let metrics = compute_manifold_metrics(&m);
1657        assert_eq!(metrics.contact_count, 0);
1658        assert_eq!(metrics.max_depth, 0.0);
1659        assert!(!metrics.is_warm);
1660    }
1661
1662    #[test]
1663    fn test_manifold_metrics_single_point() {
1664        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1665        m.points
1666            .push(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.05, 0.0));
1667        let metrics = compute_manifold_metrics(&m);
1668        assert_eq!(metrics.contact_count, 1);
1669        assert!(
1670            (metrics.max_depth - 0.05).abs() < 1e-10,
1671            "max_depth should be 0.05"
1672        );
1673        assert!(
1674            (metrics.avg_depth - 0.05).abs() < 1e-10,
1675            "avg_depth should be 0.05"
1676        );
1677        assert_eq!(metrics.spread, 0.0, "Single point has zero spread");
1678    }
1679
1680    #[test]
1681    fn test_manifold_metrics_two_points() {
1682        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1683        m.points
1684            .push(make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.02, 0.0));
1685        m.points
1686            .push(make_contact([1.0, 0.0, 0.0], [1.0, -0.01, 0.0], 0.04, 0.0));
1687        let metrics = compute_manifold_metrics(&m);
1688        assert_eq!(metrics.contact_count, 2);
1689        assert!((metrics.max_depth - 0.04).abs() < 1e-10);
1690        assert!((metrics.avg_depth - 0.03).abs() < 1e-10);
1691        assert!(
1692            (metrics.spread - 1.0).abs() < 1e-10,
1693            "Spread should be 1.0, got {}",
1694            metrics.spread
1695        );
1696    }
1697
1698    #[test]
1699    fn test_manifold_metrics_is_warm_with_impulse() {
1700        let mut m = PersistentManifold::new(0, 1, 0.5, 0.3);
1701        let mut pt = make_contact([0.0, 0.0, 0.0], [0.0, -0.01, 0.0], 0.01, 5.0);
1702        pt.normal_impulse = 5.0;
1703        m.points.push(pt);
1704        let metrics = compute_manifold_metrics(&m);
1705        assert!(
1706            metrics.is_warm,
1707            "Manifold with non-zero normal_impulse should be warm"
1708        );
1709    }
1710}