Skip to main content

khora_data/scene/
registry.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//! Open component registration system for scene serialization.
16//!
17//! Components register their serialization/deserialization functions
18//! at link time via `inventory`. The Definition and Recipe strategies
19//! iterate these registrations to handle all component types.
20
21use crate::ecs::{ComponentProvenance, World};
22use khora_core::ecs::entity::EntityId;
23use std::any::TypeId;
24
25/// Registration entry for a serializable component type.
26///
27/// Each component that derives `Component` submits an entry via
28/// `inventory::submit!` (generated by the derive macro). The serialization
29/// strategies use these registrations to handle all component types automatically.
30///
31/// The two `*_recipe` functions are the bincode round-trip used by the scene
32/// file format ([`super::recipe_strategy`]). The two `*_json` functions are
33/// the parallel serde-JSON round-trip used by the editor inspector to
34/// display and (eventually) edit components without per-type code. Both
35/// pairs go through the same auto-generated `Serializable<Type>` mirror
36/// struct; the JSON path simply picks a different encoding.
37pub struct ComponentRegistration {
38    /// The `TypeId` of the component type.
39    pub type_id: TypeId,
40
41    /// A human-readable name for the component (e.g., "Camera", "Light").
42    pub type_name: &'static str,
43
44    /// Who writes this component — see [`ComponentProvenance`].
45    ///
46    /// Lets any consumer ask "is this the author's data, or the engine's?"
47    /// instead of keeping its own hand-maintained list of type names. The
48    /// editor's "Add Component" menu and `duplicate_entity` both read it, so
49    /// components declared outside this crate are handled correctly too.
50    pub provenance: ComponentProvenance,
51
52    /// Serializes the component from the world into a Recipe command's
53    /// component_data bytes. Returns `None` if the entity doesn't have
54    /// this component.
55    pub serialize_recipe: fn(&World, EntityId) -> Option<Vec<u8>>,
56
57    /// Deserializes a Recipe command's component_data bytes and adds
58    /// the component to the entity.
59    pub deserialize_recipe: fn(&mut World, EntityId, &[u8]) -> Result<(), String>,
60
61    /// Creates a default instance of this component and adds it to the entity.
62    /// Used by the editor's "Add Component" UI.
63    pub create_default: fn(&mut World, EntityId) -> Result<(), String>,
64
65    /// Returns a JSON view of the component on `entity`, or `None` if the
66    /// entity doesn't have it. The conversion goes through the generated
67    /// `Serializable<Type>` mirror, so any field tagged `#[component(skip)]`
68    /// is omitted (same as the scene-recipe path).
69    pub to_json: fn(&World, EntityId) -> Option<serde_json::Value>,
70
71    /// Applies a JSON value back to the component on `entity`. Used by the
72    /// editor inspector to commit field edits without per-type code.
73    /// Implementations should be tolerant of partial updates by deserialising
74    /// against the full mirror struct first.
75    pub from_json: fn(&mut World, EntityId, &serde_json::Value) -> Result<(), String>,
76
77    /// Removes the component (and any siblings in the same `SemanticDomain`)
78    /// from `entity`. Used by the editor inspector's per-card delete button
79    /// to drop a component by `type_name` lookup.
80    pub remove: fn(&mut World, EntityId) -> Result<(), String>,
81}
82
83inventory::collect!(ComponentRegistration);
84
85/// Serializes every component of `entity` that belongs to the author.
86///
87/// Skips [`ComponentProvenance::Derived`] and [`ComponentProvenance::Runtime`]
88/// components, because every caller — scene files, `.kprefab` extraction and
89/// entity duplication — wants the data a human or a tool put there, not what
90/// the engine computed from it.
91///
92/// Emitting them would be actively wrong, not merely wasteful: `Children`
93/// holds the *source* entity's `EntityId`s, so a copy would claim the
94/// original's children, and `GlobalTransform` would land stale until the next
95/// `transform_propagation` tick. Hierarchy is rebuilt from the recipe's
96/// `SetParent` commands instead, which remap ids properly.
97pub fn serialize_all_components(world: &World, entity: EntityId) -> Vec<(String, Vec<u8>)> {
98    let mut results = Vec::new();
99    for reg in inventory::iter::<ComponentRegistration> {
100        if !reg.provenance.is_copied_on_duplicate() {
101            continue;
102        }
103        if let Some(data) = (reg.serialize_recipe)(world, entity) {
104            results.push((reg.type_name.to_string(), data));
105        }
106    }
107    results
108}
109
110/// Links `child` under `parent`, maintaining **both** halves of the hierarchy
111/// edge — the `Parent` back-reference and the parent's `Children` list.
112///
113/// Every `SceneCommand::SetParent` handler goes through this. They used to add
114/// only `Parent` and rely on a serialized `Children` component to supply the
115/// forward list, which quietly loaded the *source* world's entity ids; now that
116/// `Children` is `Derived` and no longer persisted, the inverse index has to be
117/// rebuilt here instead. Mirrors the invariant `GameWorld::set_parent` enforces
118/// for live edits.
119pub fn link_parent_child(world: &mut World, child: EntityId, parent: EntityId) {
120    if let Some(existing) = world.get_mut::<crate::ecs::Parent>(child) {
121        *existing = crate::ecs::Parent(parent);
122    } else {
123        world.add_component(child, crate::ecs::Parent(parent)).ok();
124    }
125
126    if let Some(children) = world.get_mut::<crate::ecs::Children>(parent) {
127        if !children.0.contains(&child) {
128            children.0.push(child);
129        }
130    } else {
131        world
132            .add_component(parent, crate::ecs::Children(vec![child]))
133            .ok();
134    }
135}
136
137/// Looks up the registered provenance of a component by its `type_name`.
138///
139/// Returns `None` for a type that never registered — the generic
140/// `HandleComponent<T>` instantiations and anything tagged
141/// `#[component(no_serializable)]`, neither of which participates in
142/// serialization, duplication or the "Add Component" menu.
143pub fn provenance_of(type_name: &str) -> Option<ComponentProvenance> {
144    inventory::iter::<ComponentRegistration>
145        .into_iter()
146        .find(|reg| reg.type_name == type_name)
147        .map(|reg| reg.provenance)
148}
149
150#[cfg(test)]
151mod provenance_tests {
152    use super::*;
153
154    /// Locks the classification of the components whose provenance is not the
155    /// default. Each of these was previously encoded in a hand-maintained list
156    /// somewhere else in the workspace; if one silently reverts to `Authored`
157    /// it would reappear in "Add Component" and be copied on duplicate.
158    #[test]
159    fn engine_written_components_are_classified() {
160        // Recomputed by `transform_propagation` from Transform + Parent.
161        assert_eq!(
162            provenance_of("GlobalTransform"),
163            Some(ComponentProvenance::Derived)
164        );
165        // Inverse index of `Parent`, maintained by `GameWorld::set_parent`.
166        // Copying it would make a duplicate claim the original's children.
167        assert_eq!(
168            provenance_of("Children"),
169            Some(ComponentProvenance::Derived)
170        );
171        // Written by "instantiate prefab"; persists and must survive a
172        // duplicate, but adding an empty one by hand is meaningless.
173        assert_eq!(
174            provenance_of("Prefab"),
175            Some(ComponentProvenance::ToolAuthored)
176        );
177        // Debug output of the physics writeback.
178        assert_eq!(
179            provenance_of("PhysicsDebugData"),
180            Some(ComponentProvenance::Runtime)
181        );
182    }
183
184    /// The components a user actually authors keep the default, including the
185    /// two whose registration is hand-written rather than derive-generated.
186    #[test]
187    fn authored_components_keep_the_default() {
188        for name in [
189            "Transform",
190            "Camera",
191            "Light",
192            "Tag",
193            "MeshRef",
194            "MaterialRef",
195        ] {
196            assert_eq!(
197                provenance_of(name),
198                Some(ComponentProvenance::Authored),
199                "{name} should be author-written"
200            );
201        }
202    }
203
204    /// The two halves of the hierarchy edge are classified differently, and
205    /// the asymmetry is the point.
206    ///
207    /// `Parent` is written by the reparent action, so it persists and a
208    /// duplicate keeps it — but nobody adds one from a menu, hence
209    /// `ToolAuthored`. `Children` is merely the inverse index rebuilt from it,
210    /// so it is `Derived` and must never be copied verbatim.
211    #[test]
212    fn parent_and_children_are_classified_asymmetrically() {
213        let parent = provenance_of("Parent").expect("Parent is registered");
214        let children = provenance_of("Children").expect("Children is registered");
215
216        assert!(!parent.is_hand_authorable());
217        assert!(parent.is_copied_on_duplicate());
218
219        assert!(!children.is_hand_authorable());
220        assert!(!children.is_copied_on_duplicate());
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::ecs::component::Component;
228    use crate::ecs::{
229        AudioSource, Camera, Children, GlobalTransform, Light, Name, Parent, SemanticDomain, Tag,
230        Transform,
231    };
232    use khora_core::math::Vec3;
233
234    /// A test-only anchor component. A fresh round-trip target entity is
235    /// spawned carrying only this, so it exists in the world without already
236    /// owning any component the round-trip is about to add — every
237    /// `from_json` / `deserialize_recipe` exercises the real "add" path.
238    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
239    struct Anchor;
240    impl Component for Anchor {}
241
242    /// Spawns the round-trip target: a live entity carrying only [`Anchor`],
243    /// in a world where `Anchor` is registered (its own page domain) so adding
244    /// any real component triggers a genuine migration.
245    fn spawn_anchor_target(world: &mut World) -> EntityId {
246        world.register_component::<Anchor>(SemanticDomain::Ui);
247        world.spawn(Anchor)
248    }
249
250    /// Spawns one entity carrying a broad, representative instance of every
251    /// commonly-authored component, with non-default field values where the
252    /// type allows it. The returned entity is the JSON/recipe round-trip
253    /// fixture for [`json_roundtrip_covers_every_present_component`] and
254    /// [`recipe_roundtrip_covers_every_present_component`].
255    ///
256    /// Components reached here are exercised end-to-end: the entity *has* them,
257    /// so `to_json` / `serialize_recipe` return `Some` and the parallel
258    /// `from_json` / `deserialize_recipe` paths are driven on a fresh entity.
259    fn spawn_representative_entity(world: &mut World) -> EntityId {
260        // A second entity so `Parent`/`Children` carry real ids.
261        let other = world.spawn((Transform::identity(), GlobalTransform::identity()));
262
263        let mut tags = Tag::new();
264        tags.insert("enemy");
265        tags.insert("boss");
266
267        world.spawn((
268            Transform::from_translation(Vec3::new(1.5, -2.0, 3.25)),
269            GlobalTransform::at_position(Vec3::new(1.5, -2.0, 3.25)),
270            Name::new("Fixture"),
271            Camera::new_perspective(std::f32::consts::FRAC_PI_3, 1.5, 0.05, 500.0),
272            Light::point(),
273            AudioSource::default(),
274            tags,
275            Parent(other),
276            Children(vec![other]),
277        ))
278    }
279
280    /// The editor inspector round-trip: for every registered component the
281    /// fixture entity carries, `to_json` must yield a value, and feeding that
282    /// value to `from_json` on a fresh entity in a fresh world must re-create
283    /// the component with the same JSON shape. This one test catches macro
284    /// `to_json`/`from_json` generation bugs, registry wiring gaps, and serde
285    /// breakage across the whole component set in a single pass.
286    #[test]
287    fn json_roundtrip_covers_every_present_component() {
288        let mut src = World::new();
289        let entity = spawn_representative_entity(&mut src);
290
291        let mut covered = 0usize;
292        for reg in inventory::iter::<ComponentRegistration> {
293            let Some(json) = (reg.to_json)(&src, entity) else {
294                // The fixture entity doesn't carry this component — nothing to
295                // assert for it here.
296                continue;
297            };
298            covered += 1;
299
300            // Fresh world + fresh entity: `from_json` must reconstruct the
301            // component from the captured JSON alone.
302            let mut dst = World::new();
303            let target = spawn_anchor_target(&mut dst);
304            (reg.from_json)(&mut dst, target, &json).unwrap_or_else(|e| {
305                panic!("from_json failed for {}: {}", reg.type_name, e);
306            });
307
308            let restored = (reg.to_json)(&dst, target).unwrap_or_else(|| {
309                panic!(
310                    "{} missing after from_json — component was not added",
311                    reg.type_name
312                )
313            });
314            assert_eq!(
315                restored, json,
316                "{} JSON did not round-trip through from_json",
317                reg.type_name
318            );
319        }
320
321        // Sanity: the fixture must reach a meaningful slice of the registry,
322        // otherwise a silent registration regression could make this test
323        // pass vacuously.
324        assert!(
325            covered >= 6,
326            "expected the fixture to cover at least 6 components, got {covered}"
327        );
328    }
329
330    /// The scene-file round-trip: the bincode recipe path mirrors the JSON
331    /// path through the same `Serializable<Type>` mirror. For every component
332    /// the fixture carries, `serialize_recipe` must yield bytes and
333    /// `deserialize_recipe` must re-add the component on a fresh entity, with
334    /// the JSON view matching afterwards.
335    #[test]
336    fn recipe_roundtrip_covers_every_present_component() {
337        let mut src = World::new();
338        let entity = spawn_representative_entity(&mut src);
339
340        for reg in inventory::iter::<ComponentRegistration> {
341            let Some(bytes) = (reg.serialize_recipe)(&src, entity) else {
342                continue;
343            };
344            let before = (reg.to_json)(&src, entity);
345
346            let mut dst = World::new();
347            let target = spawn_anchor_target(&mut dst);
348            (reg.deserialize_recipe)(&mut dst, target, &bytes).unwrap_or_else(|e| {
349                panic!("deserialize_recipe failed for {}: {}", reg.type_name, e);
350            });
351
352            let after = (reg.to_json)(&dst, target);
353            assert!(
354                after.is_some(),
355                "{} missing after deserialize_recipe",
356                reg.type_name
357            );
358            assert_eq!(
359                after, before,
360                "{} did not round-trip through the recipe path",
361                reg.type_name
362            );
363        }
364    }
365}