Skip to main content

khora_data/flow/
physics.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `PhysicsFlow` — read-only projection of the physics domain.
16//!
17//! `Flow::project` publishes a small [`PhysicsView`] (statistics) consumed by
18//! telemetry and the editor. Per CLAD, a Flow never mutates the World.
19//!
20//! The **ECS → physics-provider sync** (registering `RigidBody`s / `Collider`s
21//! with the [`PhysicsProvider`], updating handles, cleaning up orphans) is a
22//! maintenance invariant, so it lives in the `physics_provider_sync`
23//! `DataSystem` below (`PreExtract` phase, before the physics lane steps), not
24//! in the Flow.
25//!
26//! Distance-based *gameplay* gating (detaching a `RigidBody` when far from the
27//! camera) is **not** done here: it changes the simulation, so it is
28//! developer-authored (opt-in), never an automatic engine default. See
29//! `.agent/rules.md` — *adapt the HOW, never the WHAT*.
30//!
31//! The matching `physics_world_writeback` `DataSystem`
32//! ([`crate::ecs::systems::physics_world_writeback`], `Maintenance` phase) runs
33//! after the lane's `provider.step(dt)` and pulls the new transforms, kinematic
34//! results, and collision events from the provider back into the World.
35
36use std::collections::{HashMap, HashSet};
37use std::sync::{Arc, Mutex};
38
39use khora_core::ecs::entity::EntityId;
40use khora_core::lane::OutputDeck;
41use khora_core::math::Vec3;
42use khora_core::physics::{
43    ColliderDesc, ColliderHandle, PhysicsProvider, RigidBodyDesc, RigidBodyHandle,
44};
45use khora_core::Runtime;
46
47use crate::ecs::{
48    ActiveEvents, Camera, Collider, DataSystemRegistration, GlobalTransform, Parent,
49    PhysicsMaterial, RigidBody, SemanticDomain, TickPhase, World,
50};
51use crate::flow::{Flow, Selection};
52use crate::register_flow;
53
54/// View published into the [`LaneBus`](khora_core::lane::LaneBus) by
55/// `PhysicsFlow`. Carries per-frame physics statistics for downstream
56/// consumers (telemetry, editor panels).
57#[derive(Debug, Default, Clone)]
58pub struct PhysicsView {
59    /// Number of entities currently carrying an active `RigidBody`.
60    pub active_bodies: usize,
61    /// Number of entities whose `RigidBody` has been stashed by AGDF.
62    pub stashed_bodies: usize,
63    /// World-space camera position used as the relevance anchor.
64    pub camera_anchor: Option<Vec3>,
65}
66
67/// Slot type written into [`OutputDeck`](khora_core::lane::OutputDeck)
68/// by `StandardPhysicsLane` after `provider.step(dt)` completes.
69///
70/// Drained in `Maintenance` by the `physics_world_writeback` DataSystem,
71/// which uses its presence to decide whether the simulation actually
72/// advanced this frame (and therefore whether to pull fresh transforms
73/// out of the provider). When the agent skips its lane (paused agent,
74/// budget exhaustion …) no `PhysicsStepResult` lands in the deck and the
75/// writeback no-ops.
76#[derive(Debug, Default, Clone, Copy)]
77pub struct PhysicsStepResult {
78    /// The simulation timestep that was advanced.
79    pub dt: f32,
80}
81
82/// Read-only physics presentation Flow.
83///
84/// Deliberately **uncached** (no `cache_key` override): while the
85/// simulation runs, the per-frame provider sync and transform writeback
86/// mutate the Physics/Spatial domains anyway, so a cache would never hit —
87/// it would only add key computation and a clone to every tick.
88#[derive(Default)]
89pub struct PhysicsFlow;
90
91impl Flow for PhysicsFlow {
92    type View = PhysicsView;
93
94    const DOMAIN: SemanticDomain = SemanticDomain::Physics;
95    const NAME: &'static str = "physics";
96
97    fn project(&self, world: &World, _sel: &Selection, _runtime: &Runtime) -> Self::View {
98        let active_bodies = world.query::<&RigidBody>().count();
99        PhysicsView {
100            active_bodies,
101            // No automatic AGDF gameplay gating — nothing is stashed.
102            stashed_bodies: 0,
103            camera_anchor: active_camera_position(world),
104        }
105    }
106}
107
108register_flow!(PhysicsFlow);
109
110/// `DataSystem` (`PreExtract`) — syncs the ECS physics state into the
111/// [`PhysicsProvider`] backend before the physics lane steps it. This is the
112/// maintenance work that used to live in `PhysicsFlow::adapt`; moving it out
113/// keeps the Flow a read-only projector.
114fn physics_provider_sync(world: &mut World, runtime: &Runtime, _deck: &mut OutputDeck) {
115    let Some(provider_arc) = runtime
116        .backends
117        .get::<Arc<Mutex<Box<dyn PhysicsProvider>>>>()
118    else {
119        return;
120    };
121    let provider_arc = provider_arc.clone();
122    let mut guard = match provider_arc.lock() {
123        Ok(g) => g,
124        Err(e) => {
125            log::error!("physics_provider_sync: provider mutex poisoned: {}", e);
126            return;
127        }
128    };
129    sync_to_provider(world, guard.as_mut());
130}
131
132inventory::submit! {
133    DataSystemRegistration {
134        name: "physics_provider_sync",
135        phase: TickPhase::PreExtract,
136        run: physics_provider_sync,
137        order_hint: 0,
138        runs_after: &[],
139    }
140}
141
142fn active_camera_position(world: &World) -> Option<Vec3> {
143    for (camera, transform) in world.query::<(&Camera, &GlobalTransform)>() {
144        if camera.is_active {
145            return Some(transform.0.translation());
146        }
147    }
148    None
149}
150
151// ─────────────────────────────────────────────────────────────────────
152// ECS → Physics provider sync (was StandardPhysicsLane::sync_to_world)
153// ─────────────────────────────────────────────────────────────────────
154
155/// Registers / updates every `RigidBody` and `Collider` in `world` with
156/// `provider`, and cleans up orphaned handles. Mutates entity component
157/// fields in place (`rb.handle`, `collider.handle`) so the matching
158/// `physics_world_writeback` DataSystem can find them later.
159fn sync_to_provider(world: &mut World, provider: &mut dyn PhysicsProvider) {
160    let mut active_bodies = HashSet::new();
161    let mut active_colliders = HashSet::new();
162
163    let rb_map = sync_rigid_bodies(world, provider, &mut active_bodies);
164    sync_colliders(world, provider, &mut active_colliders, &rb_map);
165    cleanup_orphans(provider, &active_bodies, &active_colliders);
166}
167
168fn sync_rigid_bodies(
169    world: &mut World,
170    provider: &mut dyn PhysicsProvider,
171    active_bodies: &mut HashSet<RigidBodyHandle>,
172) -> HashMap<EntityId, RigidBodyHandle> {
173    let mut rb_map = HashMap::new();
174    let query = world.query_mut::<(EntityId, &GlobalTransform, &mut RigidBody)>();
175
176    for (entity_id, transform, rb) in query {
177        let current_pos = transform.0.translation();
178        let current_rot = transform.0.rotation();
179
180        let desc = RigidBodyDesc {
181            position: current_pos,
182            rotation: current_rot,
183            body_type: rb.body_type,
184            linear_velocity: rb.linear_velocity,
185            angular_velocity: rb.angular_velocity,
186            mass: rb.mass,
187            ccd_enabled: rb.ccd_enabled,
188        };
189
190        let handle = if let Some(handle) = rb.handle {
191            // Teleport detection.
192            let (phys_pos, phys_rot) = provider.get_body_transform(handle);
193            if (phys_pos - current_pos).length_squared() > 0.0001
194                || phys_rot.dot(current_rot).abs() < 0.9999
195            {
196                provider.set_body_transform(handle, current_pos, current_rot);
197            }
198            provider.update_body_properties(handle, desc);
199            handle
200        } else {
201            let h = provider.add_body(desc);
202            rb.handle = Some(h);
203            h
204        };
205
206        rb_map.insert(entity_id, handle);
207        active_bodies.insert(handle);
208    }
209    rb_map
210}
211
212fn sync_colliders(
213    world: &mut World,
214    provider: &mut dyn PhysicsProvider,
215    active_colliders: &mut HashSet<ColliderHandle>,
216    rb_map: &HashMap<EntityId, RigidBodyHandle>,
217) {
218    let mut parent_map = HashMap::new();
219    for (id, parent) in world.query::<(EntityId, &Parent)>() {
220        parent_map.insert(id, parent.0);
221    }
222
223    let mut parent_transforms = HashMap::new();
224    for (id, gt) in world.query::<(EntityId, &GlobalTransform)>() {
225        parent_transforms.insert(id, *gt);
226    }
227
228    let mut active_events = HashSet::new();
229    for (id, _) in world.query::<(EntityId, &ActiveEvents)>() {
230        active_events.insert(id);
231    }
232
233    let mut materials = HashMap::new();
234    for (id, mat) in world.query::<(EntityId, &PhysicsMaterial)>() {
235        materials.insert(id, *mat);
236    }
237
238    let query = world.query_mut::<(EntityId, &mut Collider, &GlobalTransform)>();
239    for (entity_id, collider, transform) in query {
240        let is_active = active_events.contains(&entity_id);
241        let material = materials.get(&entity_id).cloned().unwrap_or_default();
242
243        let desc = build_collider_desc(
244            entity_id,
245            transform,
246            collider,
247            &parent_map,
248            &parent_transforms,
249            is_active,
250            &material,
251            rb_map,
252        );
253
254        let handle = if let Some(handle) = collider.handle {
255            provider.update_collider_properties(handle, desc);
256            handle
257        } else {
258            let h = provider.add_collider(desc);
259            collider.handle = Some(h);
260            h
261        };
262
263        active_colliders.insert(handle);
264    }
265}
266
267#[allow(clippy::too_many_arguments)]
268fn build_collider_desc(
269    entity_id: EntityId,
270    transform: &GlobalTransform,
271    collider: &Collider,
272    parent_map: &HashMap<EntityId, EntityId>,
273    parent_transforms: &HashMap<EntityId, GlobalTransform>,
274    active_events: bool,
275    material: &PhysicsMaterial,
276    rb_map: &HashMap<EntityId, RigidBodyHandle>,
277) -> ColliderDesc {
278    let (parent_handle, parent_id) = find_parent_body(entity_id, parent_map, rb_map);
279    let mut pos = transform.0.translation();
280    let mut rot = transform.0.rotation();
281
282    if let Some(p_id) = parent_id {
283        if p_id != entity_id {
284            if let Some(p_global) = parent_transforms.get(&p_id) {
285                if let Some(inv_p) = p_global.0.inverse() {
286                    let local = inv_p.0 * transform.0 .0;
287                    let local_t = khora_core::math::AffineTransform(local);
288                    pos = local_t.translation();
289                    rot = local_t.rotation();
290                }
291            }
292        }
293    }
294
295    ColliderDesc {
296        parent_body: parent_handle,
297        position: pos,
298        rotation: rot,
299        shape: collider.shape.clone(),
300        active_events,
301        friction: material.friction,
302        restitution: material.restitution,
303    }
304}
305
306fn find_parent_body(
307    entity_id: EntityId,
308    parent_map: &HashMap<EntityId, EntityId>,
309    rb_map: &HashMap<EntityId, RigidBodyHandle>,
310) -> (Option<RigidBodyHandle>, Option<EntityId>) {
311    let mut curr = entity_id;
312    loop {
313        if let Some(h) = rb_map.get(&curr) {
314            return (Some(*h), Some(curr));
315        }
316        if let Some(p) = parent_map.get(&curr) {
317            curr = *p;
318        } else {
319            break;
320        }
321    }
322    (None, None)
323}
324
325fn cleanup_orphans(
326    provider: &mut dyn PhysicsProvider,
327    active_bodies: &HashSet<RigidBodyHandle>,
328    active_colliders: &HashSet<ColliderHandle>,
329) {
330    for h in provider.get_all_bodies() {
331        if !active_bodies.contains(&h) {
332            provider.remove_body(h);
333        }
334    }
335    for h in provider.get_all_colliders() {
336        if !active_colliders.contains(&h) {
337            provider.remove_collider(h);
338        }
339    }
340}