Skip to main content

concinnity_core/physics/
system.rs

1// The rigid-body simulation driver. An internal system (not a declarable
2// asset): a schedule constructs one when the world declares a `PhysicsConfig`,
3// a `RigidBody`, a `PropBody`, or a `TriggerVolume`, reading the optional
4// `PhysicsConfig` for the floor / terrain.
5
6use alloc::boxed::Box;
7use alloc::collections::{BTreeMap, BTreeSet};
8use alloc::vec::Vec;
9
10use crate::physics::{
11    BodyHandle, CharacterCapsule, CharacterMoveInput, ColliderShape, ContactHit, GRAVITY,
12    PhysicsBudget, SensorCrossing, SimConfig, Simulation,
13};
14
15use crate::components::{
16    BodyDynamics, Camera3D, Collider, ContactEvent, Held, PhysicsConfig, PhysicsJoint, Pickup,
17    RigidBody, Transform, TriggerFilter, TriggerVolume, VolumeEvent,
18};
19use crate::ecs::asset_id::AssetId;
20use crate::ecs::{
21    Entity, EntityByName, EventCursor, MenuActive, PipelineContext, ScheduleMode, SimTiming,
22    StepResult, System, WorldPhysicsBudget,
23};
24use crate::math::{cos, sin, sqrt};
25
26use super::budget::DriverCapacities;
27use super::contacts::{ContactBatch, ContactGate};
28use super::convert::{collider_shape, joint_spec};
29use super::fanout::{PhysicsFanout, SerialFanout};
30use super::index::SortedMap;
31use super::interp::PointInterp;
32use super::layers::{LAYER_CHARACTER, LAYER_PROP, LAYER_TRIGGER, LAYER_WORLD, LayerTable};
33use super::props::{PropBodies, PropCollSnap, STATIC_FRICTION};
34use super::terrain::{TerrainParams, build_heightfield, build_heightfield_collider};
35
36// Reach distance for picking up a Prop, in world units.
37const PICKUP_REACH: f32 = 3.0;
38// Minimum facing dot product (~60-degree cone) for a pickup.
39const PICKUP_MIN_DOT: f32 = 0.5;
40// Distance ahead of the camera a carried prop hovers.
41const HOLD_DISTANCE: f32 = 1.8;
42// Drop of a carried prop below eye level.
43const HOLD_DROP: f32 = 0.35;
44// Launch speed applied to a prop when it is dropped/thrown.
45const THROW_SPEED: f32 = 6.0;
46
47/// Rigid-body simulation behavior. Constructed internally by `World::start`
48/// from the world's `PhysicsConfig`; never a declarable asset.
49///
50/// [`new`](PhysicsSystem::new) steps the simulation on the calling thread; a
51/// host lends it threads through [`with_fanout`](PhysicsSystem::with_fanout).
52#[derive(Debug)]
53pub struct PhysicsSystem {
54    // Camera eye Y at spawn; the flat-floor fallback derives nothing from it,
55    // but it seeds a sensible fallback camera position.
56    floor_y: f32,
57    // Terrain parameters. None when terrain_subdivisions == 0 (flat floor).
58    terrain: Option<TerrainParams>,
59    // Reference to a `ProceduralMesh` asset whose `heightfield` generator
60    // drives the physics collider. Resolved against the live component list
61    // at `init`. Takes precedence over `terrain` when both are set.
62    terrain_mesh: Option<AssetId>,
63    // World-space Y offset applied to whichever terrain source is active
64    // (procedural noise or heightfield mesh). Matches the rendering Prop's
65    // `position[1]`.
66    terrain_offset_y: f32,
67    // The simulation, built in init() and sized from the world's budget.
68    world: Option<Simulation>,
69    // The player capsule, when the world has a Camera3D + RigidBody.
70    player: Option<PlayerPhysics>,
71    // One capsule per root-motion character rig (see `super::rig`).
72    rigs: Vec<super::rig::RigPhysics>,
73    // One entry per Prop that carries a collider.
74    props: PropBodies,
75    // Reader cursor over the `RootMotionEvent` event queue.
76    root_cursor: EventCursor,
77    // Scratch for the per-tick scan for freshly spawned collider-bearing
78    // entities, refilled every step.
79    new_props: Vec<(Entity, PropCollSnap)>,
80    // Per-step drain scratch, reused so the event handoffs never reallocate.
81    motion_scratch: Vec<crate::components::RootMotionEvent>,
82    contact_scratch: Vec<ContactHit>,
83    sensor_scratch: Vec<SensorCrossing>,
84    // Index into `props` of the prop currently being carried.
85    held: Option<usize>,
86    // Sensor tag -> the TriggerVolume it senses for, with its filter. Tags are
87    // the volume's AssetId, stamped into the sensor collider's user_data.
88    sensor_filters: SortedMap<u64, (AssetId, TriggerFilter)>,
89    // Layer-name resolution and the collide matrix from the PhysicsConfig.
90    layers: LayerTable,
91    // Minimum contact impulse for publishing a ContactEvent.
92    contact_min_impulse: f32,
93    // Strongest contact per body pair across the frame's ticks.
94    contact_batch: ContactBatch,
95    // Per-pair refractory so sustained contact reports once per impact.
96    contact_gate: ContactGate,
97    // Bodies the world authored room for beyond the ones it declares.
98    spawn_headroom: u32,
99    // Hard ceiling on live bodies: the simulation's whole reservation, so a
100    // spawn past it is refused rather than silently declined by a full pool.
101    // Resolved at init, along with the reservation itself.
102    body_cap: u32,
103    // Where a step's independent work runs. `SerialFanout` until a host lends
104    // its own.
105    fanout: Box<dyn PhysicsFanout>,
106}
107
108// Runtime physics state for the player camera capsule.
109#[derive(Debug)]
110struct PlayerPhysics {
111    handle: BodyHandle,
112    // The capsule each tick's move is resolved against. Its dimensions come
113    // from the RigidBody at init and never change afterwards.
114    shape: CharacterCapsule,
115    // Camera eye Y minus capsule-centre Y.
116    eye_offset: f32,
117    // False for a free-flying camera (no RigidBody): no gravity, no jump.
118    has_gravity: bool,
119    gravity_scale: f32,
120    jump_height: f32,
121    // Current vertical velocity (world units/second).
122    vy: f32,
123    // Whether the capsule rested on a surface last tick.
124    grounded: bool,
125    // Authoritative simulated capsule centre with its render blend snapshots.
126    center: PointInterp,
127    // The eye position written back last frame. A Camera3D position that
128    // differs was moved externally (free-fly, a teleport) and is adopted with
129    // no blend across the jump.
130    written_eye: Option<[f32; 3]>,
131}
132
133impl PhysicsSystem {
134    // Number of rigid bodies the physics world holds. Test-only observable for
135    // the body-reaping path.
136    #[cfg(test)]
137    fn physics_body_count(&self) -> usize {
138        self.world.as_ref().map_or(0, |w| w.body_count())
139    }
140
141    // Number of colliders the physics world holds. Test-only observable for
142    // the spawn/despawn leak checks.
143    #[cfg(test)]
144    fn physics_collider_count(&self) -> usize {
145        self.world.as_ref().map_or(0, |w| w.collider_count())
146    }
147
148    /// Build the simulation from the world's `PhysicsConfig` (floor / terrain).
149    /// Bodies and colliders are added from the ECS in [`System::init`].
150    pub fn new(config: PhysicsConfig) -> Self {
151        let terrain = if config.terrain_subdivisions > 0 {
152            Some(TerrainParams {
153                half_width: config.terrain_half_width,
154                half_depth: config.terrain_half_depth,
155                subdivisions: config.terrain_subdivisions,
156                amplitude: config.terrain_amplitude,
157                offset_y: config.terrain_offset_y,
158            })
159        } else {
160            None
161        };
162        Self {
163            floor_y: config.floor_y,
164            terrain,
165            terrain_mesh: config.terrain_mesh,
166            terrain_offset_y: config.terrain_offset_y,
167            world: None,
168            player: None,
169            rigs: Vec::new(),
170            props: PropBodies::default(),
171            root_cursor: EventCursor::default(),
172            new_props: Vec::new(),
173            motion_scratch: Vec::new(),
174            contact_scratch: Vec::new(),
175            sensor_scratch: Vec::new(),
176            held: None,
177            sensor_filters: SortedMap::default(),
178            layers: LayerTable::new(&config),
179            contact_min_impulse: config.contact_min_impulse.max(0.0),
180            contact_batch: ContactBatch::default(),
181            contact_gate: ContactGate::default(),
182            spawn_headroom: config.spawn_headroom,
183            body_cap: 0,
184            fanout: Box::new(SerialFanout),
185        }
186    }
187
188    /// Run the step's independent work through `fanout` rather than on the
189    /// calling thread. What a step hands out and the order its results load
190    /// back in are the simulation's; a fan-out decides only where the work
191    /// runs, so this cannot change the state a tick lands on.
192    pub fn with_fanout(mut self, fanout: Box<dyn PhysicsFanout>) -> Self {
193        self.fanout = fanout;
194        self
195    }
196
197    // The world's body budget: the record cook shipped, or, when no record
198    // shipped, the same derivation over the loaded components with the
199    // headroom its `PhysicsConfig` authored. Only a shipped record is checked
200    // against the live world.
201    //
202    // The two headrooms can differ: a shipped one is already raised to cover
203    // the spawners whose cadence bounds their population, while a directly
204    // constructed World gets only what its config authored, since nothing
205    // counted its spawners.
206    //
207    // Whichever it came from, the budget is the whole reservation: the
208    // simulation is sized from it and never grows, so its cap is the ceiling
209    // spawns are refused against.
210    fn resolve_budget(&self, ctx: &PipelineContext) -> PhysicsBudget {
211        let scan = super::budget::scan_counts(ctx);
212        let Some(record) = ctx.resource::<WorldPhysicsBudget>().map(|b| b.0) else {
213            return PhysicsBudget::derive(&scan, self.spawn_headroom);
214        };
215        let shipped = super::budget::budget_of(&record);
216        debug_assert_eq!(
217            shipped,
218            PhysicsBudget::derive(&scan, record.spawn_headroom),
219            "the shipped physics budget does not match the loaded world"
220        );
221        shipped
222    }
223
224    // Size every container the driver holds per body from the budget, once,
225    // before anything is built.
226    fn reserve(&mut self, budget: &PhysicsBudget) {
227        let caps = DriverCapacities::derive(budget);
228        self.props = PropBodies::with_capacity(&caps);
229        self.rigs = Vec::with_capacity(caps.rigs);
230        self.new_props = Vec::with_capacity(caps.new_props);
231        self.motion_scratch = Vec::with_capacity(caps.root_motions);
232        self.contact_scratch = Vec::with_capacity(caps.contacts);
233        self.sensor_scratch = Vec::with_capacity(caps.sensor_crossings);
234        self.contact_batch = ContactBatch::with_capacity(caps.contact_pairs);
235        self.contact_gate = ContactGate::with_capacity(caps.contact_pairs);
236        self.sensor_filters = SortedMap::with_capacity(caps.sensor_filters);
237    }
238
239    // Build one body per collider-bearing entity from its per-instance
240    // components (Transform + Collider + optional BodyDynamics + the Pickup
241    // tag), keying `body_handles` by AssetId (via the name index's inverse)
242    // so the joint wiring resolves.
243    fn build_prop_bodies(
244        &mut self,
245        ctx: &PipelineContext,
246        world: &mut Simulation,
247        body_handles: &mut BTreeMap<AssetId, BodyHandle>,
248    ) {
249        let entity_name: BTreeMap<Entity, AssetId> = ctx
250            .resource::<EntityByName>()
251            .map(|n| n.0.iter().map(|(&id, &e)| (e, id)).collect())
252            .unwrap_or_default();
253        let pickup: BTreeSet<Entity> = ctx.query_with_entity::<Pickup>().map(|(e, _)| e).collect();
254        let dynamics: BTreeMap<Entity, BodyDynamics> = ctx
255            .query_with_entity::<BodyDynamics>()
256            .map(|(e, b)| (e, *b))
257            .collect();
258        let snaps: Vec<(Entity, PropCollSnap)> = ctx
259            .join2::<Collider, Transform>()
260            .map(|(entity, collider, transform)| {
261                (
262                    entity,
263                    PropCollSnap {
264                        shape: collider_shape(&collider.0, transform.scale),
265                        layer: collider.0.layer.clone(),
266                        position: transform.position,
267                        rotation_deg: transform.rotation_deg,
268                        pickup: pickup.contains(&entity),
269                        dynamics: dynamics.get(&entity).copied(),
270                    },
271                )
272            })
273            .collect();
274
275        for (entity, snap) in snaps {
276            let Some(handle) = self.props.add(&self.layers, world, entity, snap) else {
277                continue;
278            };
279            if let Some(&id) = entity_name.get(&entity) {
280                body_handles.insert(id, handle);
281            }
282        }
283    }
284}
285
286impl System for PhysicsSystem {
287    fn init(&mut self, ctx: &mut PipelineContext) {
288        // Before anything is built, and before the joint wiring below drains
289        // the column the scan counts.
290        let budget = self.resolve_budget(ctx);
291        self.body_cap = budget.body_cap();
292        self.reserve(&budget);
293
294        // The simulation reserves the whole budget here, so nothing on the
295        // step path allocates and a body past the reservation is refused
296        // rather than grown into.
297        let mut world = Simulation::new(
298            SimConfig {
299                gravity: GRAVITY,
300                ..SimConfig::default()
301            },
302            budget.body_cap() as usize,
303        );
304        world.set_contact_min_impulse(self.contact_min_impulse, SimTiming::TICK_DT);
305        // The step's per-worker scratch, reserved from the schedule this world
306        // will run under. A serial schedule reserves one worker's worth, which
307        // is what a simulation that is never lent threads keeps.
308        world.reserve_workers(
309            self.fanout
310                .worker_count(ScheduleMode::current(ctx.resources)),
311        );
312        let world_mask = self.layers.mask(LAYER_WORLD);
313
314        // floor: heightfield-mesh-driven, procedural noise, or flat slab
315        let mut floor_built = false;
316        if let Some(mesh_id) = self.terrain_mesh {
317            let mesh_snap = ctx
318                .query::<crate::components::ProceduralMesh>()
319                .find(|m| m.asset_id == mesh_id)
320                .cloned();
321            // Anything else (missing asset, wrong generator, a collider that
322            // fails to build) leaves `floor_built` false and falls through to
323            // the flat-slab fallback below.
324            if let Some(m) = mesh_snap
325                && m.generator == "heightfield"
326                && build_heightfield_collider(
327                    &mut world,
328                    &m,
329                    self.terrain_offset_y,
330                    world_mask,
331                    ctx,
332                )
333                .is_ok()
334            {
335                floor_built = true;
336            }
337        }
338        if !floor_built {
339            if let Some(terrain) = self.terrain.clone() {
340                build_heightfield(&mut world, &terrain, world_mask);
341            } else {
342                // A large thin slab whose top face sits at Y = 0.
343                world.add_fixed(
344                    &ColliderShape::Cuboid {
345                        half_extents: [500.0, 5.0, 500.0],
346                    },
347                    [0.0, -5.0, 0.0],
348                    [0.0; 3],
349                    STATIC_FRICTION,
350                    world_mask,
351                );
352            }
353        }
354
355        // Sensor regions: one fixed sensor body per TriggerVolume, tagged with
356        // the volume's AssetId so step's crossing drain maps back to it.
357        let trigger_mask = self.layers.mask(LAYER_TRIGGER);
358        let volumes: Vec<TriggerVolume> = ctx.query::<TriggerVolume>().cloned().collect();
359        for volume in &volumes {
360            let shape = collider_shape(&volume.collider, [1.0; 3]);
361            let tag = u64::from(volume.asset_id.0);
362            if world
363                .add_sensor(
364                    &shape,
365                    volume.position,
366                    volume.rotation_deg,
367                    tag,
368                    trigger_mask,
369                )
370                .is_none()
371            {
372                continue;
373            }
374            self.sensor_filters
375                .insert(tag, (volume.asset_id, volume.detects));
376        }
377
378        // Prop name -> BodyHandle, populated alongside `self.prop_bodies`.
379        // Joints resolve their `body_a`/`body_b` references through this map.
380        let mut body_handles: BTreeMap<AssetId, BodyHandle> = BTreeMap::new();
381        self.build_prop_bodies(ctx, &mut world, &mut body_handles);
382
383        // joints
384        // Each PhysicsJoint references one or two Props by AssetId. Cross-reference
385        // validation already guarantees the Prop exists; here we additionally
386        // require the Prop to own a collider (and therefore a body). A PhysicsJoint
387        // with body_b empty anchors body_a to a hidden static body created on
388        // demand at the world-space `anchor_b`.
389        let joints: Vec<PhysicsJoint> = ctx.drain::<PhysicsJoint>();
390        for joint in joints {
391            let Some(body_a_id) = joint.body_a else {
392                continue;
393            };
394            let Some(handle_a) = body_handles.get(&body_a_id).copied() else {
395                continue;
396            };
397            let handle_b = if let Some(body_b_id) = joint.body_b {
398                match body_handles.get(&body_b_id).copied() {
399                    Some(h) => h,
400                    None => {
401                        continue;
402                    }
403                }
404            } else {
405                // Static world anchor at anchor_b. Sub-millimetre ball so it
406                // takes effectively no space in the broad phase.
407                let anchor = world.add_fixed(
408                    &ColliderShape::Ball { radius: 0.001 },
409                    joint.anchor_b,
410                    [0.0; 3],
411                    0.0,
412                    world_mask,
413                );
414                match anchor {
415                    Some(handle) => handle,
416                    None => continue,
417                }
418            };
419            // When body_b is the implicit world anchor, the anchor sits at the
420            // origin of that hidden body, not at the authored offset.
421            let anchor_b = if joint.body_b.is_some() {
422                joint.anchor_b
423            } else {
424                [0.0, 0.0, 0.0]
425            };
426            if !world.add_joint(
427                handle_a,
428                handle_b,
429                joint.anchor_a,
430                anchor_b,
431                joint_spec(&joint),
432            ) {
433                continue;
434            }
435        }
436        // player capsule for the Camera3D
437        // Every first-person camera is collided as a capsule. A RigidBody
438        // upgrades it from a free-flying spectator to a grounded,
439        // gravity-bound character. A third-person camera (a controller with
440        // a `follow` block) gets no capsule: it is a virtual orbit around the
441        // followed character, whose own rig capsule is the collided body.
442        let camera_pos = ctx
443            .query::<Camera3D>()
444            .next()
445            .filter(|c| {
446                c.controller
447                    .as_ref()
448                    .is_none_or(|ctrl| ctrl.follow.is_none())
449            })
450            .map(|c| c.position);
451        if let Some(cam_pos) = camera_pos {
452            let rb_opt = ctx.query::<RigidBody>().next().cloned();
453            let has_gravity = rb_opt.is_some();
454            let rb = rb_opt.unwrap_or_default();
455            if self.floor_y == 0.0 {
456                self.floor_y = cam_pos[1];
457            }
458            let radius = rb.capsule_radius.max(0.05);
459            let half_height = ((rb.capsule_height * 0.5) - radius).max(0.05);
460            // a grounded character's eye sits at the capsule top; a flying
461            // camera's capsule is centred on the eye.
462            let eye_offset = if has_gravity {
463                (rb.capsule_height * 0.5).max(radius + 0.05)
464            } else {
465                0.0
466            };
467            let center = [cam_pos[0], cam_pos[1] - eye_offset, cam_pos[2]];
468            world.configure_character(rb.max_slope_deg, rb.step_height, has_gravity);
469            let handle = world.add_character(
470                half_height,
471                radius,
472                center,
473                self.layers.mask(LAYER_CHARACTER),
474            );
475            self.player = handle.map(|handle| PlayerPhysics {
476                handle,
477                shape: CharacterCapsule::new(half_height, radius),
478                eye_offset,
479                has_gravity,
480                gravity_scale: rb.gravity_scale.max(0.0),
481                jump_height: rb.jump_height.max(0.0),
482                vy: 0.0,
483                grounded: true,
484                center: PointInterp::new(center),
485                written_eye: None,
486            });
487        }
488
489        // Kinematic capsules for the root-motion character rigs published by
490        // GraphicsSystem (which ran init first this tick).
491        super::rig::init_rigs(
492            &mut world,
493            ctx,
494            self.layers.mask(LAYER_CHARACTER),
495            &mut self.rigs,
496        );
497
498        // Published from the built world: the simulation's own storage is only
499        // knowable once it is reserved.
500        super::budget::publish_reservation(crate::memory::ledger(), &budget, &world);
501        self.world = Some(world);
502    }
503
504    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
505        // Freeze while a menu is open: skip the solve and the write-back so the
506        // world truly pauses (and an external editor's edits to simulated
507        // Transforms are not stomped by a stale blend). The simulation clock
508        // holds its accumulator across the pause, so resuming costs one normal
509        // frame. The flag is published by whichever system owns the menu, which
510        // runs first in the table, so it reflects this same tick.
511        if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
512            return StepResult::Continue;
513        }
514
515        if self.world.is_none() {
516            return StepResult::Continue;
517        }
518
519        // The frame's fixed-tick budget and render blend factor. Absent (a
520        // directly-stepped world with no App), every step runs exactly one tick
521        // and writes the freshly simulated state.
522        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
523
524        // snapshot reads (released before any query_mut below)
525        let (cam_pos, cam_yaw, cam_pitch, desired_move, jump_req, interact_req) = ctx
526            .query::<Camera3D>()
527            .next()
528            .map(|c| {
529                (
530                    c.position,
531                    c.yaw,
532                    c.pitch,
533                    c.desired_move,
534                    c.jump_requested,
535                    c.interact_requested,
536                )
537            })
538            .unwrap_or(([0.0, self.floor_y, 0.0], 0.0, 0.0, [0.0; 3], false, false));
539
540        // camera-space basis vectors
541        let fwd_flat = [-sin(cam_yaw), 0.0, -cos(cam_yaw)];
542        let fwd_full = [
543            -(sin(cam_yaw) * cos(cam_pitch)),
544            -sin(cam_pitch),
545            -(cos(cam_yaw) * cos(cam_pitch)),
546        ];
547
548        // Whichever pool the schedule names, read once for the frame. Both
549        // land in the same place; only how long the step takes differs.
550        let mode = ScheduleMode::current(ctx.resources);
551
552        let world = self.world.as_mut().expect("world checked above");
553
554        // Reap bodies whose entity was despawned before the step, keeping
555        // self.held a valid index into the compacted list.
556        self.held = self
557            .props
558            .reap(world, self.held, |entity| ctx.is_alive(entity));
559
560        // Adopt collider-bearing entities that appeared since init (runtime
561        // spawns): each gets a body at its spawn transform, with its pose
562        // snapshots seeded there so the render blend starts clean. The scan
563        // materializes into scratch because adopting one mutates the tracked
564        // set the scan itself reads.
565        self.new_props.clear();
566        self.new_props.extend(
567            ctx.join2::<Collider, Transform>()
568                .filter(|(entity, _, _)| {
569                    !self.props.is_tracked(*entity) && !self.props.is_refused(*entity)
570                })
571                .map(|(entity, collider, transform)| {
572                    (
573                        entity,
574                        PropCollSnap {
575                            shape: collider_shape(&collider.0, transform.scale),
576                            layer: collider.0.layer.clone(),
577                            position: transform.position,
578                            rotation_deg: transform.rotation_deg,
579                            pickup: false,
580                            dynamics: None,
581                        },
582                    )
583                }),
584        );
585        for (entity, mut snap) in self.new_props.drain(..) {
586            snap.pickup = ctx.get::<Pickup>(entity).is_some();
587            snap.dynamics = ctx.get::<BodyDynamics>(entity).copied();
588            self.props
589                .adopt(&self.layers, world, entity, snap, self.body_cap);
590        }
591
592        // pickup / drop on the interact edge; held_changed carries the entity to
593        // toggle the Held tag on in the write-back.
594        let mut held_changed: Option<(Entity, bool)> = None;
595        if interact_req {
596            if let Some(held_idx) = self.held.take() {
597                // drop: hand the prop back to dynamic simulation with a throw.
598                let pp = self.props.get(held_idx).expect("held index is valid");
599                let throw = [
600                    fwd_full[0] * THROW_SPEED,
601                    fwd_full[1] * THROW_SPEED + 1.0,
602                    fwd_full[2] * THROW_SPEED,
603                ];
604                world.make_dynamic(pp.handle, throw);
605                held_changed = Some((pp.entity, false));
606            } else {
607                // pickup: nearest carriable prop within reach the player faces.
608                // Entity positions for the reach test, read from the Transform
609                // column only on the interact edge (not every frame).
610                let entity_positions: BTreeMap<Entity, [f32; 3]> = ctx
611                    .query_with_entity::<Transform>()
612                    .map(|(e, t)| (e, t.position))
613                    .collect();
614                let mut best: Option<(f32, usize)> = None;
615                for (idx, pp) in self.props.iter().enumerate() {
616                    if !pp.pickup {
617                        continue;
618                    }
619                    let pos = entity_positions.get(&pp.entity).copied().unwrap_or(cam_pos);
620                    let dx = pos[0] - cam_pos[0];
621                    let dz = pos[2] - cam_pos[2];
622                    let dist = sqrt(dx * dx + dz * dz);
623                    if dist >= PICKUP_REACH || dist <= 0.0 {
624                        continue;
625                    }
626                    let dot = (fwd_flat[0] * dx + fwd_flat[2] * dz) / dist;
627                    if dot > PICKUP_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
628                        best = Some((dist, idx));
629                    }
630                }
631                if let Some((_, idx)) = best {
632                    let pp = self.props.get(idx).expect("scanned index is valid");
633                    world.make_kinematic(pp.handle);
634                    held_changed = Some((pp.entity, true));
635                    self.held = Some(idx);
636                }
637            }
638        }
639
640        // Adopt an externally moved camera (free-fly, a teleport): a position
641        // that differs from the eye written back last frame was not ours, so
642        // the capsule snaps to it with no blend across the jump.
643        if let Some(player) = self.player.as_mut()
644            && player.written_eye != Some(cam_pos)
645        {
646            player
647                .center
648                .snap([cam_pos[0], cam_pos[1] - player.eye_offset, cam_pos[2]]);
649        }
650
651        // The carried prop's hover point in front of the camera, refreshed
652        // from this frame's camera pose.
653        let hold_pos = [
654            cam_pos[0] + fwd_full[0] * HOLD_DISTANCE,
655            cam_pos[1] + fwd_full[1] * HOLD_DISTANCE - HOLD_DROP,
656            cam_pos[2] + fwd_full[2] * HOLD_DISTANCE,
657        ];
658
659        // Root-motion displacements published since last frame, applied on the
660        // frame's first tick. Rig capsules whose entity moved externally snap
661        // before any tick runs.
662        super::rig::drain_motions_into(ctx, &mut self.root_cursor, &mut self.motion_scratch);
663        super::rig::sync_rigs(ctx, &mut self.rigs);
664
665        for tick in 0..timing.ticks {
666            let dt = timing.tick_dt;
667
668            // carried prop hovers in front of the camera
669            if let Some(prop) = self.held.and_then(|idx| self.props.get(idx)) {
670                world.set_kinematic_translation(prop.handle, hold_pos);
671            }
672
673            // move the player capsule
674            if let Some(player) = self.player.as_mut() {
675                if player.has_gravity {
676                    if tick == 0 && jump_req && player.grounded && player.jump_height > 0.0 {
677                        player.vy = sqrt(2.0 * GRAVITY * player.gravity_scale * player.jump_height);
678                    }
679                    player.vy -= GRAVITY * player.gravity_scale * dt;
680                }
681
682                let center = player.center.current();
683                let desired = [desired_move[0] * dt, player.vy * dt, desired_move[2] * dt];
684                let moved = world.move_character(
685                    &player.shape,
686                    &CharacterMoveInput {
687                        center,
688                        desired,
689                        dt,
690                        exclude: player.handle,
691                        mask: self.layers.mask(LAYER_CHARACTER),
692                    },
693                );
694                let new_center = [
695                    center[0] + moved.translation[0],
696                    center[1] + moved.translation[1],
697                    center[2] + moved.translation[2],
698                ];
699                world.set_kinematic_translation(player.handle, new_center);
700
701                player.grounded = moved.grounded;
702                if moved.grounded && player.vy < 0.0 {
703                    player.vy = 0.0;
704                }
705                player.center.push(new_center);
706            }
707
708            // move the root-motion character rig capsules
709            super::rig::tick_rigs(
710                world,
711                ctx,
712                &mut self.rigs,
713                if tick == 0 { &self.motion_scratch } else { &[] },
714                dt,
715                GRAVITY,
716                self.layers.mask(LAYER_CHARACTER),
717            );
718
719            // advance the simulation
720            self.fanout.step(world, dt, mode);
721
722            // batch the tick's contact hits (strongest per pair this frame)
723            self.contact_gate.advance_tick();
724            world.drain_contact_hits_into(&mut self.contact_scratch);
725            for hit in self.contact_scratch.drain(..) {
726                self.contact_batch.add(hit);
727            }
728
729            // record the tick's dynamic prop poses for the render blend
730            self.props.record_tick_poses(world);
731        }
732
733        // answer the IK ground probes and the follow camera's occlusion probe
734        super::probes::step_probes(
735            world,
736            ctx,
737            &self.rigs,
738            self.layers
739                .query_mask(LAYER_CHARACTER, &[LAYER_WORLD, LAYER_PROP]),
740        );
741
742        // publish the frame's contact events: one per body pair that passed
743        // the impulse threshold, gated by the per-pair refractory. `a` is
744        // always a prop entity; a hit whose sides both lack one (terrain,
745        // capsules) has no consumer-visible subject and is dropped.
746        for hit in self.contact_batch.drain() {
747            let event = match (self.props.entity_of(hit.a), self.props.entity_of(hit.b)) {
748                (Some(a), b) => ContactEvent {
749                    a,
750                    b,
751                    point: hit.point,
752                    normal: hit.normal,
753                    impulse: hit.impulse,
754                },
755                (None, Some(b)) => ContactEvent {
756                    a: b,
757                    b: None,
758                    point: hit.point,
759                    normal: [-hit.normal[0], -hit.normal[1], -hit.normal[2]],
760                    impulse: hit.impulse,
761                },
762                (None, None) => continue,
763            };
764            if self.contact_gate.admit(&hit) {
765                ctx.events_mut::<ContactEvent>().send(event);
766            }
767        }
768
769        // publish the sensor boundary crossings that pass their volume's
770        // filter. A crossing whose body was removed this same step has no
771        // `other` to classify, so only an `any` volume reports it.
772        world.drain_sensor_crossings_into(&mut self.sensor_scratch);
773        for crossing in self.sensor_scratch.drain(..) {
774            let Some(&(volume, filter)) = self.sensor_filters.get(&crossing.tag) else {
775                continue;
776            };
777            let passes = match filter {
778                TriggerFilter::Player => crossing.other.is_some_and(|h| {
779                    self.player.as_ref().is_some_and(|p| p.handle == h)
780                        || self.rigs.iter().any(|r| r.handle == h)
781                }),
782                TriggerFilter::Props => crossing
783                    .other
784                    .is_some_and(|h| self.props.entity_of(h).is_some()),
785                TriggerFilter::Any => true,
786            };
787            if passes {
788                ctx.events_mut::<VolumeEvent>().send(VolumeEvent {
789                    volume,
790                    entered: crossing.entered,
791                });
792            }
793        }
794
795        // Write each dynamic prop's blended pose back to its Transform:
796        // positions lerped, rotations slerped as quaternions, with the Euler
797        // decomposition happening only here at the write boundary.
798        let alpha = timing.alpha;
799        for &(entity, pos, rot) in self.props.sample_poses(alpha) {
800            if let Some(t) = ctx.get_mut::<Transform>(entity) {
801                t.position = pos;
802                t.rotation_deg = rot;
803            }
804        }
805        if let Some((entity, is_held)) = held_changed {
806            if is_held {
807                if ctx.get::<Held>(entity).is_none() {
808                    ctx.insert(entity, Held);
809                }
810            } else {
811                ctx.remove::<Held>(entity);
812            }
813        }
814
815        // write the blended camera position + view matrix
816        let mut grounded = true;
817        if let Some(player) = self.player.as_mut() {
818            let center = player.center.sample(alpha);
819            let eye = [center[0], center[1] + player.eye_offset, center[2]];
820            player.written_eye = Some(eye);
821            grounded = player.grounded;
822            for camera in ctx.query_mut::<Camera3D>() {
823                camera.position = eye;
824                camera.view_matrix =
825                    crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
826            }
827        }
828
829        // publish grounded state for jump gating
830        for body in ctx.query_mut::<RigidBody>() {
831            body.is_grounded = grounded;
832        }
833
834        // write the blended rig positions for the render follow
835        super::rig::publish_rigs(ctx, &mut self.rigs, alpha);
836
837        StepResult::Continue
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use alloc::string::ToString;
845    use alloc::vec;
846
847    use crate::components::{CameraController, CharacterRig, FollowController, PropCollider};
848    use crate::ecs::SkinnedMeshHandle;
849    use crate::physics::budget::{record_of, scan_counts};
850    use crate::physics::test_world::TestWorld;
851
852    // Make a spawned prop dynamic, exactly as the load-time PropBody
853    // decomposition would.
854    fn make_dynamic(world: &mut TestWorld, entity: Entity) {
855        world.components.insert_typed(entity, ball_dynamics());
856    }
857
858    fn controlled_camera() -> Camera3D {
859        Camera3D {
860            fov_y_degrees: 75.0,
861            near: 0.05,
862            far: 200.0,
863            view_matrix: [[0.0; 4]; 4],
864            position: [0.0, 1.0, 0.0],
865            yaw: 0.0,
866            pitch: 0.0,
867            desired_move: [0.0; 3],
868            jump_requested: false,
869            interact_requested: false,
870            controller: Some(CameraController::default()),
871        }
872    }
873
874    // A third-person camera is a virtual orbit: no player capsule is created
875    // for it. (Regression: the spectator capsule spawned at the camera eye
876    // overlapped the followed rig's capsule and squeezed it through the
877    // floor.) A first-person camera keeps its capsule. The schedule gate that
878    // builds the system at all is covered by the engine's schedule tests.
879    #[test]
880    fn third_person_camera_gets_no_player_capsule() {
881        // Third-person (follow) camera: a virtual orbit, so no player capsule.
882        let mut world = TestWorld::new();
883        let mut camera = controlled_camera();
884        camera.controller = Some(CameraController {
885            follow: Some(FollowController {
886                target: Some(SkinnedMeshHandle(1)),
887                ..FollowController::default()
888            }),
889            ..CameraController::default()
890        });
891        world.components.push_typed(camera);
892        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
893        physics.init(&mut world.ctx());
894        assert!(physics.player.is_none(), "no capsule for the orbit camera");
895
896        // First-person camera keeps its capsule.
897        let mut world = TestWorld::new();
898        world.components.push_typed(controlled_camera());
899        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
900        physics.init(&mut world.ctx());
901        assert!(
902            physics.player.is_some(),
903            "first-person camera keeps its capsule"
904        );
905    }
906
907    fn ball_dynamics() -> BodyDynamics {
908        BodyDynamics {
909            mass: 1.0,
910            friction: 0.5,
911            linear_damping: 0.0,
912            ..Default::default()
913        }
914    }
915
916    // A camera looking down -Z with the interact field latched on, so the
917    // PhysicsSystem (which reads Camera3D.interact_requested) triggers a pickup.
918    fn interacting_camera(position: [f32; 3]) -> Camera3D {
919        Camera3D {
920            interact_requested: true,
921            controller: None,
922            position,
923            ..controlled_camera()
924        }
925    }
926
927    // The simulated pose is written back to the prop's Transform. With no
928    // `SimTiming` published, each step runs exactly one fixed tick.
929    #[test]
930    fn dynamic_prop_writes_transform() {
931        let id = AssetId(1);
932        let mut world = TestWorld::new();
933        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
934        make_dynamic(&mut world, entity);
935
936        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
937        physics.init(&mut world.ctx());
938        for _ in 0..10 {
939            physics.step(&mut world.ctx());
940        }
941
942        let transform_y = world.components.get::<Transform>(entity).unwrap().position[1];
943        assert!(
944            transform_y < 5.0,
945            "the simulated pose falls the Transform (y={transform_y})"
946        );
947    }
948
949    // A menu freezes the solve: while `MenuActive(true)` is published the body
950    // does not fall, and clearing it resumes the fall from where it froze.
951    // (The App-level simulation clock additionally holds its accumulator
952    // across the pause, so a live run resumes without a catch-up burst.)
953    #[test]
954    fn menu_active_freezes_then_resumes_physics() {
955        let id = AssetId(1);
956        let mut world = TestWorld::new();
957        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
958        make_dynamic(&mut world, entity);
959
960        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
961        physics.init(&mut world.ctx());
962
963        // Paused: the body stays put however many frames pass.
964        world.resources.insert(MenuActive(true));
965        for _ in 0..5 {
966            physics.step(&mut world.ctx());
967        }
968        let y_paused = world.components.get::<Transform>(entity).unwrap().position[1];
969        assert!(
970            (y_paused - 5.0).abs() < 1e-3,
971            "the body must not fall while a menu is active (y={y_paused})"
972        );
973
974        // Resumed: the body falls again.
975        world.resources.insert(MenuActive(false));
976        for _ in 0..5 {
977            physics.step(&mut world.ctx());
978        }
979        let y_resumed = world.components.get::<Transform>(entity).unwrap().position[1];
980        assert!(
981            y_resumed < y_paused - 1e-3,
982            "the body must fall once the menu closes (y={y_resumed})"
983        );
984    }
985
986    // A zero-tick frame (the accumulator has not crossed a tick) advances
987    // nothing; the written Transform blends between the last two ticks by the
988    // frame's alpha.
989    #[test]
990    fn zero_tick_frames_blend_between_the_last_two_ticks() {
991        let id = AssetId(1);
992        let mut world = TestWorld::new();
993        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
994        make_dynamic(&mut world, entity);
995
996        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
997        physics.init(&mut world.ctx());
998
999        let tick = |ticks, alpha| SimTiming {
1000            ticks,
1001            tick_dt: SimTiming::TICK_DT,
1002            alpha,
1003        };
1004        world.resources.insert(tick(1, 1.0));
1005        physics.step(&mut world.ctx());
1006        let y_prev = world.components.get::<Transform>(entity).unwrap().position[1];
1007        physics.step(&mut world.ctx());
1008        let y_curr = world.components.get::<Transform>(entity).unwrap().position[1];
1009        assert!(y_curr < y_prev, "the body falls tick over tick");
1010
1011        // No tick, alpha 0: the write-back returns to the previous tick's pose.
1012        world.resources.insert(tick(0, 0.0));
1013        physics.step(&mut world.ctx());
1014        let y_alpha0 = world.components.get::<Transform>(entity).unwrap().position[1];
1015        assert!(
1016            (y_alpha0 - y_prev).abs() < 1e-6,
1017            "alpha 0 samples the previous tick"
1018        );
1019
1020        // No tick, alpha 0.5: halfway between the two ticks.
1021        world.resources.insert(tick(0, 0.5));
1022        physics.step(&mut world.ctx());
1023        let y_mid = world.components.get::<Transform>(entity).unwrap().position[1];
1024        let expected = (y_prev + y_curr) * 0.5;
1025        assert!(
1026            (y_mid - expected).abs() < 1e-6,
1027            "alpha 0.5 blends the tick poses (y={y_mid}, expected {expected})"
1028        );
1029    }
1030
1031    // The same number of fixed ticks produces bit-identical world state
1032    // however they are grouped into frames: 30 fps frames (two ticks each)
1033    // against 120 fps frames (a tick every other frame).
1034    #[test]
1035    fn tick_grouping_does_not_change_the_outcome() {
1036        let run = |frames: &[u32]| -> ([f32; 3], [f32; 3]) {
1037            let id = AssetId(1);
1038            let mut world = TestWorld::new();
1039            let entity = world.spawn_prop(id, [0.3, 5.0, 0.1], false);
1040            make_dynamic(&mut world, entity);
1041
1042            let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1043            physics.init(&mut world.ctx());
1044            for &ticks in frames {
1045                world.resources.insert(SimTiming {
1046                    ticks,
1047                    tick_dt: SimTiming::TICK_DT,
1048                    alpha: 1.0,
1049                });
1050                physics.step(&mut world.ctx());
1051            }
1052            let t = world.components.get::<Transform>(entity).unwrap();
1053            (t.position, t.rotation_deg)
1054        };
1055
1056        // One simulated second: 30 frames of 2 ticks vs 120 frames alternating
1057        // 0 and 1 ticks. Both run exactly 60 fixed ticks.
1058        let thirty: Vec<u32> = core::iter::repeat_n(2, 30).collect();
1059        let one_twenty: Vec<u32> = (0..120).map(|i| i % 2).collect();
1060        assert_eq!(
1061            run(&thirty),
1062            run(&one_twenty),
1063            "the fixed-tick outcome must not depend on frame grouping"
1064        );
1065    }
1066
1067    // Despawning a decomposed prop reaps its body, so it stops simulating
1068    // (and colliding) once its entity is gone.
1069    #[test]
1070    fn despawning_a_prop_reaps_its_physics_body() {
1071        let id = AssetId(1);
1072        let mut world = TestWorld::new();
1073        let ball = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1074        make_dynamic(&mut world, ball);
1075
1076        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1077        physics.init(&mut world.ctx());
1078
1079        // Settle so the body is live and falling.
1080        for _ in 0..2 {
1081            physics.step(&mut world.ctx());
1082        }
1083        let before = physics.physics_body_count();
1084
1085        // Despawn the ball (stand-in for GraphicsSystem) and step: PhysicsSystem
1086        // reaps the orphaned body.
1087        world.components.despawn(ball);
1088        physics.step(&mut world.ctx());
1089        let after = physics.physics_body_count();
1090        assert_eq!(after, before - 1, "the despawned prop's body was removed");
1091
1092        // The sim keeps running cleanly with the body gone (no further removals).
1093        physics.step(&mut world.ctx());
1094        assert_eq!(
1095            physics.physics_body_count(),
1096            after,
1097            "no further bodies removed"
1098        );
1099    }
1100
1101    // Picking up a carriable prop tags its entity with Held.
1102    #[test]
1103    fn pickup_sets_held_tag() {
1104        let id = AssetId(1);
1105        let mut world = TestWorld::new();
1106        let carriable = world.spawn_prop(id, [0.0, 1.0, -2.0], true);
1107        make_dynamic(&mut world, carriable);
1108        world
1109            .components
1110            .push_typed(interacting_camera([0.0, 1.0, 0.0]));
1111
1112        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1113        physics.init(&mut world.ctx());
1114
1115        physics.step(&mut world.ctx());
1116
1117        assert_eq!(
1118            world.ctx().query::<Held>().count(),
1119            1,
1120            "pickup inserts the Held tag on the entity"
1121        );
1122    }
1123
1124    // A collider-bearing entity that appears after init (a runtime spawn) is
1125    // adopted on the next step: it gets a body and falls like an authored one.
1126    // The headroom is what reserves the body for it: the simulation is sized
1127    // once at init and a spawn past the reservation is refused.
1128    #[test]
1129    fn runtime_spawned_prop_gets_a_body_and_falls() {
1130        let mut world = TestWorld::new();
1131        let mut physics = PhysicsSystem::new(PhysicsConfig {
1132            spawn_headroom: 1,
1133            ..PhysicsConfig::default()
1134        });
1135        physics.init(&mut world.ctx());
1136        let baseline = physics.physics_body_count();
1137
1138        let spawned = world.spawn_prop(AssetId(7), [0.0, 5.0, 0.0], false);
1139        make_dynamic(&mut world, spawned);
1140        physics.step(&mut world.ctx());
1141        assert_eq!(
1142            physics.physics_body_count(),
1143            baseline + 1,
1144            "the spawned entity got a body on its first step"
1145        );
1146        for _ in 0..30 {
1147            physics.step(&mut world.ctx());
1148        }
1149        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1150        assert!(y < 4.5, "the spawned body falls (y = {y})");
1151        for _ in 0..300 {
1152            physics.step(&mut world.ctx());
1153        }
1154        let y = world.components.get::<Transform>(spawned).unwrap().position[1];
1155        assert!(
1156            (y - 0.5).abs() < 0.1,
1157            "the spawned ball rests on the flat floor (y = {y})"
1158        );
1159    }
1160
1161    // Spawn, despawn, and respawn leave no bodies or colliders behind: a
1162    // reaped body hands its slot back, so one body's worth of headroom covers
1163    // any number of rounds.
1164    #[test]
1165    fn spawn_despawn_respawn_cycle_is_leak_free() {
1166        let mut world = TestWorld::new();
1167        let mut physics = PhysicsSystem::new(PhysicsConfig {
1168            spawn_headroom: 1,
1169            ..PhysicsConfig::default()
1170        });
1171        physics.init(&mut world.ctx());
1172        let bodies = physics.physics_body_count();
1173        let colliders = physics.physics_collider_count();
1174
1175        for round in 0..3 {
1176            let spawned = world.spawn_prop(AssetId(100 + round), [0.0, 3.0, 0.0], false);
1177            make_dynamic(&mut world, spawned);
1178            physics.step(&mut world.ctx());
1179            assert_eq!(physics.physics_body_count(), bodies + 1, "round {round}");
1180            world.components.despawn(spawned);
1181            physics.step(&mut world.ctx());
1182            assert_eq!(
1183                physics.physics_body_count(),
1184                bodies,
1185                "round {round} reaped the body"
1186            );
1187            assert_eq!(
1188                physics.physics_collider_count(),
1189                colliders,
1190                "round {round} reaped the collider"
1191            );
1192        }
1193    }
1194
1195    // A world whose shipped budget was counted from the same content it holds
1196    // passes the init assert, and reserves the cap that budget implies.
1197    #[test]
1198    fn a_shipped_budget_matching_the_world_is_adopted() {
1199        let mut world = TestWorld::new();
1200        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1201        make_dynamic(&mut world, entity);
1202        world.components.push_typed(controlled_camera());
1203
1204        // The record cook would have written for this world: one dynamic prop,
1205        // the floor, the player capsule, and room for two spawns.
1206        let counts = scan_counts(&world.ctx());
1207        let budget = PhysicsBudget::derive(&counts, 2);
1208        assert_eq!(budget.dynamic, 1);
1209        assert_eq!(budget.kinematic, 1, "the first-person camera capsule");
1210        world
1211            .resources
1212            .insert(WorldPhysicsBudget(record_of(&budget)));
1213
1214        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1215        physics.init(&mut world.ctx());
1216        assert_eq!(physics.body_cap, budget.body_cap());
1217        assert_eq!(
1218            physics.physics_body_count(),
1219            budget.body_total() as usize,
1220            "init built exactly the bodies the budget reserved"
1221        );
1222    }
1223
1224    // A world with no shipped budget (built in memory, as every test world is)
1225    // reserves from what it holds plus the headroom its config authored, and
1226    // caps spawns there. The cap used to be left open for such a world, on the
1227    // grounds that nothing had counted its spawns; a fixed-capacity simulation
1228    // cannot honour that -- a spawn past the reservation gets no body either
1229    // way, and the cap is what turns a silently declined one into a refusal
1230    // naming the knob to raise.
1231    #[test]
1232    fn a_world_without_a_shipped_budget_reserves_from_what_it_holds() {
1233        let mut world = TestWorld::new();
1234        let entity = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1235        make_dynamic(&mut world, entity);
1236
1237        let mut physics = PhysicsSystem::new(PhysicsConfig {
1238            spawn_headroom: 4,
1239            ..PhysicsConfig::default()
1240        });
1241        physics.init(&mut world.ctx());
1242
1243        assert_eq!(
1244            physics.physics_body_count(),
1245            2,
1246            "the floor and the one authored prop"
1247        );
1248        assert_eq!(physics.body_cap, 2 + 4, "plus the authored headroom");
1249    }
1250
1251    // Past the cap, a spawned prop gets no body and the world keeps stepping.
1252    // The refusal happens once: the next tick's scan passes over the entity
1253    // rather than re-refusing it.
1254    #[test]
1255    fn a_spawn_past_the_shipped_budget_is_refused() {
1256        let mut world = TestWorld::new();
1257        let authored = world.spawn_prop(AssetId(1), [0.0, 3.0, 0.0], false);
1258        make_dynamic(&mut world, authored);
1259
1260        // A budget with no headroom: the floor and the one authored prop.
1261        let counts = scan_counts(&world.ctx());
1262        let budget = PhysicsBudget::derive(&counts, 0);
1263        world
1264            .resources
1265            .insert(WorldPhysicsBudget(record_of(&budget)));
1266
1267        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1268        physics.init(&mut world.ctx());
1269        let full = physics.physics_body_count();
1270        assert_eq!(full, budget.body_cap() as usize, "the budget is spent");
1271
1272        let spawned = world.spawn_prop(AssetId(2), [0.0, 6.0, 0.0], false);
1273        make_dynamic(&mut world, spawned);
1274        physics.step(&mut world.ctx());
1275        assert_eq!(physics.physics_body_count(), full, "no body was built");
1276        assert!(physics.props.is_refused(spawned));
1277
1278        // Stepping on keeps the refusal: the entity is skipped, not retried,
1279        // and the authored prop carries on simulating.
1280        for _ in 0..10 {
1281            physics.step(&mut world.ctx());
1282        }
1283        assert_eq!(physics.physics_body_count(), full);
1284        let refused_y = world.components.get::<Transform>(spawned).unwrap().position[1];
1285        assert_eq!(refused_y, 6.0, "a refused prop is not simulated at all");
1286        let live_y = world
1287            .components
1288            .get::<Transform>(authored)
1289            .unwrap()
1290            .position[1];
1291        assert!(live_y < 3.0, "the authored prop still falls");
1292    }
1293
1294    // A rig capsule authored standing exactly on the floor stays there: it
1295    // neither sinks tick after tick nor is lifted off it. The driver used to
1296    // spawn one a fingernail above its authored position, because the
1297    // controller ignored a hit a downward move started already touching and
1298    // the capsule sank a little every frame; the controller now separates
1299    // along the contact normal instead, so the authored position is the one
1300    // that holds.
1301    #[test]
1302    fn a_rig_capsule_spawned_on_the_floor_neither_sinks_nor_rises() {
1303        let identity = [
1304            [1.0, 0.0, 0.0, 0.0],
1305            [0.0, 1.0, 0.0, 0.0],
1306            [0.0, 0.0, 1.0, 0.0],
1307            [0.0, 0.0, 0.0, 1.0],
1308        ];
1309        let mut world = TestWorld::new();
1310        world.components.push_typed(CharacterRig::new(
1311            SkinnedMeshHandle(1),
1312            0,
1313            identity,
1314            0.6,
1315            0.3,
1316        ));
1317
1318        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1319        physics.init(&mut world.ctx());
1320        let rig_y = |world: &mut TestWorld| {
1321            world
1322                .ctx()
1323                .query::<CharacterRig>()
1324                .next()
1325                .expect("the rig is there")
1326                .position[1]
1327        };
1328        assert_eq!(
1329            rig_y(&mut world),
1330            0.0,
1331            "the capsule spawns at its authored position, unlifted"
1332        );
1333
1334        for _ in 0..30 {
1335            physics.step(&mut world.ctx());
1336        }
1337        let settled = rig_y(&mut world);
1338        for _ in 0..300 {
1339            physics.step(&mut world.ctx());
1340        }
1341        let held = rig_y(&mut world);
1342
1343        assert!(
1344            settled.abs() < 0.01,
1345            "the capsule stayed on the floor (y = {settled})"
1346        );
1347        assert!(
1348            (held - settled).abs() < 1.0e-4,
1349            "and stopped moving ({settled} -> {held})"
1350        );
1351    }
1352
1353    // A hard landing publishes one ContactEvent naming the prop; resting
1354    // afterwards stays silent.
1355    #[test]
1356    fn contact_event_fires_on_impact_and_not_at_rest() {
1357        let id = AssetId(1);
1358        let mut world = TestWorld::new();
1359        let entity = world.spawn_prop(id, [0.0, 5.0, 0.0], false);
1360        make_dynamic(&mut world, entity);
1361
1362        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1363        physics.init(&mut world.ctx());
1364
1365        let mut cursor = EventCursor::default();
1366        let mut impacts: Vec<ContactEvent> = Vec::new();
1367        for _ in 0..120 {
1368            physics.step(&mut world.ctx());
1369            let ctx = world.ctx();
1370            if let Some(events) = ctx.events::<ContactEvent>() {
1371                impacts.extend(events.read(&mut cursor).copied());
1372            }
1373        }
1374        assert_eq!(impacts.len(), 1, "one landing, one event: {impacts:?}");
1375        let impact = impacts[0];
1376        assert_eq!(impact.a, entity);
1377        assert_eq!(impact.b, None, "the floor slab has no entity");
1378        assert!(
1379            impact.impulse > 3.0 && impact.impulse < 50.0,
1380            "impulse {} out of the plausible landing range",
1381            impact.impulse
1382        );
1383
1384        // Settled: hundreds of resting ticks publish nothing further.
1385        for _ in 0..300 {
1386            physics.step(&mut world.ctx());
1387            let ctx = world.ctx();
1388            if let Some(events) = ctx.events::<ContactEvent>() {
1389                assert_eq!(
1390                    events.read(&mut cursor).count(),
1391                    0,
1392                    "resting contact must not publish events"
1393                );
1394            }
1395        }
1396    }
1397
1398    // A sensor region reports a prop crossing it, in and then out, naming the
1399    // volume that saw it.
1400    #[test]
1401    fn a_trigger_volume_reports_a_prop_crossing_it() {
1402        let volume_id = AssetId(9);
1403        let mut world = TestWorld::new();
1404        world.components.push_typed(TriggerVolume {
1405            asset_id: volume_id,
1406            position: [0.0, 3.0, 0.0],
1407            rotation_deg: [0.0; 3],
1408            collider: PropCollider {
1409                shape: "cuboid".to_string(),
1410                half_extents: [1.0, 0.5, 1.0],
1411                ..Default::default()
1412            },
1413            detects: TriggerFilter::Props,
1414        });
1415        let ball = world.spawn_prop(AssetId(1), [0.0, 6.0, 0.0], false);
1416        make_dynamic(&mut world, ball);
1417
1418        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1419        physics.init(&mut world.ctx());
1420
1421        let mut cursor = EventCursor::default();
1422        let mut crossings: Vec<VolumeEvent> = Vec::new();
1423        for _ in 0..180 {
1424            physics.step(&mut world.ctx());
1425            let ctx = world.ctx();
1426            if let Some(events) = ctx.events::<VolumeEvent>() {
1427                crossings.extend(events.read(&mut cursor).copied());
1428            }
1429        }
1430        assert_eq!(crossings.len(), 2, "in, then out: {crossings:?}");
1431        assert!(crossings.iter().all(|c| c.volume == volume_id));
1432        assert!(crossings[0].entered, "the ball entered first");
1433        assert!(!crossings[1].entered, "and left afterwards");
1434    }
1435
1436    // A joint anchored to the world holds its body up: the hidden anchor body
1437    // the driver mints for it is part of the reservation, and the prop hangs
1438    // off it instead of falling.
1439    #[test]
1440    fn a_world_anchored_joint_holds_its_prop_up() {
1441        let bob_id = AssetId(1);
1442        let mut world = TestWorld::new();
1443        let bob = world.spawn_prop(bob_id, [1.0, 4.0, 0.0], false);
1444        make_dynamic(&mut world, bob);
1445        world.components.push_typed(PhysicsJoint {
1446            asset_id: AssetId(2),
1447            kind: "spherical".to_string(),
1448            body_a: Some(bob_id),
1449            body_b: None,
1450            // The bob's own centre hangs one unit from the anchor point.
1451            anchor_a: [-1.0, 0.0, 0.0],
1452            anchor_b: [0.0, 4.0, 0.0],
1453            ..Default::default()
1454        });
1455
1456        let mut physics = PhysicsSystem::new(PhysicsConfig::default());
1457        physics.init(&mut world.ctx());
1458        for _ in 0..240 {
1459            physics.step(&mut world.ctx());
1460        }
1461
1462        let position = world.components.get::<Transform>(bob).unwrap().position;
1463        let reach =
1464            ((position[0]).powi(2) + (position[1] - 4.0).powi(2) + (position[2]).powi(2)).sqrt();
1465        assert!(
1466            (reach - 1.0).abs() < 0.05,
1467            "the bob hangs one unit from the anchor, at {position:?} ({reach})"
1468        );
1469        assert!(
1470            position[1] > 2.5,
1471            "and is held up rather than falling ({position:?})"
1472        );
1473    }
1474
1475    // The config's no_collide pairs reach the built colliders: a prop on a
1476    // layer that ignores `world` falls straight through the floor.
1477    #[test]
1478    fn no_collide_config_lets_a_layered_prop_fall_through_the_floor() {
1479        let config = PhysicsConfig {
1480            layers: vec!["ghost".to_string()],
1481            no_collide: vec![["ghost".to_string(), "world".to_string()]],
1482            ..PhysicsConfig::default()
1483        };
1484
1485        let mut world = TestWorld::new();
1486        let entity = world.spawn_prop(AssetId(1), [0.0, 2.0, 0.0], false);
1487        make_dynamic(&mut world, entity);
1488        world
1489            .components
1490            .get_mut::<Collider>(entity)
1491            .unwrap()
1492            .0
1493            .layer = "ghost".to_string();
1494
1495        let mut physics = PhysicsSystem::new(config);
1496        physics.init(&mut world.ctx());
1497        for _ in 0..240 {
1498            physics.step(&mut world.ctx());
1499        }
1500        let y = world.components.get::<Transform>(entity).unwrap().position[1];
1501        assert!(
1502            y < -10.0,
1503            "the ghost-layer prop fell through the floor (y = {y})"
1504        );
1505    }
1506}