Skip to main content

khora_data/scene/
recipe_strategy.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//! Implements the "Recipe" serialization strategy for scenes.
16//!
17//! Uses `inventory`-based component registration for open serialization.
18//! All component types that derive `Component` are automatically handled.
19
20use super::{DeserializationError, SerializationError, SerializationStrategy};
21use crate::{
22    ecs::World,
23    scene::{registry::ComponentRegistration, SceneCommand, SceneRecipe},
24};
25use bincode::config;
26use khora_core::{ecs::entity::EntityId, graph::topological_sort};
27use std::collections::HashMap;
28
29/// A serialization strategy that uses a sequence of commands (`SceneRecipe`).
30///
31/// This strategy iterates all registered components via `inventory` to
32/// serialize every component on every entity. On deserialization, it
33/// looks up the matching registration by type name and decodes.
34pub struct RecipeSerializationStrategy;
35
36impl Default for RecipeSerializationStrategy {
37    fn default() -> Self {
38        Self
39    }
40}
41
42impl RecipeSerializationStrategy {
43    /// Creates a new instance of the `RecipeSerializationStrategy`.
44    pub fn new() -> Self {
45        Self
46    }
47}
48
49/// Encodes the subtree rooted at `root` (root + all descendants reached
50/// via the `Children` component) as a stand-alone bincode-encoded
51/// `SceneRecipe`. Exists to back the editor's "Save as Prefab" flow:
52/// the resulting bytes round-trip through [`instantiate_subtree`].
53///
54/// Hierarchy edges are emitted only for parent/child pairs whose **both**
55/// endpoints live inside the subtree, so a `.kprefab` file is fully
56/// self-contained. The first `Spawn` command is the prefab root.
57pub fn serialize_subtree(
58    world: &crate::ecs::World,
59    root: EntityId,
60) -> Result<Vec<u8>, SerializationError> {
61    let mut order: Vec<EntityId> = Vec::new();
62    let mut included: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
63    let mut queue: std::collections::VecDeque<EntityId> = std::collections::VecDeque::new();
64    queue.push_back(root);
65    included.insert(root);
66    order.push(root);
67    while let Some(parent) = queue.pop_front() {
68        if let Some(children) = world.get::<crate::ecs::Children>(parent) {
69            for &child in &children.0 {
70                if included.insert(child) {
71                    order.push(child);
72                    queue.push_back(child);
73                }
74            }
75        }
76    }
77
78    let mut commands = Vec::with_capacity(order.len() * 4);
79    for entity_id in &order {
80        commands.push(SceneCommand::Spawn { id: *entity_id });
81        for (component_type, component_data) in
82            crate::scene::serialize_all_components(world, *entity_id)
83        {
84            commands.push(SceneCommand::AddComponent {
85                entity_id: *entity_id,
86                component_type,
87                component_data,
88            });
89        }
90        if let Some(parent) = world.get::<crate::ecs::Parent>(*entity_id) {
91            // Drop the root's incoming parent edge — by definition it lives
92            // outside the subtree. Children whose parent IS in the subtree
93            // emit a normal SetParent.
94            if included.contains(&parent.0) {
95                commands.push(SceneCommand::SetParent {
96                    child_id: *entity_id,
97                    parent_id: parent.0,
98                });
99            }
100        }
101    }
102
103    let scene_recipe = SceneRecipe { commands };
104    bincode::encode_to_vec(&scene_recipe, config::standard())
105        .map_err(|e| SerializationError::ProcessingFailed(e.to_string()))
106}
107
108/// Inverse of [`serialize_subtree`]. Decodes the recipe bytes and spawns
109/// the subtree into `world`, returning the new root's `EntityId` so the
110/// caller can position it / reparent it / treat it as the drop target's
111/// child.
112///
113/// The root lands at world root (no `SetParent` is emitted for it,
114/// because the original parent edge was filtered out at serialize time).
115pub fn instantiate_subtree(
116    world: &mut crate::ecs::World,
117    recipe_bytes: &[u8],
118) -> Result<EntityId, DeserializationError> {
119    let (recipe, _): (SceneRecipe, _) =
120        bincode::decode_from_slice(recipe_bytes, config::standard())
121            .map_err(|e| DeserializationError::InvalidFormat(e.to_string()))?;
122
123    let mut id_map = HashMap::<EntityId, EntityId>::new();
124    let mut new_root: Option<EntityId> = None;
125
126    for command in recipe.commands {
127        match command {
128            SceneCommand::Spawn { id } => {
129                let new_id = world.spawn(());
130                id_map.insert(id, new_id);
131                if new_root.is_none() {
132                    new_root = Some(new_id);
133                }
134            }
135            SceneCommand::AddComponent {
136                entity_id,
137                component_type,
138                component_data,
139            } => {
140                if let Some(new_id) = id_map.get(&entity_id) {
141                    for reg in inventory::iter::<ComponentRegistration> {
142                        if reg.type_name == component_type {
143                            if let Err(e) =
144                                (reg.deserialize_recipe)(world, *new_id, &component_data)
145                            {
146                                log::warn!("Failed to deserialize {}: {}", component_type, e);
147                            }
148                            break;
149                        }
150                    }
151                }
152            }
153            SceneCommand::SetParent {
154                child_id,
155                parent_id,
156            } => {
157                if let (Some(&new_child), Some(&new_parent)) =
158                    (id_map.get(&child_id), id_map.get(&parent_id))
159                {
160                    crate::scene::link_parent_child(world, new_child, new_parent);
161                }
162            }
163        }
164    }
165
166    new_root.ok_or_else(|| {
167        DeserializationError::InvalidFormat("Prefab recipe contained no Spawn commands".to_string())
168    })
169}
170
171impl SerializationStrategy for RecipeSerializationStrategy {
172    fn get_strategy_id(&self) -> &'static str {
173        "KH_RECIPE_V1"
174    }
175
176    fn serialize(&self, world: &World) -> Result<Vec<u8>, SerializationError> {
177        let mut commands = Vec::new();
178
179        // 1. Collect nodes and edges for topological sort.
180        let nodes: Vec<EntityId> = world.iter_entities().collect();
181        let mut edges: Vec<(EntityId, EntityId)> = Vec::new();
182
183        // Direct Parent access for topological sort (Parent is always registered).
184        for &entity_id in &nodes {
185            if let Some(parent) = world.get::<crate::ecs::Parent>(entity_id) {
186                edges.push((parent.0, entity_id));
187            }
188        }
189
190        // 2. Topological sort.
191        let sorted_entities = topological_sort(nodes, edges).map_err(|_| {
192            SerializationError::ProcessingFailed("Cycle detected in scene hierarchy.".to_string())
193        })?;
194
195        // 3. For each entity, iterate ALL component registrations and emit commands.
196        for entity_id in sorted_entities {
197            commands.push(SceneCommand::Spawn { id: entity_id });
198
199            for (component_type, component_data) in
200                crate::scene::serialize_all_components(world, entity_id)
201            {
202                commands.push(SceneCommand::AddComponent {
203                    entity_id,
204                    component_type,
205                    component_data,
206                });
207            }
208
209            // Emit SetParent command for hierarchy reconstruction.
210            if let Some(parent) = world.get::<crate::ecs::Parent>(entity_id) {
211                commands.push(SceneCommand::SetParent {
212                    child_id: entity_id,
213                    parent_id: parent.0,
214                });
215            }
216        }
217
218        let scene_recipe = SceneRecipe { commands };
219        bincode::encode_to_vec(&scene_recipe, config::standard())
220            .map_err(|e| SerializationError::ProcessingFailed(e.to_string()))
221    }
222
223    fn deserialize(&self, data: &[u8], world: &mut World) -> Result<(), DeserializationError> {
224        let (recipe, _): (SceneRecipe, _) = bincode::decode_from_slice(data, config::standard())
225            .map_err(|e| DeserializationError::InvalidFormat(e.to_string()))?;
226
227        let mut id_map = HashMap::<EntityId, EntityId>::new();
228
229        for command in recipe.commands {
230            match command {
231                SceneCommand::Spawn { id } => {
232                    let new_id = world.spawn(());
233                    id_map.insert(id, new_id);
234                }
235                SceneCommand::AddComponent {
236                    entity_id,
237                    component_type,
238                    component_data,
239                } => {
240                    if let Some(new_id) = id_map.get(&entity_id) {
241                        // Look up the registration by type_name.
242                        for reg in inventory::iter::<ComponentRegistration> {
243                            if reg.type_name == component_type {
244                                if let Err(e) =
245                                    (reg.deserialize_recipe)(world, *new_id, &component_data)
246                                {
247                                    log::warn!("Failed to deserialize {}: {}", component_type, e);
248                                }
249                                break;
250                            }
251                        }
252                    }
253                }
254                SceneCommand::SetParent {
255                    child_id,
256                    parent_id,
257                } => {
258                    if let (Some(&new_child), Some(&new_parent)) =
259                        (id_map.get(&child_id), id_map.get(&parent_id))
260                    {
261                        crate::scene::link_parent_child(world, new_child, new_parent);
262                    }
263                }
264            }
265        }
266
267        Ok(())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::ecs::{
275        material_from_json, material_to_json, Children, MaterialRef, Parent, Transform, World,
276    };
277    use khora_core::asset::{AssetUUID, EmissiveMaterial, Material, StandardMaterial};
278    use khora_core::math::{LinearRgba, Vec3};
279
280    /// Wraps a concrete material in an inline `MaterialRef` the way the SDK /
281    /// editor do — the authored, serialized material reference.
282    fn material_component<M: Material + 'static>(material: M) -> MaterialRef {
283        MaterialRef::inline(Box::new(material))
284    }
285
286    /// The `EditorInterchange` goal selects the Recipe strategy, so exercising
287    /// it directly reproduces the editor Play-enter → Play-exit round-trip and
288    /// the on-disk save/load path.
289    #[test]
290    fn recipe_round_trip_preserves_standard_material() {
291        let mut src = World::new();
292        let distinctive = StandardMaterial {
293            base_color: LinearRgba::new(0.12, 0.34, 0.56, 0.78),
294            roughness: 0.37,
295            metallic: 0.6,
296            base_color_texture: Some(AssetUUID::new()),
297            ..Default::default()
298        };
299        let entity = src.spawn(Transform::default());
300        src.add_component(entity, material_component(distinctive.clone()))
301            .unwrap();
302
303        let strategy = RecipeSerializationStrategy::new();
304        let bytes = strategy.serialize(&src).expect("serialize");
305
306        let mut dst = World::new();
307        strategy.deserialize(&bytes, &mut dst).expect("deserialize");
308
309        let restored = dst
310            .iter_entities()
311            .find_map(|e| dst.get::<MaterialRef>(e))
312            .expect("material ref should survive the round trip");
313        let MaterialRef::Inline { material, .. } = restored else {
314            panic!("expected an inline material ref");
315        };
316        let standard = material
317            .as_any()
318            .downcast_ref::<StandardMaterial>()
319            .expect("restored material should downcast to StandardMaterial");
320
321        assert_eq!(standard.base_color, distinctive.base_color);
322        assert_eq!(standard.roughness, distinctive.roughness);
323        assert_eq!(standard.metallic, distinctive.metallic);
324        assert_eq!(standard.base_color_texture, distinctive.base_color_texture);
325    }
326
327    #[test]
328    fn recipe_round_trip_preserves_emissive_material() {
329        let mut src = World::new();
330        let distinctive = EmissiveMaterial {
331            emissive_color: LinearRgba::new(0.9, 0.1, 0.4, 1.0),
332            intensity: 2.5,
333            ..Default::default()
334        };
335        let entity = src.spawn(Transform::default());
336        src.add_component(entity, material_component(distinctive.clone()))
337            .unwrap();
338
339        let strategy = RecipeSerializationStrategy::new();
340        let bytes = strategy.serialize(&src).expect("serialize");
341
342        let mut dst = World::new();
343        strategy.deserialize(&bytes, &mut dst).expect("deserialize");
344
345        let restored = dst
346            .iter_entities()
347            .find_map(|e| dst.get::<MaterialRef>(e))
348            .expect("material ref should survive the round trip");
349        let MaterialRef::Inline { material, .. } = restored else {
350            panic!("expected an inline material ref");
351        };
352        let emissive = material
353            .as_any()
354            .downcast_ref::<EmissiveMaterial>()
355            .expect("restored material should downcast to EmissiveMaterial");
356
357        assert_eq!(emissive.emissive_color, distinctive.emissive_color);
358        assert_eq!(emissive.intensity, distinctive.intensity);
359    }
360
361    #[test]
362    fn material_json_round_trip_is_lossless() {
363        let distinctive = StandardMaterial {
364            base_color: LinearRgba::new(0.12, 0.34, 0.56, 0.78),
365            roughness: 0.37,
366            metallic: 0.6,
367            base_color_texture: Some(AssetUUID::new()),
368            ..Default::default()
369        };
370        let material: Box<dyn Material> = Box::new(distinctive.clone());
371
372        let json = material_to_json(material.as_ref()).expect("to_json should produce a value");
373        let (handle, _uuid) = material_from_json(&json).expect("from_json should reconstruct");
374        let restored: &dyn Material = &**handle;
375        let standard = restored
376            .as_any()
377            .downcast_ref::<StandardMaterial>()
378            .expect("restored material should downcast to StandardMaterial");
379
380        assert_eq!(standard.base_color, distinctive.base_color);
381        assert_eq!(standard.roughness, distinctive.roughness);
382        assert_eq!(standard.metallic, distinctive.metallic);
383        assert_eq!(standard.base_color_texture, distinctive.base_color_texture);
384    }
385
386    /// Anti-vestigial regression: a `MaterialRef::Asset(uuid)` must round-trip
387    /// with the SAME uuid. The previous inline-handle path minted a fresh uuid
388    /// on every load, making VFS material identity impossible.
389    #[test]
390    fn recipe_round_trip_preserves_asset_material_uuid() {
391        let mut src = World::new();
392        let uuid = AssetUUID::new_v5("materials/brass.kmat");
393        let entity = src.spawn(Transform::default());
394        src.add_component(entity, MaterialRef::Asset(uuid)).unwrap();
395
396        let strategy = RecipeSerializationStrategy::new();
397        let bytes = strategy.serialize(&src).expect("serialize");
398
399        let mut dst = World::new();
400        strategy.deserialize(&bytes, &mut dst).expect("deserialize");
401
402        let restored = dst
403            .iter_entities()
404            .find_map(|e| dst.get::<MaterialRef>(e))
405            .expect("material ref should survive the round trip");
406        let MaterialRef::Asset(restored_uuid) = restored else {
407            panic!("expected an asset material ref");
408        };
409        assert_eq!(
410            *restored_uuid, uuid,
411            "asset uuid must be preserved verbatim"
412        );
413    }
414
415    /// Builds the parent's `Children` component manually since the
416    /// maintenance system that normally syncs it isn't running here.
417    fn link_parent_child(world: &mut World, parent: EntityId, child: EntityId) {
418        world.add_component(child, Parent(parent)).unwrap();
419        if let Some(existing) = world.get_mut::<Children>(parent) {
420            existing.0.push(child);
421        } else {
422            world.add_component(parent, Children(vec![child])).unwrap();
423        }
424    }
425
426    #[test]
427    fn subtree_round_trip_preserves_hierarchy_and_excludes_outsiders() {
428        let mut src = World::new();
429        let root = src.spawn(Transform {
430            translation: Vec3::new(1.0, 0.0, 0.0),
431            ..Default::default()
432        });
433        let child_a = src.spawn(Transform {
434            translation: Vec3::new(2.0, 0.0, 0.0),
435            ..Default::default()
436        });
437        let child_b = src.spawn(Transform {
438            translation: Vec3::new(3.0, 0.0, 0.0),
439            ..Default::default()
440        });
441        let grandchild = src.spawn(Transform {
442            translation: Vec3::new(4.0, 0.0, 0.0),
443            ..Default::default()
444        });
445        let outsider = src.spawn(Transform {
446            translation: Vec3::new(99.0, 0.0, 0.0),
447            ..Default::default()
448        });
449        link_parent_child(&mut src, root, child_a);
450        link_parent_child(&mut src, root, child_b);
451        link_parent_child(&mut src, child_a, grandchild);
452
453        let bytes = serialize_subtree(&src, root).expect("serialize_subtree");
454
455        let mut dst = World::new();
456        let new_root = instantiate_subtree(&mut dst, &bytes).expect("instantiate_subtree");
457
458        // Outsider must NOT be present in the destination.
459        let dst_count = dst.iter_entities().count();
460        assert_eq!(dst_count, 4, "outsider entity should not be instantiated");
461
462        // New root has no parent (root edge filtered at serialize time).
463        assert!(dst.get::<Parent>(new_root).is_none());
464
465        // Walk: new_root must have two children, one of which has one child.
466        let direct: Vec<EntityId> = dst
467            .iter_entities()
468            .filter(|e| dst.get::<Parent>(*e).map(|p| p.0) == Some(new_root))
469            .collect();
470        assert_eq!(direct.len(), 2, "expected 2 direct children of new root");
471
472        let grand: Vec<EntityId> = dst
473            .iter_entities()
474            .filter(|e| {
475                let parent = dst.get::<Parent>(*e).map(|p| p.0);
476                parent.is_some() && direct.contains(&parent.unwrap())
477            })
478            .collect();
479        assert_eq!(grand.len(), 1, "expected exactly one grandchild");
480
481        // Translation values survived bincode round-trip.
482        let mut xs: Vec<f32> = dst
483            .iter_entities()
484            .filter_map(|e| dst.get::<Transform>(e).map(|t| t.translation.x))
485            .collect();
486        xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
487        assert_eq!(xs, vec![1.0, 2.0, 3.0, 4.0]);
488
489        // Sanity: source still untouched.
490        let _ = (child_a, child_b, grandchild, outsider);
491    }
492}