1use khora_sdk::editor_ui::*;
18use khora_sdk::khora_data::ecs::{SemanticDomain, Tag};
19use khora_sdk::prelude::ecs::*;
20use khora_sdk::GameWorld;
21
22fn domain_tag(d: SemanticDomain) -> u8 {
27 match d {
28 SemanticDomain::Spatial => 0,
29 SemanticDomain::Render => 1,
30 SemanticDomain::Audio => 2,
31 SemanticDomain::Physics => 3,
32 SemanticDomain::Ui => 4,
33 }
34}
35
36struct NodeInfo {
39 name: String,
40 icon: EntityIcon,
41 tag_count: usize,
42}
43
44fn build_scene_node(
50 entity: EntityId,
51 info: &std::collections::HashMap<EntityId, NodeInfo>,
52 children_of: &std::collections::HashMap<EntityId, Vec<EntityId>>,
53 visited: &mut std::collections::HashSet<EntityId>,
54) -> Option<SceneNode> {
55 if !visited.insert(entity) {
56 return None;
57 }
58 let node_info = info.get(&entity)?;
59 let children = children_of
60 .get(&entity)
61 .map(|ids| {
62 ids.iter()
63 .filter_map(|&child| build_scene_node(child, info, children_of, visited))
64 .collect()
65 })
66 .unwrap_or_default();
67
68 Some(SceneNode {
69 entity,
70 name: node_info.name.clone(),
71 icon: node_info.icon,
72 children,
73 tag_count: node_info.tag_count,
74 })
75}
76
77pub fn extract_scene_tree(world: &GameWorld, state: &mut EditorState) {
88 let entities: Vec<EntityId> = world.iter_entities().collect();
89 state.entity_count = entities.len();
90
91 let live: std::collections::HashSet<EntityId> = entities.iter().copied().collect();
92 let mut info: std::collections::HashMap<EntityId, NodeInfo> =
93 std::collections::HashMap::with_capacity(entities.len());
94 let mut children_of: std::collections::HashMap<EntityId, Vec<EntityId>> =
95 std::collections::HashMap::new();
96 let mut parent_of: std::collections::HashMap<EntityId, EntityId> =
97 std::collections::HashMap::new();
98
99 for &entity in &entities {
100 let name = world
101 .get_component::<Name>(entity)
102 .map(|n: &Name| n.as_str().to_owned())
103 .unwrap_or_else(|| format!("Entity {}", entity.index));
104
105 let icon = if world.get_component::<Camera>(entity).is_some() {
106 EntityIcon::Camera
107 } else if world.get_component::<Light>(entity).is_some() {
108 EntityIcon::Light
109 } else if world.get_component::<AudioSource>(entity).is_some() {
110 EntityIcon::Audio
111 } else if world.get_component::<MeshRef>(entity).is_some() {
112 EntityIcon::Mesh
113 } else {
114 EntityIcon::Empty
115 };
116
117 if let Some(parent) = world.get_component::<Parent>(entity) {
120 let parent = parent.0;
121 if live.contains(&parent) && parent != entity {
122 parent_of.insert(entity, parent);
123 children_of.entry(parent).or_default().push(entity);
124 }
125 }
126
127 let tag_count = world
128 .get_component::<Tag>(entity)
129 .map(|t| t.len())
130 .unwrap_or(0);
131
132 info.insert(
133 entity,
134 NodeInfo {
135 name,
136 icon,
137 tag_count,
138 },
139 );
140 }
141
142 for siblings in children_of.values_mut() {
143 siblings.sort_unstable_by_key(|e| e.index);
144 }
145
146 let mut roots: Vec<EntityId> = entities
149 .iter()
150 .copied()
151 .filter(|e| !parent_of.contains_key(e))
152 .collect();
153 roots.sort_unstable_by_key(|e| e.index);
154
155 let mut visited = std::collections::HashSet::with_capacity(entities.len());
156 state.scene_roots = roots
157 .into_iter()
158 .filter_map(|root| build_scene_node(root, &info, &children_of, &mut visited))
159 .collect();
160}
161
162fn default_surface_material() -> khora_sdk::prelude::materials::StandardMaterial {
169 khora_sdk::prelude::materials::StandardMaterial {
170 base_color: khora_sdk::prelude::math::LinearRgba::new(0.7, 0.7, 0.7, 1.0),
171 roughness: 0.8,
172 ..Default::default()
173 }
174}
175
176pub fn spawn_mesh_asset(
181 world: &mut GameWorld,
182 uuid: khora_sdk::khora_core::asset::AssetUUID,
183 point: [f32; 3],
184 label: &str,
185) -> EntityId {
186 let mat = world.add_material(default_surface_material());
187 let name = std::path::Path::new(label)
188 .file_stem()
189 .and_then(|s| s.to_str())
190 .unwrap_or(label)
191 .to_string();
192 let entity = world.spawn((
193 Transform::from_translation(khora_sdk::prelude::math::Vec3::new(
194 point[0], point[1], point[2],
195 )),
196 GlobalTransform::identity(),
197 Name::new(name),
198 MeshRef::Asset(uuid),
199 ));
200 world.add_component(entity, mat);
201 entity
202}
203
204pub fn process_spawns(world: &mut GameWorld, state: &mut EditorState) {
205 if let Some(request) = state.pending_spawn.take() {
206 let entity = match request.as_str() {
207 "Cube" => {
208 let mat = world.add_material(default_surface_material());
209 khora_sdk::spawn_cube_at(world, khora_sdk::prelude::math::Vec3::ZERO, 1.0)
210 .with_component(Name::new("Cube"))
211 .with_component(mat)
212 .build()
213 }
214 "Sphere" => {
215 let mat = world.add_material(default_surface_material());
216 khora_sdk::spawn_sphere(world, 0.5, 16, 16)
217 .with_component(Name::new("Sphere"))
218 .with_component(mat)
219 .build()
220 }
221 "Plane" => {
222 let mat = world.add_material(default_surface_material());
223 khora_sdk::spawn_plane(world, 10.0, 0.0)
224 .with_component(Name::new("Plane"))
225 .with_component(mat)
226 .build()
227 }
228 "Light" => world.spawn((
229 Transform::identity(),
230 GlobalTransform::identity(),
231 Name::new("Light"),
232 Light::directional(),
233 )),
234 "Camera" => {
235 let cam =
236 Camera::new_perspective(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1, 1000.0);
237 world.spawn((
238 Transform::identity(),
239 GlobalTransform::identity(),
240 Name::new("Camera"),
241 cam,
242 ))
243 }
244 _ => world.spawn((
245 Transform::identity(),
246 GlobalTransform::identity(),
247 Name::new(&request),
248 )),
249 };
250
251 state.select(entity);
252 log::info!("Spawned entity {:?} ({})", entity, request);
253 }
254}
255
256pub fn process_reparents(world: &mut GameWorld, state: &mut EditorState) {
261 if let Some((child, new_parent)) = state.pending_reparent.take() {
262 world.set_parent(child, new_parent);
263 log::info!(
264 "Reparented {:?} → {:?}",
265 child,
266 new_parent
267 .map(|p| format!("{:?}", p))
268 .unwrap_or_else(|| "<root>".to_owned())
269 );
270 }
271}
272
273pub fn duplicate_entity(world: &mut GameWorld, entity: EntityId, state: &mut EditorState) {
286 let recipe = match khora_sdk::serialize_subtree(world.inner_world(), entity) {
287 Ok(bytes) => bytes,
288 Err(e) => {
289 log::error!("Duplicate failed: could not read {entity:?}: {e}");
290 return;
291 }
292 };
293
294 let original_parent = world.get_component::<Parent>(entity).map(|p: &Parent| p.0);
295 let copy_name = world
296 .get_component::<Name>(entity)
297 .map(|n: &Name| format!("{} (Copy)", n.as_str()))
298 .unwrap_or_else(|| "Copy".to_owned());
299
300 let new_entity = match khora_sdk::instantiate_subtree(world.inner_world_mut(), &recipe) {
301 Ok(id) => id,
302 Err(e) => {
303 log::error!("Duplicate failed: could not rebuild {entity:?}: {e}");
304 return;
305 }
306 };
307
308 if let Some(name) = world.get_component_mut::<Name>(new_entity) {
309 *name = Name::new(copy_name);
310 } else {
311 world.add_component(new_entity, Name::new(copy_name));
312 }
313 if let Some(parent) = original_parent {
314 world.set_parent(new_entity, Some(parent));
315 }
316
317 state.select(new_entity);
318 log::info!("Duplicated entity {:?} -> {:?}", entity, new_entity);
319}
320
321pub fn delete_selection(world: &mut GameWorld, state: &mut EditorState) {
323 let to_delete: Vec<EntityId> = state.selection.iter().copied().collect();
324 for entity in &to_delete {
325 world.despawn(*entity);
326 }
327 if !to_delete.is_empty() {
328 log::info!("Deleted {} entities", to_delete.len());
329 }
330 state.clear_selection();
331 state.inspected = None;
332}
333
334pub fn sync_scene_cameras_for_mode(world: &mut GameWorld, mode: PlayMode) {
336 let entities: Vec<EntityId> = world.iter_entities().collect();
337 let camera_states: Vec<(EntityId, bool)> = entities
338 .iter()
339 .filter_map(|&entity| {
340 world
341 .get_component::<Camera>(entity)
342 .map(|cam| (entity, cam.is_active))
343 })
344 .collect();
345
346 match mode {
347 PlayMode::Editing => {
349 for (entity, _) in camera_states {
350 if let Some(cam) = world.get_component_mut::<Camera>(entity) {
351 cam.is_active = false;
352 }
353 }
354 }
355 PlayMode::Playing | PlayMode::Paused => {
357 if camera_states.iter().any(|(_, is_active)| *is_active) {
358 return;
359 }
360 if let Some((entity, _)) = camera_states.first().copied() {
361 if let Some(cam) = world.get_component_mut::<Camera>(entity) {
362 cam.is_active = true;
363 }
364 }
365 }
366 }
367}
368
369pub fn extract_inspected(world: &GameWorld, state: &mut EditorState) {
371 let entity = match state.single_selected() {
372 Some(entity) => entity,
373 None => {
374 state.inspected = None;
375 return;
376 }
377 };
378 state.inspected_asset_path = None;
381
382 let name = world
383 .get_component::<Name>(entity)
384 .map(|n: &Name| n.as_str().to_owned())
385 .unwrap_or_else(|| format!("Entity {}", entity.index));
386
387 let inner_world = world.inner_world();
397 let mut components_json = Vec::new();
398 state.component_domain_registry.clear();
399 for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
400 let domain = inner_world.component_domain(reg.type_id).map(domain_tag);
401 if let Some(tag) = domain {
402 state
403 .component_domain_registry
404 .insert(reg.type_name.to_string(), tag);
405 }
406
407 let Some(value) = (reg.to_json)(inner_world, entity) else {
408 continue;
409 };
410 components_json.push(ComponentJson {
411 type_name: reg.type_name.to_string(),
412 domain,
413 value,
414 });
415 }
416
417 state.inspected = Some(InspectedEntity {
418 entity,
419 name,
420 components_json,
421 });
422}
423
424pub fn apply_edits(world: &mut GameWorld, state: &mut EditorState) {
431 let edits = state.drain_edits();
432 for edit in edits {
433 match edit {
434 PropertyEdit::SetName(entity, new_name) => {
435 if let Some(name) = world.get_component_mut::<Name>(entity) {
436 *name = Name::new(new_name);
437 }
438 }
439 PropertyEdit::SetComponentJson {
440 entity,
441 type_name,
442 value,
443 } => {
444 let inner = world.inner_world_mut();
445 let mut applied = false;
446 for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
447 if reg.type_name == type_name {
448 match (reg.from_json)(inner, entity, &value) {
449 Ok(()) => applied = true,
450 Err(e) => {
451 log::warn!("Failed to apply JSON edit to {}: {}", type_name, e)
452 }
453 }
454 break;
455 }
456 }
457 if !applied {
458 log::warn!("No registration found for component '{}'", type_name);
459 }
460 }
461 PropertyEdit::RemoveComponent { entity, type_name } => {
462 let inner = world.inner_world_mut();
463 let mut applied = false;
464 for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
465 if reg.type_name == type_name {
466 match (reg.remove)(inner, entity) {
467 Ok(()) => applied = true,
468 Err(e) => log::warn!("Failed to remove component {}: {}", type_name, e),
469 }
470 break;
471 }
472 }
473 if !applied {
474 log::warn!("No registration found for component '{}'", type_name);
475 }
476 }
477 }
478 }
479}
480
481pub fn add_component_to_entity(world: &mut GameWorld, entity: EntityId, type_name: &str) {
483 for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
484 if reg.type_name == type_name {
485 if let Err(e) = (reg.create_default)(world.inner_world_mut(), entity) {
486 log::error!("Failed to add component {}: {}", type_name, e);
487 } else {
488 log::info!("Added {} component to entity {:?}", type_name, entity);
489 }
490 return;
491 }
492 }
493 log::warn!("No component registration found for type '{}'", type_name);
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 fn component_json(
504 world: &GameWorld,
505 entity: EntityId,
506 type_name: &str,
507 ) -> Option<serde_json::Value> {
508 for reg in inventory::iter::<khora_sdk::ComponentRegistration> {
509 if reg.type_name == type_name {
510 return (reg.to_json)(world.inner_world(), entity);
511 }
512 }
513 None
514 }
515
516 #[test]
522 fn apply_edits_commits_name_and_component_json() {
523 let mut world = GameWorld::new();
524 let mut state = EditorState::default();
525
526 let entity = world.spawn((
527 Transform::from_translation(khora_sdk::prelude::math::Vec3::new(1.0, 2.0, 3.0)),
528 GlobalTransform::identity(),
529 Name::new("Before"),
530 ));
531
532 let mut transform_json =
535 component_json(&world, entity, "Transform").expect("Transform JSON view");
536 transform_json["translation"]["x"] = serde_json::json!(9.0);
537
538 state.push_edit(PropertyEdit::SetName(entity, "After".to_owned()));
539 state.push_edit(PropertyEdit::SetComponentJson {
540 entity,
541 type_name: "Transform".to_owned(),
542 value: transform_json,
543 });
544
545 apply_edits(&mut world, &mut state);
546
547 assert_eq!(
548 world.get_component::<Name>(entity).map(|n| n.as_str()),
549 Some("After"),
550 "SetName must rename the entity"
551 );
552 let t = world
553 .get_component::<Transform>(entity)
554 .expect("entity keeps its Transform");
555 assert_eq!(
556 t.translation.x, 9.0,
557 "SetComponentJson must patch the field"
558 );
559 assert_eq!(t.translation.y, 2.0, "untouched fields must survive");
560
561 assert!(state.pending_edits.is_empty());
563 }
564
565 #[test]
571 fn undo_redo_roundtrips_through_apply_edits() {
572 let mut world = GameWorld::new();
573 let mut state = EditorState::default();
574 let mut history = khora_sdk::editor_ui::CommandHistory::default();
575
576 let entity = world.spawn((
577 Transform::identity(),
578 GlobalTransform::identity(),
579 Name::new("Baseline"),
580 ));
581
582 let forward = PropertyEdit::SetName(entity, "Renamed".to_owned());
583 let reverse = PropertyEdit::SetName(entity, "Baseline".to_owned());
584 history.push(khora_sdk::editor_ui::EditorCommand {
585 description: "Rename".to_owned(),
586 forward: forward.clone(),
587 reverse,
588 });
589
590 state.push_edit(forward);
592 apply_edits(&mut world, &mut state);
593 assert_eq!(
594 world.get_component::<Name>(entity).map(|n| n.as_str()),
595 Some("Renamed")
596 );
597
598 let reverse_edit = history.undo().expect("a command to undo");
600 state.push_edit(reverse_edit);
601 apply_edits(&mut world, &mut state);
602 assert_eq!(
603 world.get_component::<Name>(entity).map(|n| n.as_str()),
604 Some("Baseline"),
605 "undo must restore the baseline name"
606 );
607
608 let forward_again = history.redo().expect("a command to redo");
610 state.push_edit(forward_again);
611 apply_edits(&mut world, &mut state);
612 assert_eq!(
613 world.get_component::<Name>(entity).map(|n| n.as_str()),
614 Some("Renamed"),
615 "redo must re-apply the change"
616 );
617 }
618
619 #[test]
622 fn process_reparents_links_parent_and_child() {
623 let mut world = GameWorld::new();
624 let mut state = EditorState::default();
625
626 let parent = world.spawn((Transform::identity(), GlobalTransform::identity()));
627 let child = world.spawn((Transform::identity(), GlobalTransform::identity()));
628
629 state.pending_reparent = Some((child, Some(parent)));
630 process_reparents(&mut world, &mut state);
631
632 assert_eq!(
633 world.get_component::<Parent>(child).map(|p| p.0),
634 Some(parent),
635 "child must reference its new parent"
636 );
637 let children = world
638 .get_component::<Children>(parent)
639 .expect("parent gains a Children list");
640 assert!(
641 children.0.contains(&child),
642 "parent's Children must include the reparented child"
643 );
644
645 state.pending_reparent = Some((child, None));
647 process_reparents(&mut world, &mut state);
648 assert!(
649 world.get_component::<Parent>(child).is_none(),
650 "detached child must lose its Parent"
651 );
652 let children = world.get_component::<Children>(parent).unwrap();
653 assert!(
654 !children.0.contains(&child),
655 "former parent must drop the detached child"
656 );
657 }
658
659 fn spawn_three_level_chain(
661 world: &mut GameWorld,
662 state: &mut EditorState,
663 ) -> (EntityId, EntityId, EntityId) {
664 let a = world.spawn((
665 Transform::identity(),
666 GlobalTransform::identity(),
667 Name::new("A"),
668 ));
669 let b = world.spawn((
670 Transform::identity(),
671 GlobalTransform::identity(),
672 Name::new("B"),
673 ));
674 let c = world.spawn((
675 Transform::identity(),
676 GlobalTransform::identity(),
677 Name::new("C"),
678 ));
679 state.pending_reparent = Some((b, Some(a)));
680 process_reparents(world, state);
681 state.pending_reparent = Some((c, Some(b)));
682 process_reparents(world, state);
683 (a, b, c)
684 }
685
686 #[test]
693 fn extract_scene_tree_nests_three_levels() {
694 let mut world = GameWorld::new();
695 let mut state = EditorState::default();
696 let (a, b, c) = spawn_three_level_chain(&mut world, &mut state);
697
698 extract_scene_tree(&world, &mut state);
699
700 assert_eq!(state.scene_roots.len(), 1, "only A is a root");
701 let root = &state.scene_roots[0];
702 assert_eq!(root.entity, a);
703 assert_eq!(root.children.len(), 1, "A owns B");
704 assert_eq!(root.children[0].entity, b);
705 assert_eq!(root.children[0].children.len(), 1, "B owns C");
706 assert_eq!(root.children[0].children[0].entity, c);
707 }
708
709 #[test]
714 fn extract_scene_tree_is_stable_across_extractions() {
715 let mut world = GameWorld::new();
716 let mut state = EditorState::default();
717 spawn_three_level_chain(&mut world, &mut state);
718
719 for _ in 0..4 {
721 let sibling = world.spawn((
722 Transform::identity(),
723 GlobalTransform::identity(),
724 Name::new("Sibling"),
725 ));
726 state.pending_reparent = Some((sibling, None));
727 process_reparents(&mut world, &mut state);
728 }
729
730 let shape = |s: &EditorState| -> Vec<(EntityId, Vec<EntityId>)> {
731 s.scene_roots
732 .iter()
733 .map(|n| (n.entity, n.children.iter().map(|c| c.entity).collect()))
734 .collect()
735 };
736
737 extract_scene_tree(&world, &mut state);
738 let first = shape(&state);
739 for _ in 0..8 {
740 extract_scene_tree(&world, &mut state);
741 assert_eq!(shape(&state), first, "tree shape must not vary per frame");
742 }
743 }
744
745 #[test]
748 fn extract_scene_tree_surfaces_orphans_as_roots() {
749 let mut world = GameWorld::new();
750 let mut state = EditorState::default();
751 let (a, b, _c) = spawn_three_level_chain(&mut world, &mut state);
752
753 world.despawn(a);
754 extract_scene_tree(&world, &mut state);
755
756 let roots: Vec<EntityId> = state.scene_roots.iter().map(|n| n.entity).collect();
757 assert!(
758 roots.contains(&b),
759 "B lost its parent and must surface as a root, got {roots:?}"
760 );
761 }
762
763 #[test]
768 fn duplicate_entity_copies_authored_components() {
769 let mut world = GameWorld::new();
770 let mut state = EditorState::default();
771
772 let source = world.spawn((
773 Transform::from_translation(khora_sdk::prelude::math::Vec3::new(4.0, 5.0, 6.0)),
774 GlobalTransform::identity(),
775 Name::new("Original"),
776 ));
777 world.add_component(source, Tag::from_iter(["enemy", "spawner"]));
778
779 duplicate_entity(&mut world, source, &mut state);
780 let copy = *state
781 .selection
782 .iter()
783 .next()
784 .expect("the copy becomes the selection");
785 assert_ne!(copy, source, "duplicate must be a distinct entity");
786
787 assert_eq!(
788 world.get_component::<Name>(copy).map(|n: &Name| n.as_str()),
789 Some("Original (Copy)")
790 );
791 assert_eq!(
792 world
793 .get_component::<Transform>(copy)
794 .map(|t: &Transform| t.translation),
795 world
796 .get_component::<Transform>(source)
797 .map(|t: &Transform| t.translation),
798 "the transform must survive duplication"
799 );
800 let tag = world
801 .get_component::<Tag>(copy)
802 .expect("Tag must survive duplication");
803 assert!(tag.contains("enemy") && tag.contains("spawner"));
804 }
805
806 #[test]
811 fn duplicate_entity_rebuilds_the_subtree_without_stealing_children() {
812 let mut world = GameWorld::new();
813 let mut state = EditorState::default();
814
815 let parent = world.spawn((
816 Transform::identity(),
817 GlobalTransform::identity(),
818 Name::new("Root"),
819 ));
820 let child = world.spawn((
821 Transform::identity(),
822 GlobalTransform::identity(),
823 Name::new("Child"),
824 ));
825 state.pending_reparent = Some((child, Some(parent)));
826 process_reparents(&mut world, &mut state);
827
828 duplicate_entity(&mut world, parent, &mut state);
829 let copy = *state
830 .selection
831 .iter()
832 .next()
833 .expect("the copy becomes the selection");
834
835 let copied_children = world
836 .get_component::<Children>(copy)
837 .expect("the copy owns a subtree")
838 .0
839 .clone();
840 assert_eq!(copied_children.len(), 1, "the child must be duplicated too");
841 assert_ne!(
842 copied_children[0], child,
843 "the copy must own a fresh child, not point at the original's"
844 );
845
846 let original_children = world.get_component::<Children>(parent).unwrap();
847 assert_eq!(
848 original_children.0.as_slice(),
849 &[child],
850 "the original must keep exactly its own child"
851 );
852 }
853
854 #[test]
858 fn delete_selection_removes_selected_entity() {
859 let mut world = GameWorld::new();
860 let mut state = EditorState::default();
861
862 let keep = world.spawn((
863 Transform::identity(),
864 GlobalTransform::identity(),
865 Name::new("Keep"),
866 ));
867 let drop = world.spawn((
868 Transform::identity(),
869 GlobalTransform::identity(),
870 Name::new("Drop"),
871 ));
872
873 state.select(drop);
874 delete_selection(&mut world, &mut state);
875
876 let alive: Vec<EntityId> = world.iter_entities().collect();
877 assert!(!alive.contains(&drop), "deleted entity must be gone");
878 assert!(alive.contains(&keep), "unselected entity must survive");
879 assert_eq!(
880 world.get_component::<Name>(keep).map(|n| n.as_str()),
881 Some("Keep"),
882 "survivor's data must be intact"
883 );
884 assert!(state.selection.is_empty(), "selection cleared after delete");
885 assert!(state.inspected.is_none(), "inspector cleared after delete");
886 }
887
888 #[test]
892 fn process_spawns_creates_and_selects_entity() {
893 let mut world = GameWorld::new();
894 let mut state = EditorState::default();
895
896 let before = world.iter_entities().count();
897 state.pending_spawn = Some("Marker".to_owned());
898 process_spawns(&mut world, &mut state);
899
900 assert_eq!(
901 world.iter_entities().count(),
902 before + 1,
903 "a spawn request must add exactly one entity"
904 );
905 let spawned = state
906 .single_selected()
907 .expect("spawn selects the new entity");
908 assert_eq!(
909 world.get_component::<Name>(spawned).map(|n| n.as_str()),
910 Some("Marker"),
911 "the custom request tag becomes the entity Name"
912 );
913 assert!(state.pending_spawn.is_none(), "the request is consumed");
914 }
915
916 #[test]
920 fn duplicate_entity_clones_material_ref() {
921 let mut world = GameWorld::new();
922 let mut state = EditorState::default();
923
924 let mat = world.add_material(khora_sdk::prelude::materials::StandardMaterial {
925 base_color: khora_sdk::prelude::math::LinearRgba::new(0.2, 0.4, 0.6, 1.0),
926 roughness: 0.3,
927 ..Default::default()
928 });
929 let original = world.spawn((
930 Transform::identity(),
931 GlobalTransform::identity(),
932 Name::new("Source"),
933 mat,
934 ));
935
936 duplicate_entity(&mut world, original, &mut state);
937
938 let copy = state.single_selected().expect("duplicate selects the copy");
939 assert_ne!(copy, original, "duplicate must produce a new entity");
940
941 let copy_mat = world
942 .get_component::<MaterialRef>(copy)
943 .expect("copy should carry a MaterialRef");
944 match copy_mat {
945 MaterialRef::Inline { material, .. } => {
946 assert_eq!(
947 material.base_color(),
948 khora_sdk::prelude::math::LinearRgba::new(0.2, 0.4, 0.6, 1.0),
949 "cloned inline material should preserve base color"
950 );
951 }
952 MaterialRef::Asset(_) => panic!("inline material must stay inline after duplication"),
953 }
954 }
955
956 #[test]
959 fn duplicate_entity_clones_mesh_ref() {
960 let mut world = GameWorld::new();
961 let mut state = EditorState::default();
962
963 let original = world.spawn((
964 Transform::identity(),
965 GlobalTransform::identity(),
966 Name::new("Source"),
967 MeshRef::procedural(ProceduralMeshKind::Sphere, [0.75, 32.0, 16.0, 0.0]),
968 ));
969
970 duplicate_entity(&mut world, original, &mut state);
971
972 let copy = state.single_selected().expect("duplicate selects the copy");
973 let copy_mesh = world
974 .get_component::<MeshRef>(copy)
975 .expect("copy should carry a MeshRef");
976 assert_eq!(
977 copy_mesh,
978 &MeshRef::procedural(ProceduralMeshKind::Sphere, [0.75, 32.0, 16.0, 0.0])
979 );
980 }
981}