Skip to main content

khora_data/ecs/systems/
transform_propagation.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//! Transform propagation — `Transform` → `GlobalTransform` for the scene
16//! hierarchy. Runs in [`TickPhase::PostSimulation`], after `app.update` has
17//! mutated local `Transform`s and before extraction reads `GlobalTransform`.
18
19use std::collections::{HashMap, VecDeque};
20
21use khora_core::{ecs::entity::EntityId, math::Mat4};
22
23use crate::ecs::{
24    DataSystemRegistration, GlobalTransform, Parent, TickPhase, Transform, Without, World,
25};
26
27/// Propagates local `Transform` changes through the scene hierarchy to
28/// compute the final `GlobalTransform` for each entity.
29///
30/// Performs a Breadth-First Search (BFS) traversal: parent transforms are
31/// computed before their children, ensuring correctness in a single pass.
32pub fn transform_propagation_system(world: &mut World) {
33    // Stage 1: initialize the work queue with all root entities.
34    // A root has `Transform` and `GlobalTransform` but no `Parent`.
35    let mut queue: VecDeque<EntityId> = VecDeque::new();
36    for (id, transform, global_transform, _) in
37        world.query::<(EntityId, &Transform, &mut GlobalTransform, Without<Parent>)>()
38    {
39        global_transform.0 = transform.to_mat4().into();
40        queue.push_back(id);
41    }
42
43    // Stage 2: build a parent -> children map for efficient traversal.
44    let mut children_map: HashMap<EntityId, Vec<EntityId>> = HashMap::new();
45    for (child_id, parent) in world.query::<(EntityId, &Parent)>() {
46        children_map.entry(parent.0).or_default().push(child_id);
47    }
48
49    // Stage 3: BFS through the hierarchy.
50    //
51    // Defensive against partial hierarchies (entities created mid-frame
52    // without `GlobalTransform`, recently reparented nodes whose page
53    // migration is in progress, etc.): every fetch is guarded.
54    let mut head = 0;
55    while let Some(&parent_id) = queue.get(head) {
56        head += 1;
57
58        let Some(children) = children_map.get(&parent_id) else {
59            continue;
60        };
61        let Some(parent_global) = world.get::<GlobalTransform>(parent_id) else {
62            continue;
63        };
64        let parent_matrix = parent_global.0;
65
66        for &child_id in children {
67            let Some(local_transform) = world.get::<Transform>(child_id) else {
68                continue;
69            };
70            let child_matrix = Mat4::from(parent_matrix) * local_transform.to_mat4();
71            // Only enqueue children whose `GlobalTransform` we successfully
72            // wrote — that's the invariant the next iteration of this loop
73            // relies on. Transform-only children stay out of the queue.
74            if let Some(global_transform) = world.get_mut::<GlobalTransform>(child_id) {
75                global_transform.0 = child_matrix.into();
76                queue.push_back(child_id);
77            }
78        }
79    }
80}
81
82/// Wrapper to match the `DataSystemRegistration::run` signature
83/// `fn(&mut World, &Runtime, &mut OutputDeck)`. Transform propagation
84/// needs neither the runtime containers nor the output deck, so the
85/// trailing args are unused.
86fn transform_propagation_entry(
87    world: &mut World,
88    _runtime: &khora_core::Runtime,
89    _deck: &mut khora_core::lane::OutputDeck,
90) {
91    transform_propagation_system(world);
92}
93
94inventory::submit! {
95    DataSystemRegistration {
96        name: "transform_propagation",
97        phase: TickPhase::PostSimulation,
98        run: transform_propagation_entry,
99        order_hint: 0,
100        runs_after: &[],
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::ecs::{Children, GlobalTransform, Parent, SemanticDomain, Transform, World};
108    use khora_core::math::{Mat4, Vec3, EPSILON};
109
110    fn assert_matrix_approx_eq(a: Mat4, b: Mat4) {
111        for i in 0..4 {
112            for j in 0..4 {
113                let val_a = a.cols[i][j];
114                let val_b = b.cols[i][j];
115                assert!(
116                    (val_a - val_b).abs() < EPSILON,
117                    "Matrix mismatch at col {}, row {}: {} != {}",
118                    i,
119                    j,
120                    val_a,
121                    val_b
122                );
123            }
124        }
125    }
126
127    #[test]
128    fn test_transform_propagation_simple_hierarchy() {
129        let mut world = World::default();
130
131        world.register_component::<Parent>(SemanticDomain::Spatial);
132        world.register_component::<Children>(SemanticDomain::Spatial);
133        world.register_component::<Transform>(SemanticDomain::Spatial);
134        world.register_component::<GlobalTransform>(SemanticDomain::Spatial);
135
136        let parent_transform = Transform {
137            translation: Vec3::new(10.0, 0.0, 0.0),
138            ..Default::default()
139        };
140        let parent_id = world.spawn((parent_transform, GlobalTransform::identity()));
141
142        let child_transform = Transform {
143            translation: Vec3::new(0.0, 2.0, 0.0),
144            ..Default::default()
145        };
146        let child_id = world.spawn((
147            child_transform,
148            GlobalTransform::identity(),
149            Parent(parent_id),
150        ));
151
152        transform_propagation_system(&mut world);
153
154        let child_global_transform = world
155            .get::<GlobalTransform>(child_id)
156            .expect("Child should have a GlobalTransform component");
157
158        let expected_matrix = Mat4::from_translation(Vec3::new(10.0, 2.0, 0.0));
159        assert_matrix_approx_eq(child_global_transform.0.into(), expected_matrix);
160    }
161}