use std::collections::HashSet; use bevy::ecs::reflect::ReflectComponent; use bevy::prelude::*; use bevy::reflect::serde::{TypedReflectDeserializer, TypedReflectSerializer}; use bevy::reflect::std_traits::ReflectDefault; use bevy::world_serialization::WorldInstance; use serde::de::DeserializeSeed; use shared::{ infer_actor_kind, ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, AuthoringComponentStates, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabHydrationBlocked, PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, }; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::selection::SelectedEntity; use crate::state::scene_tools_active; use crate::ui::hierarchy_ops::{ apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index, reorder_entities_under_parent, }; use crate::ui::selection_ops::is_mutable_level_object; use crate::ui::{egui_captures_keyboard_from_world, UiState}; mod commands; pub use commands::{ ComponentDelta, ComponentTransaction, EditorCommand, EditorEntitySnapshot, NavigationComponentState, PrefabEditState, ReflectedComponentValue, SiblingChange, }; #[derive(Resource, Debug, Default)] pub struct EditorHistory { undo_stack: Vec, redo_stack: Vec, pub status: String, } impl EditorHistory { pub fn push(&mut self, command: EditorCommand) { let label = command.label(); self.undo_stack.push(command); self.redo_stack.clear(); self.status = format!("Undo: {label}"); } pub fn can_undo(&self) -> bool { !self.undo_stack.is_empty() } pub fn can_redo(&self) -> bool { !self.redo_stack.is_empty() } pub fn undo_depth(&self) -> usize { self.undo_stack.len() } pub fn set_undo_status(&mut self, label: impl Into) { self.status = format!("Undo: {}", label.into()); } fn set_redo_status(&mut self, label: impl Into) { self.status = format!("Redo: {}", label.into()); } fn pop_undo(&mut self) -> Option { self.undo_stack.pop() } fn push_undo(&mut self, command: EditorCommand) { self.undo_stack.push(command); } fn pop_redo(&mut self) -> Option { self.redo_stack.pop() } fn push_redo(&mut self, command: EditorCommand) { self.redo_stack.push(command); } pub fn clear(&mut self) { self.undo_stack.clear(); self.redo_stack.clear(); self.status = "History cleared".to_string(); } } #[derive(Resource, Debug, Default)] struct TransformEditTracker { active: Option<(Entity, Transform, Vec<(Entity, Transform)>)>, } pub struct EditorHistoryPlugin; impl Plugin for EditorHistoryPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems( Update, (handle_history_hotkeys, capture_gizmo_transform_edits) .chain() .run_if(scene_tools_active), ); } } pub fn snapshot_entity(world: &World, entity: Entity) -> Option { let entity_ref = world.get_entity(entity).ok()?; if !entity_ref.contains::() || entity_ref.contains::() || entity_ref.contains::() { return None; } let children = entity_ref .get::() .map(|children| { children .iter() .filter_map(|child| snapshot_entity(world, child)) .collect() }) .unwrap_or_default(); let actor_kind = entity_ref .get::() .copied() .or_else(|| infer_actor_kind(entity_ref)) .unwrap_or(ActorKind::Empty); Some(EditorEntitySnapshot { actor_id: entity_ref.get::().cloned(), actor_kind, actor_name: entity_ref.get::().cloned(), name: entity_ref .get::() .map(|name| name.as_str().to_string()), transform: entity_ref.get::().copied().unwrap_or_default(), primitive: entity_ref.get::().cloned(), brush: entity_ref.get::().cloned(), static_mesh_renderer: entity_ref.get::().cloned(), skinned_mesh_renderer: entity_ref.get::().cloned(), material: entity_ref.get::().cloned(), material_override: entity_ref.get::().cloned(), rigid_body: entity_ref.get::().cloned(), collider: entity_ref.get::().cloned(), physics: entity_ref.get::().cloned(), light: entity_ref.get::().cloned(), animation_controller: entity_ref.get::().cloned(), audio_source: entity_ref.get::().cloned(), audio_listener: entity_ref.get::().copied(), player_spawn: entity_ref.contains::(), model: entity_ref.get::().cloned(), prefab: entity_ref.get::().cloned(), prefab_instance: entity_ref.get::().cloned(), weapon_spawn: entity_ref.get::().cloned(), trigger_volume: entity_ref.get::().cloned(), post_process_volume: entity_ref.get::().cloned(), team_spawn: entity_ref.get::().cloned(), objective: entity_ref.get::().cloned(), navigation_bounds: entity_ref.get::().cloned(), navigation_obstacle: entity_ref.get::().cloned(), navigation_area: entity_ref.get::().cloned(), navigation_link: entity_ref.get::().cloned(), hierarchy_sibling_index: entity_ref .get::() .map(|index| index.0) .unwrap_or(0), editor_visibility: entity_ref .get::() .copied() .unwrap_or_default(), inspector_order: entity_ref.get::().cloned(), component_states: entity_ref.get::().cloned(), children, }) } pub fn spawn_snapshot(world: &mut World, snapshot: &EditorEntitySnapshot) -> Entity { spawn_snapshot_with_parent(world, snapshot, None) } fn new_actor_id() -> ActorId { ActorId::new(uuid::Uuid::new_v4().to_string()) } fn spawn_snapshot_with_parent( world: &mut World, snapshot: &EditorEntitySnapshot, parent: Option, ) -> Entity { let mut entity_mut = world.spawn((LevelObject, snapshot.actor_kind, snapshot.transform)); entity_mut.insert( snapshot .actor_id .clone() .filter(|id| !id.0.trim().is_empty()) .unwrap_or_else(new_actor_id), ); if let Some(actor_name) = &snapshot.actor_name { entity_mut.insert(actor_name.clone()); } if let Some(name) = &snapshot.name { entity_mut.insert(Name::new(name.clone())); } if let Some(primitive) = &snapshot.primitive { entity_mut.insert(primitive.clone()); } if let Some(brush) = &snapshot.brush { entity_mut.insert(brush.clone()); } if let Some(renderer) = &snapshot.static_mesh_renderer { entity_mut.insert(renderer.clone()); } if let Some(renderer) = &snapshot.skinned_mesh_renderer { entity_mut.insert(renderer.clone()); } if let Some(material) = &snapshot.material { entity_mut.insert(material.clone()); } if let Some(material_override) = &snapshot.material_override { entity_mut.insert(material_override.clone()); } if let Some(rigid_body) = &snapshot.rigid_body { entity_mut.insert(*rigid_body); } if let Some(collider) = &snapshot.collider { entity_mut.insert(collider.clone()); } if let Some(physics) = &snapshot.physics { entity_mut.insert(physics.clone()); } if let Some(light) = &snapshot.light { entity_mut.insert(light.clone()); } if let Some(controller) = &snapshot.animation_controller { entity_mut.insert(controller.clone()); } if let Some(audio_source) = &snapshot.audio_source { entity_mut.insert(audio_source.clone()); } if let Some(audio_listener) = snapshot.audio_listener { entity_mut.insert(audio_listener); } if snapshot.player_spawn { entity_mut.insert(PlayerSpawn); } if let Some(model) = &snapshot.model { entity_mut.insert(model.clone()); } if let Some(prefab) = &snapshot.prefab { entity_mut.insert(prefab.clone()); } if let Some(instance) = &snapshot.prefab_instance { entity_mut.insert(instance.clone()); } if let Some(weapon) = &snapshot.weapon_spawn { entity_mut.insert(weapon.clone()); } if let Some(trigger) = &snapshot.trigger_volume { entity_mut.insert(trigger.clone()); } if let Some(pp) = &snapshot.post_process_volume { entity_mut.insert(pp.clone()); } if let Some(team) = &snapshot.team_spawn { entity_mut.insert(team.clone()); } if let Some(objective) = &snapshot.objective { entity_mut.insert(objective.clone()); } if let Some(bounds) = &snapshot.navigation_bounds { entity_mut.insert(bounds.clone()); } if let Some(obstacle) = &snapshot.navigation_obstacle { entity_mut.insert(obstacle.clone()); } if let Some(area) = &snapshot.navigation_area { entity_mut.insert(area.clone()); } if let Some(link) = &snapshot.navigation_link { entity_mut.insert(link.clone()); } entity_mut.insert(HierarchySiblingIndex(snapshot.hierarchy_sibling_index)); entity_mut.insert(snapshot.editor_visibility); if let Some(order) = &snapshot.inspector_order { entity_mut.insert(order.clone()); } if let Some(states) = &snapshot.component_states { entity_mut.insert(states.clone()); } if let Some(parent) = parent { entity_mut.insert(ChildOf(parent)); } let entity = entity_mut.id(); for child in &snapshot.children { spawn_snapshot_with_parent(world, child, Some(entity)); } entity } pub fn clear_level_objects(world: &mut World) { let mut query = world.query_filtered::>(); let entities: Vec = query.iter(world).collect(); for entity in entities { if let Ok(entity_mut) = world.get_entity_mut(entity) { entity_mut.despawn(); } } } pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot) -> Entity { assign_missing_actor_ids(&mut snapshot); snapshot.hierarchy_sibling_index = next_sibling_index(world, None); let entity = spawn_snapshot(world, &snapshot); push_history( world, EditorCommand::Spawn { snapshot, entity: Some(entity), }, ); select_one(world, entity); entity } pub fn spawn_many_with_history( world: &mut World, snapshots: impl IntoIterator, ) -> Vec { let mut snapshots: Vec = snapshots.into_iter().collect(); if snapshots.is_empty() { return Vec::new(); } for snapshot in &mut snapshots { assign_missing_actor_ids(snapshot); } for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) { snapshot.hierarchy_sibling_index = sibling_index; } let entities: Vec = snapshots .iter() .map(|snapshot| spawn_snapshot(world, snapshot)) .collect(); push_history( world, EditorCommand::SpawnMany { snapshots, entities: entities.clone(), }, ); select_many(world, &entities); entities } pub fn delete_entities_with_history(world: &mut World, entities: &[Entity]) { let mut deleted = Vec::new(); for entity in entities { if !is_mutable_level_object(world, *entity) { continue; } if let Some(snapshot) = snapshot_entity(world, *entity) { deleted.push((snapshot, *entity)); } } for (_, entity) in &deleted { if let Ok(entity_mut) = world.get_entity_mut(*entity) { entity_mut.despawn(); } } for (snapshot, entity) in deleted { push_history( world, EditorCommand::Despawn { snapshot, entity: Some(entity), }, ); } clear_selection(world); } pub fn duplicate_entities_with_history(world: &mut World, entities: &[Entity]) { let snapshots: Vec = entities .iter() .filter(|entity| is_mutable_level_object(world, **entity)) .filter_map(|entity| snapshot_entity(world, *entity)) .map(|mut snapshot| { assign_fresh_actor_ids(&mut snapshot); snapshot.transform.translation += Vec3::new(1.0, 0.0, 1.0); if let Some(name) = &mut snapshot.name { name.push_str(" Copy"); } snapshot }) .collect(); if snapshots.is_empty() { return; } let entities: Vec = snapshots .iter() .map(|snapshot| spawn_snapshot(world, snapshot)) .collect(); push_history( world, EditorCommand::Duplicate { snapshots, entities: entities.clone(), }, ); select_many(world, &entities); } fn assign_fresh_actor_ids(snapshot: &mut EditorEntitySnapshot) { let actor_id = new_actor_id(); if let Some(bounds) = &mut snapshot.navigation_bounds { bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0); } snapshot.actor_id = Some(actor_id); for child in &mut snapshot.children { assign_fresh_actor_ids(child); } } fn assign_missing_actor_ids(snapshot: &mut EditorEntitySnapshot) { let existing_actor_id = snapshot .actor_id .clone() .filter(|id| !id.0.trim().is_empty()); let assigned_new_id = existing_actor_id.is_none(); let actor_id = existing_actor_id.unwrap_or_else(new_actor_id); if let Some(bounds) = &mut snapshot.navigation_bounds { if assigned_new_id || bounds.artifact_path == NavigationBounds::default().artifact_path || shared::navigation_generated_artifact_path(&bounds.artifact_path).is_err() { bounds.artifact_path = shared::navigation_artifact_path_for_actor(&actor_id.0); } } snapshot.actor_id = Some(actor_id); for child in &mut snapshot.children { assign_missing_actor_ids(child); } } pub fn rename_entity_with_history(world: &mut World, entity: Entity, new_name: String) { if !is_mutable_level_object(world, entity) { return; } let old = world .get::(entity) .map(|name| name.as_str().to_string()) .unwrap_or_default(); if old == new_name { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(Name::new(new_name.clone())); } push_history( world, EditorCommand::Rename { entity, old, new: new_name, }, ); } pub fn set_material_with_history(world: &mut World, entity: Entity, new: MaterialDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref().is_some_and(|old| material_eq(old, &new)) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetMaterial { entity, old, new }); } pub fn set_material_group_with_history( world: &mut World, changes: impl IntoIterator, ) { let mut entities = Vec::new(); let mut olds = Vec::new(); let mut news = Vec::new(); for (entity, new) in changes { if !is_mutable_level_object(world, entity) { continue; } let old = world.get::(entity).cloned(); if old.as_ref().is_some_and(|old| material_eq(old, &new)) { continue; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); entities.push(entity); olds.push(old); news.push(new); } } if !entities.is_empty() { push_history( world, EditorCommand::SetMaterialGroup { entities, olds, news, }, ); } } pub fn set_material_override_with_history( world: &mut World, entity: Entity, new: MaterialOverride, ) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history( world, EditorCommand::SetMaterialOverride { entity, old, new }, ); } pub fn set_light_with_history(world: &mut World, entity: Entity, new: LightDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetLight { entity, old, new }); } pub fn set_audio_source_with_history(world: &mut World, entity: Entity, new: AudioSourceDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetAudioSource { entity, old, new }); } pub fn set_animation_controller_with_history( world: &mut World, entity: Entity, new: AnimationControllerDesc, ) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history( world, EditorCommand::SetAnimationController { entity, old, new }, ); } pub fn set_audio_listener_with_history(world: &mut World, entity: Entity, new: AudioListenerDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).copied(); if old == Some(new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new); } push_history(world, EditorCommand::SetAudioListener { entity, old, new }); } pub fn set_post_process_volume_with_history( world: &mut World, entity: Entity, new: PostProcessVolumeDesc, ) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history( world, EditorCommand::SetPostProcessVolume { entity, old, new }, ); } pub fn navigation_component_state(world: &World, entity: Entity) -> NavigationComponentState { NavigationComponentState { bounds: world.get::(entity).cloned(), obstacle: world.get::(entity).cloned(), area: world.get::(entity).cloned(), link: world.get::(entity).cloned(), } } pub fn set_navigation_with_history( world: &mut World, entity: Entity, new: NavigationComponentState, ) { if !is_mutable_level_object(world, entity) { return; } let old = navigation_component_state(world, entity); if old == new { return; } apply_navigation_state(world, entity, &new); push_history(world, EditorCommand::SetNavigation { entity, old, new }); } pub fn set_physics_with_history(world: &mut World, entity: Entity, new: PhysicsBody) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetPhysics { entity, old, new }); } pub fn set_rigid_body_with_history(world: &mut World, entity: Entity, new: RigidBodyDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).copied(); if old == Some(new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new); } push_history(world, EditorCommand::SetRigidBody { entity, old, new }); } pub fn set_collider_with_history(world: &mut World, entity: Entity, new: ColliderDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetCollider { entity, old, new }); } pub fn set_primitive_with_history(world: &mut World, entity: Entity, new: Primitive) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetPrimitive { entity, old, new }); } pub fn set_brush_with_history(world: &mut World, entity: Entity, new: BrushDesc) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetBrush { entity, old, new }); } pub fn set_brush_transform_with_history( world: &mut World, entity: Entity, new_transform: Transform, new_brush: BrushDesc, ) { if !is_mutable_level_object(world, entity) { return; } let old_transform = world.get::(entity).copied().unwrap_or_default(); let old_brush = world.get::(entity).cloned(); if old_transform == new_transform && old_brush.as_ref() == Some(&new_brush) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new_transform); entity_mut.insert(new_brush.clone()); } push_history( world, EditorCommand::SetBrushTransform { entity, old_transform, new_transform, old_brush, new_brush, }, ); } pub fn apply_brush_csg_with_history( world: &mut World, primary: Entity, new_transform: Transform, new_brush: BrushDesc, delete_entities: &[Entity], ) { if !is_level_object(world, primary) { return; } let old_transform = world.get::(primary).copied().unwrap_or_default(); let old_brush = world.get::(primary).cloned(); let deleted: Vec<(EditorEntitySnapshot, Option)> = delete_entities .iter() .copied() .filter_map(|entity| { snapshot_entity(world, entity).map(|snapshot| (snapshot, Some(entity))) }) .collect(); if old_transform == new_transform && old_brush.as_ref() == Some(&new_brush) && deleted.is_empty() { return; } if let Ok(mut entity_mut) = world.get_entity_mut(primary) { entity_mut.insert(new_transform); entity_mut.insert(new_brush.clone()); } for (_, entity) in &deleted { despawn_entity(world, *entity); } push_history( world, EditorCommand::ApplyBrushCsg { primary, old_transform, new_transform, old_brush, new_brush, deleted, }, ); select_one(world, primary); } pub fn set_static_mesh_renderer_with_history( world: &mut World, entity: Entity, new: StaticMeshRenderer, ) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history( world, EditorCommand::SetStaticMeshRenderer { entity, old, new }, ); } pub fn reparent_with_history(world: &mut World, entity: Entity, new_parent: Option) { reorder_siblings_with_history(world, &[entity], new_parent, i32::MAX); } pub fn reorder_siblings_with_history( world: &mut World, entities: &[Entity], new_parent: Option, insert_index: i32, ) { let entities: Vec = entities .iter() .copied() .filter(|entity| is_mutable_level_object(world, *entity)) .collect(); if entities.is_empty() { return; } if let Some(violation) = hierarchy_drop_violation(world, &entities, new_parent) { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.set_status(match violation { crate::ui::hierarchy_ops::HierarchyDropViolation::Cycle => { "Hierarchy change blocked: it would create a cycle".to_string() } crate::ui::hierarchy_ops::HierarchyDropViolation::PrefabBoundary { .. } => "Hierarchy change blocked: prefab instance contents are read-only; unpack the instance first" .to_string(), }); } return; } let changes = reorder_entities_under_parent(world, &entities, new_parent, insert_index); if changes.is_empty() { return; } push_history(world, EditorCommand::ReorderSibling { changes }); } pub fn set_editor_visibility_with_history( world: &mut World, entity: Entity, new: EditorVisibility, ) { if !is_mutable_level_object(world, entity) { return; } let old = world .get::(entity) .copied() .unwrap_or_default(); if old.visible == new.visible { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new); } push_history( world, EditorCommand::SetEditorVisibility { entity, old, new }, ); } pub fn set_inspector_order_with_history(world: &mut World, entity: Entity, new: InspectorOrder) { if !is_mutable_level_object(world, entity) { return; } let old = world.get::(entity).cloned(); if old.as_ref() == Some(&new) { return; } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(new.clone()); } push_history(world, EditorCommand::SetInspectorOrder { entity, old, new }); } pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) { let entities: Vec = entities .iter() .copied() .filter(|entity| is_mutable_level_object(world, *entity)) .collect(); if entities.is_empty() { return; } let snapshot = EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::Empty, actor_name: None, name: Some("Group".to_string()), transform: Transform::default(), primitive: None, brush: None, static_mesh_renderer: None, skinned_mesh_renderer: None, material: None, material_override: None, rigid_body: None, collider: None, physics: None, light: None, animation_controller: None, audio_source: None, audio_listener: None, player_spawn: false, model: None, prefab: None, prefab_instance: None, weapon_spawn: None, trigger_volume: None, post_process_volume: None, team_spawn: None, objective: None, navigation_bounds: None, navigation_obstacle: None, navigation_area: None, navigation_link: None, hierarchy_sibling_index: next_sibling_index(world, None), editor_visibility: EditorVisibility::default(), inspector_order: None, component_states: None, children: Vec::new(), }; let group = spawn_snapshot(world, &snapshot); push_history( world, EditorCommand::Spawn { snapshot, entity: Some(group), }, ); reorder_siblings_with_history(world, &entities, Some(group), i32::MAX); } pub fn create_empty_child_with_history(world: &mut World, parent: Entity) { if !is_level_object(world, parent) || world.get::(parent).is_some() { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io .set_status("Create child blocked: generated prefab source content is read-only"); } return; } let snapshot = EditorEntitySnapshot { actor_id: None, actor_kind: ActorKind::Empty, actor_name: None, name: Some("Empty".to_string()), transform: Transform::default(), primitive: None, brush: None, static_mesh_renderer: None, skinned_mesh_renderer: None, material: None, material_override: None, rigid_body: None, collider: None, physics: None, light: None, animation_controller: None, audio_source: None, audio_listener: None, player_spawn: false, model: None, prefab: None, prefab_instance: None, weapon_spawn: None, trigger_volume: None, post_process_volume: None, team_spawn: None, objective: None, navigation_bounds: None, navigation_obstacle: None, navigation_area: None, navigation_link: None, hierarchy_sibling_index: next_sibling_index(world, Some(parent)), editor_visibility: EditorVisibility::default(), inspector_order: None, component_states: None, children: Vec::new(), }; let entity = spawn_snapshot_with_parent(world, &snapshot, Some(parent)); push_history( world, EditorCommand::Spawn { snapshot, entity: Some(entity), }, ); select_one(world, entity); } pub fn unpack_prefab_instance(world: &mut World, entity: Entity) { let Some(instance) = world.get::(entity).cloned() else { return; }; let prefab_ref = world .get::(entity) .cloned() .unwrap_or_else(|| PrefabRef::new(instance.source_path.clone())); if world.get::(entity).is_none() { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.set_status("Unpack blocked: prefab source is still loading or unavailable"); } return; } let linked_actor_kind = world .get::(entity) .copied() .unwrap_or(ActorKind::PrefabAnchor); if let Err(error) = crate::assets::prefab_overrides::fold_nested_overrides_for_unpack(world, entity) { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io.set_status(format!("Unpack blocked: {error}")); } return; } let unpacked_roots = perform_prefab_unpack(world, entity); let unpacked_parented_to_anchor = unpacked_roots .iter() .map(|root| world.get::(*root).map(ChildOf::parent) == Some(entity)) .collect(); let unpacked_snapshots = unpacked_roots .iter() .filter_map(|root| snapshot_entity(world, *root)) .collect(); push_history( world, EditorCommand::UnpackPrefab { entity, instance, prefab_ref, linked_actor_kind, fully_local: false, linked_local_snapshots: Vec::new(), linked_local_roots: Vec::new(), unpacked_snapshots, unpacked_parented_to_anchor, unpacked_roots, }, ); } pub fn convert_prefab_completely_to_local(world: &mut World, entity: Entity) { let Some(instance) = world.get::(entity).cloned() else { return; }; let prefab_ref = world .get::(entity) .cloned() .unwrap_or_else(|| PrefabRef::new(instance.source_path.clone())); let linked_local: Vec<(Entity, EditorEntitySnapshot)> = world .get::(entity) .map(|children| { children .iter() .filter(|child| world.get::(*child).is_none()) .filter_map(|child| snapshot_entity(world, child).map(|snapshot| (child, snapshot))) .collect() }) .unwrap_or_default(); let linked_local_roots: Vec = linked_local.iter().map(|(root, _)| *root).collect(); let linked_local_snapshots = linked_local .into_iter() .map(|(_, snapshot)| snapshot) .collect(); let mut all_instances: Vec<(usize, Entity)> = world .query_filtered::>() .iter(world) .filter_map(|candidate| { prefab_conversion_depth(world, candidate, entity).map(|depth| (depth, candidate)) }) .collect(); all_instances.sort_by_key(|(depth, _)| *depth); if all_instances .iter() .any(|(_, instance_root)| world.get::(*instance_root).is_none()) { if let Some(mut scene_io) = world.get_resource_mut::() { scene_io .set_status("Complete conversion blocked: every nested prefab must finish loading"); } return; } let linked_actor_kind = world .get::(entity) .copied() .unwrap_or(ActorKind::PrefabAnchor); let mut candidate_roots = linked_local_roots.clone(); for (_, instance_root) in all_instances { candidate_roots.extend(perform_prefab_unpack(world, instance_root)); } let candidate_set: HashSet<_> = candidate_roots.iter().copied().collect(); candidate_roots.retain(|root| !has_candidate_ancestor(world, *root, &candidate_set)); let mut unpacked_roots = Vec::new(); let mut unpacked_snapshots = Vec::new(); let mut unpacked_parented_to_anchor = Vec::new(); for root in candidate_roots { if let Some(snapshot) = snapshot_entity(world, root) { unpacked_parented_to_anchor .push(world.get::(root).map(ChildOf::parent) == Some(entity)); unpacked_roots.push(root); unpacked_snapshots.push(snapshot); } } push_history( world, EditorCommand::UnpackPrefab { entity, instance, prefab_ref, linked_actor_kind, fully_local: true, linked_local_snapshots, linked_local_roots: Vec::new(), unpacked_snapshots, unpacked_parented_to_anchor, unpacked_roots, }, ); } fn prefab_conversion_depth(world: &World, entity: Entity, root: Entity) -> Option { let mut current = entity; let mut depth = 0; let mut seen = HashSet::new(); while seen.insert(current) { if current == root { return Some(depth); } current = world .get::(current) .map(|owner| owner.instance_root) .or_else(|| world.get::(current).map(ChildOf::parent))?; depth += 1; } None } fn has_candidate_ancestor(world: &World, entity: Entity, candidates: &HashSet) -> bool { let mut current = world.get::(entity).map(ChildOf::parent); let mut seen = HashSet::new(); while let Some(parent) = current { if candidates.contains(&parent) { return true; } if !seen.insert(parent) { break; } current = world.get::(parent).map(ChildOf::parent); } false } fn perform_prefab_unpack(world: &mut World, entity: Entity) -> Vec { let members: Vec = world .query::<(Entity, &HydratedPrefabMember)>() .iter(world) .filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member)) .collect(); let member_set: HashSet = members.iter().copied().collect(); let roots = members .iter() .copied() .filter(|member| { world .get::(*member) .map(ChildOf::parent) .is_none_or(|parent| !member_set.contains(&parent)) }) .collect(); for member in members { if let Ok(mut member_mut) = world.get_entity_mut(member) { member_mut.remove::(); if member_mut.contains::() { member_mut.insert(new_actor_id()); } } } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut .insert(ActorKind::Empty) .remove::() .remove::() .remove::() .remove::() .remove::() .remove::(); } roots } fn despawn_hydrated_prefab_members(world: &mut World, entity: Entity) { let members: Vec = world .query::<(Entity, &HydratedPrefabMember)>() .iter(world) .filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member)) .collect(); let member_set: HashSet = members.iter().copied().collect(); let roots: Vec = members .into_iter() .filter(|member| { world .get::(*member) .map(ChildOf::parent) .is_none_or(|parent| !member_set.contains(&parent)) }) .collect(); for root in roots { despawn_entity(world, Some(root)); } } pub fn capture_prefab_edit_state(world: &World, entity: Entity) -> PrefabEditState { PrefabEditState { instance: world.get::(entity).cloned(), prefab_ref: world.get::(entity).cloned(), blocked_reason: world .get::(entity) .map(|blocked| blocked.reason.clone()), transform: world.get::(entity).copied(), material: world.get::(entity).cloned(), } } pub fn record_prefab_edit_state(world: &mut World, entity: Entity, old: PrefabEditState) { let new = capture_prefab_edit_state(world, entity); if old != new { push_history(world, EditorCommand::SetPrefabState { entity, old, new }); } } pub fn record_prefab_source_apply( world: &mut World, entity: Entity, source_path: std::path::PathBuf, before_source: String, after_source: String, old: PrefabEditState, ) { let new = capture_prefab_edit_state(world, entity); push_history( world, EditorCommand::ApplyPrefabToSource { entity, source_path, before_source, after_source, old, new, }, ); } pub fn set_transform_with_history( world: &mut World, entity: Entity, old: Transform, new: Transform, ) { if !is_mutable_level_object(world, entity) { return; } if transform_nearly_eq(&old, &new) { return; } if let Some(mut transform) = world.get_mut::(entity) { *transform = new; } push_history(world, EditorCommand::SetTransform { entity, old, new }); } pub fn set_transform_group_with_history( world: &mut World, changes: impl IntoIterator, ) { let mut entities = Vec::new(); let mut olds = Vec::new(); let mut news = Vec::new(); for (entity, old, new) in changes { if !is_mutable_level_object(world, entity) { continue; } if transform_nearly_eq(&old, &new) { continue; } if let Some(mut transform) = world.get_mut::(entity) { *transform = new; entities.push(entity); olds.push(old); news.push(new); } } if !entities.is_empty() { push_history( world, EditorCommand::SetTransformGroup { entities, olds, news, }, ); } } pub fn set_actor_kind_with_history( world: &mut World, entity: Entity, old: ActorKind, new: ActorKind, ) { if !is_mutable_level_object(world, entity) || old == new { return; } push_history(world, EditorCommand::SetActorKind { entity, old, new }); } pub fn apply_actor_kind(world: &mut World, entity: Entity, kind: ActorKind) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(kind); } } pub const ACTOR_KIND_COMPONENT_ID: &str = "core.actor_kind"; pub const ACTOR_KIND_TYPE_PATH: &str = "shared::components::ActorKind"; /// Captures any reflected component without adding a typed history branch. pub fn capture_reflected_component( world: &World, entity: Entity, component_id: &str, type_path: &str, ) -> Result, String> { let entity_ref = world .get_entity(entity) .map_err(|_| format!("entity {entity:?} no longer exists"))?; let app_registry = world .get_resource::() .ok_or_else(|| "AppTypeRegistry is unavailable".to_string())?; let type_registry = app_registry.read(); let registration = type_registry .get_with_type_path(type_path) .ok_or_else(|| format!("component type `{type_path}` is not registered"))?; let reflect_component = registration .data::() .ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?; let Some(value) = reflect_component.reflect(entity_ref) else { return Ok(None); }; let ron = ron::ser::to_string(&TypedReflectSerializer::new(value, &type_registry)) .map_err(|error| format!("could not serialize `{type_path}`: {error}"))?; Ok(Some(ReflectedComponentValue { component_id: component_id.to_string(), type_path: type_path.to_string(), ron, })) } /// Applies or removes one reflected component value. pub fn apply_reflected_component( world: &mut World, entity: Entity, type_path: &str, value: Option<&ReflectedComponentValue>, ) -> Result<(), String> { if world.get_entity(entity).is_err() { return Err(format!("entity {entity:?} no longer exists")); } world.resource_scope(|world, app_registry: Mut| { let type_registry = app_registry.read(); let registration = type_registry .get_with_type_path(type_path) .ok_or_else(|| format!("component type `{type_path}` is not registered"))?; let reflect_component = registration .data::() .cloned() .ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?; match value { Some(value) => { if value.type_path != type_path { return Err(format!( "clipboard type `{}` does not match `{type_path}`", value.type_path )); } let mut deserializer = ron::de::Deserializer::from_str(&value.ron) .map_err(|error| error.to_string())?; let reflected = TypedReflectDeserializer::new(registration, &type_registry) .deserialize(&mut deserializer) .map_err(|error| error.to_string())?; // Reflected `apply` merges list fields and cannot restore a shorter snapshot. // Inserting the captured concrete component replaces the value atomically. reflect_component.insert( &mut world.entity_mut(entity), reflected.as_ref(), &type_registry, ); } None => reflect_component.remove(&mut world.entity_mut(entity)), } Ok(()) }) } /// Inserts the reflected `Default` for a registered authoring component. pub fn apply_reflected_default( world: &mut World, entity: Entity, type_path: &str, ) -> Result<(), String> { world.resource_scope(|world, app_registry: Mut| { let type_registry = app_registry.read(); let registration = type_registry .get_with_type_path(type_path) .ok_or_else(|| format!("component type `{type_path}` is not registered"))?; let reflect_component = registration .data::() .cloned() .ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?; let default = registration .data::() .ok_or_else(|| format!("component `{type_path}` has no reflected Default"))? .default(); reflect_component.insert( &mut world.entity_mut(entity), default.as_ref(), &type_registry, ); Ok(()) }) } pub fn sync_actor_kind_hint(world: &mut World, entity: Entity) { let kind = world .get_entity(entity) .ok() .and_then(infer_actor_kind) .unwrap_or(ActorKind::Empty); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(kind); } } /// Executes one atomic reflected component edit and records all changed values, /// including the derived ActorKind presentation hint, as a single history item. pub fn reflected_component_transaction( world: &mut World, entity: Entity, label: &'static str, component_id: &str, type_path: &str, edit: impl FnOnce(&mut World, Entity) -> Result<(), String>, ) -> Result<(), String> { if !is_mutable_level_object(world, entity) { return Err("actor is not editable".to_string()); } let tracked = [ (component_id, type_path), (ACTOR_KIND_COMPONENT_ID, ACTOR_KIND_TYPE_PATH), ]; let before = tracked .iter() .map(|(id, path)| capture_reflected_component(world, entity, id, path)) .collect::, _>>()?; if let Err(error) = edit(world, entity) { for ((_, path), value) in tracked.iter().zip(before.iter()) { let _ = apply_reflected_component(world, entity, path, value.as_ref()); } return Err(error); } sync_actor_kind_hint(world, entity); let after = tracked .iter() .map(|(id, path)| capture_reflected_component(world, entity, id, path)) .collect::, _>>()?; let deltas = tracked .iter() .zip(before) .zip(after) .filter_map(|(((id, path), before), after)| { (before != after).then(|| ComponentDelta { component_id: (*id).to_string(), type_path: (*path).to_string(), before, after, }) }) .collect::>(); if !deltas.is_empty() { push_history( world, EditorCommand::ComponentTransaction(ComponentTransaction { entity, label, deltas, }), ); } Ok(()) } fn apply_component_transaction( world: &mut World, transaction: &ComponentTransaction, use_after: bool, ) { for delta in &transaction.deltas { let value = if use_after { delta.after.as_ref() } else { delta.before.as_ref() }; if let Err(error) = apply_reflected_component(world, transaction.entity, &delta.type_path, value) { world.resource_mut::().status = format!( "{} partially failed for {}: {error}", transaction.label, delta.component_id ); break; } } } pub fn set_scene_composition_with_history(world: &mut World, new: SceneComposition) { let old = world.resource::().clone(); if old == new { return; } world.insert_resource(new.clone()); push_history(world, EditorCommand::SetSceneComposition { old, new }); world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); } pub fn apply_command_undo(world: &mut World) { let candidate = world.resource::().undo_stack.last().cloned(); if let Some(candidate) = candidate { if let Err(error) = prepare_prefab_source_history(world, &candidate, true) { world.resource_mut::().status = format!("Undo blocked: {error}"); world .resource_mut::() .set_status(format!("Undo blocked: {error}")); return; } } let Some(mut command) = world.resource_mut::().pop_undo() else { return; }; let label = command.label(); undo_command(world, &mut command); world.resource_mut::().push_redo(command); world.resource_mut::().set_redo_status(label); mark_dirty(world); } pub fn apply_command_redo(world: &mut World) { let candidate = world.resource::().redo_stack.last().cloned(); if let Some(candidate) = candidate { if let Err(error) = prepare_prefab_source_history(world, &candidate, false) { world.resource_mut::().status = format!("Redo blocked: {error}"); world .resource_mut::() .set_status(format!("Redo blocked: {error}")); return; } } let Some(mut command) = world.resource_mut::().pop_redo() else { return; }; let label = command.label(); redo_command(world, &mut command); world.resource_mut::().push_undo(command); world.resource_mut::().set_undo_status(label); mark_dirty(world); } fn prepare_prefab_source_history( world: &mut World, command: &EditorCommand, undo: bool, ) -> Result<(), String> { let EditorCommand::ApplyPrefabToSource { entity, source_path, before_source, after_source, .. } = command else { return Ok(()); }; let (expected, replacement) = if undo { (after_source, before_source) } else { (before_source, after_source) }; let expected = crate::collaboration::FileSnapshot::from_loaded_bytes(source_path, expected.as_bytes()); crate::collaboration::publish_authored_file( world, source_path, replacement.as_bytes(), &expected, crate::collaboration::FileWriteIntent::PrefabSourceHistory { instance_root: *entity, }, ) .map(|_| ()) } fn undo_command(world: &mut World, command: &mut EditorCommand) { match command { EditorCommand::Spawn { entity, .. } => { despawn_entity(world, entity.take()); clear_selection(world); } EditorCommand::SpawnMany { entities, .. } => { for entity in std::mem::take(entities) { despawn_entity(world, Some(entity)); } clear_selection(world); } EditorCommand::Despawn { snapshot, entity } => { let spawned = spawn_snapshot(world, snapshot); *entity = Some(spawned); select_one(world, spawned); } EditorCommand::Duplicate { entities, .. } => { for entity in std::mem::take(entities) { despawn_entity(world, Some(entity)); } clear_selection(world); } EditorCommand::Rename { entity, old, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(Name::new(old.clone())); } } EditorCommand::SetTransform { entity, old, .. } => { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *old; } } EditorCommand::SetMaterial { entity, old, .. } => { apply_material(world, *entity, old); } EditorCommand::SetMaterialGroup { entities, olds, .. } => { for (entity, old) in entities.iter().zip(olds.iter()) { apply_material(world, *entity, old); } } EditorCommand::SetMaterialOverride { entity, old, .. } => { apply_material_override(world, *entity, old); } EditorCommand::SetLight { entity, old, .. } => { apply_light(world, *entity, old); } EditorCommand::SetAnimationController { entity, old, .. } => { apply_animation_controller(world, *entity, old); } EditorCommand::SetAudioSource { entity, old, .. } => { apply_audio_source(world, *entity, old); } EditorCommand::SetAudioListener { entity, old, .. } => { apply_audio_listener(world, *entity, old); } EditorCommand::SetPostProcessVolume { entity, old, .. } => { apply_post_process_volume(world, *entity, old); } EditorCommand::SetNavigation { entity, old, .. } => { apply_navigation_state(world, *entity, old); } EditorCommand::SetPhysics { entity, old, .. } => { apply_physics(world, *entity, old); } EditorCommand::SetRigidBody { entity, old, .. } => { apply_rigid_body(world, *entity, old); } EditorCommand::SetCollider { entity, old, .. } => { apply_collider(world, *entity, old); } EditorCommand::SetPrimitive { entity, old, .. } => { apply_primitive(world, *entity, old); } EditorCommand::SetBrush { entity, old, .. } => { apply_brush(world, *entity, old); } EditorCommand::SetBrushTransform { entity, old_transform, old_brush, .. } => { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *old_transform; } apply_brush(world, *entity, old_brush); } EditorCommand::ApplyBrushCsg { primary, old_transform, old_brush, deleted, .. } => { if let Some(mut transform) = world.get_mut::(*primary) { *transform = *old_transform; } apply_brush(world, *primary, old_brush); let mut restored = vec![*primary]; for (snapshot, entity) in deleted.iter_mut() { let spawned = spawn_snapshot(world, snapshot); *entity = Some(spawned); restored.push(spawned); } select_many(world, &restored); } EditorCommand::SetStaticMeshRenderer { entity, old, .. } => { apply_static_mesh_renderer(world, *entity, old); } EditorCommand::SetTransformGroup { entities, olds, .. } => { for (entity, old) in entities.iter().zip(olds.iter()) { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *old; } } } EditorCommand::Reparent { entity, old_parent, .. } => { apply_parent(world, *entity, *old_parent); } EditorCommand::SetActorKind { entity, old, .. } => { apply_actor_kind(world, *entity, *old); } EditorCommand::ReorderSibling { changes } => { for change in changes.iter().rev() { apply_sibling_change(world, change); } } EditorCommand::SetEditorVisibility { entity, old, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(*old); } } EditorCommand::SetInspectorOrder { entity, old, .. } => { apply_inspector_order(world, *entity, old); } EditorCommand::SetSceneComposition { old, .. } => { world.insert_resource(old.clone()); world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); } EditorCommand::SetPrefabState { entity, old, .. } => { apply_prefab_edit_state(world, *entity, old); } EditorCommand::ApplyPrefabToSource { entity, old, .. } => { apply_prefab_edit_state(world, *entity, old); } EditorCommand::UnpackPrefab { entity, instance, prefab_ref, linked_actor_kind, linked_local_snapshots, linked_local_roots, unpacked_roots, .. } => { for root in std::mem::take(unpacked_roots) { despawn_entity(world, Some(root)); } if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert((*linked_actor_kind, instance.clone(), prefab_ref.clone())); } *linked_local_roots = linked_local_snapshots .iter() .map(|snapshot| spawn_snapshot_with_parent(world, snapshot, Some(*entity))) .collect(); } EditorCommand::AddComponent { entity, snapshot } => { remove_authored_components(world, *entity, snapshot); } EditorCommand::RemoveComponent { entity, snapshot } => { apply_authored_components(world, *entity, snapshot); } EditorCommand::ComponentTransaction(transaction) => { apply_component_transaction(world, transaction, false); } } } fn redo_command(world: &mut World, command: &mut EditorCommand) { match command { EditorCommand::Spawn { snapshot, entity } => { let spawned = spawn_snapshot(world, snapshot); *entity = Some(spawned); select_one(world, spawned); } EditorCommand::SpawnMany { snapshots, entities, } => { let spawned: Vec = snapshots .iter() .map(|snapshot| spawn_snapshot(world, snapshot)) .collect(); *entities = spawned.clone(); select_many(world, &spawned); } EditorCommand::Despawn { entity, .. } => { despawn_entity(world, entity.take()); clear_selection(world); } EditorCommand::Duplicate { snapshots, entities, } => { let spawned: Vec = snapshots .iter() .map(|snapshot| spawn_snapshot(world, snapshot)) .collect(); *entities = spawned.clone(); select_many(world, &spawned); } EditorCommand::Rename { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(Name::new(new.clone())); } } EditorCommand::SetTransform { entity, new, .. } => { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *new; } } EditorCommand::SetMaterial { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetMaterialGroup { entities, news, .. } => { for (entity, new) in entities.iter().zip(news.iter()) { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } } EditorCommand::SetMaterialOverride { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetLight { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetAnimationController { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetAudioSource { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetAudioListener { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(*new); } } EditorCommand::SetPostProcessVolume { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetNavigation { entity, new, .. } => { apply_navigation_state(world, *entity, new); } EditorCommand::SetPhysics { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetRigidBody { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(*new); } } EditorCommand::SetCollider { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetPrimitive { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetBrush { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetBrushTransform { entity, new_transform, new_brush, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(*new_transform); entity_mut.insert(new_brush.clone()); } } EditorCommand::ApplyBrushCsg { primary, new_transform, new_brush, deleted, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*primary) { entity_mut.insert(*new_transform); entity_mut.insert(new_brush.clone()); } for (_, entity) in deleted.iter_mut() { despawn_entity(world, entity.take()); } select_one(world, *primary); } EditorCommand::SetStaticMeshRenderer { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetTransformGroup { entities, news, .. } => { for (entity, new) in entities.iter().zip(news.iter()) { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *new; } } } EditorCommand::Reparent { entity, new_parent, .. } => { apply_parent(world, *entity, *new_parent); } EditorCommand::SetActorKind { entity, new, .. } => { apply_actor_kind(world, *entity, *new); } EditorCommand::ReorderSibling { changes } => { for change in changes { apply_sibling_change_new(world, change); } } EditorCommand::SetEditorVisibility { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(*new); } } EditorCommand::SetInspectorOrder { entity, new, .. } => { if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(new.clone()); } } EditorCommand::SetSceneComposition { new, .. } => { world.insert_resource(new.clone()); world.resource_mut::().request = Some(SceneIoRequest::ReloadComposition); } EditorCommand::SetPrefabState { entity, new, .. } => { apply_prefab_edit_state(world, *entity, new); } EditorCommand::ApplyPrefabToSource { entity, new, .. } => { apply_prefab_edit_state(world, *entity, new); } EditorCommand::UnpackPrefab { entity, linked_local_roots, unpacked_snapshots, unpacked_parented_to_anchor, unpacked_roots, .. } => { for root in std::mem::take(linked_local_roots) { despawn_entity(world, Some(root)); } despawn_hydrated_prefab_members(world, *entity); if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut .insert(ActorKind::Empty) .remove::() .remove::() .remove::() .remove::() .remove::() .remove::(); } *unpacked_roots = unpacked_snapshots .iter() .zip(unpacked_parented_to_anchor.iter()) .map(|(snapshot, parented)| { spawn_snapshot_with_parent(world, snapshot, parented.then_some(*entity)) }) .collect(); } EditorCommand::AddComponent { entity, snapshot } => { apply_authored_components(world, *entity, snapshot); } EditorCommand::RemoveComponent { entity, snapshot } => { remove_authored_components(world, *entity, snapshot); } EditorCommand::ComponentTransaction(transaction) => { apply_component_transaction(world, transaction, true); } } } fn apply_material(world: &mut World, entity: Entity, material: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match material { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_prefab_edit_state(world: &mut World, entity: Entity, state: &PrefabEditState) { if state.blocked_reason.is_some() { despawn_hydrated_prefab_members(world, entity); } if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match &state.instance { Some(instance) => { entity_mut.insert(instance.clone()); } None => { entity_mut.remove::(); } } match &state.prefab_ref { Some(prefab_ref) => { entity_mut.insert(prefab_ref.clone()); } None => { entity_mut.remove::(); } } match &state.blocked_reason { Some(reason) => { entity_mut .insert(PrefabHydrationBlocked { reason: reason.clone(), }) .remove::() .remove::() .remove::(); } None => { entity_mut.remove::(); } } if let Some(transform) = state.transform { entity_mut.insert(transform); } match &state.material { Some(material) => { entity_mut.insert(material.clone()); } None => { entity_mut.remove::(); } } } crate::assets::prefab_overrides::apply_stored_overrides(world, entity); } fn apply_material_override( world: &mut World, entity: Entity, material_override: &Option, ) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match material_override { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_light(world: &mut World, entity: Entity, light: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match light { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_animation_controller( world: &mut World, entity: Entity, controller: &Option, ) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match controller { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_audio_source(world: &mut World, entity: Entity, source: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match source { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_audio_listener(world: &mut World, entity: Entity, listener: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match listener { Some(value) => { entity_mut.insert(*value); } None => { entity_mut.remove::(); } } } } fn apply_post_process_volume( world: &mut World, entity: Entity, volume: &Option, ) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match volume { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_navigation_state(world: &mut World, entity: Entity, state: &NavigationComponentState) { let has_navigation = state.bounds.is_some() || state.obstacle.is_some() || state.area.is_some() || state.link.is_some(); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut .remove::() .remove::() .remove::() .remove::(); if let Some(value) = &state.bounds { entity_mut.insert(value.clone()); } if let Some(value) = &state.obstacle { entity_mut.insert(value.clone()); } if let Some(value) = &state.area { entity_mut.insert(value.clone()); } if let Some(value) = &state.link { entity_mut.insert(value.clone()); } if has_navigation { entity_mut.insert(ActorKind::Navigation); } } if !has_navigation { let fallback = world .get_entity(entity) .ok() .and_then(infer_actor_kind) .unwrap_or(ActorKind::Empty); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(fallback); } } } fn apply_physics(world: &mut World, entity: Entity, physics: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match physics { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_rigid_body(world: &mut World, entity: Entity, rigid_body: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match rigid_body { Some(value) => { entity_mut.insert(*value); } None => { entity_mut.remove::(); } } } } fn apply_collider(world: &mut World, entity: Entity, collider: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match collider { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_primitive(world: &mut World, entity: Entity, primitive: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match primitive { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_brush(world: &mut World, entity: Entity, brush: &Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match brush { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_static_mesh_renderer( world: &mut World, entity: Entity, renderer: &Option, ) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match renderer { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_inspector_order( world: &mut World, entity: Entity, inspector_order: &Option, ) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match inspector_order { Some(value) => { entity_mut.insert(value.clone()); } None => { entity_mut.remove::(); } } } } fn apply_parent(world: &mut World, entity: Entity, parent: Option) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match parent { Some(p) => { entity_mut.insert(ChildOf(p)); } None => { entity_mut.remove::(); } } } } fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &EditorEntitySnapshot) { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(snapshot.actor_kind); if let Some(v) = &snapshot.primitive { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.brush { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.static_mesh_renderer { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.skinned_mesh_renderer { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.material { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.material_override { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.rigid_body { entity_mut.insert(*v); } if let Some(v) = &snapshot.collider { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.physics { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.light { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.animation_controller { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.audio_source { entity_mut.insert(v.clone()); } if let Some(v) = snapshot.audio_listener { entity_mut.insert(v); } if snapshot.player_spawn { entity_mut.insert(PlayerSpawn); } if let Some(v) = &snapshot.model { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.prefab { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.prefab_instance { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.weapon_spawn { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.trigger_volume { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.post_process_volume { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.team_spawn { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.objective { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.navigation_bounds { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.navigation_obstacle { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.navigation_area { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.navigation_link { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.inspector_order { entity_mut.insert(v.clone()); } if let Some(v) = &snapshot.component_states { entity_mut.insert(v.clone()); } } } fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &EditorEntitySnapshot) { let removed_dedicated_audio_kind = matches!( world.get::(entity).copied(), Some(ActorKind::AudioSource) if snapshot.audio_source.is_some() ) || matches!( world.get::(entity).copied(), Some(ActorKind::AudioListener) if snapshot.audio_listener.is_some() ); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { if snapshot.primitive.is_some() { entity_mut.remove::(); } if snapshot.brush.is_some() { entity_mut.remove::(); } if snapshot.static_mesh_renderer.is_some() { entity_mut.remove::(); } if snapshot.skinned_mesh_renderer.is_some() { entity_mut.remove::(); } if snapshot.material.is_some() { entity_mut.remove::(); } if snapshot.material_override.is_some() { entity_mut.remove::(); } if snapshot.rigid_body.is_some() { entity_mut.remove::(); } if snapshot.collider.is_some() { entity_mut.remove::(); } if snapshot.physics.is_some() { entity_mut.remove::(); } if snapshot.light.is_some() { entity_mut.remove::(); } if snapshot.animation_controller.is_some() { entity_mut.remove::(); } if snapshot.audio_source.is_some() { entity_mut.remove::(); } if snapshot.audio_listener.is_some() { entity_mut.remove::(); } if snapshot.player_spawn { entity_mut.remove::(); } if snapshot.model.is_some() { entity_mut.remove::(); } if snapshot.prefab.is_some() { entity_mut.remove::(); } if snapshot.prefab_instance.is_some() { entity_mut.remove::(); } if snapshot.weapon_spawn.is_some() { entity_mut.remove::(); } if snapshot.trigger_volume.is_some() { entity_mut.remove::(); } if snapshot.post_process_volume.is_some() { entity_mut.remove::(); } if snapshot.team_spawn.is_some() { entity_mut.remove::(); } if snapshot.objective.is_some() { entity_mut.remove::(); } if snapshot.navigation_bounds.is_some() { entity_mut.remove::(); } if snapshot.navigation_obstacle.is_some() { entity_mut.remove::(); } if snapshot.navigation_area.is_some() { entity_mut.remove::(); } if snapshot.navigation_link.is_some() { entity_mut.remove::(); } if snapshot.inspector_order.is_some() { entity_mut.remove::(); } if snapshot.component_states.is_some() { entity_mut.remove::(); } } if removed_dedicated_audio_kind { let fallback = world .get_entity(entity) .ok() .and_then(infer_actor_kind) .unwrap_or(ActorKind::Empty); if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(fallback); } } } fn handle_history_hotkeys(world: &mut World) { if egui_captures_keyboard_from_world(world) { return; } let (undo, redo, delete, duplicate) = { let keys = world.resource::>(); let ctrl = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); ( ctrl && keys.just_pressed(KeyCode::KeyZ) && !shift, ctrl && (keys.just_pressed(KeyCode::KeyY) || (shift && keys.just_pressed(KeyCode::KeyZ))), keys.just_pressed(KeyCode::Delete) || keys.just_pressed(KeyCode::Backspace), ctrl && keys.just_pressed(KeyCode::KeyD), ) }; if undo { apply_command_undo(world); return; } if redo { apply_command_redo(world); return; } let selection: Vec = world .get_resource::() .map(|ui_state| { ui_state .selected_entities .iter() .filter(|entity| is_mutable_level_object(world, *entity)) .collect() }) .unwrap_or_default(); if delete && !selection.is_empty() { delete_entities_with_history(world, &selection); } else if duplicate && !selection.is_empty() { duplicate_entities_with_history(world, &selection); } } fn capture_gizmo_transform_edits(world: &mut World) { let selection: Vec = world .get_resource::() .map(|ui| { ui.selected_entities .iter() .filter(|entity| is_mutable_level_object(world, *entity)) .collect() }) .unwrap_or_default(); let mut query = world.query::<( Entity, &Transform, &transform_gizmo_bevy::prelude::GizmoTarget, Option<&crate::viewport::brush_edit::BrushElementGizmo>, )>(); let active = query .iter(world) .find(|(_, _, target, brush_element_gizmo)| { brush_element_gizmo.is_none() && target.is_active() }) .map(|(entity, transform, _, _)| (entity, *transform)); let start_group = active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform)); let (finished_edit, live_edit) = { let mut tracker = world.resource_mut::(); if tracker.active.is_none() { if let (Some((entity, transform)), Some(group)) = (active, start_group) { tracker.active = Some((entity, transform, group)); return; } } match tracker.active.take() { Some((primary, old, group)) if active.is_none() => (Some((primary, old, group)), None), Some(state) if active.is_some() => { let live = Some(state.clone()); tracker.active = Some(state); (None, live) } _ => (None, None), } }; if let Some((primary, old_primary, group)) = live_edit { let Some(new_primary) = world.get::(primary).copied() else { return; }; if group.len() > 1 { let changes = transform_group_changes(old_primary, new_primary, group); apply_transform_group(world, &changes); } } if let Some((primary, old_primary, group)) = finished_edit { let Some(new_primary) = world.get::(primary).copied() else { return; }; if group.len() <= 1 { set_transform_with_history(world, primary, old_primary, new_primary); return; } let changes = transform_group_changes(old_primary, new_primary, group); set_transform_group_with_history(world, changes); } } fn transform_group_changes( old_primary: Transform, new_primary: Transform, group: Vec<(Entity, Transform)>, ) -> Vec<(Entity, Transform, Transform)> { let translation_delta = new_primary.translation - old_primary.translation; let rotation_delta = new_primary.rotation * old_primary.rotation.inverse(); let scale_ratio = new_primary.scale / old_primary.scale; group .into_iter() .map(|(entity, old)| { let new = Transform { translation: old.translation + translation_delta, rotation: rotation_delta * old.rotation, scale: old.scale * scale_ratio, }; (entity, old, new) }) .collect() } fn apply_transform_group(world: &mut World, changes: &[(Entity, Transform, Transform)]) { for (entity, _, new) in changes { if let Some(mut transform) = world.get_mut::(*entity) { *transform = *new; } } } fn selected_transforms( world: &World, selection: &[Entity], primary: Entity, primary_transform: Transform, ) -> Vec<(Entity, Transform)> { if selection.len() > 1 && selection.contains(&primary) { selection .iter() .filter_map(|entity| { world .get::(*entity) .copied() .map(|transform| (*entity, transform)) }) .collect() } else { vec![(primary, primary_transform)] } } fn push_history(world: &mut World, command: EditorCommand) { world.resource_mut::().push(command); mark_dirty(world); } pub fn push_command(world: &mut World, command: EditorCommand) { push_history(world, command); } fn mark_dirty(world: &mut World) { if let Some(mut io) = world.get_resource_mut::() { io.mark_dirty(); } } fn despawn_entity(world: &mut World, entity: Option) { if let Some(entity) = entity { if let Ok(entity_mut) = world.get_entity_mut(entity) { entity_mut.despawn(); } } } fn select_one(world: &mut World, entity: Entity) { if let Some(mut ui_state) = world.get_resource_mut::() { ui_state.selected_entities.clear(); ui_state.selected_entities.select_replace(entity); } world.resource_mut::().0 = Some(entity); } fn select_many(world: &mut World, entities: &[Entity]) { if let Some(mut ui_state) = world.get_resource_mut::() { ui_state.selected_entities.clear(); for (index, entity) in entities.iter().enumerate() { if index == 0 { ui_state.selected_entities.select_replace(*entity); } else { ui_state.selected_entities.select_maybe_add(*entity, true); } } } world.resource_mut::().0 = entities.first().copied(); } pub fn clear_selection(world: &mut World) { if let Some(mut ui_state) = world.get_resource_mut::() { ui_state.selected_entities.clear(); } world.resource_mut::().0 = None; } pub fn is_level_object(world: &World, entity: Entity) -> bool { world .get_entity(entity) .is_ok_and(|entity_ref| entity_ref.contains::()) } fn transform_nearly_eq(a: &Transform, b: &Transform) -> bool { a.translation.abs_diff_eq(b.translation, 0.0001) && a.rotation.abs_diff_eq(b.rotation, 0.0001) && a.scale.abs_diff_eq(b.scale, 0.0001) } pub(crate) fn material_eq(a: &MaterialDesc, b: &MaterialDesc) -> bool { a.shader == b.shader && a.base_color.r == b.base_color.r && a.base_color.g == b.base_color.g && a.base_color.b == b.base_color.b && a.base_color.a == b.base_color.a && a.metallic == b.metallic && a.roughness == b.roughness && a.emissive_color.r == b.emissive_color.r && a.emissive_color.g == b.emissive_color.g && a.emissive_color.b == b.emissive_color.b && a.emissive_color.a == b.emissive_color.a && a.emissive_intensity == b.emissive_intensity && a.base_color_texture == b.base_color_texture && a.emissive_texture == b.emissive_texture && a.normal_map_texture == b.normal_map_texture && a.metallic_roughness_texture == b.metallic_roughness_texture && a.material_asset_path == b.material_asset_path && a.parameters == b.parameters && a.textures == b.textures } #[cfg(test)] mod tests { use super::*; use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; #[derive(Component, Reflect, Default, Debug, Clone, PartialEq)] #[reflect(Component, Default)] struct StaticExtensionFixture { value: u32, } #[test] fn reflected_extension_component_transaction_round_trips_atomically() { let mut app = App::new(); app.register_type::() .register_type::(); let world = app.world_mut(); world.init_resource::(); world.init_resource::(); let entity = world .spawn((LevelObject, ActorKind::Light, Transform::default())) .id(); let type_path = std::any::type_name::(); reflected_component_transaction( world, entity, "Add Fixture", "game.static_extension_fixture", type_path, |world, entity| { world .entity_mut(entity) .insert(StaticExtensionFixture { value: 42 }); Ok(()) }, ) .unwrap(); assert_eq!( world.get::(entity).unwrap().value, 42 ); assert_eq!(world.get::(entity), Some(&ActorKind::Empty)); assert_eq!(world.resource::().undo_depth(), 1); apply_command_undo(world); assert!(world.get::(entity).is_none()); assert_eq!(world.get::(entity), Some(&ActorKind::Light)); apply_command_redo(world); assert_eq!( world.get::(entity).unwrap().value, 42 ); assert_eq!(world.get::(entity), Some(&ActorKind::Empty)); } #[test] fn snapshots_preserve_component_order_and_independent_active_state() { let mut world = World::new(); let order = InspectorOrder { component_ids: vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()], ..Default::default() }; let mut states = AuthoringComponentStates::default(); states.set_component_active(shared::COMPONENT_LIGHT_DESC, false); let entity = world .spawn(( LevelObject, ActorKind::Light, Transform::default(), LightDesc::default(), order.clone(), states.clone(), )) .id(); let snapshot = snapshot_entity(&world, entity).unwrap(); world.entity_mut(entity).despawn(); let restored = spawn_snapshot(&mut world, &snapshot); assert_eq!(world.get::(restored), Some(&order)); assert_eq!( world.get::(restored), Some(&states) ); } fn animation_controller(state_id: &str, crossfade_seconds: f32) -> AnimationControllerDesc { AnimationControllerDesc { skeleton: Some( shared::EditorAssetRef::new("robot-asset", "animation:skeleton:0:robot", "Robot") .with_source_path("assets/models/robot.glb"), ), states: vec![shared::AnimationStateDesc { id: state_id.to_string(), label: state_id.to_ascii_uppercase(), clip: shared::EditorAssetRef::new( "robot-asset", format!("animation:clip:0:{state_id}"), state_id, ) .with_source_path("assets/models/robot.glb"), looping: true, speed: 1.25, range: shared::AnimationPlaybackRange { start_seconds: 0.1, end_seconds: Some(1.8), }, }], default_state: state_id.to_string(), default_crossfade_seconds: crossfade_seconds, } } #[test] fn animation_controller_history_round_trips_and_marks_scene_dirty() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let old = animation_controller("idle", 0.15); let new = animation_controller("run", 0.3); let entity = world .spawn(( LevelObject, ActorKind::SkinnedMesh, Transform::IDENTITY, old.clone(), )) .id(); set_animation_controller_with_history(&mut world, entity, new.clone()); assert_eq!(world.get::(entity), Some(&new)); assert_eq!(world.resource::().undo_depth(), 1); assert!(world.resource::().dirty); apply_command_undo(&mut world); assert_eq!(world.get::(entity), Some(&old)); apply_command_redo(&mut world); assert_eq!(world.get::(entity), Some(&new)); } #[test] fn navigation_component_history_round_trips_and_preserves_kind() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let entity = world .spawn((LevelObject, ActorKind::Empty, Transform::IDENTITY)) .id(); let bounds = NavigationBounds::default(); set_navigation_with_history( &mut world, entity, NavigationComponentState { bounds: Some(bounds.clone()), ..Default::default() }, ); assert_eq!(world.get::(entity), Some(&bounds)); assert_eq!(world.get::(entity), Some(&ActorKind::Navigation)); apply_command_undo(&mut world); assert!(world.get::(entity).is_none()); assert_eq!(world.get::(entity), Some(&ActorKind::Empty)); apply_command_redo(&mut world); assert_eq!(world.get::(entity), Some(&bounds)); assert_eq!(world.get::(entity), Some(&ActorKind::Navigation)); } #[test] fn undoing_a_new_animation_controller_removes_the_component() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let controller = animation_controller("idle", 0.2); let entity = world .spawn((LevelObject, ActorKind::SkinnedMesh, Transform::IDENTITY)) .id(); set_animation_controller_with_history(&mut world, entity, controller.clone()); apply_command_undo(&mut world); assert!(world.get::(entity).is_none()); apply_command_redo(&mut world); assert_eq!( world.get::(entity), Some(&controller) ); } #[test] fn animation_controller_survives_snapshot_respawn() { let mut world = World::new(); let controller = animation_controller("idle", 0.2); let source = world .spawn(( LevelObject, ActorKind::SkinnedMesh, Name::new("Animated Robot"), Transform::from_xyz(1.0, 2.0, 3.0), SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset"), controller.clone(), )) .id(); let snapshot = snapshot_entity(&world, source).expect("level object is snapshotable"); world.entity_mut(source).despawn(); let respawned = spawn_snapshot(&mut world, &snapshot); assert_eq!( world.get::(respawned), Some(&controller) ); assert_eq!( world.get::(respawned).map(Name::as_str), Some("Animated Robot") ); assert_eq!( world.get::(respawned), Some(&SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset")) ); } #[test] fn removing_dedicated_audio_components_repairs_and_restores_actor_kind() { let mut world = World::new(); let source = world .spawn(( LevelObject, ActorKind::AudioSource, Transform::IDENTITY, AudioSourceDesc::default(), )) .id(); let source_snapshot = snapshot_entity(&world, source).unwrap(); remove_authored_components(&mut world, source, &source_snapshot); assert!(world.get::(source).is_none()); assert_eq!(world.get::(source), Some(&ActorKind::Empty)); apply_authored_components(&mut world, source, &source_snapshot); assert!(world.get::(source).is_some()); assert_eq!( world.get::(source), Some(&ActorKind::AudioSource) ); let listener = world .spawn(( LevelObject, ActorKind::AudioListener, Transform::IDENTITY, AudioListenerDesc::default(), )) .id(); let listener_snapshot = snapshot_entity(&world, listener).unwrap(); remove_authored_components(&mut world, listener, &listener_snapshot); assert!(world.get::(listener).is_none()); assert_eq!(world.get::(listener), Some(&ActorKind::Empty)); apply_authored_components(&mut world, listener, &listener_snapshot); assert!(world.get::(listener).is_some()); assert_eq!( world.get::(listener), Some(&ActorKind::AudioListener) ); } #[test] fn continuous_multi_transform_finalizes_as_one_undo_group() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let first = world.spawn((LevelObject, Transform::default())).id(); let second = world .spawn(( LevelObject, Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)), )) .id(); let initial = vec![Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0)]; let committed = vec![Vec3::Z, Vec3::new(2.0, 0.0, 1.0)]; let harness = OperatorInvariantHarness::capture(&mut world); set_transform_group_with_history( &mut world, [ ( first, Transform::default(), Transform::from_translation(committed[0]), ), ( second, Transform::from_translation(initial[1]), Transform::from_translation(committed[1]), ), ], ); world.resource_mut::().status = Some(OperatorStatus { id: "transform.gizmo".to_string(), label: "Transform Gizmo".to_string(), phase: OperatorPhase::Committed, hint: "Committed multi-selection drag".to_string(), warnings: Vec::new(), }); harness.assert_committed(&mut world, 1, 0); assert_undo_redo_round_trip(&mut world, initial, committed, |world| { [first, second] .into_iter() .map(|entity| world.get::(entity).unwrap().translation) .collect::>() }); } #[test] fn unpack_prefab_is_one_undoable_history_command() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let instance = PrefabInstance::new("asset", "assets/prefabs/crate.scn.ron"); let root = world .spawn(( LevelObject, ActorKind::PrefabAnchor, instance.clone(), PrefabRef::new(instance.source_path.clone()), HydratedPrefabReady, )) .id(); let generated = world .spawn(( LevelObject, ActorId::new("generated-child"), ChildOf(root), HydratedPrefabMember { instance_root: root, }, )) .id(); unpack_prefab_instance(&mut world, root); assert_eq!(world.resource::().undo_depth(), 1); assert!(world.get::(root).is_none()); assert!(world.get::(root).is_none()); assert_eq!(world.get::(root), Some(&ActorKind::Empty)); assert!(world.get::(generated).is_none()); let unpacked_actor_id = world.get::(generated).unwrap().0.clone(); assert_ne!(unpacked_actor_id, "generated-child"); apply_command_undo(&mut world); assert_eq!(world.get::(root), Some(&instance)); assert!(world.get::(root).is_some()); assert_eq!(world.get::(root), Some(&ActorKind::PrefabAnchor)); assert!(world.get_entity(generated).is_err()); world .entity_mut(root) .insert(PrefabHydrationBlocked { reason: "source removed after undo".to_string(), }) .remove::(); apply_command_redo(&mut world); assert!(world.get::(root).is_none()); assert!(world.get::(root).is_none()); assert!(world.get::(root).is_none()); assert_eq!(world.get::(root), Some(&ActorKind::Empty)); assert!(!world.resource::().can_redo()); let restored = world .query::<(Entity, &ActorId)>() .iter(&world) .find_map(|(entity, actor_id)| (actor_id.0 == unpacked_actor_id).then_some(entity)) .expect("redo should restore the committed local actor without source readiness"); assert_eq!( world.get::(restored).map(ChildOf::parent), Some(root) ); assert_eq!(world.get::(restored).unwrap().0, unpacked_actor_id); } #[test] fn unpack_redo_uses_committed_snapshot_instead_of_mutated_source() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let instance = PrefabInstance::new("asset", "assets/prefabs/crate.scn.ron"); let root = world .spawn(( LevelObject, ActorKind::PrefabAnchor, instance.clone(), PrefabRef::new(instance.source_path.clone()), HydratedPrefabReady, )) .id(); let generated = world .spawn(( LevelObject, ActorKind::StaticMesh, ActorId::new("source-child"), Name::new("Committed Source Actor"), Transform::from_xyz(1.0, 2.0, 3.0), ChildOf(root), HydratedPrefabMember { instance_root: root, }, )) .id(); unpack_prefab_instance(&mut world, root); let committed_actor_id = world.get::(generated).unwrap().0.clone(); apply_command_undo(&mut world); let mutated_source = world .spawn(( LevelObject, ActorKind::Light, ActorId::new("source-child"), Name::new("Mutated Source Actor"), Transform::from_xyz(90.0, 80.0, 70.0), ChildOf(root), HydratedPrefabMember { instance_root: root, }, )) .id(); world.entity_mut(root).insert(HydratedPrefabReady); apply_command_redo(&mut world); assert!(world.get_entity(mutated_source).is_err()); let restored = world .query::<(Entity, &ActorId)>() .iter(&world) .find_map(|(entity, actor_id)| (actor_id.0 == committed_actor_id).then_some(entity)) .expect("redo should recreate the originally committed local actor"); assert_eq!( world.get::(restored).map(Name::as_str), Some("Committed Source Actor") ); assert_eq!( world.get::(restored).unwrap().translation, Vec3::new(1.0, 2.0, 3.0) ); assert_eq!( world.get::(restored), Some(&ActorKind::StaticMesh) ); assert!(world.get::(restored).is_none()); } #[test] fn unpack_redo_preserves_structurally_unparented_roots() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let root = world .spawn(( LevelObject, ActorKind::PrefabAnchor, PrefabInstance::new("asset", "assets/prefabs/test.scn.ron"), PrefabRef::new("assets/prefabs/test.scn.ron"), HydratedPrefabReady, )) .id(); let generated = world .spawn(( LevelObject, ActorId::new("unparented-source-child"), HydratedPrefabMember { instance_root: root, }, )) .id(); unpack_prefab_instance(&mut world, root); let local_actor_id = world.get::(generated).unwrap().0.clone(); assert!(world.get::(generated).is_none()); apply_command_undo(&mut world); apply_command_redo(&mut world); let restored = world .query::<(Entity, &ActorId)>() .iter(&world) .find_map(|(entity, actor_id)| (actor_id.0 == local_actor_id).then_some(entity)) .expect("redo should restore the committed unparented root"); assert!(world.get::(restored).is_none()); } #[test] fn unpack_layer_preserves_nested_prefab_links() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let base_material = MaterialDesc::default(); let mut overridden_material = base_material.clone(); overridden_material.roughness = 0.21; let mut outer_instance = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"); outer_instance.overrides_ron = shared::encode_prefab_overrides(&shared::PrefabOverrides { properties: vec![shared::PrefabPropertyOverride { target: shared::PrefabActorPath { instance_chain: vec!["nested-anchor".to_string()], actor_id: "nested-child".to_string(), }, component_type: std::any::type_name::().to_string(), property_path: "roughness".to_string(), base_component_ron: ron::to_string(&base_material).unwrap(), value_component_ron: ron::to_string(&overridden_material).unwrap(), }], ..default() }) .unwrap(); let root = world .spawn(( LevelObject, ActorKind::PrefabAnchor, outer_instance, PrefabRef::new("assets/prefabs/outer.scn.ron"), HydratedPrefabReady, )) .id(); let nested = world .spawn(( LevelObject, ActorKind::PrefabAnchor, ActorId::new("nested-anchor"), PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"), PrefabRef::new("assets/prefabs/nested.scn.ron"), HydratedPrefabReady, ChildOf(root), HydratedPrefabMember { instance_root: root, }, )) .id(); let nested_child = world .spawn(( LevelObject, ActorId::new("nested-child"), overridden_material, ChildOf(nested), HydratedPrefabMember { instance_root: nested, }, )) .id(); unpack_prefab_instance(&mut world, root); assert!(world.get::(root).is_none()); assert!(world.get::(nested).is_some()); assert!(world.get::(nested).is_none()); assert_eq!( world .get::(nested_child) .map(|owner| owner.instance_root), Some(nested) ); let nested_overrides = shared::decode_prefab_overrides(world.get::(nested).unwrap()).unwrap(); assert_eq!(nested_overrides.properties.len(), 1); assert!(nested_overrides.properties[0] .target .instance_chain .is_empty()); assert_eq!( ron::from_str::(&nested_overrides.properties[0].value_component_ron) .unwrap() .roughness, 0.21 ); } #[test] fn complete_prefab_conversion_is_recursive_unique_and_undoable() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let outer_instance = PrefabInstance::new("outer", "assets/prefabs/outer.scn.ron"); let root = world .spawn(( LevelObject, ActorKind::PrefabAnchor, outer_instance.clone(), PrefabRef::new(outer_instance.source_path.clone()), HydratedPrefabReady, )) .id(); world.spawn(( LevelObject, ActorId::new("repeated-source-id"), Name::new("Outer Child"), ChildOf(root), HydratedPrefabMember { instance_root: root, }, )); let nested = world .spawn(( LevelObject, ActorKind::PrefabAnchor, ActorId::new("nested-anchor"), Name::new("Nested Anchor"), PrefabInstance::new("nested", "assets/prefabs/nested.scn.ron"), PrefabRef::new("assets/prefabs/nested.scn.ron"), HydratedPrefabReady, ChildOf(root), HydratedPrefabMember { instance_root: root, }, )) .id(); world.spawn(( LevelObject, ActorId::new("repeated-source-id"), Name::new("Nested Child"), ChildOf(nested), HydratedPrefabMember { instance_root: nested, }, )); let local_nested = world .spawn(( LevelObject, ActorKind::PrefabAnchor, ActorId::new("authored-local-anchor"), Name::new("Authored Local Nested"), PrefabInstance::new("local-nested", "assets/prefabs/local.scn.ron"), PrefabRef::new("assets/prefabs/local.scn.ron"), HydratedPrefabReady, ChildOf(root), )) .id(); world.spawn(( LevelObject, ActorId::new("repeated-source-id"), Name::new("Authored Local Child"), ChildOf(local_nested), HydratedPrefabMember { instance_root: local_nested, }, )); convert_prefab_completely_to_local(&mut world, root); let mut prefab_instances = world.query::<&PrefabInstance>(); assert_eq!(prefab_instances.iter(&world).count(), 0); let mut generated_members = world.query::<&HydratedPrefabMember>(); assert_eq!(generated_members.iter(&world).count(), 0); let ids: Vec = world .query_filtered::<&ActorId, With>() .iter(&world) .map(|actor_id| actor_id.0.clone()) .collect(); let unique: HashSet<_> = ids.iter().collect(); assert_eq!(ids.len(), unique.len()); assert_eq!(world.resource::().undo_depth(), 1); apply_command_undo(&mut world); assert_eq!(world.get::(root), Some(&outer_instance)); let mut prefab_instances = world.query::<(Entity, &PrefabInstance)>(); let restored_instances: Vec<_> = prefab_instances.iter(&world).collect(); assert_eq!(restored_instances.len(), 2); let restored_local = world .query::<(Entity, &Name, &PrefabInstance)>() .iter(&world) .find_map(|(entity, name, _)| { (name.as_str() == "Authored Local Nested").then_some(entity) }) .expect("undo should restore authored local nested links"); assert_eq!( world.get::(restored_local).map(ChildOf::parent), Some(root) ); apply_command_redo(&mut world); assert!(world.get::(root).is_none()); let mut prefab_instances = world.query::<&PrefabInstance>(); assert_eq!(prefab_instances.iter(&world).count(), 0); let ids: Vec = world .query_filtered::<&ActorId, With>() .iter(&world) .map(|actor_id| actor_id.0.clone()) .collect(); let unique: HashSet<_> = ids.iter().collect(); assert_eq!(ids.len(), unique.len()); } #[test] fn unpack_waits_for_source_readiness() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let root = world .spawn(( LevelObject, PrefabInstance::new("asset", "assets/prefabs/missing.scn.ron"), PrefabRef::new("assets/prefabs/missing.scn.ron"), )) .id(); unpack_prefab_instance(&mut world, root); assert!(world.get::(root).is_some()); assert_eq!(world.resource::().undo_depth(), 0); assert!(world.resource::().status.contains("still loading")); } #[test] fn duplicate_remaps_actor_ids_and_excludes_generated_members() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let root = world .spawn(( LevelObject, ActorId::new("root"), PrefabInstance::new("asset", "assets/prefabs/test.scn.ron"), )) .id(); world.spawn(( LevelObject, ActorId::new("generated"), ChildOf(root), HydratedPrefabMember { instance_root: root, }, )); world.spawn((LevelObject, ActorId::new("local"), ChildOf(root))); duplicate_entities_with_history(&mut world, &[root]); let ids: Vec = world .query_filtered::<&ActorId, With>() .iter(&world) .map(|id| id.0.clone()) .collect(); let unique: HashSet<_> = ids.iter().collect(); assert_eq!(ids.len(), 5, "generated source content must not be copied"); assert_eq!(unique.len(), ids.len(), "duplicated actors need fresh IDs"); } #[test] fn duplicate_navigation_bounds_gets_actor_owned_artifact_path() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let original_id = ActorId::new("navigation-bounds-original"); let original_bounds = NavigationBounds::for_actor(&original_id.0); let original = world .spawn(( LevelObject, ActorKind::Navigation, original_id, Transform::IDENTITY, original_bounds.clone(), )) .id(); duplicate_entities_with_history(&mut world, &[original]); let (duplicate_id, duplicate_bounds) = world .query::<(Entity, &ActorId, &NavigationBounds)>() .iter(&world) .find_map(|(entity, actor_id, bounds)| { (entity != original).then_some((actor_id.clone(), bounds.clone())) }) .expect("duplicated navigation bounds should exist"); assert_ne!( duplicate_bounds.artifact_path, original_bounds.artifact_path ); assert_eq!( duplicate_bounds.artifact_path, shared::navigation_artifact_path_for_actor(&duplicate_id.0) ); apply_command_undo(&mut world); apply_command_redo(&mut world); let redone = world .query::<(&ActorId, &NavigationBounds)>() .iter(&world) .find(|(actor_id, _)| **actor_id == duplicate_id) .expect("redo should preserve the duplicate actor identity"); assert_eq!(redone.1, &duplicate_bounds); } #[test] fn existing_navigation_snapshot_keeps_deliberate_generated_path() { let mut world = World::new(); let mut bounds = NavigationBounds::for_actor("bounds-existing"); bounds.artifact_path = "assets/navigation/generated/custom-zone.nav.ron".into(); let entity = world .spawn(( LevelObject, ActorKind::Navigation, ActorId::new("bounds-existing"), Transform::IDENTITY, bounds.clone(), )) .id(); let mut snapshot = snapshot_entity(&world, entity).unwrap(); assign_missing_actor_ids(&mut snapshot); assert_eq!(snapshot.navigation_bounds.as_ref(), Some(&bounds)); } #[test] fn linked_members_reject_direct_history_mutations() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let root = world.spawn(LevelObject).id(); let member = world .spawn(( LevelObject, Name::new("Source Actor"), Transform::default(), HydratedPrefabMember { instance_root: root, }, )) .id(); rename_entity_with_history(&mut world, member, "Mutated".to_string()); set_transform_with_history( &mut world, member, Transform::default(), Transform::from_xyz(9.0, 0.0, 0.0), ); set_editor_visibility_with_history(&mut world, member, EditorVisibility { visible: false }); delete_entities_with_history(&mut world, &[member]); assert_eq!(world.get::(member).unwrap().as_str(), "Source Actor"); assert_eq!( world.get::(member).unwrap(), &Transform::default() ); assert!(world.get_entity(member).is_ok()); assert_eq!(world.resource::().undo_depth(), 0); } #[test] fn undoing_prefab_relink_restores_broken_hydration_guard() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); let broken = PrefabInstance::new("broken", "assets/prefabs/missing.scn.ron"); let root = world .spawn(( LevelObject, broken.clone(), PrefabHydrationBlocked { reason: "missing prefab source".to_string(), }, )) .id(); let old = capture_prefab_edit_state(&world, root); let valid = PrefabInstance::new("valid", "assets/prefabs/valid.scn.ron"); world .entity_mut(root) .remove::() .insert((valid.clone(), PrefabRef::new(valid.source_path.clone()))); record_prefab_edit_state(&mut world, root, old); apply_command_undo(&mut world); assert_eq!(world.get::(root), Some(&broken)); assert!(world.get::(root).is_none()); assert!(world .get::(root) .is_some_and(|blocked| blocked.reason == "missing prefab source")); assert!(world.get::(root).is_none()); assert!(world.get::(root).is_none()); } }