Skip to main content

symtropy_physics/
world.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4//! Physics world: owns bodies, steps simulation, resolves collisions.
5
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8
9use nalgebra::SVector;
10use symtropy_math::Point;
11
12use crate::body::{BodyHandle, NetId, RigidBody};
13use crate::broadphase;
14use crate::contact::{CollisionEvent, ContactCache, ContactManifold};
15use crate::constraint::Constraint;
16use crate::gjk;
17use crate::integrator;
18use crate::manifold_gen;
19
20/// Quantize a float to Q16.16 fixed-point (range ±32768, resolution 1/65536).
21///
22/// Used by the `deterministic-net` feature to guarantee bit-identical simulation
23/// across heterogeneous hardware for P2P multiplayer and replay files.
24#[cfg(feature = "deterministic-net")]
25#[inline]
26fn quantize_q16_16(v: f64) -> f64 {
27    (v * 65536.0).round() / 65536.0
28}
29
30/// Callback trait for consciousness-physics coupling.
31///
32/// The physics world calls these methods during collision resolution,
33/// allowing consciousness to modulate forces, impulses, and friction
34/// without creating a circular dependency between crates.
35pub trait PhysicsCallback<const D: usize> {
36    /// Modulate a force by the entity's consciousness level.
37    /// Returns the modified force vector.
38    fn modulate_force(&self, body: BodyHandle, force: &SVector<f64, D>) -> SVector<f64, D>;
39
40    /// Modulate a collision impulse at a contact point.
41    /// Returns the modified impulse magnitude.
42    fn modulate_impulse(&self, impulse: f64, contact_point: &SVector<f64, D>) -> f64;
43
44    /// Friction multiplier at a contact point (harmony field effect).
45    /// 1.0 = normal, <1.0 = reduced (resonance), >1.0 = increased (dissonance).
46    fn friction_multiplier(&self, contact_point: &SVector<f64, D>, body: BodyHandle) -> f64;
47
48    /// Called after collision resolution with the collision event.
49    fn on_collision(&mut self, event: &CollisionEvent<D>);
50
51    /// Called after each physics step to record energy dissipated.
52    fn record_dissipation(&mut self, energy: f64);
53}
54
55/// No-op callback for physics-only usage (no consciousness coupling).
56pub struct NoOpCallback;
57
58impl<const D: usize> PhysicsCallback<D> for NoOpCallback {
59    fn modulate_force(&self, _: BodyHandle, force: &SVector<f64, D>) -> SVector<f64, D> { *force }
60    fn modulate_impulse(&self, impulse: f64, _: &SVector<f64, D>) -> f64 { impulse }
61    fn friction_multiplier(&self, _: &SVector<f64, D>, _: BodyHandle) -> f64 { 1.0 }
62    fn on_collision(&mut self, _: &CollisionEvent<D>) {}
63    fn record_dissipation(&mut self, _: f64) {}
64}
65
66/// The physics world manages all rigid bodies and steps the simulation.
67pub struct PhysicsWorld<const D: usize> {
68    pub bodies: Vec<RigidBody<D>>,
69    pub constraints: Vec<Box<dyn Constraint<D>>>,
70    pub gravity: SVector<f64, D>,
71    /// Contacts from the last step.
72    pub contacts: Vec<ContactManifold<D>>,
73    /// Collision events from the last step (for game logic callbacks).
74    pub collision_events: Vec<CollisionEvent<D>>,
75    /// Sensor overlap events from the last step.
76    pub sensor_events: Vec<crate::contact::SensorEvent>,
77    /// Contact cache for warm-starting (previous frame's impulses).
78    contact_cache: ContactCache<D>,
79    /// Warm-starting from the previous frame (swapped at frame boundaries).
80    prev_cache: ContactCache<D>,
81    /// Position correction iterations per step.
82    pub solver_iterations: usize,
83    /// Sleep velocity threshold.
84    pub sleep_threshold: f64,
85    /// Ticks below sleep threshold before sleeping.
86    pub sleep_ticks: u32,
87    /// Penetration slop (small overlap allowed to prevent jitter).
88    pub slop: f64,
89    /// Baumgarte stabilization factor (TGS Soft position-bias coefficient).
90    ///
91    /// Used as `bias = baumgarte * (depth - slop).max(0) / dt` in TGS Soft.
92    /// Typical values: 0.1–0.4. Default: 0.2.
93    pub baumgarte: f64,
94    /// Constraint compliance (softness). 0.0 = rigid (default). Higher values
95    /// make contacts softer (spring-like). Applied as `α = compliance / dt²`.
96    pub compliance: f64,
97    /// NetId → BodyHandle mapping for cross-machine replay determinism.
98    net_id_map: BTreeMap<NetId, BodyHandle>,
99    /// BodyHandle → Vec index for O(1) body lookup.
100    handle_to_index: HashMap<BodyHandle, usize>,
101    next_handle: usize,
102    /// Cached broadphase data for Static/Kinematic bodies (rebuilt only on add/remove).
103    static_broadphase: broadphase::StaticBroadphase<D>,
104    /// Set to true when a static or kinematic body is added or removed.
105    static_tree_dirty: bool,
106}
107
108impl<const D: usize> PhysicsWorld<D> {
109    /// Create an empty physics world.
110    pub fn new(gravity: SVector<f64, D>) -> Self {
111        Self {
112            bodies: Vec::new(),
113            constraints: Vec::new(),
114            gravity,
115            contacts: Vec::new(),
116            collision_events: Vec::new(),
117            sensor_events: Vec::new(),
118            contact_cache: ContactCache::new(),
119            prev_cache: ContactCache::new(),
120            solver_iterations: 4,
121            slop: 0.01,
122            baumgarte: 0.2,
123            compliance: 0.0,
124            sleep_threshold: 0.5,
125            sleep_ticks: 60, // ~1 second at 64Hz
126            net_id_map: BTreeMap::new(),
127            handle_to_index: HashMap::new(),
128            next_handle: 0,
129            static_broadphase: broadphase::StaticBroadphase::new(),
130            static_tree_dirty: false,
131        }
132    }
133
134    /// Add a dynamic sphere body and return its handle.
135    pub fn add_sphere(
136        &mut self,
137        position: Point<D>,
138        radius: f64,
139        mass: f64,
140    ) -> BodyHandle {
141        let handle = self.allocate_handle();
142        let body = RigidBody::dynamic_sphere(handle, position, radius, mass);
143        let idx = self.bodies.len();
144        self.bodies.push(body);
145        self.handle_to_index.insert(handle, idx);
146        handle
147    }
148
149    /// Add a dynamic body with a custom collider.
150    pub fn add_body(&mut self, mut body: RigidBody<D>) -> BodyHandle {
151        use crate::body::BodyType;
152        let handle = self.allocate_handle();
153        body.handle = handle;
154        if body.body_type == BodyType::Static || body.body_type == BodyType::Kinematic {
155            self.static_tree_dirty = true;
156        }
157        let idx = self.bodies.len();
158        self.bodies.push(body);
159        self.handle_to_index.insert(handle, idx);
160        handle
161    }
162
163    /// Add multiple bodies with stable network IDs in deterministic order.
164    ///
165    /// Bodies are sorted by `NetId` before insertion, ensuring the same
166    /// `BodyHandle` assignment regardless of the caller's iteration order.
167    /// Returns an error if any `NetId` is duplicated.
168    pub fn add_bodies_deterministic(
169        &mut self,
170        mut bodies: Vec<(NetId, RigidBody<D>)>,
171    ) -> Result<Vec<BodyHandle>, String> {
172        bodies.sort_by_key(|(id, _)| *id);
173        let mut handles = Vec::with_capacity(bodies.len());
174        for (net_id, mut body) in bodies {
175            if self.net_id_map.contains_key(&net_id) {
176                return Err(format!("duplicate NetId({})", net_id.0));
177            }
178            let handle = self.allocate_handle();
179            body.handle = handle;
180            body.net_id = Some(net_id);
181            self.net_id_map.insert(net_id, handle);
182            let idx = self.bodies.len();
183            self.bodies.push(body);
184            self.handle_to_index.insert(handle, idx);
185            handles.push(handle);
186        }
187        Ok(handles)
188    }
189
190    /// Resolve a stable `NetId` to its `BodyHandle`.
191    pub fn handle_for_net_id(&self, net_id: NetId) -> Option<BodyHandle> {
192        self.net_id_map.get(&net_id).copied()
193    }
194
195    /// Add a constraint between two bodies.
196    pub fn add_constraint(&mut self, constraint: Box<dyn Constraint<D>>) {
197        self.constraints.push(constraint);
198    }
199
200    /// Get a reference to a body by handle.
201    pub fn body(&self, handle: BodyHandle) -> Option<&RigidBody<D>> {
202        self.handle_to_index
203            .get(&handle)
204            .and_then(|&idx| self.bodies.get(idx))
205    }
206
207    /// Get a mutable reference to a body by handle.
208    pub fn body_mut(&mut self, handle: BodyHandle) -> Option<&mut RigidBody<D>> {
209        self.handle_to_index
210            .get(&handle)
211            .copied()
212            .and_then(|idx| self.bodies.get_mut(idx))
213    }
214
215    /// Step with consciousness-physics callback.
216    ///
217    /// The callback modulates forces, impulses, and friction based on
218    /// consciousness state, closing the consciousness-physics loop.
219    pub fn step_with_callback(&mut self, dt: f64, callback: &mut dyn PhysicsCallback<D>) {
220        self.step_internal(dt, Some(callback));
221    }
222
223    /// Step without consciousness coupling (pure physics).
224    pub fn step(&mut self, dt: f64) {
225        self.step_internal(dt, None);
226    }
227
228    fn step_internal(&mut self, dt: f64, mut callback: Option<&mut dyn PhysicsCallback<D>>) {
229        // 0. Clear events from previous step
230        self.collision_events.clear();
231        self.sensor_events.clear();
232        // Warm-starting: swap caches so prev_cache has last frame's impulses
233        std::mem::swap(&mut self.contact_cache, &mut self.prev_cache);
234        self.contact_cache.begin_frame();
235
236        // 0b. Rebuild static broadphase cache if bodies have been added/removed.
237        if self.static_tree_dirty {
238            self.static_broadphase.rebuild(&self.bodies);
239            self.static_tree_dirty = false;
240        }
241
242        // 1. Integrate all bodies (skip sleeping)
243        #[cfg(feature = "deterministic-net")]
244        for body in &mut self.bodies {
245            // Snap accumulated forces to Q16.16 before integration for
246            // bit-identical simulation across platforms (P2P / replay).
247            if !body.sleeping {
248                for i in 0..D {
249                    body.force_accumulator[i] = quantize_q16_16(body.force_accumulator[i]);
250                }
251            }
252        }
253        for body in &mut self.bodies {
254            if !body.sleeping {
255                integrator::integrate(body, &self.gravity, dt);
256            }
257        }
258        #[cfg(feature = "deterministic-net")]
259        for body in &mut self.bodies {
260            // Snap positions and velocities after integration.
261            for i in 0..D {
262                body.transform.translation.0[i] = quantize_q16_16(body.transform.translation.0[i]);
263                body.linear_velocity[i] = quantize_q16_16(body.linear_velocity[i]);
264            }
265        }
266
267        // 2. Broadphase: find potentially colliding pairs (incremental static cache)
268        let pairs = broadphase::find_pairs_incremental(&self.bodies, &self.static_broadphase);
269
270        // 3. Narrowphase: GJK for each pair
271        self.contacts.clear();
272        for pair in &pairs {
273            let (idx_a, idx_b) = self.find_body_indices(pair.0, pair.1);
274            if let (Some(a), Some(b)) = (idx_a, idx_b) {
275                let pos_a = self.bodies[a].transform.translation.0;
276                let pos_b = self.bodies[b].transform.translation.0;
277
278                // Fast path: analytical HalfSpace contacts (bypass GJK+EPA)
279                if let Some(manifold) = self.try_halfspace_contact(a, b, pair.0, pair.1) {
280                    if self.bodies[a].is_sensor || self.bodies[b].is_sensor {
281                        let (sensor, other) = if self.bodies[a].is_sensor {
282                            (pair.0, pair.1)
283                        } else {
284                            (pair.1, pair.0)
285                        };
286                        self.sensor_events.push(crate::contact::SensorEvent { sensor, other });
287                        continue;
288                    }
289                    self.contacts.push(manifold);
290                    continue;
291                }
292
293                let result = gjk::intersects(
294                    self.bodies[a].collider.as_ref(),
295                    &pos_a,
296                    self.bodies[b].collider.as_ref(),
297                    &pos_b,
298                );
299
300                if result.intersecting {
301                    // Sensor detection: emit event but skip collision resolution
302                    if self.bodies[a].is_sensor || self.bodies[b].is_sensor {
303                        let (sensor, other) = if self.bodies[a].is_sensor {
304                            (pair.0, pair.1)
305                        } else {
306                            (pair.1, pair.0)
307                        };
308                        self.sensor_events.push(crate::contact::SensorEvent { sensor, other });
309                        continue;
310                    }
311
312                    // EPA for accurate penetration depth and normal
313                    if let Some(epa_result) = crate::epa::penetration(
314                        self.bodies[a].collider.as_ref(),
315                        &pos_a,
316                        self.bodies[b].collider.as_ref(),
317                        &pos_b,
318                        &result.simplex,
319                    ) {
320                        if epa_result.depth > 0.0 {
321                            // Multi-point manifold: contact perturbation for stable stacking
322                            let manifold = manifold_gen::generate_contact_manifold(
323                                self.bodies[a].collider.as_ref(),
324                                &pos_a,
325                                self.bodies[b].collider.as_ref(),
326                                &pos_b,
327                                epa_result.normal,
328                                epa_result.depth,
329                                pair.0,
330                                pair.1,
331                            );
332                            self.contacts.push(manifold);
333                        }
334                    }
335                }
336            }
337        }
338
339        // 4. Build islands and skip sleeping ones
340        let islands = crate::island::build_islands(
341            &self.bodies,
342            &self.contacts,
343            &self.constraints,
344            &self.handle_to_index,
345        );
346        let active_contact_indices: Vec<usize> = islands.iter()
347            .filter(|island| !island.sleeping)
348            .flat_map(|island| island.contact_indices.iter().copied())
349            .collect();
350
351        // 5a. Warm-start: apply previous-frame impulse ONCE before the solver loop.
352        //     (The old code accidentally applied warm-starting N times per frame.)
353        for &ci in &active_contact_indices {
354            if ci < self.contacts.len() {
355                self.warm_start_contact(ci);
356            }
357        }
358
359        // 5b. TGS Soft velocity solver: position-correction bias folded into impulse.
360        //     Each iteration accumulates per-point lambda; write updated manifold back.
361        for _ in 0..self.solver_iterations {
362            for &ci in &active_contact_indices {
363                if ci < self.contacts.len() {
364                    let contact = self.contacts[ci].clone();
365                    let updated = self.resolve_contact(contact, dt, &mut callback);
366                    self.contacts[ci] = updated;
367                }
368            }
369        }
370
371        // 5c. After the solve, cache per-manifold total impulse for next frame's warm-start.
372        for &ci in &active_contact_indices {
373            if ci < self.contacts.len() {
374                let c = &self.contacts[ci];
375                let total_lambda: f64 = c.points.iter().map(|p| p.lambda).sum();
376                self.contact_cache.store(
377                    c.body_a, c.body_b, c.point(), total_lambda, 0.0,
378                );
379            }
380        }
381
382        // 6. Solve constraints (active islands only)
383        let active_constraint_indices: Vec<usize> = islands.iter()
384            .filter(|island| !island.sleeping)
385            .flat_map(|island| island.constraint_indices.iter().copied())
386            .collect();
387
388        for _ in 0..self.solver_iterations {
389            for &ci in &active_constraint_indices {
390                if ci >= self.constraints.len() { continue; }
391                let (ha, hb) = self.constraints[ci].bodies();
392                let (idx_a, idx_b) = self.find_body_indices(ha, hb);
393                if let (Some(a), Some(b)) = (idx_a, idx_b) {
394                    if a < b {
395                        let (left, right) = self.bodies.split_at_mut(b);
396                        self.constraints[ci].solve(&mut left[a], &mut right[0], dt);
397                    } else {
398                        let (left, right) = self.bodies.split_at_mut(a);
399                        self.constraints[ci].solve(&mut right[0], &mut left[b], dt);
400                    }
401                }
402            }
403        }
404
405        // 6b. Velocity-level constraint solve (active islands only)
406        for _ in 0..self.solver_iterations {
407            for &ci in &active_constraint_indices {
408                if ci >= self.constraints.len() { continue; }
409                let (ha, hb) = self.constraints[ci].bodies();
410                let (idx_a, idx_b) = self.find_body_indices(ha, hb);
411                if let (Some(a), Some(b)) = (idx_a, idx_b) {
412                    if a < b {
413                        let (left, right) = self.bodies.split_at_mut(b);
414                        self.constraints[ci].solve_velocity(&mut left[a], &mut right[0], dt);
415                    } else {
416                        let (left, right) = self.bodies.split_at_mut(a);
417                        self.constraints[ci].solve_velocity(&mut right[0], &mut left[b], dt);
418                    }
419                }
420            }
421        }
422
423        // 7. Body sleeping: deactivate near-stationary bodies
424        let threshold = self.sleep_threshold;
425        let ticks = self.sleep_ticks;
426        for body in &mut self.bodies {
427            body.try_sleep(threshold, ticks);
428        }
429    }
430
431    /// Warm-start a single contact from the previous frame's impulse cache.
432    ///
433    /// Called ONCE per contact before the solver loop. Distributes the cached
434    /// total impulse evenly across all contact points and initialises `lambda`.
435    fn warm_start_contact(&mut self, ci: usize) {
436        let contact = self.contacts[ci].clone();
437        let (idx_a, idx_b) = self.find_body_indices(contact.body_a, contact.body_b);
438        let (Some(a), Some(b)) = (idx_a, idx_b) else { return; };
439
440        let primary_pt = contact.primary_point().position;
441        let cached_total = self.prev_cache
442            .lookup(contact.body_a, contact.body_b, &primary_pt)
443            .map(|c| c.normal_impulse * 0.8) // 80% of previous frame
444            .unwrap_or(0.0);
445
446        if cached_total > 1e-15 {
447            let n_pts = contact.points.len().max(1) as f64;
448            let per_pt = cached_total / n_pts;
449
450            // Apply warm-start impulse
451            let impulse = contact.normal * cached_total;
452            integrator::apply_impulse(&mut self.bodies[a], &(-impulse));
453            integrator::apply_impulse(&mut self.bodies[b], &impulse);
454
455            // Seed lambda so TGS Soft starts from warm-start value
456            for pt in &mut self.contacts[ci].points {
457                pt.lambda = per_pt;
458            }
459        }
460    }
461
462    /// Resolve a single contact using TGS Soft (Temporal Gauss-Seidel with compliance).
463    ///
464    /// Replaces Baumgarte stabilisation: position correction is folded into a
465    /// "bias velocity" `bias = baumgarte * (depth - slop).max(0) / dt`, which is
466    /// added to the velocity constraint. Lambda clamping ensures contacts only push
467    /// (never pull), eliminating ghost-acceleration artefacts.
468    ///
469    /// Returns the updated manifold so accumulated lambdas persist across iterations.
470    fn resolve_contact(
471        &mut self,
472        mut contact: ContactManifold<D>,
473        dt: f64,
474        callback: &mut Option<&mut dyn PhysicsCallback<D>>,
475    ) -> ContactManifold<D> {
476        let (idx_a, idx_b) = self.find_body_indices(contact.body_a, contact.body_b);
477        let (Some(a), Some(b)) = (idx_a, idx_b) else {
478            return contact;
479        };
480
481        let inv_mass_a = self.bodies[a].inv_mass;
482        let inv_mass_b = self.bodies[b].inv_mass;
483        let total_inv_mass = inv_mass_a + inv_mass_b;
484        if total_inv_mass < 1e-15 {
485            return contact;
486        }
487
488        // Compliance: α = compliance / dt² (adds softness to the constraint)
489        let alpha = self.compliance / (dt * dt).max(1e-20);
490        let safe_dt = dt.max(1e-10);
491
492        let baumgarte = self.baumgarte;
493        let slop = self.slop;
494
495        // ─── TGS Soft: per-point velocity+position constraint ───
496        let mut total_normal_impulse = 0.0_f64;
497
498        for pt in &mut contact.points {
499            let v_rel_n = {
500                let va = self.bodies[a].linear_velocity;
501                let vb = self.bodies[b].linear_velocity;
502                (va - vb).dot(&contact.normal)
503            };
504
505            // Position-correction bias (replaces Baumgarte teleport)
506            let bias = (pt.depth - slop).max(0.0) * baumgarte / safe_dt;
507
508            // TGS Soft impulse: Δλ = (-v_rel·n + bias) / (w_a + w_b + α)
509            let denom = total_inv_mass + alpha;
510            let delta_lambda = (-v_rel_n + bias) / denom;
511
512            // Clamp: accumulated normal impulse must stay ≥ 0 (no pulling)
513            let new_lambda = (pt.lambda + delta_lambda).max(0.0);
514            let actual_delta = new_lambda - pt.lambda;
515            pt.lambda = new_lambda;
516
517            if actual_delta.abs() > 1e-15 {
518                // ═══ CONSCIOUSNESS MODULATION: sanctuary + harmony fields ═══
519                let modulated_delta = if let Some(ref cb) = callback {
520                    cb.modulate_impulse(actual_delta, &pt.position)
521                } else {
522                    actual_delta
523                };
524
525                let impulse = contact.normal * modulated_delta;
526                integrator::apply_impulse(&mut self.bodies[a], &(-impulse));
527                integrator::apply_impulse(&mut self.bodies[b], &impulse);
528                total_normal_impulse += modulated_delta.abs();
529            }
530        }
531
532        // ─── Coulomb friction (once per manifold, based on total normal impulse) ───
533        if total_normal_impulse > 1e-15 {
534            let primary_pt = contact.point();
535            let v_rel = self.bodies[b].linear_velocity - self.bodies[a].linear_velocity;
536            let v_n = contact.normal * v_rel.dot(&contact.normal);
537            let v_t = v_rel - v_n;
538            let v_t_mag = v_t.norm();
539
540            if v_t_mag > 1e-10 {
541                let tangent = v_t / v_t_mag;
542                let mut mu = (self.bodies[a].friction * self.bodies[b].friction).sqrt();
543
544                // ═══ HARMONY FIELD FRICTION MODULATION ═══
545                if let Some(ref cb) = callback {
546                    mu *= cb.friction_multiplier(&primary_pt, contact.body_a);
547                }
548
549                let j_t_desired = -v_t_mag / total_inv_mass;
550                let j_t = j_t_desired.clamp(-mu * total_normal_impulse, mu * total_normal_impulse);
551                let friction_impulse = tangent * j_t;
552                integrator::apply_impulse(&mut self.bodies[a], &(-friction_impulse));
553                integrator::apply_impulse(&mut self.bodies[b], &friction_impulse);
554
555                // Record friction dissipation for consciousness energy budget
556                if let Some(ref mut cb) = callback {
557                    cb.record_dissipation(j_t.abs() * 0.1);
558                }
559            }
560
561            // ─── Emit collision event ───
562            let event = CollisionEvent {
563                body_a: contact.body_a,
564                body_b: contact.body_b,
565                impulse: total_normal_impulse,
566                normal: contact.normal,
567                depth: contact.depth(),
568            };
569
570            // ═══ CONSCIOUSNESS FEEDBACK: collision → prediction error ═══
571            if let Some(ref mut cb) = callback {
572                cb.on_collision(&event);
573            }
574
575            self.collision_events.push(event);
576
577            // Wake both bodies
578            self.bodies[a].wake();
579            self.bodies[b].wake();
580        }
581
582        contact
583    }
584
585    /// Try analytical HalfSpace contact. Returns None if neither body is a HalfSpace.
586    fn try_halfspace_contact(
587        &self,
588        idx_a: usize,
589        idx_b: usize,
590        handle_a: BodyHandle,
591        handle_b: BodyHandle,
592    ) -> Option<ContactManifold<D>> {
593        use symtropy_math::HalfSpace;
594
595        // Check if body A is a HalfSpace
596        if let Some(hs) = self.bodies[idx_a].collider.as_any().downcast_ref::<HalfSpace<D>>() {
597            let (center_b, radius_b) = self.bodies[idx_b].collider.bounding_sphere();
598            let world_center_b = self.bodies[idx_b].transform.transform_point(&center_b).0;
599            if let Some((contact_pt, depth)) = hs.contact_sphere(&world_center_b, radius_b) {
600                return Some(ContactManifold::single(
601                    handle_a, handle_b, hs.normal, contact_pt, depth,
602                ));
603            }
604            return None;
605        }
606
607        // Check if body B is a HalfSpace
608        if let Some(hs) = self.bodies[idx_b].collider.as_any().downcast_ref::<HalfSpace<D>>() {
609            let (center_a, radius_a) = self.bodies[idx_a].collider.bounding_sphere();
610            let world_center_a = self.bodies[idx_a].transform.transform_point(&center_a).0;
611            if let Some((contact_pt, depth)) = hs.contact_sphere(&world_center_a, radius_a) {
612                return Some(ContactManifold::single(
613                    handle_a, handle_b, -hs.normal, contact_pt, depth,
614                ));
615            }
616            return None;
617        }
618
619        None // Neither body is a HalfSpace
620    }
621
622    fn find_body_indices(&self, a: BodyHandle, b: BodyHandle) -> (Option<usize>, Option<usize>) {
623        (
624            self.handle_to_index.get(&a).copied(),
625            self.handle_to_index.get(&b).copied(),
626        )
627    }
628
629    fn allocate_handle(&mut self) -> BodyHandle {
630        let h = BodyHandle(self.next_handle);
631        self.next_handle += 1;
632        h
633    }
634
635    /// Number of bodies in the world.
636    pub fn body_count(&self) -> usize {
637        self.bodies.len()
638    }
639
640    /// Total kinetic energy across all bodies.
641    pub fn total_kinetic_energy(&self) -> f64 {
642        self.bodies.iter().map(|b| b.kinetic_energy()).sum()
643    }
644
645    /// Collision events from the last step.
646    pub fn drain_collision_events(&mut self) -> Vec<CollisionEvent<D>> {
647        std::mem::take(&mut self.collision_events)
648    }
649
650    /// Number of sleeping bodies.
651    pub fn sleeping_count(&self) -> usize {
652        self.bodies.iter().filter(|b| b.sleeping).count()
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn empty_world_steps() {
662        let mut world = PhysicsWorld::<3>::new(SVector::from([0.0, -9.81, 0.0]));
663        world.step(0.01);
664        assert_eq!(world.body_count(), 0);
665    }
666
667    #[test]
668    fn falling_sphere() {
669        let mut world = PhysicsWorld::<3>::new(SVector::from([0.0, -9.81, 0.0]));
670        let h = world.add_sphere(Point::new([0.0, 10.0, 0.0]), 0.5, 1.0);
671
672        for _ in 0..100 {
673            world.step(0.01);
674        }
675
676        let y = world.body(h).unwrap().transform.translation.coord(1);
677        assert!(y < 10.0, "sphere should have fallen, y = {y}");
678        assert!(y > 0.0, "sphere fell too far, y = {y}");
679    }
680
681    #[test]
682    fn two_spheres_collide() {
683        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
684        let a = world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
685        let b = world.add_sphere(Point::new([3.0, 0.0, 0.0]), 1.0, 1.0);
686
687        // Push them toward each other
688        world.body_mut(a).unwrap().linear_velocity = SVector::from([5.0, 0.0, 0.0]);
689        world.body_mut(b).unwrap().linear_velocity = SVector::from([-5.0, 0.0, 0.0]);
690
691        for _ in 0..100 {
692            world.step(0.01);
693        }
694
695        // After collision they should have bounced — velocities should have swapped/reduced
696        let va = world.body(a).unwrap().linear_velocity[0];
697        let vb = world.body(b).unwrap().linear_velocity[0];
698        // A was moving right, should now be moving left (or slower)
699        assert!(va < 5.0, "va = {va}, should have changed after collision");
700    }
701
702    #[test]
703    fn sphere_bounces_off_static() {
704        let mut world = PhysicsWorld::<3>::new(SVector::from([0.0, -9.81, 0.0]));
705
706        // Static floor: small box at y=-0.5 (top face at y=0)
707        let floor = RigidBody::<3>::static_body(
708            BodyHandle(999),
709            Point::new([0.0, -0.5, 0.0]),
710            Box::new(symtropy_math::Sphere::<3>::new(symtropy_math::Point::origin(), 5.0)),
711        );
712        world.add_body(floor);
713
714        // Falling sphere starting at y=3
715        let sphere = world.add_sphere(Point::new([0.0, 3.0, 0.0]), 0.5, 1.0);
716
717        for _ in 0..200 {
718            world.step(0.01);
719        }
720
721        // Sphere should not have fallen far below the floor
722        let y = world.body(sphere).unwrap().transform.translation.coord(1);
723        assert!(y > -3.0, "sphere fell through floor, y = {y}");
724    }
725
726    #[test]
727    fn physics_4d() {
728        let mut world = PhysicsWorld::<4>::new(SVector::from([0.0, -9.81, 0.0, 0.0]));
729        let h = world.add_sphere(Point::new([0.0, 10.0, 0.0, 0.0]), 1.0, 1.0);
730
731        for _ in 0..100 {
732            world.step(0.01);
733        }
734
735        let y = world.body(h).unwrap().transform.translation.coord(1);
736        assert!(y < 10.0, "4D sphere should fall, y = {y}");
737    }
738
739    #[test]
740    fn physics_2d() {
741        let mut world = PhysicsWorld::<2>::new(SVector::from([0.0, -9.81]));
742        let h = world.add_sphere(Point::new([0.0, 10.0]), 1.0, 1.0);
743
744        for _ in 0..100 {
745            world.step(0.01);
746        }
747
748        let y = world.body(h).unwrap().transform.translation.coord(1);
749        assert!(y < 10.0, "2D sphere should fall, y = {y}");
750    }
751
752    #[test]
753    fn energy_decreases_with_damping() {
754        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
755        let h = world.add_sphere(Point::origin(), 1.0, 1.0);
756        world.body_mut(h).unwrap().linear_velocity = SVector::from([10.0, 0.0, 0.0]);
757
758        let initial_ke = world.total_kinetic_energy();
759
760        for _ in 0..100 {
761            world.step(0.01);
762        }
763
764        let final_ke = world.total_kinetic_energy();
765        assert!(final_ke < initial_ke, "KE should decrease with damping");
766    }
767
768    #[test]
769    fn add_bodies_deterministic_assigns_net_ids() {
770        use symtropy_math::{Point, Sphere};
771        let mut world = PhysicsWorld::<3>::new(SVector::from([0.0, -9.81, 0.0]));
772
773        let bodies = vec![
774            (NetId(42), RigidBody::dynamic_sphere(BodyHandle(0), Point::new([0.0, 5.0, 0.0]), 1.0, 1.0)),
775            (NetId(7), RigidBody::dynamic_sphere(BodyHandle(0), Point::new([3.0, 5.0, 0.0]), 1.0, 2.0)),
776            (NetId(99), RigidBody::static_body(BodyHandle(0), Point::origin(), Box::new(Sphere::<3>::new(Point::origin(), 10.0)))),
777        ];
778
779        let handles = world.add_bodies_deterministic(bodies).unwrap();
780        assert_eq!(handles.len(), 3);
781        assert_eq!(world.body_count(), 3);
782
783        // Verify NetId lookup works
784        let h7 = world.handle_for_net_id(NetId(7)).unwrap();
785        let h42 = world.handle_for_net_id(NetId(42)).unwrap();
786        let h99 = world.handle_for_net_id(NetId(99)).unwrap();
787
788        // Bodies are sorted by NetId, so NetId(7) gets handle first
789        assert!(h7 < h42, "NetId(7) inserted before NetId(42)");
790        assert!(h42 < h99, "NetId(42) inserted before NetId(99)");
791
792        // Verify the bodies have correct net_id stored
793        assert_eq!(world.body(h42).unwrap().net_id, Some(NetId(42)));
794
795        // Unknown NetId returns None
796        assert!(world.handle_for_net_id(NetId(999)).is_none());
797    }
798
799    #[test]
800    fn add_bodies_deterministic_rejects_duplicates() {
801        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
802        let bodies = vec![
803            (NetId(1), RigidBody::dynamic_sphere(BodyHandle(0), Point::origin(), 1.0, 1.0)),
804            (NetId(1), RigidBody::dynamic_sphere(BodyHandle(0), Point::origin(), 1.0, 1.0)),
805        ];
806        assert!(world.add_bodies_deterministic(bodies).is_err());
807    }
808
809    #[test]
810    fn collision_groups_prevent_collision() {
811        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
812
813        // Two overlapping spheres in different groups
814        let h1 = world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
815        let h2 = world.add_sphere(Point::new([0.5, 0.0, 0.0]), 1.0, 1.0);
816
817        // Put them in non-overlapping groups
818        world.body_mut(h1).unwrap().collision_group = 0x01;
819        world.body_mut(h1).unwrap().collision_mask = 0x01;
820        world.body_mut(h2).unwrap().collision_group = 0x02;
821        world.body_mut(h2).unwrap().collision_mask = 0x02;
822
823        // Step — they overlap but shouldn't collide
824        world.step(0.016);
825        assert!(
826            world.collision_events.is_empty(),
827            "bodies in different groups should not collide"
828        );
829    }
830
831    #[test]
832    fn collision_groups_allow_matching() {
833        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
834
835        // Two overlapping spheres in the same group
836        let h1 = world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
837        let h2 = world.add_sphere(Point::new([0.5, 0.0, 0.0]), 1.0, 1.0);
838
839        world.body_mut(h1).unwrap().collision_group = 0x01;
840        world.body_mut(h1).unwrap().collision_mask = 0x01;
841        world.body_mut(h2).unwrap().collision_group = 0x01;
842        world.body_mut(h2).unwrap().collision_mask = 0x01;
843
844        // Give converging velocity so impulse is generated
845        world.body_mut(h1).unwrap().linear_velocity = SVector::from([1.0, 0.0, 0.0]);
846        world.body_mut(h2).unwrap().linear_velocity = SVector::from([-1.0, 0.0, 0.0]);
847
848        world.step(0.016);
849        // Check contacts were generated (impulse events require relative velocity)
850        assert!(
851            !world.contacts.is_empty(),
852            "bodies in matching groups should generate contacts"
853        );
854    }
855
856    #[test]
857    fn sensor_detects_overlap_without_impulse() {
858        let mut world = PhysicsWorld::<3>::new(SVector::zeros());
859
860        let h1 = world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
861        let h2 = world.add_sphere(Point::new([0.5, 0.0, 0.0]), 1.0, 1.0);
862
863        // Mark h1 as a sensor
864        world.body_mut(h1).unwrap().is_sensor = true;
865
866        world.step(0.016);
867
868        // Should have sensor event, not collision event
869        assert!(
870            world.collision_events.is_empty(),
871            "sensor should not produce collision events"
872        );
873        assert!(
874            !world.sensor_events.is_empty(),
875            "sensor should produce sensor events"
876        );
877        assert_eq!(world.sensor_events[0].sensor, h1);
878        assert_eq!(world.sensor_events[0].other, h2);
879    }
880}