1use 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
29pub struct RecipeSerializationStrategy;
35
36impl Default for RecipeSerializationStrategy {
37 fn default() -> Self {
38 Self
39 }
40}
41
42impl RecipeSerializationStrategy {
43 pub fn new() -> Self {
45 Self
46 }
47}
48
49pub 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 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
108pub 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 let nodes: Vec<EntityId> = world.iter_entities().collect();
181 let mut edges: Vec<(EntityId, EntityId)> = Vec::new();
182
183 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 let sorted_entities = topological_sort(nodes, edges).map_err(|_| {
192 SerializationError::ProcessingFailed("Cycle detected in scene hierarchy.".to_string())
193 })?;
194
195 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 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 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 fn material_component<M: Material + 'static>(material: M) -> MaterialRef {
283 MaterialRef::inline(Box::new(material))
284 }
285
286 #[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 #[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 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 let dst_count = dst.iter_entities().count();
460 assert_eq!(dst_count, 4, "outsider entity should not be instantiated");
461
462 assert!(dst.get::<Parent>(new_root).is_none());
464
465 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 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 let _ = (child_a, child_b, grandchild, outsider);
491 }
492}