Skip to main content

khora_editor/
ops.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//! Pure ECS operations used by the editor application.
16
17use khora_sdk::editor_ui::*;
18use khora_sdk::khora_data::ecs::{SemanticDomain, Tag};
19use khora_sdk::prelude::ecs::*;
20use khora_sdk::GameWorld;
21
22/// Maps [`SemanticDomain`] to the small integer tag the editor side uses
23/// in [`ComponentJson::domain`]. Kept here so `khora-core` doesn't have to
24/// know about `khora-data`'s domain enum — the inspector reads the tag and
25/// dispatches to category labels.
26fn 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
36/// Display data for one entity, gathered in the first pass so the tree can be
37/// assembled top-down afterwards without touching the `World` again.
38struct NodeInfo {
39    name: String,
40    icon: EntityIcon,
41    tag_count: usize,
42}
43
44/// Builds the `SceneNode` for `entity` and, recursively, its children.
45///
46/// `visited` guards against a malformed `Parent` chain looping back on itself:
47/// a cycle would otherwise recurse until the stack blew. Returns `None` for an
48/// entity already placed in the tree, which is what breaks the loop.
49fn 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
77/// Extracts a scene tree snapshot from the ECS world into editor state.
78///
79/// Assembles the tree **top-down from the roots**, in `entity.index` order at
80/// every level. The previous bottom-up pass folded each child into its parent
81/// by draining a `HashMap`, which made the result depend on iteration order —
82/// and `HashMap` re-seeds its hasher per instance, so the order differed every
83/// frame. Two symptoms followed: a grandchild whose parent had already been
84/// moved was re-inserted as a root (so three-level hierarchies lost a level at
85/// random), and siblings reordered continuously, which let a click land on a
86/// different entity than the one aimed at.
87pub 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        // Trust `Parent` rather than the parent's `Children` list: `Parent` is
118        // the authored edge, `Children` only its derived inverse index.
119        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    // An entity is a root when it has no parent, or when its parent was
147    // despawned — an orphan must still be reachable in the panel.
148    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
162/// Processes pending spawn requests from the scene tree panel.
163/// The material the editor attaches to a freshly-spawned primitive so it is
164/// immediately visible and editable. The engine projection has no implicit
165/// default material (a meshed entity without one is a clear, logged error),
166/// so the authoring tool supplies an explicit one — a neutral matte grey the
167/// user then tweaks in the inspector.
168fn 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
176/// Spawns an entity that references a mesh asset by UUID at a world point,
177/// attaching a default surface material so it renders immediately. The
178/// `asset_resolver_system` loads the mesh from the `MeshRef::Asset` next tick.
179/// Returns the new entity. `label` is used to derive a readable `Name`.
180pub 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
256/// Drains `state.pending_reparent` and applies it to the ECS hierarchy.
257///
258/// Set by `scene_tree`'s drag-and-drop handler. The actual cycle check and
259/// `Parent`/`Children` bookkeeping live in `GameWorld::set_parent`.
260pub 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
273/// Duplicates an entity and everything below it.
274///
275/// Goes through the same subtree recipe round-trip as "Save as Prefab" rather
276/// than copying a hand-listed set of components, so the copy carries `Tag`,
277/// every user-defined component and the whole descendant subtree — none of
278/// which a fixed list could know about. `serialize_all_components` filters out
279/// engine-written components, so `GlobalTransform` and `Children` are rebuilt
280/// for the copy instead of being cloned with the original's entity ids.
281///
282/// `serialize_subtree` deliberately drops the root's own parent edge (a
283/// `.kprefab` has to be self-contained), so the copy is re-parented here to
284/// land as a sibling of the original.
285pub 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
321/// Deletes every currently selected entity and clears selection/inspector state.
322pub 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
334/// Keeps ECS scene-camera activation consistent with the current play mode.
335pub 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        // Editing mode always uses the dedicated editor camera.
348        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        // During play/pause, ensure at least one scene camera is active.
356        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
369/// Extracts inspectable component snapshots for the single selected entity.
370pub 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    // An entity is selected — drop any prior asset selection so the
379    // Inspector switches out of asset-metadata mode.
380    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    // Collect every component on this entity, captured generically as
388    // JSON via the macro-generated `to_json`. The inspector walks this
389    // list and renders every entry through a single field-typed walker
390    // — adding a new ECS component costs zero editor code.
391    //
392    // We also populate the global `component_domain_registry` with the
393    // domain of every registered type (regardless of whether this entity
394    // has it) so the "+ Add Component" menu can categorise candidates
395    // without re-querying the world.
396    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
424/// Applies queued property edits back into ECS components.
425///
426/// All component edits go through one path: the inspector ships a JSON
427/// patch and we look up the component's `from_json` from the inventory
428/// registration. The single special case is `Name`, which lives in the
429/// inspector header (not as a component card) and so has its own variant.
430pub 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
481/// Adds a new component to an existing entity by dispatching through the inventory registry.
482pub 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    /// Captures the live JSON of a component on `entity` via the inventory
501    /// registration — mirrors exactly what the inspector renders, so edits
502    /// built on top of it patch the same shape `from_json` consumes.
503    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    /// Inspector edit → commit: a queued `SetName` plus a `SetComponentJson`
517    /// (patching a real component's field through its JSON shape) must land in
518    /// the live `World` once `apply_edits` runs. This is the editor's single
519    /// mutation path; if `drain_edits` / registry dispatch / `from_json` break,
520    /// inspector edits silently no-op.
521    #[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        // Patch the Transform translation through its JSON shape, exactly as
533        // the inspector does: read the live value, mutate one field, ship back.
534        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        // Edits are drained — a second apply is a no-op.
562        assert!(state.pending_edits.is_empty());
563    }
564
565    /// Undo/redo driven through the real apply path: push a forward edit to the
566    /// history and apply it, then `undo()` → apply reverse → back to baseline,
567    /// then `redo()` → apply forward → changed again. The history state machine
568    /// is unit-tested elsewhere; this locks in its integration with
569    /// `apply_edits`.
570    #[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        // Apply forward.
591        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        // Undo → apply the reverse edit the history hands back.
599        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        // Redo → re-apply the forward edit.
609        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    /// `process_reparents` must wire both sides of the hierarchy: the child
620    /// gains a `Parent`, the parent gains the child in its `Children` list.
621    #[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        // Detach back to root: Parent drops, parent's Children empties.
646        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    /// Spawns `A → B → C` and returns the three ids, in depth order.
660    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    /// A three-level hierarchy must come out as one root with the full chain
687    /// nested under it.
688    ///
689    /// The old bottom-up fold drained a `HashMap`, so when `(B,A)` happened to
690    /// be processed before `(C,B)`, `B` had already been moved into `A` and the
691    /// lookup for `C`'s parent missed — re-rooting `C` at the top level.
692    #[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    /// The extracted tree must be identical on every extraction. `HashMap`
710    /// re-seeds its hasher per instance, so an order-dependent build produced a
711    /// different shape each frame — rows visibly jittered and a click could
712    /// land on the wrong entity.
713    #[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        // Several root-level siblings exercise sibling ordering too.
720        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    /// An entity whose parent was despawned must still appear, as a root —
746    /// otherwise it becomes unreachable in the panel.
747    #[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    /// Duplication must carry everything the author put on the entity, not a
764    /// fixed list of component types. `Tag` was the component the old
765    /// hand-written list forgot; the subtree recipe path covers it — and any
766    /// component a game crate defines — without naming it.
767    #[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    /// Duplicating a parent must rebuild its subtree rather than copy the
807    /// `Children` list verbatim: a cloned list would hold the *source's* entity
808    /// ids, so the copy would claim the original's children and both would
809    /// render the same subtree.
810    #[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    /// `delete_selection` removes the selected entity from the world and clears
855    /// the editor's selection/inspector state. A surviving sibling is left
856    /// untouched.
857    #[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    /// `process_spawns` honours a queued spawn request, creating the entity and
889    /// selecting it. A simple, dependency-free case ("Empty"/custom tag) keeps
890    /// the test off the procedural-mesh path.
891    #[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    /// Regression: duplicating an entity must carry its material across.
917    /// A broken implementation drops `MaterialRef`, so the copy renders
918    /// with no material (logged error) instead of the original look.
919    #[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    /// Regression: duplicating an entity must carry its authored `MeshRef`
957    /// across so the resolver regenerates the copy's runtime mesh handle.
958    #[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}