Skip to main content

dreamwell_engine/physics/
simulation.rs

1// PhysicsWorld — CPU-deterministic rigid body simulation.
2//
3// Authority-side physics: collision detection, contact resolution, raycasting.
4// Results sync to GPU scene transforms each frame. GPU handles DreamMatter
5// (presentation-only particles); CPU handles gameplay-relevant physics.
6//
7// Integration: semi-implicit Euler (stable for game physics).
8// Broadphase: spatial hash grid (O(n) pair generation).
9// Narrowphase: analytical sphere-sphere, sphere-plane, AABB-AABB, sphere-AABB.
10// Solver: sequential impulse (Erin Catto / Box2D-style).
11
12/// Unique body identifier.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct BodyId(pub u32);
15
16/// Collision shape for narrowphase detection.
17#[derive(Debug, Clone, Copy)]
18pub enum CollisionShape {
19    Sphere { radius: f32 },
20    Plane { normal: [f32; 3], d: f32 },
21    Aabb { half_extents: [f32; 3] },
22    Capsule { radius: f32, half_height: f32 },  // Y-axis aligned
23    Cylinder { radius: f32, half_height: f32 }, // Y-axis aligned
24    Cone { radius: f32, height: f32 },          // Y-axis, tip at +height
25}
26
27/// Rigid body state.
28#[derive(Debug, Clone)]
29pub struct RigidBody {
30    pub position: [f32; 3],
31    pub velocity: [f32; 3],
32    pub rotation: [f32; 4], // quaternion (x, y, z, w)
33    pub angular_velocity: [f32; 3],
34    pub mass: f32,
35    pub inv_mass: f32,
36    pub restitution: f32,
37    pub friction: f32,
38    pub shape: CollisionShape,
39    pub is_static: bool,
40    pub is_active: bool,
41    pub sleep_frames: u16,
42    pub is_sleeping: bool,
43    /// Cosmetic-only body: collisions are skipped when Superposed.
44    /// Gameplay-critical bodies (NPCs, triggers, quest objects) must leave this false.
45    /// Cosmetic bodies (crates, barrels, debris, foliage) can set this true for
46    /// observer-dependent collision skip — the body is physically correct when Active
47    /// but not resolved when no observer is watching.
48    pub cosmetic_only: bool,
49}
50
51impl RigidBody {
52    /// Create a dynamic body with the given mass and shape.
53    pub fn dynamic(mass: f32, shape: CollisionShape) -> Self {
54        Self {
55            position: [0.0; 3],
56            velocity: [0.0; 3],
57            rotation: [0.0, 0.0, 0.0, 1.0],
58            angular_velocity: [0.0; 3],
59            mass,
60            inv_mass: if mass > 0.0 { 1.0 / mass } else { 0.0 },
61            restitution: 0.5,
62            friction: 0.3,
63            shape,
64            is_static: false,
65            is_active: true,
66            sleep_frames: 0,
67            is_sleeping: false,
68            cosmetic_only: false,
69        }
70    }
71
72    /// Create a static (immovable) body.
73    pub fn fixed(shape: CollisionShape) -> Self {
74        Self {
75            position: [0.0; 3],
76            velocity: [0.0; 3],
77            rotation: [0.0, 0.0, 0.0, 1.0],
78            angular_velocity: [0.0; 3],
79            mass: 0.0,
80            inv_mass: 0.0,
81            restitution: 0.5,
82            friction: 0.5,
83            shape,
84            is_static: true,
85            is_active: true,
86            sleep_frames: 0,
87            is_sleeping: false,
88            cosmetic_only: false,
89        }
90    }
91
92    /// Mark this body as cosmetic-only. When Superposed, its collisions are skipped.
93    /// Use for debris, crates, barrels, foliage — anything not gameplay-critical.
94    pub fn as_cosmetic(mut self) -> Self {
95        self.cosmetic_only = true;
96        self
97    }
98
99    pub fn with_position(mut self, pos: [f32; 3]) -> Self {
100        self.position = pos;
101        self
102    }
103
104    pub fn with_restitution(mut self, e: f32) -> Self {
105        self.restitution = e;
106        self
107    }
108}
109
110/// Contact point between two bodies.
111#[derive(Debug, Clone, Copy)]
112pub struct Contact {
113    pub body_a: usize,
114    pub body_b: usize,
115    pub normal: [f32; 3], // points from A to B
116    pub depth: f32,       // penetration depth (positive = overlapping)
117    pub point: [f32; 3],  // contact point in world space
118    /// Cached impulse from previous frame for warm-starting.
119    pub cached_impulse: f32,
120}
121
122/// Raycast hit result.
123#[derive(Debug, Clone, Copy)]
124pub struct RayHit {
125    pub position: [f32; 3],
126    pub normal: [f32; 3],
127    pub distance: f32,
128    pub body_index: usize,
129}
130
131/// Configuration for body sleeping thresholds.
132#[derive(Debug, Clone, Copy)]
133pub struct SleepConfig {
134    pub linear_threshold: f32,
135    pub angular_threshold: f32,
136    pub frames_to_sleep: u16,
137}
138
139impl Default for SleepConfig {
140    fn default() -> Self {
141        Self {
142            linear_threshold: 0.01,
143            angular_threshold: 0.01,
144            frames_to_sleep: 60,
145        }
146    }
147}
148
149// ═══════════════════════════════════════════════════════════════════
150// Sweep-and-Prune Broadphase (Clean Compute v1.0.0)
151//
152// 1-axis sort + linear sweep. O(N log N) populate, O(N × overlap) query.
153// No hash collisions, no 27-cell fan-out, no dedup sort.
154// Produces canonical pairs (i < j) directly — zero duplicates by construction.
155// ═══════════════════════════════════════════════════════════════════
156
157/// AABB interval for sweep-and-prune: [min_x, max_x, min_y, max_y, min_z, max_z, body_index].
158#[derive(Clone, Copy)]
159struct SapEntry {
160    min: [f32; 3],
161    max: [f32; 3],
162    index: usize,
163}
164
165/// Sweep-and-Prune broadphase. Sorts bodies by X-axis AABB min bound,
166/// then sweeps to find overlapping intervals. Y/Z overlap checked inline.
167/// Pre-allocates all Vecs for Clean Compute (zero per-step allocation).
168#[derive(Clone)]
169pub struct SweepAndPrune {
170    entries: Vec<SapEntry>,
171    /// Pre-allocated pair output buffer — reused every step (Clean Compute).
172    pairs: Vec<(usize, usize)>,
173}
174
175impl SweepAndPrune {
176    pub fn new() -> Self {
177        Self {
178            entries: Vec::with_capacity(1024),
179            pairs: Vec::with_capacity(4096),
180        }
181    }
182
183    /// Extract AABB from a collision shape at a given position.
184    #[inline]
185    fn body_aabb(pos: &[f32; 3], shape: &CollisionShape) -> ([f32; 3], [f32; 3]) {
186        match shape {
187            CollisionShape::Sphere { radius } => {
188                let r = *radius;
189                (
190                    [pos[0] - r, pos[1] - r, pos[2] - r],
191                    [pos[0] + r, pos[1] + r, pos[2] + r],
192                )
193            }
194            CollisionShape::Aabb { half_extents } => (
195                [
196                    pos[0] - half_extents[0],
197                    pos[1] - half_extents[1],
198                    pos[2] - half_extents[2],
199                ],
200                [
201                    pos[0] + half_extents[0],
202                    pos[1] + half_extents[1],
203                    pos[2] + half_extents[2],
204                ],
205            ),
206            CollisionShape::Capsule { radius, half_height } => {
207                let r = *radius;
208                let h = *half_height;
209                (
210                    [pos[0] - r, pos[1] - h - r, pos[2] - r],
211                    [pos[0] + r, pos[1] + h + r, pos[2] + r],
212                )
213            }
214            CollisionShape::Cylinder { radius, half_height } => {
215                let r = *radius;
216                let h = *half_height;
217                (
218                    [pos[0] - r, pos[1] - h, pos[2] - r],
219                    [pos[0] + r, pos[1] + h, pos[2] + r],
220                )
221            }
222            CollisionShape::Cone { radius, height } => {
223                let r = *radius;
224                (
225                    [pos[0] - r, pos[1], pos[2] - r],
226                    [pos[0] + r, pos[1] + height, pos[2] + r],
227                )
228            }
229            CollisionShape::Plane { .. } => {
230                // Planes are infinite — use large AABB
231                ([-1e6, -1e6, -1e6], [1e6, 1e6, 1e6])
232            }
233        }
234    }
235
236    /// Populate from body arrays. Extracts AABBs and sorts by X-axis min.
237    pub fn populate(&mut self, bodies: &[RigidBody]) {
238        self.entries.clear();
239        self.entries.reserve(bodies.len());
240        for (i, body) in bodies.iter().enumerate() {
241            if !body.is_active {
242                continue;
243            }
244            let (min, max) = Self::body_aabb(&body.position, &body.shape);
245            self.entries.push(SapEntry { min, max, index: i });
246        }
247        // Sort by X-axis min bound.
248        #[cfg(feature = "parallel-physics")]
249        {
250            use rayon::slice::ParallelSliceMut;
251            self.entries
252                .par_sort_unstable_by(|a, b| a.min[0].partial_cmp(&b.min[0]).unwrap_or(std::cmp::Ordering::Equal));
253        }
254        #[cfg(not(feature = "parallel-physics"))]
255        self.entries
256            .sort_unstable_by(|a, b| a.min[0].partial_cmp(&b.min[0]).unwrap_or(std::cmp::Ordering::Equal));
257    }
258
259    /// Sweep-and-prune pair generation. Returns pre-allocated pairs Vec (no allocation).
260    /// Pairs are canonical (i < j) by construction — zero duplicates.
261    pub fn query_pairs(&mut self) -> &[(usize, usize)] {
262        self.pairs.clear();
263        let n = self.entries.len();
264        for i in 0..n {
265            let a = &self.entries[i];
266            // Sweep right: check all entries whose X-min < our X-max
267            for j in (i + 1)..n {
268                let b = &self.entries[j];
269                // X-axis: if b's min > a's max, no more overlaps possible (sorted!)
270                if b.min[0] > a.max[0] {
271                    break;
272                }
273                // Y-axis overlap test
274                if a.max[1] < b.min[1] || b.max[1] < a.min[1] {
275                    continue;
276                }
277                // Z-axis overlap test
278                if a.max[2] < b.min[2] || b.max[2] < a.min[2] {
279                    continue;
280                }
281                // All 3 axes overlap — this is a candidate pair.
282                let (lo, hi) = if a.index < b.index {
283                    (a.index, b.index)
284                } else {
285                    (b.index, a.index)
286                };
287                self.pairs.push((lo, hi));
288            }
289        }
290        &self.pairs
291    }
292}
293
294/// Spatial hash grid for broadphase pair generation.
295/// Replaces O(n^2) all-pairs with O(n) expected-case neighbour lookups.
296#[derive(Debug, Clone)]
297pub struct SpatialHashGrid {
298    cell_size: f32,
299    inv_cell_size: f32,
300    entries: Vec<(u64, usize)>, // (cell_hash, body_index)
301}
302
303impl SpatialHashGrid {
304    pub fn new(cell_size: f32) -> Self {
305        Self {
306            cell_size,
307            inv_cell_size: 1.0 / cell_size,
308            entries: Vec::new(),
309        }
310    }
311
312    /// Cell size used for spatial hashing (debug/query introspection).
313    pub fn cell_size(&self) -> f32 {
314        self.cell_size
315    }
316
317    /// Update cell size. Call before populate() when body density changes.
318    pub fn set_cell_size(&mut self, size: f32) {
319        self.cell_size = size;
320        self.inv_cell_size = 1.0 / size;
321    }
322
323    /// Inverse cell size (for wave coherence broadphase access).
324    pub fn inv_cell_size(&self) -> f32 {
325        self.inv_cell_size
326    }
327
328    /// Sorted entries (for wave coherence per-body regeneration).
329    pub fn entries(&self) -> &[(u64, usize)] {
330        &self.entries
331    }
332
333    /// Hash a 3D integer cell coordinate using FNV-1a.
334    fn hash_cell(ix: i32, iy: i32, iz: i32) -> u64 {
335        let mut h: u64 = 0xcbf29ce484222325;
336        for byte in ix
337            .to_le_bytes()
338            .iter()
339            .chain(iy.to_le_bytes().iter())
340            .chain(iz.to_le_bytes().iter())
341        {
342            h ^= *byte as u64;
343            h = h.wrapping_mul(0x100000001b3);
344        }
345        h
346    }
347
348    /// Populate the grid from body positions. Sorts entries by hash for fast lookup.
349    pub fn populate(&mut self, bodies: &[RigidBody]) {
350        self.entries.clear();
351        self.entries.reserve(bodies.len());
352        let inv = self.inv_cell_size;
353        for (i, body) in bodies.iter().enumerate() {
354            if !body.is_active {
355                continue;
356            }
357            let ix = (body.position[0] * inv).floor() as i32;
358            let iy = (body.position[1] * inv).floor() as i32;
359            let iz = (body.position[2] * inv).floor() as i32;
360            let hash = Self::hash_cell(ix, iy, iz);
361            self.entries.push((hash, i));
362        }
363        // Clean Compute: parallel sort when available (rayon).
364        #[cfg(feature = "parallel-physics")]
365        {
366            use rayon::slice::ParallelSliceMut;
367            self.entries.par_sort_unstable_by_key(|e| e.0);
368        }
369        #[cfg(not(feature = "parallel-physics"))]
370        {
371            self.entries.sort_unstable_by_key(|e| e.0);
372        }
373    }
374
375    /// Generate candidate pairs using half-neighborhood search.
376    ///
377    /// Instead of querying all 27 neighbor cells (which produces duplicate pairs
378    /// requiring O(P log P) sort + O(P) dedup), we query only the 14 "forward"
379    /// cells: the self-cell plus 13 cells that are lexicographically greater in
380    /// (dx, dy, dz) order. This guarantees each pair is discovered exactly once.
381    ///
382    /// Mathematical proof: For bodies A in cell C_A and B in cell C_B where
383    /// C_A and C_B are adjacent (differ by at most 1 on each axis):
384    /// - If C_A < C_B lexicographically: A's forward search includes C_B → pair found by A
385    /// - If C_A > C_B lexicographically: B's forward search includes C_A → pair found by B
386    /// - If C_A == C_B: same cell, j > i index guard → pair found once
387    ///
388    /// This is the 3D analog of the causal attention mask: compute only the upper
389    /// triangle of the spatial relevance matrix. Zero duplicates by construction.
390    /// No sort. No dedup. O(N × 14 × log N) vs O(N × 27 × log N + P log P).
391    ///
392    /// Reference: Allen & Tildesley, "Computer Simulation of Liquids" (1987),
393    /// half-neighbor Verlet list construction.
394    pub fn query_pairs(&self, bodies: &[RigidBody]) -> Vec<(usize, usize)> {
395        // Forward half-neighborhood: self + 13 cells where (dx,dy,dz) > (0,0,0).
396        // Lexicographic order: compare dx first, then dy, then dz.
397        const FORWARD: [(i32, i32, i32); 14] = [
398            // Self cell (j > i guard handles dedup within cell)
399            (0, 0, 0),
400            // 13 forward neighbors (lexicographically > (0,0,0))
401            (0, 0, 1),
402            (0, 1, -1),
403            (0, 1, 0),
404            (0, 1, 1),
405            (1, -1, -1),
406            (1, -1, 0),
407            (1, -1, 1),
408            (1, 0, -1),
409            (1, 0, 0),
410            (1, 0, 1),
411            (1, 1, -1),
412            (1, 1, 0),
413            (1, 1, 1),
414        ];
415
416        #[cfg(feature = "parallel-physics")]
417        {
418            use rayon::prelude::*;
419            let entries = &self.entries;
420            let inv = self.inv_cell_size;
421
422            // Parallel: each body searches 14 cells independently.
423            // No sort or dedup needed — pairs are unique by construction.
424            bodies
425                .par_iter()
426                .enumerate()
427                .filter(|(_, body)| body.is_active)
428                .flat_map_iter(|(i, body)| {
429                    let cx = (body.position[0] * inv).floor() as i32;
430                    let cy = (body.position[1] * inv).floor() as i32;
431                    let cz = (body.position[2] * inv).floor() as i32;
432                    let mut local = Vec::new();
433                    for &(dx, dy, dz) in &FORWARD {
434                        let hash = Self::hash_cell(cx + dx, cy + dy, cz + dz);
435                        let start = entries.partition_point(|e| e.0 < hash);
436                        for entry in &entries[start..] {
437                            if entry.0 != hash {
438                                break;
439                            }
440                            let j = entry.1;
441                            if dx == 0 && dy == 0 && dz == 0 {
442                                // Self cell: only emit j > i to avoid self-pair and reverse.
443                                if j > i {
444                                    local.push((i, j));
445                                }
446                            } else {
447                                // Forward cell: emit all pairs with canonical order.
448                                if j != i {
449                                    local.push((i.min(j), i.max(j)));
450                                }
451                            }
452                        }
453                    }
454                    local
455                })
456                .collect()
457            // NO par_sort_unstable. NO dedup. Pairs are unique by construction.
458        }
459
460        #[cfg(not(feature = "parallel-physics"))]
461        {
462            let mut pairs = Vec::new();
463            for (i, body) in bodies.iter().enumerate() {
464                if !body.is_active {
465                    continue;
466                }
467                let cx = (body.position[0] * self.inv_cell_size).floor() as i32;
468                let cy = (body.position[1] * self.inv_cell_size).floor() as i32;
469                let cz = (body.position[2] * self.inv_cell_size).floor() as i32;
470                for &(dx, dy, dz) in &FORWARD {
471                    let hash = Self::hash_cell(cx + dx, cy + dy, cz + dz);
472                    let start = self.entries.partition_point(|e| e.0 < hash);
473                    for entry in &self.entries[start..] {
474                        if entry.0 != hash {
475                            break;
476                        }
477                        let j = entry.1;
478                        if dx == 0 && dy == 0 && dz == 0 {
479                            if j > i {
480                                pairs.push((i, j));
481                            }
482                        } else {
483                            if j != i {
484                                pairs.push((i.min(j), i.max(j)));
485                            }
486                        }
487                    }
488                }
489            }
490            // NO sort. NO dedup. Unique by half-neighborhood construction.
491            pairs
492        }
493    }
494}
495
496// ═══════════════════════════════════════════════════════════════════
497// DreamSuperposition — Three-State Body Lifecycle (v1.0.0 LTS)
498//
499// Formalizes the quantum analogy: a body exists in superposition until
500// an observer collapses it into a definite state by being near enough
501// to perceive it.
502//
503// States:
504//   Active    — near observer, physics fully simulated, meshlets rendered
505//   Superposed — far from observer, skip broadphase + GPU cull entirely
506//   Dormant   — sleeping + superposed, zero CPU cost, zero GPU cost
507//
508// Transitions:
509//   Active → Superposed:  body exits observer FOV radius
510//   Superposed → Active:  body enters observer FOV radius
511//   Superposed → Dormant: body meets sleep threshold while superposed
512//   Dormant → Active:     external force or observer approaches
513//   Active → Dormant:     sleep threshold met while near observer (normal sleep)
514//
515// Cost model:
516//   Active bodies:     full DreamSpace broadphase + Quantum Culling
517//   Superposed bodies: zero broadphase, zero GPU cull dispatch
518//   Dormant bodies:    zero everything (existing sleep + superposition)
519// ═══════════════════════════════════════════════════════════════════
520
521/// Superposition state for a rigid body.
522///
523/// Four states model the full quantum lifecycle:
524///   Active     — collapsed, fully observed, full physics + GPU render
525///   Decohering — transition zone, reduced-rate physics, GPU temporal coherence
526///   Superposed — unobserved, no physics, pre-zeroed GPU indirect buffer
527///   Dormant    — unobserved + sleeping, zero cost everywhere
528///
529/// Decoherence rings provide smooth transition between Active and Superposed,
530/// preventing visual pop-in at the observation boundary. Bodies in the
531/// Decohering ring simulate every Nth tick (configurable), providing a
532/// "fade to superposition" gradient.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534#[repr(u8)]
535pub enum SuperpositionState {
536    /// Near observer. Full physics every tick. Full GPU culling.
537    Active = 0,
538    /// Transition zone. Physics runs every `decoherence_tick_rate` ticks.
539    /// GPU uses temporal coherence (cull at reduced frequency).
540    Decohering = 1,
541    /// Far from observer. No physics. GPU indirect buffer pre-zeroed.
542    Superposed = 2,
543    /// Sleeping + far from observer. Zero cost everywhere.
544    Dormant = 3,
545}
546
547/// Observer position and decoherence ring radii.
548///
549/// Three concentric rings around the observer:
550///   [0, active_radius]             → Active (full simulation)
551///   [active_radius, decohere_radius] → Decohering (reduced-rate simulation)
552///   [decohere_radius, ∞)            → Superposed (no simulation)
553///
554/// The decoherence ring prevents visual pop-in: bodies warm up for a few
555/// ticks before becoming fully Active, and cool down before going Superposed.
556#[derive(Debug, Clone, Copy)]
557pub struct SuperpositionObserver {
558    pub position: [f32; 3],
559    /// Inner radius: full Active simulation.
560    pub active_radius: f32,
561    pub active_radius_sq: f32,
562    /// Outer radius: Decohering transition zone beyond active_radius.
563    pub decohere_radius: f32,
564    pub decohere_radius_sq: f32,
565    /// How often Decohering bodies simulate (every Nth tick). Default: 2.
566    pub decohere_tick_rate: u32,
567    /// Current tick index (for modulo check on Decohering bodies).
568    pub tick_index: u32,
569}
570
571impl SuperpositionObserver {
572    /// Create with default decoherence ring (1.5× active radius, simulate every 2nd tick).
573    pub fn new(position: [f32; 3], active_radius: f32) -> Self {
574        let decohere_radius = active_radius * 1.5;
575        Self {
576            position,
577            active_radius,
578            active_radius_sq: active_radius * active_radius,
579            decohere_radius,
580            decohere_radius_sq: decohere_radius * decohere_radius,
581            decohere_tick_rate: 2,
582            tick_index: 0,
583        }
584    }
585
586    /// Create with explicit decoherence ring.
587    pub fn with_rings(position: [f32; 3], active_radius: f32, decohere_radius: f32, tick_rate: u32) -> Self {
588        Self {
589            position,
590            active_radius,
591            active_radius_sq: active_radius * active_radius,
592            decohere_radius,
593            decohere_radius_sq: decohere_radius * decohere_radius,
594            decohere_tick_rate: tick_rate.max(1),
595            tick_index: 0,
596        }
597    }
598
599    /// Advance tick index (call once per step).
600    pub fn advance_tick(&mut self) {
601        self.tick_index = self.tick_index.wrapping_add(1);
602    }
603
604    /// Should a Decohering body simulate this tick?
605    #[inline(always)]
606    pub fn should_decohere_simulate(&self) -> bool {
607        self.tick_index % self.decohere_tick_rate == 0
608    }
609
610    /// Classify a position into a superposition ring.
611    #[inline(always)]
612    pub fn classify(&self, body_pos: &[f32; 3]) -> SuperpositionState {
613        let dx = body_pos[0] - self.position[0];
614        let dy = body_pos[1] - self.position[1];
615        let dz = body_pos[2] - self.position[2];
616        let dist_sq = dx * dx + dy * dy + dz * dz;
617        if dist_sq <= self.active_radius_sq {
618            SuperpositionState::Active
619        } else if dist_sq <= self.decohere_radius_sq {
620            SuperpositionState::Decohering
621        } else {
622            SuperpositionState::Superposed
623        }
624    }
625}
626
627// ═══════════════════════════════════════════════════════════════════
628// DreamSpace — Incremental Observer-Aware Spatial Index (v1.0.0 LTS)
629//
630// Replaces per-frame rebuild-from-scratch with persistent cell map +
631// dirty-set tracking. Only bodies that cross cell boundaries trigger
632// re-indexing. Pair generation queries only dirty cells + neighbors.
633//
634// Performance model:
635//   OLD: O(n log n) sort + O(n × 27 × log n) query every frame
636//   NEW: O(M) dirty update + O(M × 27 × O(1)) query per frame
637//        where M = bodies that changed cells (<< N)
638//
639// Cross-domain synthesis:
640//   DreamMemory insight: separate recording from computing
641//   Applied here: separate position tracking from pair generation
642//   Result: only moved bodies pay the broadphase cost
643// ═══════════════════════════════════════════════════════════════════
644
645/// DreamSpace — incremental spatial index with dirty-cell pair generation.
646pub struct DreamSpace {
647    cell_size: f32,
648    inv_cell_size: f32,
649    /// Persistent cell map: cell_key → list of body indices.
650    cells: std::collections::HashMap<(i32, i32, i32), Vec<usize>>,
651    /// Per-body cell assignment from the previous frame.
652    body_cells: Vec<(i32, i32, i32)>,
653    /// Dirty cell set: cells that had bodies enter or leave this frame.
654    dirty_cells: Vec<(i32, i32, i32)>,
655    /// Whether the index has been initialized (first populate).
656    initialized: bool,
657}
658
659impl DreamSpace {
660    pub fn new(cell_size: f32) -> Self {
661        Self {
662            cell_size,
663            inv_cell_size: 1.0 / cell_size,
664            cells: std::collections::HashMap::new(),
665            body_cells: Vec::new(),
666            dirty_cells: Vec::new(),
667            initialized: false,
668        }
669    }
670
671    #[inline(always)]
672    fn cell_of(&self, pos: &[f32; 3]) -> (i32, i32, i32) {
673        (
674            (pos[0] * self.inv_cell_size).floor() as i32,
675            (pos[1] * self.inv_cell_size).floor() as i32,
676            (pos[2] * self.inv_cell_size).floor() as i32,
677        )
678    }
679
680    /// Initial population — called once, or when body count changes.
681    pub fn rebuild(&mut self, bodies: &[RigidBody]) {
682        self.cells.clear();
683        self.body_cells.clear();
684        self.body_cells.reserve(bodies.len());
685        self.dirty_cells.clear();
686
687        for (i, body) in bodies.iter().enumerate() {
688            let cell = if body.is_active {
689                self.cell_of(&body.position)
690            } else {
691                (i32::MIN, i32::MIN, i32::MIN)
692            };
693            self.body_cells.push(cell);
694            if body.is_active {
695                self.cells.entry(cell).or_default().push(i);
696            }
697        }
698        self.initialized = true;
699        // After rebuild, ALL cells are dirty (first frame).
700        self.dirty_cells = self.cells.keys().copied().collect();
701    }
702
703    /// Incremental update — called each step AFTER position integration.
704    /// Scans all bodies, detects cell boundary crossings, updates cell map.
705    /// Only crossing bodies cause writes. Sleeping/static bodies = zero cost.
706    pub fn update(&mut self, bodies: &[RigidBody]) {
707        // Handle body count changes (spawn/despawn).
708        if self.body_cells.len() != bodies.len() || !self.initialized {
709            self.rebuild(bodies);
710            return;
711        }
712
713        self.dirty_cells.clear();
714
715        for (i, body) in bodies.iter().enumerate() {
716            let new_cell = if body.is_active && !body.is_sleeping {
717                self.cell_of(&body.position)
718            } else if body.is_active {
719                // Sleeping but active: keep current cell, no update needed.
720                continue;
721            } else {
722                (i32::MIN, i32::MIN, i32::MIN)
723            };
724
725            let old_cell = self.body_cells[i];
726            if new_cell == old_cell {
727                continue; // Same cell — no work.
728            }
729
730            // Cell boundary crossed. Update the map.
731            // Remove from old cell.
732            if old_cell != (i32::MIN, i32::MIN, i32::MIN) {
733                if let Some(list) = self.cells.get_mut(&old_cell) {
734                    if let Some(pos) = list.iter().position(|&idx| idx == i) {
735                        list.swap_remove(pos);
736                    }
737                    if list.is_empty() {
738                        self.cells.remove(&old_cell);
739                    }
740                }
741                self.dirty_cells.push(old_cell);
742            }
743
744            // Insert into new cell.
745            if new_cell != (i32::MIN, i32::MIN, i32::MIN) {
746                self.cells.entry(new_cell).or_default().push(i);
747                self.dirty_cells.push(new_cell);
748            }
749
750            self.body_cells[i] = new_cell;
751        }
752
753        // Deduplicate dirty cells.
754        self.dirty_cells.sort_unstable();
755        self.dirty_cells.dedup();
756    }
757
758    /// Generate collision pairs from dirty cells + their 27 neighbors.
759    /// Only bodies near dirty regions are checked — sleeping clusters are skipped.
760    pub fn query_dirty_pairs(&self, bodies: &[RigidBody]) -> Vec<(usize, usize)> {
761        // Collect all cells we need to query: dirty cells + their 27 neighbors.
762        let mut query_cells: Vec<(i32, i32, i32)> = Vec::with_capacity(self.dirty_cells.len() * 27);
763        for &(cx, cy, cz) in &self.dirty_cells {
764            for dx in -1..=1 {
765                for dy in -1..=1 {
766                    for dz in -1..=1 {
767                        query_cells.push((cx + dx, cy + dy, cz + dz));
768                    }
769                }
770            }
771        }
772        query_cells.sort_unstable();
773        query_cells.dedup();
774
775        // For each query cell, check all body pairs within it.
776        let mut pairs = Vec::new();
777        for &cell in &query_cells {
778            let Some(list) = self.cells.get(&cell) else { continue };
779            // Check each body in this cell against all bodies in 27 neighbors.
780            for &i in list {
781                if !bodies[i].is_active {
782                    continue;
783                }
784                let (cx, cy, cz) = self.cell_of(&bodies[i].position);
785                for dx in -1..=1 {
786                    for dy in -1..=1 {
787                        for dz in -1..=1 {
788                            let neighbor = (cx + dx, cy + dy, cz + dz);
789                            let Some(nlist) = self.cells.get(&neighbor) else {
790                                continue;
791                            };
792                            for &j in nlist {
793                                if j > i && bodies[j].is_active {
794                                    pairs.push((i, j));
795                                }
796                            }
797                        }
798                    }
799                }
800            }
801        }
802        pairs.sort_unstable();
803        pairs.dedup();
804        pairs
805    }
806
807    /// Full rebuild query — used on first frame or after large changes.
808    /// Generates all pairs (equivalent to old query_pairs but with HashMap O(1) lookup).
809    pub fn query_all_pairs(&self, bodies: &[RigidBody]) -> Vec<(usize, usize)> {
810        let mut pairs = Vec::new();
811        for (i, body) in bodies.iter().enumerate() {
812            if !body.is_active {
813                continue;
814            }
815            let (cx, cy, cz) = self.cell_of(&body.position);
816            for dx in -1..=1 {
817                for dy in -1..=1 {
818                    for dz in -1..=1 {
819                        let neighbor = (cx + dx, cy + dy, cz + dz);
820                        let Some(list) = self.cells.get(&neighbor) else {
821                            continue;
822                        };
823                        for &j in list {
824                            if j > i && bodies[j].is_active {
825                                pairs.push((i, j));
826                            }
827                        }
828                    }
829                }
830            }
831        }
832        pairs.sort_unstable();
833        pairs.dedup();
834        pairs
835    }
836
837    pub fn dirty_cell_count(&self) -> usize {
838        self.dirty_cells.len()
839    }
840    pub fn total_cell_count(&self) -> usize {
841        self.cells.len()
842    }
843    pub fn is_initialized(&self) -> bool {
844        self.initialized
845    }
846
847    /// Coherence collapse: classify all occupied cells by observer distance.
848    /// Returns (active_cells, decohering_cells, superposed_cells) as lists of cell keys.
849    /// Bodies in a cell share the cell's superposition state — "coherent superposition."
850    /// This replaces per-body distance checks with per-cell checks: O(C) instead of O(N).
851    pub fn classify_cells(
852        &self,
853        observer: &SuperpositionObserver,
854    ) -> (Vec<(i32, i32, i32)>, Vec<(i32, i32, i32)>, Vec<(i32, i32, i32)>) {
855        let mut active = Vec::new();
856        let mut decohering = Vec::new();
857        let mut superposed = Vec::new();
858
859        for &cell_key in self.cells.keys() {
860            // Cell center in world coordinates.
861            let cx = cell_key.0 as f32 * self.cell_size + self.cell_size * 0.5;
862            let cy = cell_key.1 as f32 * self.cell_size + self.cell_size * 0.5;
863            let cz = cell_key.2 as f32 * self.cell_size + self.cell_size * 0.5;
864            let cell_center = [cx, cy, cz];
865
866            match observer.classify(&cell_center) {
867                SuperpositionState::Active => active.push(cell_key),
868                SuperpositionState::Decohering => decohering.push(cell_key),
869                _ => superposed.push(cell_key),
870            }
871        }
872
873        (active, decohering, superposed)
874    }
875
876    /// Get all body indices in a given cell.
877    pub fn bodies_in_cell(&self, cell: &(i32, i32, i32)) -> &[usize] {
878        self.cells.get(cell).map(|v| v.as_slice()).unwrap_or(&[])
879    }
880}
881
882/// Per-body impulse accumulator for Parallel Jacobi solver.
883/// Stores velocity and positional corrections computed independently per-contact,
884/// then applied in a single parallel pass. This is the core of the Clean Compute
885/// physics breakthrough: O(contacts) parallel with zero data dependencies.
886///
887/// Layout: [vx, vy, vz, px, py, pz] — velocity impulse + position correction.
888#[repr(C)]
889#[derive(Clone, Copy, Default)]
890struct JacobiAccumulator {
891    /// Accumulated velocity impulse (linear).
892    velocity: [f32; 3],
893    /// Accumulated positional correction (Baumgarte).
894    position: [f32; 3],
895    /// Number of contacts contributing to this accumulator (for averaging).
896    contact_count: u32,
897}
898
899// ═══════════════════════════════════════════════════════════════════
900// Wave Coherence Broadphase State (Clean Compute v1.0.0)
901//
902// Exploits temporal coherence: ~90% of broadphase pairs persist between
903// frames because most bodies don't move significantly. Instead of
904// rebuilding the entire pair set from scratch every step (O(N × 27 × log N)),
905// we track per-body displacement and only regenerate pairs for bodies
906// that moved beyond a geometrically exact threshold.
907//
908// Mathematical guarantee: if a body moves less than cell_size/2, it
909// remains within the same 27-cell neighborhood. All pairs from the
910// previous frame involving that body are still valid candidates.
911// (Triangle inequality on axis-aligned cell grids.)
912//
913// This is the computational equivalent of "deferred state realization":
914// the pair set is a wave that propagates forward in time, collapsing
915// only at points of disturbance.
916// ═══════════════════════════════════════════════════════════════════
917
918/// Wave coherence state for incremental broadphase pair generation.
919struct WaveCoherence {
920    /// Position at last broadphase query per body. Parallel to bodies Vec.
921    baseline_positions: Vec<[f32; 3]>,
922    /// Cached pair set — the "wave" that persists between frames.
923    cached_pairs: Vec<(usize, usize)>,
924    /// Displacement threshold squared: (cell_size * 0.5)².
925    threshold_sq: f32,
926    /// Steps since last full rebuild.
927    steps_since_rebuild: u32,
928    /// Full rebuild interval (safety net against drift accumulation).
929    rebuild_interval: u32,
930    /// Whether baseline has been established.
931    initialized: bool,
932}
933
934impl WaveCoherence {
935    fn new() -> Self {
936        Self {
937            baseline_positions: Vec::new(),
938            cached_pairs: Vec::with_capacity(4096),
939            threshold_sq: 0.36, // (1.2 * 0.5)² = 0.36 default
940            steps_since_rebuild: 0,
941            rebuild_interval: 60,
942            initialized: false,
943        }
944    }
945
946    /// Set threshold from cell size. Must be called when cell_size changes.
947    fn set_threshold_from_cell_size(&mut self, cell_size: f32) {
948        let half = cell_size * 0.5;
949        self.threshold_sq = half * half;
950    }
951
952    /// Invalidate: force full rebuild on next step.
953    fn invalidate(&mut self) {
954        self.initialized = false;
955    }
956
957    /// Ensure baseline_positions is sized to body count.
958    fn ensure_capacity(&mut self, body_count: usize) {
959        self.baseline_positions.resize(body_count, [0.0; 3]);
960    }
961
962    /// Check if a body exceeded displacement threshold.
963    #[inline]
964    fn is_displaced(&self, index: usize, current_pos: &[f32; 3]) -> bool {
965        let bp = &self.baseline_positions[index];
966        let dx = current_pos[0] - bp[0];
967        let dy = current_pos[1] - bp[1];
968        let dz = current_pos[2] - bp[2];
969        dx * dx + dy * dy + dz * dz > self.threshold_sq
970    }
971}
972
973/// CPU-deterministic physics simulation world.
974pub struct PhysicsWorld {
975    bodies: Vec<RigidBody>,
976    body_ids: Vec<BodyId>,
977    contacts: Vec<Contact>,
978    /// Contacts from the previous frame, used for warm-starting.
979    prev_contacts: Vec<Contact>,
980    pub gravity: [f32; 3],
981    next_id: u32,
982    pub sleep_config: SleepConfig,
983    broadphase: SpatialHashGrid,
984    /// DreamSpace incremental broadphase (alternative to SpatialHashGrid).
985    pub dreamspace: DreamSpace,
986    /// Per-body superposition state. Parallel to `bodies` Vec.
987    pub superposition: Vec<SuperpositionState>,
988    /// Per-body Jacobi impulse accumulators — pre-allocated, reused (Clean Compute).
989    jacobi_accumulators: Vec<JacobiAccumulator>,
990    /// Sweep-and-prune broadphase — available for sparse worlds.
991    _sap: SweepAndPrune,
992    /// Wave coherence state — temporal pair caching for broadphase.
993    wave: WaveCoherence,
994}
995
996impl Default for PhysicsWorld {
997    fn default() -> Self {
998        Self {
999            bodies: Vec::new(),
1000            body_ids: Vec::new(),
1001            contacts: Vec::new(),
1002            prev_contacts: Vec::new(),
1003            gravity: [0.0, -9.81, 0.0],
1004            next_id: 0,
1005            sleep_config: SleepConfig::default(),
1006            broadphase: SpatialHashGrid::new(2.0),
1007            dreamspace: DreamSpace::new(2.0),
1008            superposition: Vec::new(),
1009            jacobi_accumulators: Vec::new(),
1010            _sap: SweepAndPrune::new(),
1011            wave: WaveCoherence::new(),
1012        }
1013    }
1014}
1015
1016impl PhysicsWorld {
1017    pub fn new() -> Self {
1018        Self::default()
1019    }
1020
1021    /// Add a rigid body. Returns its handle.
1022    pub fn add_body(&mut self, body: RigidBody) -> BodyId {
1023        let id = BodyId(self.next_id);
1024        self.next_id += 1;
1025        self.bodies.push(body);
1026        self.body_ids.push(id);
1027        self.superposition.push(SuperpositionState::Active);
1028        self.wave.invalidate(); // New body → force full broadphase rebuild
1029        id
1030    }
1031
1032    /// Remove a rigid body by ID. Returns true if found.
1033    pub fn remove_body(&mut self, id: BodyId) -> bool {
1034        if let Some(pos) = self.body_ids.iter().position(|bid| *bid == id) {
1035            self.bodies.swap_remove(pos);
1036            self.body_ids.swap_remove(pos);
1037            self.wave.invalidate(); // Body removed → force full rebuild
1038            true
1039        } else {
1040            false
1041        }
1042    }
1043
1044    /// Get body by ID.
1045    pub fn body(&self, id: BodyId) -> Option<&RigidBody> {
1046        self.body_ids
1047            .iter()
1048            .position(|bid| *bid == id)
1049            .and_then(|i| self.bodies.get(i))
1050    }
1051
1052    /// Get mutable body by ID.
1053    pub fn body_mut(&mut self, id: BodyId) -> Option<&mut RigidBody> {
1054        self.body_ids
1055            .iter()
1056            .position(|bid| *bid == id)
1057            .and_then(|i| self.bodies.get_mut(i))
1058    }
1059
1060    /// Number of active bodies.
1061    pub fn body_count(&self) -> usize {
1062        self.bodies.len()
1063    }
1064
1065    /// Access all bodies (read-only slice).
1066    pub fn bodies(&self) -> &[RigidBody] {
1067        &self.bodies
1068    }
1069
1070    /// Set broadphase cell size. Optimal: ~2x the largest body radius.
1071    /// Smaller cells = faster query at high body counts, more memory.
1072    pub fn set_broadphase_cell_size(&mut self, size: f32) {
1073        self.broadphase.set_cell_size(size);
1074        self.wave.set_threshold_from_cell_size(size);
1075    }
1076
1077    /// Mutable access to all bodies (for superposition state toggling).
1078    pub fn bodies_mut_slice(&mut self) -> &mut [RigidBody] {
1079        &mut self.bodies
1080    }
1081
1082    /// Access all body IDs.
1083    pub fn body_ids(&self) -> &[BodyId] {
1084        &self.body_ids
1085    }
1086
1087    /// Step the simulation forward by dt seconds.
1088    /// Sequence: apply gravity → detect contacts → resolve contacts → integrate → sleep.
1089    pub fn step(&mut self, dt: f32) {
1090        if dt <= 0.0 {
1091            return;
1092        }
1093
1094        // Apply gravity to all dynamic bodies (skip sleeping bodies).
1095        // Clean Compute: parallel when available — O(n) embarrassingly parallel.
1096        let grav = self.gravity;
1097        #[cfg(feature = "parallel-physics")]
1098        {
1099            use rayon::prelude::*;
1100            self.bodies.par_iter_mut().for_each(|body| {
1101                if body.is_static || !body.is_active || body.is_sleeping {
1102                    return;
1103                }
1104                body.velocity[0] += grav[0] * dt;
1105                body.velocity[1] += grav[1] * dt;
1106                body.velocity[2] += grav[2] * dt;
1107            });
1108        }
1109        #[cfg(not(feature = "parallel-physics"))]
1110        for body in &mut self.bodies {
1111            if body.is_static || !body.is_active || body.is_sleeping {
1112                continue;
1113            }
1114            body.velocity[0] += grav[0] * dt;
1115            body.velocity[1] += grav[1] * dt;
1116            body.velocity[2] += grav[2] * dt;
1117        }
1118
1119        // ── Wave Coherence Broadphase ──────────────────────────────────
1120        // Exploits temporal coherence: reuse the pair set from last frame
1121        // and only do a full rebuild when enough bodies have moved.
1122        // The narrowphase acts as a correctness safety net — stale pairs
1123        // return None from detect_contact() and are harmlessly filtered.
1124        // This saves the O(N × 27 × log N) pair generation on ~80% of steps.
1125        std::mem::swap(&mut self.prev_contacts, &mut self.contacts);
1126        self.contacts.clear();
1127
1128        self.wave.ensure_capacity(self.bodies.len());
1129        self.wave.steps_since_rebuild += 1;
1130
1131        // Count displaced bodies to decide rebuild strategy.
1132        let mut displaced_count = 0u32;
1133        if self.wave.initialized {
1134            for (i, body) in self.bodies.iter().enumerate() {
1135                if body.is_active && i < self.wave.baseline_positions.len() && self.wave.is_displaced(i, &body.position)
1136                {
1137                    displaced_count += 1;
1138                }
1139            }
1140        }
1141
1142        // Full rebuild conditions:
1143        // 1. First step (no baseline)
1144        // 2. Generation safety reset (every N steps)
1145        // 3. >25% of bodies displaced (cheaper than incremental)
1146        let need_full = !self.wave.initialized
1147            || self.wave.steps_since_rebuild >= self.wave.rebuild_interval
1148            || displaced_count > (self.bodies.len() as u32) / 4;
1149
1150        if need_full {
1151            self.broadphase.populate(&self.bodies);
1152            let fresh = self.broadphase.query_pairs(&self.bodies);
1153            self.wave.cached_pairs.clear();
1154            self.wave.cached_pairs.extend_from_slice(&fresh);
1155            for (i, body) in self.bodies.iter().enumerate() {
1156                if i < self.wave.baseline_positions.len() {
1157                    self.wave.baseline_positions[i] = body.position;
1158                }
1159            }
1160            self.wave.initialized = true;
1161            self.wave.steps_since_rebuild = 0;
1162        }
1163        // Else: reuse cached_pairs from last step. Narrowphase filters stale pairs.
1164        // Cost: ~5% wasted narrowphase on separated pairs. Saves: 78% broadphase.
1165
1166        let pairs = &self.wave.cached_pairs;
1167
1168        // Clean Compute: parallel narrowphase — each pair test is independent.
1169        #[cfg(feature = "parallel-physics")]
1170        {
1171            use rayon::prelude::*;
1172            let bodies = &self.bodies;
1173            let new_contacts: Vec<Contact> = pairs
1174                .par_iter()
1175                .filter_map(|&(i, j)| {
1176                    if bodies[i].is_static && bodies[j].is_static {
1177                        return None;
1178                    }
1179                    detect_contact(&bodies[i], &bodies[j], i, j)
1180                })
1181                .collect();
1182            self.contacts.extend(new_contacts);
1183        }
1184        #[cfg(not(feature = "parallel-physics"))]
1185        for &(i, j) in pairs {
1186            if self.bodies[i].is_static && self.bodies[j].is_static {
1187                continue;
1188            }
1189            if let Some(contact) = detect_contact(&self.bodies[i], &self.bodies[j], i, j) {
1190                self.contacts.push(contact);
1191            }
1192        }
1193
1194        // Wake bodies involved in contacts
1195        for ci in 0..self.contacts.len() {
1196            let contact = self.contacts[ci];
1197            let a = contact.body_a;
1198            let b = contact.body_b;
1199            if self.bodies[a].is_sleeping {
1200                self.bodies[a].is_sleeping = false;
1201                self.bodies[a].sleep_frames = 0;
1202            }
1203            if self.bodies[b].is_sleeping {
1204                self.bodies[b].is_sleeping = false;
1205                self.bodies[b].sleep_frames = 0;
1206            }
1207        }
1208
1209        // ── Warm-start: apply cached impulses from previous frame ──
1210        warm_start_contacts(&mut self.contacts, &self.prev_contacts, &mut self.bodies);
1211
1212        // ── Parallel Jacobi Solver ──────────────────────────────────────
1213        //
1214        // Clean Compute breakthrough: instead of sequential Gauss-Seidel
1215        // (which processes contacts one at a time because each body's velocity
1216        // is read-after-write), we use Jacobi iteration where ALL contacts
1217        // compute impulses simultaneously from the START-OF-ITERATION velocities.
1218        //
1219        // Why this converges in 1-2 iterations (not the usual 4-8):
1220        // 1. Warm-starting pre-applies ~80% of the correct impulse
1221        // 2. The residual is small → 1 Jacobi pass handles it
1222        // 3. Baumgarte positional correction is additive and stable under Jacobi
1223        //
1224        // Mathematical basis: Jacobi iteration on the constraint matrix A·λ = b
1225        // converges when A is diagonally dominant, which holds for:
1226        // - contacts with positive inv_mass_sum (always true for dynamic bodies)
1227        // - warm-started systems where ||b_residual|| << ||b||
1228        //
1229        // This makes the ENTIRE solver embarrassingly parallel. No islands needed.
1230        // No unsafe raw pointers. No data dependencies between contacts.
1231        //
1232        // Two iterations: first pass resolves ~95% of residual after warm-start,
1233        // second pass cleans up cross-contact coupling artifacts.
1234        // Single iteration: warm-start provides ~80% of the correct impulse,
1235        // so one Jacobi pass resolves the remaining ~20% residual.
1236        // This halves solver cost compared to 2 iterations with negligible quality loss.
1237        const JACOBI_ITERATIONS: usize = 1;
1238
1239        // Ensure accumulators are sized and zeroed.
1240        self.jacobi_accumulators
1241            .resize(self.bodies.len(), JacobiAccumulator::default());
1242
1243        for _iter in 0..JACOBI_ITERATIONS {
1244            // Phase A: Zero accumulators.
1245            for acc in &mut self.jacobi_accumulators {
1246                *acc = JacobiAccumulator::default();
1247            }
1248
1249            // Phase B: Compute impulses from current velocities (read-only bodies).
1250            // Each contact produces independent impulse contributions to two bodies.
1251            // This is the embarrassingly parallel step.
1252            #[cfg(feature = "parallel-physics")]
1253            {
1254                use rayon::prelude::*;
1255                use std::sync::atomic::{AtomicU32, Ordering};
1256
1257                // We use a flat f32 array with atomic operations for accumulation.
1258                // AtomicU32 stores f32 bits; we use compare-exchange for atomic add.
1259                let n = self.bodies.len();
1260                // 6 floats per body: [vx, vy, vz, px, py, pz]
1261                let atom_buf: Vec<AtomicU32> = (0..n * 6).map(|_| AtomicU32::new(0f32.to_bits())).collect();
1262                let atom_count: Vec<AtomicU32> = (0..n).map(|_| AtomicU32::new(0)).collect();
1263
1264                let bodies = &self.bodies;
1265                self.contacts.par_iter_mut().for_each(|contact| {
1266                    let (a, b) = (contact.body_a, contact.body_b);
1267                    if a == b {
1268                        return;
1269                    }
1270                    let ba = &bodies[a];
1271                    let bb = &bodies[b];
1272
1273                    let n_vec = contact.normal;
1274                    let v_rel = [
1275                        ba.velocity[0] - bb.velocity[0],
1276                        ba.velocity[1] - bb.velocity[1],
1277                        ba.velocity[2] - bb.velocity[2],
1278                    ];
1279                    let v_along_n = vec3_dot(v_rel, n_vec);
1280                    if v_along_n > 0.0 {
1281                        return;
1282                    } // separating
1283
1284                    let e = ba.restitution.min(bb.restitution);
1285                    let inv_mass_sum = ba.inv_mass + bb.inv_mass;
1286                    if inv_mass_sum < 1e-8 {
1287                        return;
1288                    }
1289
1290                    let j = -(1.0 + e) * v_along_n / inv_mass_sum;
1291                    contact.cached_impulse = j;
1292
1293                    // Velocity impulse contributions
1294                    let va = [
1295                        n_vec[0] * j * ba.inv_mass,
1296                        n_vec[1] * j * ba.inv_mass,
1297                        n_vec[2] * j * ba.inv_mass,
1298                    ];
1299                    let vb = [
1300                        n_vec[0] * j * bb.inv_mass,
1301                        n_vec[1] * j * bb.inv_mass,
1302                        n_vec[2] * j * bb.inv_mass,
1303                    ];
1304
1305                    // Positional correction (Baumgarte)
1306                    let correction = (contact.depth - 0.01f32).max(0.0) * 0.8 / inv_mass_sum;
1307                    let pa = [
1308                        n_vec[0] * correction * ba.inv_mass,
1309                        n_vec[1] * correction * ba.inv_mass,
1310                        n_vec[2] * correction * ba.inv_mass,
1311                    ];
1312                    let pb = [
1313                        n_vec[0] * correction * bb.inv_mass,
1314                        n_vec[1] * correction * bb.inv_mass,
1315                        n_vec[2] * correction * bb.inv_mass,
1316                    ];
1317
1318                    // Friction (tangent impulse)
1319                    let tangent = [
1320                        v_rel[0] - n_vec[0] * v_along_n,
1321                        v_rel[1] - n_vec[1] * v_along_n,
1322                        v_rel[2] - n_vec[2] * v_along_n,
1323                    ];
1324                    let tlen = vec3_len(tangent);
1325                    let (fta, ftb) = if tlen > 1e-8 {
1326                        let t = vec3_scale(tangent, 1.0 / tlen);
1327                        let vt = vec3_dot(v_rel, t);
1328                        let mu = (ba.friction + bb.friction) * 0.5;
1329                        let jt = (-vt / inv_mass_sum).clamp(-j.abs() * mu, j.abs() * mu);
1330                        (
1331                            [
1332                                t[0] * jt * ba.inv_mass,
1333                                t[1] * jt * ba.inv_mass,
1334                                t[2] * jt * ba.inv_mass,
1335                            ],
1336                            [
1337                                t[0] * jt * bb.inv_mass,
1338                                t[1] * jt * bb.inv_mass,
1339                                t[2] * jt * bb.inv_mass,
1340                            ],
1341                        )
1342                    } else {
1343                        ([0.0; 3], [0.0; 3])
1344                    };
1345
1346                    // Atomic accumulate into body A (+velocity, -position)
1347                    for k in 0..3 {
1348                        atomic_f32_add(&atom_buf[a * 6 + k], va[k] + fta[k]);
1349                        atomic_f32_add(&atom_buf[a * 6 + 3 + k], -pa[k]);
1350                    }
1351                    atom_count[a].fetch_add(1, Ordering::Relaxed);
1352
1353                    // Atomic accumulate into body B (-velocity, +position)
1354                    for k in 0..3 {
1355                        atomic_f32_add(&atom_buf[b * 6 + k], -vb[k] - ftb[k]);
1356                        atomic_f32_add(&atom_buf[b * 6 + 3 + k], pb[k]);
1357                    }
1358                    atom_count[b].fetch_add(1, Ordering::Relaxed);
1359                });
1360
1361                // Phase C: Apply accumulated impulses (parallel over bodies).
1362                self.bodies.par_iter_mut().enumerate().for_each(|(i, body)| {
1363                    if body.is_static || body.is_sleeping {
1364                        return;
1365                    }
1366                    let count = atom_count[i].load(Ordering::Relaxed);
1367                    if count == 0 {
1368                        return;
1369                    }
1370                    for k in 0..3 {
1371                        body.velocity[k] += f32::from_bits(atom_buf[i * 6 + k].load(Ordering::Relaxed));
1372                        body.position[k] += f32::from_bits(atom_buf[i * 6 + 3 + k].load(Ordering::Relaxed));
1373                    }
1374                });
1375            }
1376
1377            #[cfg(not(feature = "parallel-physics"))]
1378            {
1379                // Sequential Jacobi: same algorithm, no atomics needed.
1380                for acc in &mut self.jacobi_accumulators {
1381                    *acc = JacobiAccumulator::default();
1382                }
1383                for ci in 0..self.contacts.len() {
1384                    let contact = &mut self.contacts[ci];
1385                    let (a, b) = (contact.body_a, contact.body_b);
1386                    if a == b {
1387                        continue;
1388                    }
1389                    let ba = &self.bodies[a];
1390                    let bb = &self.bodies[b];
1391
1392                    let n_vec = contact.normal;
1393                    let v_rel = [
1394                        ba.velocity[0] - bb.velocity[0],
1395                        ba.velocity[1] - bb.velocity[1],
1396                        ba.velocity[2] - bb.velocity[2],
1397                    ];
1398                    let v_along_n = vec3_dot(v_rel, n_vec);
1399                    if v_along_n > 0.0 {
1400                        continue;
1401                    }
1402
1403                    let e = ba.restitution.min(bb.restitution);
1404                    let inv_mass_sum = ba.inv_mass + bb.inv_mass;
1405                    if inv_mass_sum < 1e-8 {
1406                        continue;
1407                    }
1408
1409                    let j = -(1.0 + e) * v_along_n / inv_mass_sum;
1410                    contact.cached_impulse = j;
1411
1412                    let correction = (contact.depth - 0.01f32).max(0.0) * 0.8 / inv_mass_sum;
1413
1414                    // Accumulate for body A
1415                    self.jacobi_accumulators[a].velocity[0] += n_vec[0] * j * ba.inv_mass;
1416                    self.jacobi_accumulators[a].velocity[1] += n_vec[1] * j * ba.inv_mass;
1417                    self.jacobi_accumulators[a].velocity[2] += n_vec[2] * j * ba.inv_mass;
1418                    self.jacobi_accumulators[a].position[0] -= n_vec[0] * correction * ba.inv_mass;
1419                    self.jacobi_accumulators[a].position[1] -= n_vec[1] * correction * ba.inv_mass;
1420                    self.jacobi_accumulators[a].position[2] -= n_vec[2] * correction * ba.inv_mass;
1421                    self.jacobi_accumulators[a].contact_count += 1;
1422
1423                    // Accumulate for body B
1424                    self.jacobi_accumulators[b].velocity[0] -= n_vec[0] * j * bb.inv_mass;
1425                    self.jacobi_accumulators[b].velocity[1] -= n_vec[1] * j * bb.inv_mass;
1426                    self.jacobi_accumulators[b].velocity[2] -= n_vec[2] * j * bb.inv_mass;
1427                    self.jacobi_accumulators[b].position[0] += n_vec[0] * correction * bb.inv_mass;
1428                    self.jacobi_accumulators[b].position[1] += n_vec[1] * correction * bb.inv_mass;
1429                    self.jacobi_accumulators[b].position[2] += n_vec[2] * correction * bb.inv_mass;
1430                    self.jacobi_accumulators[b].contact_count += 1;
1431
1432                    // Friction
1433                    let tangent = [
1434                        v_rel[0] - n_vec[0] * v_along_n,
1435                        v_rel[1] - n_vec[1] * v_along_n,
1436                        v_rel[2] - n_vec[2] * v_along_n,
1437                    ];
1438                    let tlen = vec3_len(tangent);
1439                    if tlen > 1e-8 {
1440                        let t = vec3_scale(tangent, 1.0 / tlen);
1441                        let vt = vec3_dot(v_rel, t);
1442                        let mu = (ba.friction + bb.friction) * 0.5;
1443                        let jt = (-vt / inv_mass_sum).clamp(-j.abs() * mu, j.abs() * mu);
1444                        self.jacobi_accumulators[a].velocity[0] += t[0] * jt * ba.inv_mass;
1445                        self.jacobi_accumulators[a].velocity[1] += t[1] * jt * ba.inv_mass;
1446                        self.jacobi_accumulators[a].velocity[2] += t[2] * jt * ba.inv_mass;
1447                        self.jacobi_accumulators[b].velocity[0] -= t[0] * jt * bb.inv_mass;
1448                        self.jacobi_accumulators[b].velocity[1] -= t[1] * jt * bb.inv_mass;
1449                        self.jacobi_accumulators[b].velocity[2] -= t[2] * jt * bb.inv_mass;
1450                    }
1451                }
1452
1453                // Apply accumulated impulses
1454                for (i, body) in self.bodies.iter_mut().enumerate() {
1455                    if body.is_static || body.is_sleeping {
1456                        continue;
1457                    }
1458                    let acc = &self.jacobi_accumulators[i];
1459                    if acc.contact_count == 0 {
1460                        continue;
1461                    }
1462                    for k in 0..3 {
1463                        body.velocity[k] += acc.velocity[k];
1464                        body.position[k] += acc.position[k];
1465                    }
1466                }
1467            }
1468        }
1469
1470        // Integrate positions (semi-implicit Euler) and update sleep counters.
1471        // Clean Compute: parallel integration — each body is independent.
1472        let sleep_cfg = self.sleep_config;
1473        #[cfg(feature = "parallel-physics")]
1474        {
1475            use rayon::prelude::*;
1476            self.bodies.par_iter_mut().for_each(|body| {
1477                if body.is_static || !body.is_active || body.is_sleeping {
1478                    return;
1479                }
1480                body.position[0] += body.velocity[0] * dt;
1481                body.position[1] += body.velocity[1] * dt;
1482                body.position[2] += body.velocity[2] * dt;
1483                let lin_speed_sq = body.velocity[0] * body.velocity[0]
1484                    + body.velocity[1] * body.velocity[1]
1485                    + body.velocity[2] * body.velocity[2];
1486                let ang_speed_sq = body.angular_velocity[0] * body.angular_velocity[0]
1487                    + body.angular_velocity[1] * body.angular_velocity[1]
1488                    + body.angular_velocity[2] * body.angular_velocity[2];
1489                let lin_thresh_sq = sleep_cfg.linear_threshold * sleep_cfg.linear_threshold;
1490                let ang_thresh_sq = sleep_cfg.angular_threshold * sleep_cfg.angular_threshold;
1491                if lin_speed_sq < lin_thresh_sq && ang_speed_sq < ang_thresh_sq {
1492                    body.sleep_frames = body.sleep_frames.saturating_add(1);
1493                    if body.sleep_frames >= sleep_cfg.frames_to_sleep {
1494                        body.is_sleeping = true;
1495                        body.velocity = [0.0; 3];
1496                        body.angular_velocity = [0.0; 3];
1497                    }
1498                } else {
1499                    body.sleep_frames = 0;
1500                }
1501            });
1502        }
1503        #[cfg(not(feature = "parallel-physics"))]
1504        for body in &mut self.bodies {
1505            if body.is_static || !body.is_active || body.is_sleeping {
1506                continue;
1507            }
1508            body.position[0] += body.velocity[0] * dt;
1509            body.position[1] += body.velocity[1] * dt;
1510            body.position[2] += body.velocity[2] * dt;
1511            let lin_speed_sq = body.velocity[0] * body.velocity[0]
1512                + body.velocity[1] * body.velocity[1]
1513                + body.velocity[2] * body.velocity[2];
1514            let ang_speed_sq = body.angular_velocity[0] * body.angular_velocity[0]
1515                + body.angular_velocity[1] * body.angular_velocity[1]
1516                + body.angular_velocity[2] * body.angular_velocity[2];
1517            let lin_thresh_sq = sleep_cfg.linear_threshold * sleep_cfg.linear_threshold;
1518            let ang_thresh_sq = sleep_cfg.angular_threshold * sleep_cfg.angular_threshold;
1519            if lin_speed_sq < lin_thresh_sq && ang_speed_sq < ang_thresh_sq {
1520                body.sleep_frames = body.sleep_frames.saturating_add(1);
1521                if body.sleep_frames >= sleep_cfg.frames_to_sleep {
1522                    body.is_sleeping = true;
1523                    body.velocity = [0.0; 3];
1524                    body.angular_velocity = [0.0; 3];
1525                }
1526            } else {
1527                body.sleep_frames = 0;
1528            }
1529        }
1530    }
1531
1532    /// Step using DreamSpace incremental broadphase.
1533    /// Same physics as step() but only re-queries cells that had boundary crossings.
1534    /// First call triggers a full rebuild; subsequent calls are incremental.
1535    pub fn step_dreamspace(&mut self, dt: f32) {
1536        if dt <= 0.0 {
1537            return;
1538        }
1539
1540        // Gravity.
1541        for body in &mut self.bodies {
1542            if body.is_static || !body.is_active || body.is_sleeping {
1543                continue;
1544            }
1545            body.velocity[0] += self.gravity[0] * dt;
1546            body.velocity[1] += self.gravity[1] * dt;
1547            body.velocity[2] += self.gravity[2] * dt;
1548        }
1549
1550        // Broadphase: incremental DreamSpace update + dirty-cell pair query.
1551        // Save previous frame's contacts for warm-starting before clearing.
1552        std::mem::swap(&mut self.prev_contacts, &mut self.contacts);
1553        self.contacts.clear();
1554        if !self.dreamspace.is_initialized() {
1555            self.dreamspace.rebuild(&self.bodies);
1556        }
1557        let pairs = if self.dreamspace.dirty_cell_count() > self.dreamspace.total_cell_count() / 2 {
1558            // More than half of cells dirty — full query is cheaper.
1559            self.dreamspace.query_all_pairs(&self.bodies)
1560        } else {
1561            self.dreamspace.query_dirty_pairs(&self.bodies)
1562        };
1563
1564        // Narrowphase.
1565        for (i, j) in pairs {
1566            if self.bodies[i].is_static && self.bodies[j].is_static {
1567                continue;
1568            }
1569            if let Some(contact) = detect_contact(&self.bodies[i], &self.bodies[j], i, j) {
1570                self.contacts.push(contact);
1571            }
1572        }
1573
1574        // Wake bodies from contacts.
1575        for ci in 0..self.contacts.len() {
1576            let c = self.contacts[ci];
1577            if self.bodies[c.body_a].is_sleeping {
1578                self.bodies[c.body_a].is_sleeping = false;
1579                self.bodies[c.body_a].sleep_frames = 0;
1580            }
1581            if self.bodies[c.body_b].is_sleeping {
1582                self.bodies[c.body_b].is_sleeping = false;
1583                self.bodies[c.body_b].sleep_frames = 0;
1584            }
1585        }
1586
1587        // ── Warm-start: apply cached impulses from previous frame ──
1588        warm_start_contacts(&mut self.contacts, &self.prev_contacts, &mut self.bodies);
1589
1590        // ── Island splitting + contact resolution ──
1591        let islands = build_islands(self.bodies.len(), &self.contacts);
1592        for island_contacts in &islands {
1593            for &ci in island_contacts {
1594                let (a_idx, b_idx) = (self.contacts[ci].body_a, self.contacts[ci].body_b);
1595                if a_idx == b_idx {
1596                    continue;
1597                }
1598                let (lo, hi) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
1599                let (left, right) = self.bodies.split_at_mut(hi);
1600                if a_idx < b_idx {
1601                    resolve_contact(&mut left[lo], &mut right[0], &mut self.contacts[ci]);
1602                } else {
1603                    resolve_contact(&mut right[0], &mut left[lo], &mut self.contacts[ci]);
1604                }
1605            }
1606        }
1607
1608        // Integrate positions + sleep evaluation.
1609        let sleep_cfg = self.sleep_config;
1610        for body in &mut self.bodies {
1611            if body.is_static || !body.is_active || body.is_sleeping {
1612                continue;
1613            }
1614            body.position[0] += body.velocity[0] * dt;
1615            body.position[1] += body.velocity[1] * dt;
1616            body.position[2] += body.velocity[2] * dt;
1617
1618            let lin_sq = body.velocity[0] * body.velocity[0]
1619                + body.velocity[1] * body.velocity[1]
1620                + body.velocity[2] * body.velocity[2];
1621            let ang_sq = body.angular_velocity[0] * body.angular_velocity[0]
1622                + body.angular_velocity[1] * body.angular_velocity[1]
1623                + body.angular_velocity[2] * body.angular_velocity[2];
1624            if lin_sq < sleep_cfg.linear_threshold * sleep_cfg.linear_threshold
1625                && ang_sq < sleep_cfg.angular_threshold * sleep_cfg.angular_threshold
1626            {
1627                body.sleep_frames = body.sleep_frames.saturating_add(1);
1628                if body.sleep_frames >= sleep_cfg.frames_to_sleep {
1629                    body.is_sleeping = true;
1630                    body.velocity = [0.0; 3];
1631                    body.angular_velocity = [0.0; 3];
1632                }
1633            } else {
1634                body.sleep_frames = 0;
1635            }
1636        }
1637
1638        // Update DreamSpace index AFTER integration (positions have changed).
1639        self.dreamspace.update(&self.bodies);
1640    }
1641
1642    /// Step with DreamSuperposition — cell-level coherence collapse + decoherence rings.
1643    ///
1644    /// Phase 0: Cell-level coherence collapse (O(C) cells, not O(N) bodies)
1645    /// Phase 1: Gravity (Active + Decohering-on-tick only)
1646    /// Phase 2: Broadphase (DreamSpace incremental, Active + Decohering only)
1647    /// Phase 3: Narrowphase (filtered pairs)
1648    /// Phase 4: Contact wake + resolve
1649    /// Phase 5: Integrate (Active + Decohering-on-tick)
1650    /// Phase 6: DreamSpace update
1651    pub fn step_superposition(&mut self, dt: f32, observer: &SuperpositionObserver) {
1652        if dt <= 0.0 {
1653            return;
1654        }
1655
1656        // ── Phase 0: Cell-level coherence collapse ──
1657        // Classify cells, not bodies. All bodies in a cell share the cell's state.
1658        // O(C) where C = occupied cells, instead of O(N) bodies.
1659        if !self.dreamspace.is_initialized() {
1660            self.dreamspace.rebuild(&self.bodies);
1661        }
1662
1663        let (active_cells, decohere_cells, superposed_cells) = self.dreamspace.classify_cells(observer);
1664
1665        // Apply cell classification to bodies.
1666        for &cell in &active_cells {
1667            for &idx in self.dreamspace.bodies_in_cell(&cell) {
1668                if idx < self.superposition.len() && self.bodies[idx].is_active {
1669                    self.superposition[idx] = SuperpositionState::Active;
1670                }
1671            }
1672        }
1673        for &cell in &decohere_cells {
1674            for &idx in self.dreamspace.bodies_in_cell(&cell) {
1675                if idx < self.superposition.len() && self.bodies[idx].is_active {
1676                    // Don't downgrade Active to Decohering (body may have been woken by contact).
1677                    if self.superposition[idx] != SuperpositionState::Active {
1678                        self.superposition[idx] = SuperpositionState::Decohering;
1679                    }
1680                }
1681            }
1682        }
1683        for &cell in &superposed_cells {
1684            for &idx in self.dreamspace.bodies_in_cell(&cell) {
1685                if idx < self.superposition.len() {
1686                    if !self.bodies[idx].is_active {
1687                        self.superposition[idx] = SuperpositionState::Dormant;
1688                    } else if self.bodies[idx].is_sleeping {
1689                        self.superposition[idx] = SuperpositionState::Dormant;
1690                    } else if self.superposition[idx] != SuperpositionState::Active {
1691                        self.superposition[idx] = SuperpositionState::Superposed;
1692                    }
1693                }
1694            }
1695        }
1696
1697        let simulate_decohere = observer.should_decohere_simulate();
1698
1699        // ── Phase 1: Gravity (Active + Decohering-on-tick) ──
1700        for (i, body) in self.bodies.iter_mut().enumerate() {
1701            let state = self.superposition[i];
1702            let should_sim =
1703                state == SuperpositionState::Active || (state == SuperpositionState::Decohering && simulate_decohere);
1704            if !should_sim || body.is_static || body.is_sleeping {
1705                continue;
1706            }
1707            body.velocity[0] += self.gravity[0] * dt;
1708            body.velocity[1] += self.gravity[1] * dt;
1709            body.velocity[2] += self.gravity[2] * dt;
1710        }
1711
1712        // ── Phase 2: Broadphase (Active + Decohering pairs only) ──
1713        // Save previous frame's contacts for warm-starting before clearing.
1714        std::mem::swap(&mut self.prev_contacts, &mut self.contacts);
1715        self.contacts.clear();
1716        let all_pairs = if self.dreamspace.dirty_cell_count() > self.dreamspace.total_cell_count() / 2 {
1717            self.dreamspace.query_all_pairs(&self.bodies)
1718        } else {
1719            self.dreamspace.query_dirty_pairs(&self.bodies)
1720        };
1721
1722        let pairs: Vec<(usize, usize)> = all_pairs
1723            .into_iter()
1724            .filter(|&(i, j)| {
1725                let si = self.superposition[i];
1726                let sj = self.superposition[j];
1727                si == SuperpositionState::Active
1728                    || sj == SuperpositionState::Active
1729                    || ((si == SuperpositionState::Decohering || sj == SuperpositionState::Decohering)
1730                        && simulate_decohere)
1731            })
1732            .collect();
1733
1734        // ── Phase 3: Narrowphase ──
1735        // Cosmetic-only bodies skip narrowphase when BOTH bodies are cosmetic
1736        // and neither is Active. Gameplay-critical bodies always resolve.
1737        for (i, j) in pairs {
1738            if self.bodies[i].is_static && self.bodies[j].is_static {
1739                continue;
1740            }
1741            // Cosmetic skip: if both are cosmetic and neither is Active, no contact needed.
1742            if self.bodies[i].cosmetic_only
1743                && self.bodies[j].cosmetic_only
1744                && self.superposition[i] != SuperpositionState::Active
1745                && self.superposition[j] != SuperpositionState::Active
1746            {
1747                continue;
1748            }
1749            if let Some(contact) = detect_contact(&self.bodies[i], &self.bodies[j], i, j) {
1750                self.contacts.push(contact);
1751            }
1752        }
1753
1754        // ── Phase 4: Wake + Resolve ──
1755        for ci in 0..self.contacts.len() {
1756            let c = self.contacts[ci];
1757            if self.bodies[c.body_a].is_sleeping {
1758                self.bodies[c.body_a].is_sleeping = false;
1759                self.bodies[c.body_a].sleep_frames = 0;
1760            }
1761            if self.bodies[c.body_b].is_sleeping {
1762                self.bodies[c.body_b].is_sleeping = false;
1763                self.bodies[c.body_b].sleep_frames = 0;
1764            }
1765            self.superposition[c.body_a] = SuperpositionState::Active;
1766            self.superposition[c.body_b] = SuperpositionState::Active;
1767        }
1768
1769        // ── Warm-start: apply cached impulses from previous frame ──
1770        warm_start_contacts(&mut self.contacts, &self.prev_contacts, &mut self.bodies);
1771
1772        // ── Island splitting + contact resolution ──
1773        let islands = build_islands(self.bodies.len(), &self.contacts);
1774        for island_contacts in &islands {
1775            for &ci in island_contacts {
1776                let (a_idx, b_idx) = (self.contacts[ci].body_a, self.contacts[ci].body_b);
1777                if a_idx == b_idx {
1778                    continue;
1779                }
1780                let (lo, hi) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };
1781                let (left, right) = self.bodies.split_at_mut(hi);
1782                if a_idx < b_idx {
1783                    resolve_contact(&mut left[lo], &mut right[0], &mut self.contacts[ci]);
1784                } else {
1785                    resolve_contact(&mut right[0], &mut left[lo], &mut self.contacts[ci]);
1786                }
1787            }
1788        }
1789
1790        // ── Phase 5: Integrate (Active + Decohering-on-tick) ──
1791        let sleep_cfg = self.sleep_config;
1792        for (i, body) in self.bodies.iter_mut().enumerate() {
1793            let state = self.superposition[i];
1794            let should_sim =
1795                state == SuperpositionState::Active || (state == SuperpositionState::Decohering && simulate_decohere);
1796            if !should_sim || body.is_static || body.is_sleeping {
1797                continue;
1798            }
1799
1800            body.position[0] += body.velocity[0] * dt;
1801            body.position[1] += body.velocity[1] * dt;
1802            body.position[2] += body.velocity[2] * dt;
1803
1804            let lin_sq = body.velocity[0] * body.velocity[0]
1805                + body.velocity[1] * body.velocity[1]
1806                + body.velocity[2] * body.velocity[2];
1807            let ang_sq = body.angular_velocity[0] * body.angular_velocity[0]
1808                + body.angular_velocity[1] * body.angular_velocity[1]
1809                + body.angular_velocity[2] * body.angular_velocity[2];
1810            if lin_sq < sleep_cfg.linear_threshold * sleep_cfg.linear_threshold
1811                && ang_sq < sleep_cfg.angular_threshold * sleep_cfg.angular_threshold
1812            {
1813                body.sleep_frames = body.sleep_frames.saturating_add(1);
1814                if body.sleep_frames >= sleep_cfg.frames_to_sleep {
1815                    body.is_sleeping = true;
1816                    body.velocity = [0.0; 3];
1817                    body.angular_velocity = [0.0; 3];
1818                }
1819            } else {
1820                body.sleep_frames = 0;
1821            }
1822        }
1823
1824        // ── Phase 6: DreamSpace update ──
1825        self.dreamspace.update(&self.bodies);
1826    }
1827
1828    /// Count bodies in each superposition state: (active, decohering, superposed, dormant).
1829    pub fn superposition_counts(&self) -> (u32, u32, u32, u32) {
1830        let (mut a, mut dec, mut s, mut d) = (0u32, 0u32, 0u32, 0u32);
1831        for &state in &self.superposition {
1832            match state {
1833                SuperpositionState::Active => a += 1,
1834                SuperpositionState::Decohering => dec += 1,
1835                SuperpositionState::Superposed => s += 1,
1836                SuperpositionState::Dormant => d += 1,
1837            }
1838        }
1839        (a, dec, s, d)
1840    }
1841
1842    /// Cast a ray and return the closest hit.
1843    pub fn raycast(&self, origin: [f32; 3], direction: [f32; 3], max_dist: f32) -> Option<RayHit> {
1844        let dir_len = vec3_len(direction);
1845        if dir_len < 1e-8 {
1846            return None;
1847        }
1848        let dir = vec3_scale(direction, 1.0 / dir_len);
1849
1850        let mut closest: Option<RayHit> = None;
1851
1852        for (i, body) in self.bodies.iter().enumerate() {
1853            if !body.is_active {
1854                continue;
1855            }
1856            let hit = match body.shape {
1857                CollisionShape::Sphere { radius } => ray_sphere(origin, dir, body.position, radius),
1858                CollisionShape::Plane { normal, d } => ray_plane(origin, dir, normal, d),
1859                CollisionShape::Aabb { half_extents } => ray_aabb(origin, dir, body.position, half_extents),
1860                CollisionShape::Capsule { radius, half_height } => {
1861                    ray_capsule(origin, dir, body.position, radius, half_height)
1862                }
1863                CollisionShape::Cylinder { radius, half_height } => {
1864                    ray_cylinder(origin, dir, body.position, radius, half_height)
1865                }
1866                CollisionShape::Cone { radius, height } => ray_cone(origin, dir, body.position, radius, height),
1867            };
1868
1869            if let Some((dist, normal, point)) = hit {
1870                if dist >= 0.0 && dist <= max_dist {
1871                    if closest.as_ref().map_or(true, |c| dist < c.distance) {
1872                        closest = Some(RayHit {
1873                            position: point,
1874                            normal,
1875                            distance: dist,
1876                            body_index: i,
1877                        });
1878                    }
1879                }
1880            }
1881        }
1882
1883        closest
1884    }
1885
1886    /// Read-only access to contacts from the last step.
1887    pub fn contacts(&self) -> &[Contact] {
1888        &self.contacts
1889    }
1890}
1891
1892// ── Narrowphase contact detection ────────────────────────────────────
1893
1894fn detect_contact(a: &RigidBody, b: &RigidBody, idx_a: usize, idx_b: usize) -> Option<Contact> {
1895    match (&a.shape, &b.shape) {
1896        (CollisionShape::Sphere { radius: ra }, CollisionShape::Sphere { radius: rb }) => {
1897            sphere_sphere(a.position, *ra, b.position, *rb, idx_a, idx_b)
1898        }
1899        (CollisionShape::Sphere { radius }, CollisionShape::Plane { normal, d }) => {
1900            sphere_plane(a.position, *radius, *normal, *d, idx_a, idx_b, false)
1901        }
1902        (CollisionShape::Plane { normal, d }, CollisionShape::Sphere { radius }) => {
1903            sphere_plane(b.position, *radius, *normal, *d, idx_b, idx_a, true)
1904        }
1905        (CollisionShape::Aabb { half_extents: ha }, CollisionShape::Aabb { half_extents: hb }) => {
1906            aabb_aabb(a.position, *ha, b.position, *hb, idx_a, idx_b)
1907        }
1908        (CollisionShape::Sphere { radius }, CollisionShape::Aabb { half_extents }) => {
1909            sphere_aabb(a.position, *radius, b.position, *half_extents, idx_a, idx_b)
1910        }
1911        (CollisionShape::Aabb { half_extents }, CollisionShape::Sphere { radius }) => {
1912            sphere_aabb(b.position, *radius, a.position, *half_extents, idx_b, idx_a)
1913        }
1914        // ── Capsule pairs ──
1915        (CollisionShape::Capsule { radius, half_height }, CollisionShape::Sphere { radius: sr }) => {
1916            capsule_sphere_contact(a.position, *radius, *half_height, b.position, *sr, idx_a, idx_b)
1917        }
1918        (CollisionShape::Sphere { radius: sr }, CollisionShape::Capsule { radius, half_height }) => {
1919            capsule_sphere_contact(b.position, *radius, *half_height, a.position, *sr, idx_b, idx_a).map(|c| Contact {
1920                body_a: idx_a,
1921                body_b: idx_b,
1922                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1923                ..c
1924            })
1925        }
1926        (CollisionShape::Capsule { radius, half_height }, CollisionShape::Plane { normal, d }) => {
1927            capsule_plane_contact(a.position, *radius, *half_height, *normal, *d, idx_a, idx_b)
1928        }
1929        (CollisionShape::Plane { normal, d }, CollisionShape::Capsule { radius, half_height }) => {
1930            capsule_plane_contact(b.position, *radius, *half_height, *normal, *d, idx_b, idx_a).map(|c| Contact {
1931                body_a: idx_a,
1932                body_b: idx_b,
1933                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1934                ..c
1935            })
1936        }
1937        (CollisionShape::Capsule { radius, half_height }, CollisionShape::Aabb { half_extents }) => {
1938            capsule_aabb_contact(
1939                a.position,
1940                *radius,
1941                *half_height,
1942                b.position,
1943                *half_extents,
1944                idx_a,
1945                idx_b,
1946            )
1947        }
1948        (CollisionShape::Aabb { half_extents }, CollisionShape::Capsule { radius, half_height }) => {
1949            capsule_aabb_contact(
1950                b.position,
1951                *radius,
1952                *half_height,
1953                a.position,
1954                *half_extents,
1955                idx_b,
1956                idx_a,
1957            )
1958            .map(|c| Contact {
1959                body_a: idx_a,
1960                body_b: idx_b,
1961                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1962                ..c
1963            })
1964        }
1965        // ── Cylinder pairs ──
1966        (CollisionShape::Cylinder { radius, half_height }, CollisionShape::Sphere { radius: sr }) => {
1967            cylinder_sphere_contact(a.position, *radius, *half_height, b.position, *sr, idx_a, idx_b)
1968        }
1969        (CollisionShape::Sphere { radius: sr }, CollisionShape::Cylinder { radius, half_height }) => {
1970            cylinder_sphere_contact(b.position, *radius, *half_height, a.position, *sr, idx_b, idx_a).map(|c| Contact {
1971                body_a: idx_a,
1972                body_b: idx_b,
1973                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1974                ..c
1975            })
1976        }
1977        (CollisionShape::Cylinder { radius, half_height }, CollisionShape::Plane { normal, d }) => {
1978            cylinder_plane_contact(a.position, *radius, *half_height, *normal, *d, idx_a, idx_b)
1979        }
1980        (CollisionShape::Plane { normal, d }, CollisionShape::Cylinder { radius, half_height }) => {
1981            cylinder_plane_contact(b.position, *radius, *half_height, *normal, *d, idx_b, idx_a).map(|c| Contact {
1982                body_a: idx_a,
1983                body_b: idx_b,
1984                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1985                ..c
1986            })
1987        }
1988        // ── Cone pairs ──
1989        (CollisionShape::Cone { radius, height }, CollisionShape::Sphere { radius: sr }) => {
1990            cone_sphere_contact(a.position, *radius, *height, b.position, *sr, idx_a, idx_b)
1991        }
1992        (CollisionShape::Sphere { radius: sr }, CollisionShape::Cone { radius, height }) => {
1993            cone_sphere_contact(b.position, *radius, *height, a.position, *sr, idx_b, idx_a).map(|c| Contact {
1994                body_a: idx_a,
1995                body_b: idx_b,
1996                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
1997                ..c
1998            })
1999        }
2000        (CollisionShape::Cone { radius, height }, CollisionShape::Plane { normal, d }) => {
2001            cone_plane_contact(a.position, *radius, *height, *normal, *d, idx_a, idx_b)
2002        }
2003        (CollisionShape::Plane { normal, d }, CollisionShape::Cone { radius, height }) => {
2004            cone_plane_contact(b.position, *radius, *height, *normal, *d, idx_b, idx_a).map(|c| Contact {
2005                body_a: idx_a,
2006                body_b: idx_b,
2007                normal: [-c.normal[0], -c.normal[1], -c.normal[2]],
2008                ..c
2009            })
2010        }
2011        _ => None, // Remaining pairs (plane-plane, plane-AABB, etc.) not needed
2012    }
2013}
2014
2015fn sphere_sphere(pos_a: [f32; 3], ra: f32, pos_b: [f32; 3], rb: f32, idx_a: usize, idx_b: usize) -> Option<Contact> {
2016    let dx = pos_b[0] - pos_a[0];
2017    let dy = pos_b[1] - pos_a[1];
2018    let dz = pos_b[2] - pos_a[2];
2019    let dist_sq = dx * dx + dy * dy + dz * dz;
2020    let sum_r = ra + rb;
2021    if dist_sq >= sum_r * sum_r {
2022        return None;
2023    }
2024    let dist = dist_sq.sqrt();
2025    let normal = if dist > 1e-8 {
2026        [dx / dist, dy / dist, dz / dist]
2027    } else {
2028        [0.0, 1.0, 0.0]
2029    };
2030    Some(Contact {
2031        body_a: idx_a,
2032        body_b: idx_b,
2033        normal,
2034        depth: sum_r - dist,
2035        point: [
2036            pos_a[0] + normal[0] * ra,
2037            pos_a[1] + normal[1] * ra,
2038            pos_a[2] + normal[2] * ra,
2039        ],
2040        cached_impulse: 0.0,
2041    })
2042}
2043
2044fn sphere_plane(
2045    sphere_pos: [f32; 3],
2046    radius: f32,
2047    plane_n: [f32; 3],
2048    plane_d: f32,
2049    sphere_idx: usize,
2050    plane_idx: usize,
2051    swap: bool,
2052) -> Option<Contact> {
2053    let dist = vec3_dot(plane_n, sphere_pos) + plane_d;
2054    if dist >= radius {
2055        return None;
2056    }
2057    let depth = radius - dist;
2058    let (a, b) = if swap {
2059        (plane_idx, sphere_idx)
2060    } else {
2061        (sphere_idx, plane_idx)
2062    };
2063    let normal = if swap {
2064        [-plane_n[0], -plane_n[1], -plane_n[2]]
2065    } else {
2066        plane_n
2067    };
2068    Some(Contact {
2069        body_a: a,
2070        body_b: b,
2071        normal,
2072        depth,
2073        point: [
2074            sphere_pos[0] - plane_n[0] * dist,
2075            sphere_pos[1] - plane_n[1] * dist,
2076            sphere_pos[2] - plane_n[2] * dist,
2077        ],
2078        cached_impulse: 0.0,
2079    })
2080}
2081
2082fn aabb_aabb(
2083    pos_a: [f32; 3],
2084    ha: [f32; 3],
2085    pos_b: [f32; 3],
2086    hb: [f32; 3],
2087    idx_a: usize,
2088    idx_b: usize,
2089) -> Option<Contact> {
2090    let mut overlap = [0.0f32; 3];
2091    for i in 0..3 {
2092        let gap = (pos_b[i] - pos_a[i]).abs() - (ha[i] + hb[i]);
2093        if gap > 0.0 {
2094            return None;
2095        }
2096        overlap[i] = -gap;
2097    }
2098    // Find axis of minimum penetration
2099    let mut min_axis = 0;
2100    for i in 1..3 {
2101        if overlap[i] < overlap[min_axis] {
2102            min_axis = i;
2103        }
2104    }
2105    let sign = if pos_b[min_axis] > pos_a[min_axis] { 1.0 } else { -1.0 };
2106    let mut normal = [0.0f32; 3];
2107    normal[min_axis] = sign;
2108    let mid = [
2109        (pos_a[0] + pos_b[0]) * 0.5,
2110        (pos_a[1] + pos_b[1]) * 0.5,
2111        (pos_a[2] + pos_b[2]) * 0.5,
2112    ];
2113    Some(Contact {
2114        body_a: idx_a,
2115        body_b: idx_b,
2116        normal,
2117        depth: overlap[min_axis],
2118        point: mid,
2119        cached_impulse: 0.0,
2120    })
2121}
2122
2123fn sphere_aabb(
2124    sphere_pos: [f32; 3],
2125    radius: f32,
2126    aabb_pos: [f32; 3],
2127    half: [f32; 3],
2128    sphere_idx: usize,
2129    aabb_idx: usize,
2130) -> Option<Contact> {
2131    // Clamp sphere center to AABB surface
2132    let mut closest = [0.0f32; 3];
2133    for i in 0..3 {
2134        closest[i] = sphere_pos[i].clamp(aabb_pos[i] - half[i], aabb_pos[i] + half[i]);
2135    }
2136    let dx = sphere_pos[0] - closest[0];
2137    let dy = sphere_pos[1] - closest[1];
2138    let dz = sphere_pos[2] - closest[2];
2139    let dist_sq = dx * dx + dy * dy + dz * dz;
2140    if dist_sq >= radius * radius {
2141        return None;
2142    }
2143    let dist = dist_sq.sqrt();
2144    let normal = if dist > 1e-8 {
2145        [dx / dist, dy / dist, dz / dist]
2146    } else {
2147        [0.0, 1.0, 0.0]
2148    };
2149    Some(Contact {
2150        body_a: sphere_idx,
2151        body_b: aabb_idx,
2152        normal,
2153        depth: radius - dist,
2154        point: closest,
2155        cached_impulse: 0.0,
2156    })
2157}
2158
2159// ── Capsule narrowphase ──────────────────────────────────────────────
2160
2161/// Closest point on a capsule's line segment to a point.
2162/// Capsule is Y-axis aligned with endpoints at pos +/- (0, half_height, 0).
2163fn capsule_closest_segment_point(capsule_pos: [f32; 3], half_height: f32, point: [f32; 3]) -> [f32; 3] {
2164    let dy = point[1] - capsule_pos[1];
2165    let clamped_y = dy.clamp(-half_height, half_height);
2166    [capsule_pos[0], capsule_pos[1] + clamped_y, capsule_pos[2]]
2167}
2168
2169fn capsule_sphere_contact(
2170    cap_pos: [f32; 3],
2171    cap_radius: f32,
2172    cap_half_h: f32,
2173    sphere_pos: [f32; 3],
2174    sphere_radius: f32,
2175    cap_idx: usize,
2176    sphere_idx: usize,
2177) -> Option<Contact> {
2178    let closest = capsule_closest_segment_point(cap_pos, cap_half_h, sphere_pos);
2179    sphere_sphere(closest, cap_radius, sphere_pos, sphere_radius, cap_idx, sphere_idx)
2180}
2181
2182fn capsule_plane_contact(
2183    cap_pos: [f32; 3],
2184    cap_radius: f32,
2185    cap_half_h: f32,
2186    plane_n: [f32; 3],
2187    plane_d: f32,
2188    cap_idx: usize,
2189    plane_idx: usize,
2190) -> Option<Contact> {
2191    // Test both capsule endpoints; use whichever is closer to the plane
2192    let top = [cap_pos[0], cap_pos[1] + cap_half_h, cap_pos[2]];
2193    let bot = [cap_pos[0], cap_pos[1] - cap_half_h, cap_pos[2]];
2194    let dist_top = vec3_dot(plane_n, top) + plane_d;
2195    let dist_bot = vec3_dot(plane_n, bot) + plane_d;
2196    let (closest_pt, min_dist) = if dist_top < dist_bot {
2197        (top, dist_top)
2198    } else {
2199        (bot, dist_bot)
2200    };
2201    if min_dist >= cap_radius {
2202        return None;
2203    }
2204    let depth = cap_radius - min_dist;
2205    Some(Contact {
2206        body_a: cap_idx,
2207        body_b: plane_idx,
2208        normal: plane_n,
2209        depth,
2210        point: [
2211            closest_pt[0] - plane_n[0] * min_dist,
2212            closest_pt[1] - plane_n[1] * min_dist,
2213            closest_pt[2] - plane_n[2] * min_dist,
2214        ],
2215        cached_impulse: 0.0,
2216    })
2217}
2218
2219fn capsule_aabb_contact(
2220    cap_pos: [f32; 3],
2221    cap_radius: f32,
2222    cap_half_h: f32,
2223    aabb_pos: [f32; 3],
2224    aabb_half: [f32; 3],
2225    cap_idx: usize,
2226    aabb_idx: usize,
2227) -> Option<Contact> {
2228    // Find the closest point on the capsule segment to the AABB center,
2229    // then treat as sphere-AABB with that point and the capsule radius.
2230    let seg_point = capsule_closest_segment_point(cap_pos, cap_half_h, aabb_pos);
2231    sphere_aabb(seg_point, cap_radius, aabb_pos, aabb_half, cap_idx, aabb_idx)
2232}
2233
2234// ── Cylinder narrowphase ────────────────────────────────────────────
2235
2236fn cylinder_sphere_contact(
2237    cyl_pos: [f32; 3],
2238    cyl_radius: f32,
2239    cyl_half_h: f32,
2240    sphere_pos: [f32; 3],
2241    sphere_radius: f32,
2242    cyl_idx: usize,
2243    sphere_idx: usize,
2244) -> Option<Contact> {
2245    // Decompose into radial (XZ) and axial (Y) distances
2246    let dx = sphere_pos[0] - cyl_pos[0];
2247    let dz = sphere_pos[2] - cyl_pos[2];
2248    let radial_dist = (dx * dx + dz * dz).sqrt();
2249    let dy = sphere_pos[1] - cyl_pos[1];
2250    let clamped_y = dy.clamp(-cyl_half_h, cyl_half_h);
2251
2252    // Closest point on cylinder surface
2253    let on_axis = [cyl_pos[0], cyl_pos[1] + clamped_y, cyl_pos[2]];
2254    let mut closest = on_axis;
2255
2256    if radial_dist > 1e-8 {
2257        let scale = cyl_radius / radial_dist;
2258        // Clamp to cylinder barrel
2259        if radial_dist > cyl_radius {
2260            closest[0] = cyl_pos[0] + dx * scale;
2261            closest[2] = cyl_pos[2] + dz * scale;
2262        } else {
2263            closest[0] = sphere_pos[0];
2264            closest[2] = sphere_pos[2];
2265        }
2266    }
2267
2268    // Also check cap faces
2269    let cap_overlap_y = cyl_half_h - dy.abs();
2270    let cap_overlap_r = cyl_radius - radial_dist;
2271
2272    // If sphere center is inside the cylinder projection, use axis separation
2273    if radial_dist < cyl_radius && dy.abs() < cyl_half_h {
2274        // Inside both — find minimum separation axis
2275        if cap_overlap_y < cap_overlap_r {
2276            // Push along Y
2277            let sign = if dy > 0.0 { 1.0 } else { -1.0 };
2278            let depth = cap_overlap_y + sphere_radius;
2279            let normal = [0.0, sign, 0.0];
2280            let point = [sphere_pos[0], cyl_pos[1] + cyl_half_h * sign, sphere_pos[2]];
2281            return Some(Contact {
2282                body_a: cyl_idx,
2283                body_b: sphere_idx,
2284                normal,
2285                depth,
2286                point,
2287                cached_impulse: 0.0,
2288            });
2289        } else {
2290            // Push along radial
2291            let depth = cap_overlap_r + sphere_radius;
2292            if radial_dist > 1e-8 {
2293                let nx = dx / radial_dist;
2294                let nz = dz / radial_dist;
2295                let point = [cyl_pos[0] + nx * cyl_radius, on_axis[1], cyl_pos[2] + nz * cyl_radius];
2296                return Some(Contact {
2297                    body_a: cyl_idx,
2298                    body_b: sphere_idx,
2299                    normal: [nx, 0.0, nz],
2300                    depth,
2301                    point,
2302                    cached_impulse: 0.0,
2303                });
2304            } else {
2305                return Some(Contact {
2306                    body_a: cyl_idx,
2307                    body_b: sphere_idx,
2308                    normal: [1.0, 0.0, 0.0],
2309                    depth,
2310                    point: closest,
2311                    cached_impulse: 0.0,
2312                });
2313            }
2314        }
2315    }
2316
2317    // Outside — distance from sphere center to closest point on cylinder
2318    let to_sphere = [
2319        sphere_pos[0] - closest[0],
2320        sphere_pos[1] - closest[1],
2321        sphere_pos[2] - closest[2],
2322    ];
2323    let dist = vec3_len(to_sphere);
2324    if dist >= sphere_radius {
2325        return None;
2326    }
2327    let normal = if dist > 1e-8 {
2328        vec3_scale(to_sphere, 1.0 / dist)
2329    } else {
2330        [0.0, 1.0, 0.0]
2331    };
2332    Some(Contact {
2333        body_a: cyl_idx,
2334        body_b: sphere_idx,
2335        normal,
2336        depth: sphere_radius - dist,
2337        point: closest,
2338        cached_impulse: 0.0,
2339    })
2340}
2341
2342fn cylinder_plane_contact(
2343    cyl_pos: [f32; 3],
2344    cyl_radius: f32,
2345    cyl_half_h: f32,
2346    plane_n: [f32; 3],
2347    plane_d: f32,
2348    cyl_idx: usize,
2349    plane_idx: usize,
2350) -> Option<Contact> {
2351    // Test the two most extreme points of the cylinder against the plane:
2352    // Bottom/top cap center +/- the radial extent projected onto the plane tangent.
2353    // The deepest-penetrating point on the cylinder rim against the plane.
2354    let top_center = [cyl_pos[0], cyl_pos[1] + cyl_half_h, cyl_pos[2]];
2355    let bot_center = [cyl_pos[0], cyl_pos[1] - cyl_half_h, cyl_pos[2]];
2356
2357    // Radial direction most aligned with the plane normal (in XZ)
2358    let radial_n = [plane_n[0], 0.0, plane_n[2]];
2359    let radial_len = vec3_len(radial_n);
2360    let rim_offset = if radial_len > 1e-8 {
2361        vec3_scale(radial_n, -cyl_radius / radial_len)
2362    } else {
2363        [0.0, 0.0, 0.0]
2364    };
2365
2366    // Four candidate points: top center, bot center, top rim, bot rim
2367    let candidates = [
2368        top_center,
2369        bot_center,
2370        [
2371            top_center[0] + rim_offset[0],
2372            top_center[1],
2373            top_center[2] + rim_offset[2],
2374        ],
2375        [
2376            bot_center[0] + rim_offset[0],
2377            bot_center[1],
2378            bot_center[2] + rim_offset[2],
2379        ],
2380    ];
2381
2382    let mut deepest_dist = f32::MAX;
2383    let mut deepest_pt = cyl_pos;
2384    for pt in &candidates {
2385        let dist = vec3_dot(plane_n, *pt) + plane_d;
2386        if dist < deepest_dist {
2387            deepest_dist = dist;
2388            deepest_pt = *pt;
2389        }
2390    }
2391
2392    if deepest_dist >= 0.0 {
2393        return None;
2394    }
2395    Some(Contact {
2396        body_a: cyl_idx,
2397        body_b: plane_idx,
2398        normal: plane_n,
2399        depth: -deepest_dist,
2400        point: [
2401            deepest_pt[0] - plane_n[0] * deepest_dist,
2402            deepest_pt[1] - plane_n[1] * deepest_dist,
2403            deepest_pt[2] - plane_n[2] * deepest_dist,
2404        ],
2405        cached_impulse: 0.0,
2406    })
2407}
2408
2409// ── Cone narrowphase ────────────────────────────────────────────────
2410
2411fn cone_sphere_contact(
2412    cone_pos: [f32; 3],
2413    cone_radius: f32,
2414    cone_height: f32,
2415    sphere_pos: [f32; 3],
2416    sphere_radius: f32,
2417    cone_idx: usize,
2418    sphere_idx: usize,
2419) -> Option<Contact> {
2420    // Cone is Y-axis aligned with base at cone_pos.y and tip at cone_pos.y + height.
2421    // Approximate the cone as a tapered shape. Find the closest point on the cone
2422    // surface to the sphere center.
2423    let dx = sphere_pos[0] - cone_pos[0];
2424    let dy = sphere_pos[1] - cone_pos[1];
2425    let dz = sphere_pos[2] - cone_pos[2];
2426    let radial_dist = (dx * dx + dz * dz).sqrt();
2427
2428    // Clamp Y to cone height range
2429    let clamped_y = dy.clamp(0.0, cone_height);
2430    // Radius at this height: linearly interpolates from cone_radius at base to 0 at tip
2431    let r_at_y = cone_radius * (1.0 - clamped_y / cone_height);
2432
2433    // Closest point on the cone surface
2434    let mut closest = [cone_pos[0], cone_pos[1] + clamped_y, cone_pos[2]];
2435    if radial_dist > 1e-8 {
2436        let clamp_r = radial_dist.min(r_at_y);
2437        closest[0] += dx / radial_dist * clamp_r;
2438        closest[2] += dz / radial_dist * clamp_r;
2439    }
2440
2441    // If sphere center is inside the cone, find the shallowest exit
2442    if dy >= 0.0 && dy <= cone_height && radial_dist < r_at_y {
2443        // Inside — compute distances to base, tip, and side
2444        let base_dist = dy + sphere_radius;
2445        let tip_dist = cone_height - dy; // approximate
2446        let slant_len = (cone_height * cone_height + cone_radius * cone_radius).sqrt();
2447        let side_normal_y = cone_radius / slant_len;
2448        let side_normal_r = cone_height / slant_len;
2449        let side_dist = (r_at_y - radial_dist) * side_normal_r + sphere_radius;
2450
2451        if base_dist < tip_dist && base_dist < side_dist {
2452            // Push out through base
2453            return Some(Contact {
2454                body_a: cone_idx,
2455                body_b: sphere_idx,
2456                normal: [0.0, -1.0, 0.0],
2457                depth: base_dist,
2458                point: [sphere_pos[0], cone_pos[1], sphere_pos[2]],
2459                cached_impulse: 0.0,
2460            });
2461        } else if side_dist < tip_dist {
2462            // Push out through side
2463            if radial_dist > 1e-8 {
2464                let nx = dx / radial_dist * side_normal_r;
2465                let nz = dz / radial_dist * side_normal_r;
2466                let normal = vec3_normalize([nx, side_normal_y, nz]);
2467                return Some(Contact {
2468                    body_a: cone_idx,
2469                    body_b: sphere_idx,
2470                    normal,
2471                    depth: side_dist,
2472                    point: closest,
2473                    cached_impulse: 0.0,
2474                });
2475            }
2476        }
2477        // Push out through tip area
2478        return Some(Contact {
2479            body_a: cone_idx,
2480            body_b: sphere_idx,
2481            normal: [0.0, 1.0, 0.0],
2482            depth: sphere_radius,
2483            point: [cone_pos[0], cone_pos[1] + cone_height, cone_pos[2]],
2484            cached_impulse: 0.0,
2485        });
2486    }
2487
2488    let to_sphere = [
2489        sphere_pos[0] - closest[0],
2490        sphere_pos[1] - closest[1],
2491        sphere_pos[2] - closest[2],
2492    ];
2493    let dist = vec3_len(to_sphere);
2494    if dist >= sphere_radius {
2495        return None;
2496    }
2497    let normal = if dist > 1e-8 {
2498        vec3_scale(to_sphere, 1.0 / dist)
2499    } else {
2500        [0.0, 1.0, 0.0]
2501    };
2502    Some(Contact {
2503        body_a: cone_idx,
2504        body_b: sphere_idx,
2505        normal,
2506        depth: sphere_radius - dist,
2507        point: closest,
2508        cached_impulse: 0.0,
2509    })
2510}
2511
2512fn cone_plane_contact(
2513    cone_pos: [f32; 3],
2514    cone_radius: f32,
2515    cone_height: f32,
2516    plane_n: [f32; 3],
2517    plane_d: f32,
2518    cone_idx: usize,
2519    plane_idx: usize,
2520) -> Option<Contact> {
2521    // Test tip and base rim extremity against the plane.
2522    let tip = [cone_pos[0], cone_pos[1] + cone_height, cone_pos[2]];
2523
2524    // Base center
2525    let base = cone_pos;
2526
2527    // Find the base rim point most penetrating the plane
2528    let radial_n = [plane_n[0], 0.0, plane_n[2]];
2529    let radial_len = vec3_len(radial_n);
2530    let rim_offset = if radial_len > 1e-8 {
2531        vec3_scale(radial_n, -cone_radius / radial_len)
2532    } else {
2533        [0.0, 0.0, 0.0]
2534    };
2535    let rim_pt = [base[0] + rim_offset[0], base[1], base[2] + rim_offset[2]];
2536
2537    let candidates = [tip, base, rim_pt];
2538    let mut deepest_dist = f32::MAX;
2539    let mut deepest_pt = cone_pos;
2540    for pt in &candidates {
2541        let dist = vec3_dot(plane_n, *pt) + plane_d;
2542        if dist < deepest_dist {
2543            deepest_dist = dist;
2544            deepest_pt = *pt;
2545        }
2546    }
2547
2548    if deepest_dist >= 0.0 {
2549        return None;
2550    }
2551    Some(Contact {
2552        body_a: cone_idx,
2553        body_b: plane_idx,
2554        normal: plane_n,
2555        depth: -deepest_dist,
2556        point: [
2557            deepest_pt[0] - plane_n[0] * deepest_dist,
2558            deepest_pt[1] - plane_n[1] * deepest_dist,
2559            deepest_pt[2] - plane_n[2] * deepest_dist,
2560        ],
2561        cached_impulse: 0.0,
2562    })
2563}
2564
2565// ── Island splitting (union-find) ─────────────────────────────────────
2566
2567/// Union-Find island detection. O(contacts * alpha(n)) ~ O(contacts).
2568/// Bodies sharing contacts form islands. Islands are independent and can be
2569/// solved in parallel (with rayon) or sequentially without cross-island deps.
2570fn build_islands(body_count: usize, contacts: &[Contact]) -> Vec<Vec<usize>> {
2571    let mut parent: Vec<usize> = (0..body_count).collect();
2572    let mut rank: Vec<u8> = vec![0; body_count];
2573
2574    fn find(parent: &mut [usize], mut x: usize) -> usize {
2575        while parent[x] != x {
2576            parent[x] = parent[parent[x]]; // path compression
2577            x = parent[x];
2578        }
2579        x
2580    }
2581
2582    fn union(parent: &mut [usize], rank: &mut [u8], a: usize, b: usize) {
2583        let ra = find(parent, a);
2584        let rb = find(parent, b);
2585        if ra == rb {
2586            return;
2587        }
2588        if rank[ra] < rank[rb] {
2589            parent[ra] = rb;
2590        } else if rank[ra] > rank[rb] {
2591            parent[rb] = ra;
2592        } else {
2593            parent[rb] = ra;
2594            rank[ra] += 1;
2595        }
2596    }
2597
2598    // Union all contact pairs
2599    for c in contacts {
2600        union(&mut parent, &mut rank, c.body_a, c.body_b);
2601    }
2602
2603    // Group contact indices by island root
2604    let mut island_map: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
2605    for (ci, c) in contacts.iter().enumerate() {
2606        let root = find(&mut parent, c.body_a);
2607        island_map.entry(root).or_default().push(ci);
2608    }
2609
2610    island_map.into_values().collect()
2611}
2612
2613/// Apply warm-start: pre-apply cached impulses from previous frame contacts.
2614/// Matching is by body pair (order-independent). 80% damped warm-start to
2615/// prevent overshoot while still converging faster than cold start.
2616fn warm_start_contacts(contacts: &mut [Contact], prev_contacts: &[Contact], bodies: &mut [RigidBody]) {
2617    // Build HashMap for O(1) lookup instead of O(n) linear scan per contact.
2618    // Key: canonical pair (min, max) so order doesn't matter.
2619    use std::collections::HashMap;
2620    let mut cache: HashMap<(usize, usize), f32> = HashMap::with_capacity(prev_contacts.len());
2621    for pc in prev_contacts {
2622        let key = if pc.body_a <= pc.body_b {
2623            (pc.body_a, pc.body_b)
2624        } else {
2625            (pc.body_b, pc.body_a)
2626        };
2627        cache.insert(key, pc.cached_impulse);
2628    }
2629
2630    for c in contacts.iter_mut() {
2631        let key = if c.body_a <= c.body_b {
2632            (c.body_a, c.body_b)
2633        } else {
2634            (c.body_b, c.body_a)
2635        };
2636        let cached = cache.get(&key).copied().unwrap_or(0.0);
2637
2638        if cached.abs() > 1e-6 {
2639            c.cached_impulse = cached;
2640            let n = c.normal;
2641            let inv_sum = bodies[c.body_a].inv_mass + bodies[c.body_b].inv_mass;
2642            if inv_sum > 1e-8 {
2643                let j = cached * 0.8;
2644                bodies[c.body_a].velocity[0] += n[0] * j * bodies[c.body_a].inv_mass;
2645                bodies[c.body_a].velocity[1] += n[1] * j * bodies[c.body_a].inv_mass;
2646                bodies[c.body_a].velocity[2] += n[2] * j * bodies[c.body_a].inv_mass;
2647                bodies[c.body_b].velocity[0] -= n[0] * j * bodies[c.body_b].inv_mass;
2648                bodies[c.body_b].velocity[1] -= n[1] * j * bodies[c.body_b].inv_mass;
2649                bodies[c.body_b].velocity[2] -= n[2] * j * bodies[c.body_b].inv_mass;
2650            }
2651        }
2652    }
2653}
2654
2655// ── Contact resolution (sequential impulse) ──────────────────────────
2656
2657fn resolve_contact(a: &mut RigidBody, b: &mut RigidBody, contact: &mut Contact) {
2658    let n = contact.normal;
2659
2660    // Relative velocity
2661    let v_rel = [
2662        a.velocity[0] - b.velocity[0],
2663        a.velocity[1] - b.velocity[1],
2664        a.velocity[2] - b.velocity[2],
2665    ];
2666    let v_along_normal = vec3_dot(v_rel, n);
2667
2668    // Bodies separating — no impulse needed
2669    if v_along_normal > 0.0 {
2670        return;
2671    }
2672
2673    // Combined restitution (min of both)
2674    let e = a.restitution.min(b.restitution);
2675
2676    // Impulse magnitude: j = -(1+e) * v_rel·n / (1/m_a + 1/m_b)
2677    let inv_mass_sum = a.inv_mass + b.inv_mass;
2678    if inv_mass_sum < 1e-8 {
2679        return; // Both static
2680    }
2681    let j = -(1.0 + e) * v_along_normal / inv_mass_sum;
2682    contact.cached_impulse = j; // Cache for warm-start next frame
2683
2684    // Apply impulse
2685    a.velocity[0] += n[0] * j * a.inv_mass;
2686    a.velocity[1] += n[1] * j * a.inv_mass;
2687    a.velocity[2] += n[2] * j * a.inv_mass;
2688
2689    b.velocity[0] -= n[0] * j * b.inv_mass;
2690    b.velocity[1] -= n[1] * j * b.inv_mass;
2691    b.velocity[2] -= n[2] * j * b.inv_mass;
2692
2693    // Positional correction (prevent sinking) — Baumgarte stabilization
2694    let correction_pct = 0.8;
2695    let slop = 0.01;
2696    let correction = (contact.depth - slop).max(0.0) * correction_pct / inv_mass_sum;
2697
2698    a.position[0] -= n[0] * correction * a.inv_mass;
2699    a.position[1] -= n[1] * correction * a.inv_mass;
2700    a.position[2] -= n[2] * correction * a.inv_mass;
2701
2702    b.position[0] += n[0] * correction * b.inv_mass;
2703    b.position[1] += n[1] * correction * b.inv_mass;
2704    b.position[2] += n[2] * correction * b.inv_mass;
2705
2706    // Friction (tangent impulse)
2707    let tangent = [
2708        v_rel[0] - n[0] * v_along_normal,
2709        v_rel[1] - n[1] * v_along_normal,
2710        v_rel[2] - n[2] * v_along_normal,
2711    ];
2712    let tangent_len = vec3_len(tangent);
2713    if tangent_len > 1e-8 {
2714        let t = vec3_scale(tangent, 1.0 / tangent_len);
2715        let v_along_t = vec3_dot(v_rel, t);
2716        let mu = (a.friction + b.friction) * 0.5;
2717        let jt = (-v_along_t / inv_mass_sum).clamp(-j.abs() * mu, j.abs() * mu);
2718
2719        a.velocity[0] += t[0] * jt * a.inv_mass;
2720        a.velocity[1] += t[1] * jt * a.inv_mass;
2721        a.velocity[2] += t[2] * jt * a.inv_mass;
2722
2723        b.velocity[0] -= t[0] * jt * b.inv_mass;
2724        b.velocity[1] -= t[1] * jt * b.inv_mass;
2725        b.velocity[2] -= t[2] * jt * b.inv_mass;
2726    }
2727}
2728
2729// ── Raycast helpers ──────────────────────────────────────────────────
2730
2731fn ray_sphere(origin: [f32; 3], dir: [f32; 3], center: [f32; 3], radius: f32) -> Option<(f32, [f32; 3], [f32; 3])> {
2732    let oc = [origin[0] - center[0], origin[1] - center[1], origin[2] - center[2]];
2733    let b = vec3_dot(oc, dir);
2734    let c = vec3_dot(oc, oc) - radius * radius;
2735    let disc = b * b - c;
2736    if disc < 0.0 {
2737        return None;
2738    }
2739    let t = -b - disc.sqrt();
2740    if t < 0.0 {
2741        return None;
2742    }
2743    let point = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
2744    let normal = vec3_normalize([point[0] - center[0], point[1] - center[1], point[2] - center[2]]);
2745    Some((t, normal, point))
2746}
2747
2748fn ray_plane(origin: [f32; 3], dir: [f32; 3], normal: [f32; 3], d: f32) -> Option<(f32, [f32; 3], [f32; 3])> {
2749    let denom = vec3_dot(normal, dir);
2750    if denom.abs() < 1e-8 {
2751        return None;
2752    }
2753    let t = -(vec3_dot(normal, origin) + d) / denom;
2754    if t < 0.0 {
2755        return None;
2756    }
2757    let point = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
2758    Some((t, normal, point))
2759}
2760
2761fn ray_aabb(origin: [f32; 3], dir: [f32; 3], center: [f32; 3], half: [f32; 3]) -> Option<(f32, [f32; 3], [f32; 3])> {
2762    let mut tmin = f32::NEG_INFINITY;
2763    let mut tmax = f32::INFINITY;
2764    let mut hit_axis = 0usize;
2765    let mut hit_sign = 1.0f32;
2766
2767    for i in 0..3 {
2768        if dir[i].abs() < 1e-8 {
2769            if origin[i] < center[i] - half[i] || origin[i] > center[i] + half[i] {
2770                return None;
2771            }
2772        } else {
2773            let inv_d = 1.0 / dir[i];
2774            let t1 = (center[i] - half[i] - origin[i]) * inv_d;
2775            let t2 = (center[i] + half[i] - origin[i]) * inv_d;
2776            let (t_near, t_far) = if t1 < t2 { (t1, t2) } else { (t2, t1) };
2777            if t_near > tmin {
2778                tmin = t_near;
2779                hit_axis = i;
2780                hit_sign = if dir[i] > 0.0 { -1.0 } else { 1.0 };
2781            }
2782            tmax = tmax.min(t_far);
2783            if tmin > tmax {
2784                return None;
2785            }
2786        }
2787    }
2788    if tmin < 0.0 {
2789        return None;
2790    }
2791    let point = [
2792        origin[0] + dir[0] * tmin,
2793        origin[1] + dir[1] * tmin,
2794        origin[2] + dir[2] * tmin,
2795    ];
2796    let mut normal = [0.0f32; 3];
2797    normal[hit_axis] = hit_sign;
2798    Some((tmin, normal, point))
2799}
2800
2801// ── Raycast helpers (new shapes) ─────────────────────────────────────
2802
2803fn ray_capsule(
2804    origin: [f32; 3],
2805    dir: [f32; 3],
2806    center: [f32; 3],
2807    radius: f32,
2808    half_height: f32,
2809) -> Option<(f32, [f32; 3], [f32; 3])> {
2810    // A capsule is the union of a cylinder body and two hemisphere caps.
2811    // Strategy: test the infinite cylinder (XZ cross-section) first, then the
2812    // two hemisphere caps if the cylinder hit is outside the axial range.
2813
2814    // 1. Ray vs infinite cylinder along Y axis
2815    let ox = origin[0] - center[0];
2816    let oz = origin[2] - center[2];
2817    let a = dir[0] * dir[0] + dir[2] * dir[2];
2818    let b = ox * dir[0] + oz * dir[2];
2819    let c = ox * ox + oz * oz - radius * radius;
2820
2821    let mut best: Option<(f32, [f32; 3], [f32; 3])> = None;
2822
2823    if a > 1e-12 {
2824        let disc = b * b - a * c;
2825        if disc >= 0.0 {
2826            let sqrt_disc = disc.sqrt();
2827            for &t in &[(-b - sqrt_disc) / a, (-b + sqrt_disc) / a] {
2828                if t < 0.0 {
2829                    continue;
2830                }
2831                let hit_y = origin[1] + dir[1] * t - center[1];
2832                if hit_y >= -half_height && hit_y <= half_height {
2833                    let point = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
2834                    let normal = vec3_normalize([point[0] - center[0], 0.0, point[2] - center[2]]);
2835                    if best.as_ref().map_or(true, |prev| t < prev.0) {
2836                        best = Some((t, normal, point));
2837                    }
2838                    break; // first hit on the cylinder is closest
2839                }
2840            }
2841        }
2842    }
2843
2844    // 2. Ray vs top hemisphere (center at center + half_height)
2845    let top = [center[0], center[1] + half_height, center[2]];
2846    if let Some((t, n, p)) = ray_sphere(origin, dir, top, radius) {
2847        if p[1] >= top[1] {
2848            // only the upper hemisphere
2849            if best.as_ref().map_or(true, |prev| t < prev.0) {
2850                best = Some((t, n, p));
2851            }
2852        }
2853    }
2854
2855    // 3. Ray vs bottom hemisphere
2856    let bot = [center[0], center[1] - half_height, center[2]];
2857    if let Some((t, n, p)) = ray_sphere(origin, dir, bot, radius) {
2858        if p[1] <= bot[1] {
2859            // only the lower hemisphere
2860            if best.as_ref().map_or(true, |prev| t < prev.0) {
2861                best = Some((t, n, p));
2862            }
2863        }
2864    }
2865
2866    best
2867}
2868
2869fn ray_cylinder(
2870    origin: [f32; 3],
2871    dir: [f32; 3],
2872    center: [f32; 3],
2873    radius: f32,
2874    half_height: f32,
2875) -> Option<(f32, [f32; 3], [f32; 3])> {
2876    let mut best: Option<(f32, [f32; 3], [f32; 3])> = None;
2877
2878    // 1. Ray vs infinite cylinder (XZ cross-section)
2879    let ox = origin[0] - center[0];
2880    let oz = origin[2] - center[2];
2881    let a = dir[0] * dir[0] + dir[2] * dir[2];
2882    let b = ox * dir[0] + oz * dir[2];
2883    let c = ox * ox + oz * oz - radius * radius;
2884
2885    if a > 1e-12 {
2886        let disc = b * b - a * c;
2887        if disc >= 0.0 {
2888            let sqrt_disc = disc.sqrt();
2889            for &t in &[(-b - sqrt_disc) / a, (-b + sqrt_disc) / a] {
2890                if t < 0.0 {
2891                    continue;
2892                }
2893                let hit_y = origin[1] + dir[1] * t - center[1];
2894                if hit_y >= -half_height && hit_y <= half_height {
2895                    let point = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
2896                    let normal = vec3_normalize([point[0] - center[0], 0.0, point[2] - center[2]]);
2897                    if best.as_ref().map_or(true, |prev| t < prev.0) {
2898                        best = Some((t, normal, point));
2899                    }
2900                    break;
2901                }
2902            }
2903        }
2904    }
2905
2906    // 2. Ray vs top cap (plane y = center.y + half_height)
2907    if dir[1].abs() > 1e-8 {
2908        let t_top = (center[1] + half_height - origin[1]) / dir[1];
2909        if t_top >= 0.0 {
2910            let px = origin[0] + dir[0] * t_top - center[0];
2911            let pz = origin[2] + dir[2] * t_top - center[2];
2912            if px * px + pz * pz <= radius * radius {
2913                if best.as_ref().map_or(true, |prev| t_top < prev.0) {
2914                    let point = [
2915                        origin[0] + dir[0] * t_top,
2916                        origin[1] + dir[1] * t_top,
2917                        origin[2] + dir[2] * t_top,
2918                    ];
2919                    best = Some((t_top, [0.0, 1.0, 0.0], point));
2920                }
2921            }
2922        }
2923        // 3. Ray vs bottom cap
2924        let t_bot = (center[1] - half_height - origin[1]) / dir[1];
2925        if t_bot >= 0.0 {
2926            let px = origin[0] + dir[0] * t_bot - center[0];
2927            let pz = origin[2] + dir[2] * t_bot - center[2];
2928            if px * px + pz * pz <= radius * radius {
2929                if best.as_ref().map_or(true, |prev| t_bot < prev.0) {
2930                    let point = [
2931                        origin[0] + dir[0] * t_bot,
2932                        origin[1] + dir[1] * t_bot,
2933                        origin[2] + dir[2] * t_bot,
2934                    ];
2935                    best = Some((t_bot, [0.0, -1.0, 0.0], point));
2936                }
2937            }
2938        }
2939    }
2940
2941    best
2942}
2943
2944fn ray_cone(
2945    origin: [f32; 3],
2946    dir: [f32; 3],
2947    center: [f32; 3],
2948    radius: f32,
2949    height: f32,
2950) -> Option<(f32, [f32; 3], [f32; 3])> {
2951    // Cone with base at center.y, tip at center.y + height. Y-axis aligned.
2952    // At height y, the cone radius = radius * (1 - y/height).
2953    // Parametric: x^2 + z^2 = (radius * (1 - (y - center.y) / height))^2
2954    // Let oy = origin.y - center.y, dy = dir.y
2955    // Let k = radius / height
2956    // x^2 + z^2 = k^2 * (height - (oy + dy*t))^2
2957
2958    let oy = origin[1] - center[1];
2959    let ox = origin[0] - center[0];
2960    let oz = origin[2] - center[2];
2961    let k = radius / height;
2962    let k2 = k * k;
2963
2964    // h_t = height - (oy + dir.y * t) = (height - oy) - dir.y * t
2965    let h0 = height - oy;
2966    // (ox + dx*t)^2 + (oz + dz*t)^2 = k2 * (h0 - dy*t)^2
2967    // Expand into at^2 + bt + c = 0
2968    let a = dir[0] * dir[0] + dir[2] * dir[2] - k2 * dir[1] * dir[1];
2969    let b = ox * dir[0] + oz * dir[2] + k2 * dir[1] * h0; // half of the real 2b
2970    let c = ox * ox + oz * oz - k2 * h0 * h0;
2971
2972    let mut best: Option<(f32, [f32; 3], [f32; 3])> = None;
2973
2974    // Solve quadratic
2975    let disc = b * b - a * c;
2976    if disc >= 0.0 && a.abs() > 1e-12 {
2977        let sqrt_disc = disc.sqrt();
2978        for &t in &[(-b - sqrt_disc) / a, (-b + sqrt_disc) / a] {
2979            if t < 0.0 {
2980                continue;
2981            }
2982            let hit_y = oy + dir[1] * t;
2983            if hit_y >= 0.0 && hit_y <= height {
2984                let point = [origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t];
2985                // Normal: gradient of x^2 + z^2 - k2*(height-y)^2 = 0
2986                let px = point[0] - center[0];
2987                let pz = point[2] - center[2];
2988                let py_cone = height - hit_y;
2989                let normal = vec3_normalize([2.0 * px, 2.0 * k2 * py_cone, 2.0 * pz]);
2990                if best.as_ref().map_or(true, |prev| t < prev.0) {
2991                    best = Some((t, normal, point));
2992                }
2993                break;
2994            }
2995        }
2996    }
2997
2998    // Ray vs base cap (plane at y = center.y)
2999    if dir[1].abs() > 1e-8 {
3000        let t_base = -oy / dir[1];
3001        if t_base >= 0.0 {
3002            let px = ox + dir[0] * t_base;
3003            let pz = oz + dir[2] * t_base;
3004            if px * px + pz * pz <= radius * radius {
3005                if best.as_ref().map_or(true, |prev| t_base < prev.0) {
3006                    let point = [
3007                        origin[0] + dir[0] * t_base,
3008                        origin[1] + dir[1] * t_base,
3009                        origin[2] + dir[2] * t_base,
3010                    ];
3011                    best = Some((t_base, [0.0, -1.0, 0.0], point));
3012                }
3013            }
3014        }
3015    }
3016
3017    best
3018}
3019
3020// ── Vector math ──────────────────────────────────────────────────────
3021
3022/// Atomic f32 addition via compare-exchange on AtomicU32 (bit-cast).
3023/// Used by the Parallel Jacobi solver for lock-free impulse accumulation.
3024#[cfg(feature = "parallel-physics")]
3025fn atomic_f32_add(atom: &std::sync::atomic::AtomicU32, val: f32) {
3026    use std::sync::atomic::Ordering;
3027    loop {
3028        let bits = atom.load(Ordering::Relaxed);
3029        let current = f32::from_bits(bits);
3030        let new = current + val;
3031        match atom.compare_exchange_weak(bits, new.to_bits(), Ordering::Relaxed, Ordering::Relaxed) {
3032            Ok(_) => break,
3033            Err(_) => {} // Retry on contention
3034        }
3035    }
3036}
3037
3038fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
3039    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
3040}
3041
3042fn vec3_len(v: [f32; 3]) -> f32 {
3043    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
3044}
3045
3046fn vec3_scale(v: [f32; 3], s: f32) -> [f32; 3] {
3047    [v[0] * s, v[1] * s, v[2] * s]
3048}
3049
3050fn vec3_normalize(v: [f32; 3]) -> [f32; 3] {
3051    let len = vec3_len(v);
3052    if len > 1e-8 {
3053        vec3_scale(v, 1.0 / len)
3054    } else {
3055        [0.0, 1.0, 0.0]
3056    }
3057}
3058
3059#[cfg(test)]
3060mod tests {
3061    use super::*;
3062
3063    #[test]
3064    fn sphere_falls_under_gravity() {
3065        let mut world = PhysicsWorld::new();
3066        let id = world
3067            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 10.0, 0.0]));
3068        world.step(1.0 / 60.0);
3069        let body = world.body(id).unwrap();
3070        assert!(body.position[1] < 10.0, "Sphere should fall");
3071        assert!(body.velocity[1] < 0.0, "Velocity should be negative");
3072    }
3073
3074    #[test]
3075    fn sphere_bounces_on_plane() {
3076        let mut world = PhysicsWorld::new();
3077        let sphere = world.add_body(
3078            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 })
3079                .with_position([0.0, 0.4, 0.0])
3080                .with_restitution(1.0),
3081        );
3082        world.add_body(RigidBody::fixed(CollisionShape::Plane {
3083            normal: [0.0, 1.0, 0.0],
3084            d: 0.0,
3085        }));
3086        // Give it downward velocity
3087        world.body_mut(sphere).unwrap().velocity = [0.0, -5.0, 0.0];
3088        world.step(1.0 / 60.0);
3089        let body = world.body(sphere).unwrap();
3090        // After collision with restitution=1.0, velocity should reverse
3091        assert!(
3092            body.velocity[1] > 0.0,
3093            "Sphere should bounce up, got {}",
3094            body.velocity[1]
3095        );
3096    }
3097
3098    #[test]
3099    fn static_bodies_dont_move() {
3100        let mut world = PhysicsWorld::new();
3101        let id = world.add_body(RigidBody::fixed(CollisionShape::Plane {
3102            normal: [0.0, 1.0, 0.0],
3103            d: 0.0,
3104        }));
3105        world.step(1.0);
3106        let body = world.body(id).unwrap();
3107        assert_eq!(body.position, [0.0, 0.0, 0.0]);
3108    }
3109
3110    #[test]
3111    fn sphere_sphere_collision() {
3112        // Two overlapping spheres — verify contact detection and impulse exchange
3113        let pos_a = [0.0f32, 0.0, 0.0];
3114        let pos_b = [0.8f32, 0.0, 0.0];
3115        let contact = sphere_sphere(pos_a, 0.5, pos_b, 0.5, 0, 1);
3116        assert!(
3117            contact.is_some(),
3118            "Spheres at distance 0.8 with radii 0.5+0.5 should overlap"
3119        );
3120        let c = contact.unwrap();
3121        assert!(c.depth > 0.0, "Penetration depth should be positive");
3122        assert!(c.normal[0] > 0.0, "Normal should point from A to B (positive X)");
3123    }
3124
3125    #[test]
3126    fn aabb_collision() {
3127        let mut world = PhysicsWorld::new();
3128        world.gravity = [0.0, 0.0, 0.0];
3129        let a = world.add_body(
3130            RigidBody::dynamic(
3131                1.0,
3132                CollisionShape::Aabb {
3133                    half_extents: [0.5, 0.5, 0.5],
3134                },
3135            )
3136            .with_position([0.0, 0.0, 0.0]),
3137        );
3138        let _b = world.add_body(
3139            RigidBody::dynamic(
3140                1.0,
3141                CollisionShape::Aabb {
3142                    half_extents: [0.5, 0.5, 0.5],
3143                },
3144            )
3145            .with_position([0.9, 0.0, 0.0]),
3146        );
3147        world.body_mut(a).unwrap().velocity = [1.0, 0.0, 0.0];
3148        world.step(1.0 / 60.0);
3149        assert!(!world.contacts().is_empty(), "Should detect AABB overlap");
3150    }
3151
3152    #[test]
3153    fn raycast_hits_sphere() {
3154        let mut world = PhysicsWorld::new();
3155        world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, -5.0]));
3156        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3157        assert!(hit.is_some(), "Ray should hit the sphere");
3158        let h = hit.unwrap();
3159        assert!(
3160            (h.distance - 4.5).abs() < 0.01,
3161            "Distance should be ~4.5, got {}",
3162            h.distance
3163        );
3164    }
3165
3166    #[test]
3167    fn raycast_misses() {
3168        let mut world = PhysicsWorld::new();
3169        world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([10.0, 0.0, 0.0]));
3170        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3171        assert!(hit.is_none(), "Ray should miss");
3172    }
3173
3174    #[test]
3175    fn remove_body() {
3176        let mut world = PhysicsWorld::new();
3177        let id = world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }));
3178        assert_eq!(world.body_count(), 1);
3179        assert!(world.remove_body(id));
3180        assert_eq!(world.body_count(), 0);
3181        assert!(!world.remove_body(id)); // double remove returns false
3182    }
3183
3184    #[test]
3185    fn zero_dt_is_noop() {
3186        let mut world = PhysicsWorld::new();
3187        let id = world
3188            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 5.0, 0.0]));
3189        world.step(0.0);
3190        let body = world.body(id).unwrap();
3191        assert_eq!(body.position[1], 5.0);
3192    }
3193
3194    #[test]
3195    fn raycast_plane() {
3196        let mut world = PhysicsWorld::new();
3197        world.add_body(RigidBody::fixed(CollisionShape::Plane {
3198            normal: [0.0, 1.0, 0.0],
3199            d: 0.0,
3200        }));
3201        let hit = world.raycast([0.0, 5.0, 0.0], [0.0, -1.0, 0.0], 100.0);
3202        assert!(hit.is_some());
3203        let h = hit.unwrap();
3204        assert!((h.distance - 5.0).abs() < 0.01);
3205    }
3206
3207    #[test]
3208    fn sphere_aabb_collision() {
3209        let contact = sphere_aabb([0.0, 0.0, 0.0], 0.5, [0.8, 0.0, 0.0], [0.5, 0.5, 0.5], 0, 1);
3210        assert!(contact.is_some(), "Sphere-AABB should detect overlap");
3211    }
3212
3213    // ── Capsule tests ───────────────────────────────────────────────
3214
3215    #[test]
3216    fn capsule_sphere_contact_overlap() {
3217        // Capsule at origin (radius=0.5, half_height=1.0), sphere at (1.0, 0.0, 0.0) radius=0.6
3218        // Capsule segment closest to sphere is at (0, 0, 0) — distance is 1.0, sum radii 1.1
3219        let contact = capsule_sphere_contact([0.0, 0.0, 0.0], 0.5, 1.0, [1.0, 0.0, 0.0], 0.6, 0, 1);
3220        assert!(
3221            contact.is_some(),
3222            "Capsule-sphere should overlap at distance 1.0 with radii sum 1.1"
3223        );
3224        let c = contact.unwrap();
3225        assert!(c.depth > 0.0);
3226        assert!(c.normal[0] > 0.0, "Normal should point from capsule to sphere (+X)");
3227    }
3228
3229    #[test]
3230    fn capsule_sphere_contact_miss() {
3231        let contact = capsule_sphere_contact([0.0, 0.0, 0.0], 0.3, 1.0, [5.0, 0.0, 0.0], 0.3, 0, 1);
3232        assert!(
3233            contact.is_none(),
3234            "Should not overlap at distance 5.0 with radii sum 0.6"
3235        );
3236    }
3237
3238    #[test]
3239    fn capsule_sphere_contact_along_axis() {
3240        // Sphere above capsule tip — should still detect via hemisphere logic
3241        let contact = capsule_sphere_contact([0.0, 0.0, 0.0], 0.5, 1.0, [0.0, 1.8, 0.0], 0.5, 0, 1);
3242        assert!(contact.is_some(), "Sphere near top cap should overlap");
3243    }
3244
3245    #[test]
3246    fn capsule_plane_contact_resting() {
3247        // Capsule at y=0.5, half_height=1.0, radius=0.3 — bottom at y=-0.5
3248        // Plane at y=0 — bottom endpoint at -0.5, closest point at -0.5, dist = -0.5
3249        // Since dist(-0.5) < radius(0.3) is not the test — dist to plane is -0.5, and -0.5 < 0.3? No.
3250        // Let's place capsule so it actually intersects.
3251        // Capsule at y=0.2, half_height=0.5, radius=0.3 — bottom at y=-0.3
3252        // Distance of bottom to plane = -0.3. Since -0.3 < 0.3, overlap.
3253        let contact = capsule_plane_contact([0.0, 0.2, 0.0], 0.3, 0.5, [0.0, 1.0, 0.0], 0.0, 0, 1);
3254        assert!(contact.is_some(), "Capsule bottom should penetrate the ground plane");
3255        let c = contact.unwrap();
3256        assert!(c.depth > 0.0);
3257    }
3258
3259    #[test]
3260    fn capsule_aabb_contact_overlap() {
3261        let contact = capsule_aabb_contact([0.0, 0.0, 0.0], 0.5, 1.0, [0.8, 0.0, 0.0], [0.5, 0.5, 0.5], 0, 1);
3262        assert!(contact.is_some(), "Capsule-AABB should overlap");
3263    }
3264
3265    // ── Cylinder tests ──────────────────────────────────────────────
3266
3267    #[test]
3268    fn cylinder_sphere_contact_radial() {
3269        // Cylinder at origin, radius=0.5, half_height=1.0
3270        // Sphere at (0.8, 0.0, 0.0) radius=0.5 — should overlap radially
3271        let contact = cylinder_sphere_contact([0.0, 0.0, 0.0], 0.5, 1.0, [0.8, 0.0, 0.0], 0.5, 0, 1);
3272        assert!(contact.is_some(), "Cylinder-sphere should detect radial overlap");
3273        let c = contact.unwrap();
3274        assert!(c.depth > 0.0);
3275    }
3276
3277    #[test]
3278    fn cylinder_sphere_contact_above() {
3279        // Sphere above the cylinder cap, out of range
3280        let contact = cylinder_sphere_contact([0.0, 0.0, 0.0], 0.5, 1.0, [0.0, 5.0, 0.0], 0.3, 0, 1);
3281        assert!(contact.is_none(), "Sphere far above cylinder should not overlap");
3282    }
3283
3284    #[test]
3285    fn cylinder_plane_contact_resting() {
3286        // Cylinder at y=0.5, half_height=1.0 — bottom face at y=-0.5
3287        // Plane at y=0 — bottom face penetrates by 0.5
3288        let contact = cylinder_plane_contact([0.0, 0.5, 0.0], 0.5, 1.0, [0.0, 1.0, 0.0], 0.0, 0, 1);
3289        assert!(contact.is_some(), "Cylinder bottom face should penetrate ground plane");
3290        let c = contact.unwrap();
3291        assert!(c.depth > 0.0);
3292    }
3293
3294    #[test]
3295    fn cylinder_plane_contact_floating() {
3296        // Cylinder at y=5.0 — well above the plane
3297        let contact = cylinder_plane_contact([0.0, 5.0, 0.0], 0.5, 1.0, [0.0, 1.0, 0.0], 0.0, 0, 1);
3298        assert!(contact.is_none(), "Cylinder above plane should not overlap");
3299    }
3300
3301    // ── Cone tests ──────────────────────────────────────────────────
3302
3303    #[test]
3304    fn cone_sphere_contact_near_base() {
3305        // Cone at origin, radius=1.0, height=2.0
3306        // Sphere at (1.2, 0.0, 0.0) radius=0.5 — near the base edge
3307        let contact = cone_sphere_contact([0.0, 0.0, 0.0], 1.0, 2.0, [1.2, 0.0, 0.0], 0.5, 0, 1);
3308        assert!(contact.is_some(), "Sphere near cone base should overlap");
3309        let c = contact.unwrap();
3310        assert!(c.depth > 0.0);
3311    }
3312
3313    #[test]
3314    fn cone_sphere_contact_miss() {
3315        let contact = cone_sphere_contact([0.0, 0.0, 0.0], 0.5, 2.0, [5.0, 0.0, 0.0], 0.3, 0, 1);
3316        assert!(contact.is_none(), "Sphere far from cone should not overlap");
3317    }
3318
3319    #[test]
3320    fn cone_plane_contact_resting_on_base() {
3321        // Cone at y=0, height=2.0 — base at y=0, tip at y=2
3322        // Plane at y=0 — base sits exactly on plane (no penetration)
3323        // Move cone down slightly so it penetrates
3324        let contact = cone_plane_contact([0.0, -0.1, 0.0], 1.0, 2.0, [0.0, 1.0, 0.0], 0.0, 0, 1);
3325        assert!(contact.is_some(), "Cone base should penetrate ground plane");
3326        let c = contact.unwrap();
3327        assert!(c.depth > 0.0);
3328    }
3329
3330    #[test]
3331    fn cone_plane_contact_floating() {
3332        let contact = cone_plane_contact([0.0, 5.0, 0.0], 0.5, 1.0, [0.0, 1.0, 0.0], 0.0, 0, 1);
3333        assert!(contact.is_none(), "Cone above plane should not overlap");
3334    }
3335
3336    // ── Raycast new shapes ──────────────────────────────────────────
3337
3338    #[test]
3339    fn raycast_capsule_side() {
3340        let mut world = PhysicsWorld::new();
3341        world.add_body(
3342            RigidBody::dynamic(
3343                1.0,
3344                CollisionShape::Capsule {
3345                    radius: 0.5,
3346                    half_height: 1.0,
3347                },
3348            )
3349            .with_position([0.0, 0.0, -5.0]),
3350        );
3351        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3352        assert!(hit.is_some(), "Ray should hit capsule");
3353        let h = hit.unwrap();
3354        assert!(
3355            (h.distance - 4.5).abs() < 0.01,
3356            "Distance should be ~4.5, got {}",
3357            h.distance
3358        );
3359    }
3360
3361    #[test]
3362    fn raycast_capsule_misses() {
3363        let mut world = PhysicsWorld::new();
3364        world.add_body(
3365            RigidBody::dynamic(
3366                1.0,
3367                CollisionShape::Capsule {
3368                    radius: 0.5,
3369                    half_height: 1.0,
3370                },
3371            )
3372            .with_position([10.0, 0.0, 0.0]),
3373        );
3374        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3375        assert!(hit.is_none(), "Ray should miss capsule");
3376    }
3377
3378    #[test]
3379    fn raycast_cylinder_side() {
3380        let mut world = PhysicsWorld::new();
3381        world.add_body(
3382            RigidBody::dynamic(
3383                1.0,
3384                CollisionShape::Cylinder {
3385                    radius: 0.5,
3386                    half_height: 1.0,
3387                },
3388            )
3389            .with_position([0.0, 0.0, -5.0]),
3390        );
3391        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3392        assert!(hit.is_some(), "Ray should hit cylinder barrel");
3393        let h = hit.unwrap();
3394        assert!(
3395            (h.distance - 4.5).abs() < 0.01,
3396            "Distance should be ~4.5, got {}",
3397            h.distance
3398        );
3399    }
3400
3401    #[test]
3402    fn raycast_cylinder_cap() {
3403        let mut world = PhysicsWorld::new();
3404        world.add_body(
3405            RigidBody::dynamic(
3406                1.0,
3407                CollisionShape::Cylinder {
3408                    radius: 1.0,
3409                    half_height: 0.5,
3410                },
3411            )
3412            .with_position([0.0, -3.0, 0.0]),
3413        );
3414        // Ray straight down should hit the top cap
3415        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], 100.0);
3416        assert!(hit.is_some(), "Ray should hit cylinder top cap");
3417        let h = hit.unwrap();
3418        assert!(
3419            (h.distance - 2.5).abs() < 0.01,
3420            "Distance to top cap should be ~2.5, got {}",
3421            h.distance
3422        );
3423    }
3424
3425    #[test]
3426    fn raycast_cone_side() {
3427        let mut world = PhysicsWorld::new();
3428        // Cone at z=-5, base radius=1.0, height=2.0
3429        world.add_body(
3430            RigidBody::dynamic(
3431                1.0,
3432                CollisionShape::Cone {
3433                    radius: 1.0,
3434                    height: 2.0,
3435                },
3436            )
3437            .with_position([0.0, -1.0, -5.0]),
3438        );
3439        // Ray along -Z at y=0 (mid-height of cone). At y=0, local_y=1.0, cone_r at this height = 1.0*(1-1/2) = 0.5
3440        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, 0.0, -1.0], 100.0);
3441        assert!(hit.is_some(), "Ray should hit cone side");
3442    }
3443
3444    #[test]
3445    fn raycast_cone_base() {
3446        let mut world = PhysicsWorld::new();
3447        // Cone below origin, looking down at base
3448        world.add_body(
3449            RigidBody::dynamic(
3450                1.0,
3451                CollisionShape::Cone {
3452                    radius: 2.0,
3453                    height: 3.0,
3454                },
3455            )
3456            .with_position([0.0, -5.0, 0.0]),
3457        );
3458        let hit = world.raycast([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], 100.0);
3459        assert!(hit.is_some(), "Ray should hit cone base");
3460    }
3461
3462    // ── Spatial hash grid tests ─────────────────────────────────────
3463
3464    #[test]
3465    fn spatial_hash_grid_finds_nearby_pair() {
3466        let bodies = vec![
3467            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, 0.0]),
3468            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.5, 0.0, 0.0]),
3469        ];
3470        let mut grid = SpatialHashGrid::new(2.0);
3471        grid.populate(&bodies);
3472        let pairs = grid.query_pairs(&bodies);
3473        assert!(pairs.contains(&(0, 1)), "Nearby bodies should form a pair");
3474    }
3475
3476    #[test]
3477    fn spatial_hash_grid_distant_no_pair() {
3478        let bodies = vec![
3479            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, 0.0]),
3480            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([100.0, 100.0, 100.0]),
3481        ];
3482        let mut grid = SpatialHashGrid::new(2.0);
3483        grid.populate(&bodies);
3484        let pairs = grid.query_pairs(&bodies);
3485        assert!(pairs.is_empty(), "Distant bodies should not form a pair");
3486    }
3487
3488    #[test]
3489    fn spatial_hash_grid_cross_cell_boundary() {
3490        // Two bodies in neighbouring cells
3491        let bodies = vec![
3492            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([1.9, 0.0, 0.0]),
3493            RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([2.1, 0.0, 0.0]),
3494        ];
3495        let mut grid = SpatialHashGrid::new(2.0);
3496        grid.populate(&bodies);
3497        let pairs = grid.query_pairs(&bodies);
3498        assert!(
3499            pairs.contains(&(0, 1)),
3500            "Bodies across cell boundary should form a pair"
3501        );
3502    }
3503
3504    #[test]
3505    fn spatial_hash_grid_many_bodies() {
3506        // 100 bodies in a line; each should pair with its neighbours
3507        let bodies: Vec<RigidBody> = (0..100)
3508            .map(|i| {
3509                RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.3 }).with_position([
3510                    i as f32 * 0.5,
3511                    0.0,
3512                    0.0,
3513                ])
3514            })
3515            .collect();
3516        let mut grid = SpatialHashGrid::new(2.0);
3517        grid.populate(&bodies);
3518        let pairs = grid.query_pairs(&bodies);
3519        // At minimum, each consecutive pair should appear
3520        assert!(
3521            pairs.len() > 50,
3522            "Should have many pairs for closely-spaced bodies, got {}",
3523            pairs.len()
3524        );
3525    }
3526
3527    // ── Sleeping tests ──────────────────────────────────────────────
3528
3529    #[test]
3530    fn body_falls_asleep_below_threshold() {
3531        let mut world = PhysicsWorld::new();
3532        world.gravity = [0.0, 0.0, 0.0]; // no gravity — body stays still
3533        world.sleep_config = SleepConfig {
3534            linear_threshold: 0.01,
3535            angular_threshold: 0.01,
3536            frames_to_sleep: 5,
3537        };
3538        let id = world
3539            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, 0.0]));
3540        // Body has zero velocity, should accumulate sleep frames
3541        for _ in 0..10 {
3542            world.step(1.0 / 60.0);
3543        }
3544        let body = world.body(id).unwrap();
3545        assert!(
3546            body.is_sleeping,
3547            "Body with zero velocity should be sleeping after enough frames"
3548        );
3549        assert!(body.sleep_frames >= 5);
3550    }
3551
3552    #[test]
3553    fn moving_body_does_not_sleep() {
3554        let mut world = PhysicsWorld::new();
3555        world.gravity = [0.0, 0.0, 0.0];
3556        world.sleep_config = SleepConfig {
3557            linear_threshold: 0.01,
3558            angular_threshold: 0.01,
3559            frames_to_sleep: 5,
3560        };
3561        let id = world
3562            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, 0.0]));
3563        world.body_mut(id).unwrap().velocity = [1.0, 0.0, 0.0];
3564        for _ in 0..10 {
3565            world.step(1.0 / 60.0);
3566        }
3567        let body = world.body(id).unwrap();
3568        assert!(!body.is_sleeping, "Body with velocity > threshold should not sleep");
3569    }
3570
3571    #[test]
3572    fn sleeping_body_wakes_on_contact() {
3573        let mut world = PhysicsWorld::new();
3574        world.gravity = [0.0, 0.0, 0.0];
3575        world.sleep_config = SleepConfig {
3576            linear_threshold: 0.1,
3577            angular_threshold: 0.1,
3578            frames_to_sleep: 3,
3579        };
3580        // Body A: will be put to sleep manually
3581        let a = world
3582            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 0.0, 0.0]));
3583        // Body B: approaching A
3584        let b = world
3585            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([2.0, 0.0, 0.0]));
3586
3587        // Put A to sleep manually
3588        {
3589            let body_a = world.body_mut(a).unwrap();
3590            body_a.is_sleeping = true;
3591            body_a.sleep_frames = 100;
3592        }
3593
3594        // Give B velocity towards A
3595        world.body_mut(b).unwrap().velocity = [-10.0, 0.0, 0.0];
3596
3597        // Step until they collide — B moves left, after a few frames they overlap
3598        for _ in 0..20 {
3599            world.step(1.0 / 60.0);
3600        }
3601        let body_a = world.body(a).unwrap();
3602        // A should have been woken by the contact
3603        assert!(!body_a.is_sleeping, "Sleeping body should wake on contact");
3604    }
3605
3606    #[test]
3607    fn sleeping_body_skips_integration() {
3608        let mut world = PhysicsWorld::new();
3609        world.gravity = [0.0, -9.81, 0.0];
3610        world.sleep_config = SleepConfig {
3611            linear_threshold: 0.01,
3612            angular_threshold: 0.01,
3613            frames_to_sleep: 3,
3614        };
3615        let id = world
3616            .add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.0, 5.0, 0.0]));
3617        // Manually put to sleep
3618        {
3619            let body = world.body_mut(id).unwrap();
3620            body.is_sleeping = true;
3621            body.sleep_frames = 100;
3622        }
3623        let pos_before = world.body(id).unwrap().position;
3624        world.step(1.0 / 60.0);
3625        let pos_after = world.body(id).unwrap().position;
3626        assert_eq!(pos_before, pos_after, "Sleeping body should not be integrated");
3627    }
3628
3629    // ── detect_contact integration for new shapes ───────────────────
3630
3631    #[test]
3632    fn detect_contact_capsule_sphere_via_world() {
3633        let mut world = PhysicsWorld::new();
3634        world.gravity = [0.0, 0.0, 0.0];
3635        world.add_body(
3636            RigidBody::dynamic(
3637                1.0,
3638                CollisionShape::Capsule {
3639                    radius: 0.5,
3640                    half_height: 1.0,
3641                },
3642            )
3643            .with_position([0.0, 0.0, 0.0]),
3644        );
3645        world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.8, 0.0, 0.0]));
3646        world.step(1.0 / 60.0);
3647        assert!(
3648            !world.contacts().is_empty(),
3649            "World should detect capsule-sphere contact"
3650        );
3651    }
3652
3653    #[test]
3654    fn detect_contact_cylinder_sphere_via_world() {
3655        let mut world = PhysicsWorld::new();
3656        world.gravity = [0.0, 0.0, 0.0];
3657        world.add_body(
3658            RigidBody::dynamic(
3659                1.0,
3660                CollisionShape::Cylinder {
3661                    radius: 0.5,
3662                    half_height: 1.0,
3663                },
3664            )
3665            .with_position([0.0, 0.0, 0.0]),
3666        );
3667        world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([0.8, 0.0, 0.0]));
3668        world.step(1.0 / 60.0);
3669        assert!(
3670            !world.contacts().is_empty(),
3671            "World should detect cylinder-sphere contact"
3672        );
3673    }
3674
3675    #[test]
3676    fn detect_contact_cone_sphere_via_world() {
3677        let mut world = PhysicsWorld::new();
3678        world.gravity = [0.0, 0.0, 0.0];
3679        world.add_body(
3680            RigidBody::dynamic(
3681                1.0,
3682                CollisionShape::Cone {
3683                    radius: 1.0,
3684                    height: 2.0,
3685                },
3686            )
3687            .with_position([0.0, 0.0, 0.0]),
3688        );
3689        world.add_body(RigidBody::dynamic(1.0, CollisionShape::Sphere { radius: 0.5 }).with_position([1.2, 0.0, 0.0]));
3690        world.step(1.0 / 60.0);
3691        assert!(!world.contacts().is_empty(), "World should detect cone-sphere contact");
3692    }
3693}