Blacksite/crates/editor/src/history/mod.rs
Rbanh 1ab3886028
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Complete editor operator invariants
2026-07-12 23:53:46 -04:00

3865 lines
130 KiB
Rust

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::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
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<EditorCommand>,
redo_stack: Vec<EditorCommand>,
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<String>) {
self.status = format!("Undo: {}", label.into());
}
fn set_redo_status(&mut self, label: impl Into<String>) {
self.status = format!("Redo: {}", label.into());
}
fn pop_undo(&mut self) -> Option<EditorCommand> {
self.undo_stack.pop()
}
fn push_undo(&mut self, command: EditorCommand) {
self.undo_stack.push(command);
}
fn pop_redo(&mut self) -> Option<EditorCommand> {
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::<EditorHistory>()
.init_resource::<TransformEditTracker>()
.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<EditorEntitySnapshot> {
let entity_ref = world.get_entity(entity).ok()?;
if !entity_ref.contains::<LevelObject>()
|| entity_ref.contains::<HydratedPrefabMember>()
|| entity_ref.contains::<crate::scene_io::ComposedSceneMember>()
{
return None;
}
let children = entity_ref
.get::<Children>()
.map(|children| {
children
.iter()
.filter_map(|child| snapshot_entity(world, child))
.collect()
})
.unwrap_or_default();
let actor_kind = entity_ref
.get::<ActorKind>()
.copied()
.or_else(|| infer_actor_kind(entity_ref))
.unwrap_or(ActorKind::Empty);
Some(EditorEntitySnapshot {
actor_id: entity_ref.get::<ActorId>().cloned(),
actor_kind,
actor_name: entity_ref.get::<ActorName>().cloned(),
name: entity_ref
.get::<Name>()
.map(|name| name.as_str().to_string()),
transform: entity_ref.get::<Transform>().copied().unwrap_or_default(),
primitive: entity_ref.get::<Primitive>().cloned(),
brush: entity_ref.get::<BrushDesc>().cloned(),
static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(),
skinned_mesh_renderer: entity_ref.get::<SkinnedMeshRenderer>().cloned(),
material: entity_ref.get::<MaterialDesc>().cloned(),
material_override: entity_ref.get::<MaterialOverride>().cloned(),
rigid_body: entity_ref.get::<RigidBodyDesc>().cloned(),
collider: entity_ref.get::<ColliderDesc>().cloned(),
physics: entity_ref.get::<PhysicsBody>().cloned(),
light: entity_ref.get::<LightDesc>().cloned(),
animation_controller: entity_ref.get::<AnimationControllerDesc>().cloned(),
audio_source: entity_ref.get::<AudioSourceDesc>().cloned(),
audio_listener: entity_ref.get::<AudioListenerDesc>().copied(),
player_spawn: entity_ref.contains::<PlayerSpawn>(),
model: entity_ref.get::<ModelRef>().cloned(),
prefab: entity_ref.get::<PrefabRef>().cloned(),
prefab_instance: entity_ref.get::<PrefabInstance>().cloned(),
weapon_spawn: entity_ref.get::<WeaponSpawn>().cloned(),
trigger_volume: entity_ref.get::<TriggerVolume>().cloned(),
post_process_volume: entity_ref.get::<PostProcessVolumeDesc>().cloned(),
team_spawn: entity_ref.get::<TeamSpawn>().cloned(),
objective: entity_ref.get::<ObjectiveMarker>().cloned(),
navigation_bounds: entity_ref.get::<NavigationBounds>().cloned(),
navigation_obstacle: entity_ref.get::<NavigationObstacle>().cloned(),
navigation_area: entity_ref.get::<NavigationArea>().cloned(),
navigation_link: entity_ref.get::<NavigationLink>().cloned(),
hierarchy_sibling_index: entity_ref
.get::<HierarchySiblingIndex>()
.map(|index| index.0)
.unwrap_or(0),
editor_visibility: entity_ref
.get::<EditorVisibility>()
.copied()
.unwrap_or_default(),
inspector_order: entity_ref.get::<InspectorOrder>().cloned(),
component_states: entity_ref.get::<AuthoringComponentStates>().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>,
) -> 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::<Entity, With<LevelObject>>();
let entities: Vec<Entity> = 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<Item = EditorEntitySnapshot>,
) -> Vec<Entity> {
let mut snapshots: Vec<EditorEntitySnapshot> = 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<Entity> = 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<EditorEntitySnapshot> = 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<Entity> = 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::<Name>(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::<MaterialDesc>(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<Item = (Entity, MaterialDesc)>,
) {
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::<MaterialDesc>(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::<MaterialOverride>(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::<LightDesc>(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_light_group_with_history(
world: &mut World,
changes: impl IntoIterator<Item = (Entity, Option<LightDesc>)>,
label: &'static str,
) {
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::<LightDesc>(entity).cloned();
if old.is_none() && new.is_none() {
continue;
}
apply_light(world, entity, &new);
entities.push(entity);
olds.push(old);
news.push(new);
}
if !entities.is_empty() {
push_history(
world,
EditorCommand::SetLightGroup {
entities,
olds,
news,
label,
},
);
}
}
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::<AudioSourceDesc>(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::<AnimationControllerDesc>(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::<AudioListenerDesc>(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::<PostProcessVolumeDesc>(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::<NavigationBounds>(entity).cloned(),
obstacle: world.get::<NavigationObstacle>(entity).cloned(),
area: world.get::<NavigationArea>(entity).cloned(),
link: world.get::<NavigationLink>(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::<PhysicsBody>(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::<RigidBodyDesc>(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::<ColliderDesc>(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::<Primitive>(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::<BrushDesc>(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::<Transform>(entity).copied().unwrap_or_default();
let old_brush = world.get::<BrushDesc>(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],
) -> bool {
if !is_mutable_level_object(world, primary)
|| delete_entities
.iter()
.any(|entity| !is_mutable_level_object(world, *entity))
{
return false;
}
let old_transform = world.get::<Transform>(primary).copied().unwrap_or_default();
let old_brush = world.get::<BrushDesc>(primary).cloned();
let deleted: Vec<(EditorEntitySnapshot, Option<Entity>)> = 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 false;
}
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);
true
}
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::<StaticMeshRenderer>(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<Entity>) {
reorder_siblings_with_history(world, &[entity], new_parent, i32::MAX);
}
pub fn reorder_siblings_with_history(
world: &mut World,
entities: &[Entity],
new_parent: Option<Entity>,
insert_index: i32,
) {
let entities: Vec<Entity> = 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::<SceneIo>() {
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::<EditorVisibility>(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::<InspectorOrder>(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<Entity> = 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);
let changes = reorder_entities_under_parent(world, &entities, Some(group), i32::MAX);
push_history(
world,
EditorCommand::GroupSelection {
snapshot,
group: Some(group),
changes,
},
);
select_one(world, group);
}
pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
if !is_level_object(world, parent) || world.get::<HydratedPrefabMember>(parent).is_some() {
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
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::<PrefabInstance>(entity).cloned() else {
return;
};
let prefab_ref = world
.get::<PrefabRef>(entity)
.cloned()
.unwrap_or_else(|| PrefabRef::new(instance.source_path.clone()));
if world.get::<HydratedPrefabReady>(entity).is_none() {
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io.set_status("Unpack blocked: prefab source is still loading or unavailable");
}
return;
}
let linked_actor_kind = world
.get::<ActorKind>(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::<SceneIo>() {
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::<ChildOf>(*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::<PrefabInstance>(entity).cloned() else {
return;
};
let prefab_ref = world
.get::<PrefabRef>(entity)
.cloned()
.unwrap_or_else(|| PrefabRef::new(instance.source_path.clone()));
let linked_local: Vec<(Entity, EditorEntitySnapshot)> = world
.get::<Children>(entity)
.map(|children| {
children
.iter()
.filter(|child| world.get::<HydratedPrefabMember>(*child).is_none())
.filter_map(|child| snapshot_entity(world, child).map(|snapshot| (child, snapshot)))
.collect()
})
.unwrap_or_default();
let linked_local_roots: Vec<Entity> = 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::<Entity, With<PrefabInstance>>()
.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::<HydratedPrefabReady>(*instance_root).is_none())
{
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io
.set_status("Complete conversion blocked: every nested prefab must finish loading");
}
return;
}
let linked_actor_kind = world
.get::<ActorKind>(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::<ChildOf>(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<usize> {
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::<HydratedPrefabMember>(current)
.map(|owner| owner.instance_root)
.or_else(|| world.get::<ChildOf>(current).map(ChildOf::parent))?;
depth += 1;
}
None
}
fn has_candidate_ancestor(world: &World, entity: Entity, candidates: &HashSet<Entity>) -> bool {
let mut current = world.get::<ChildOf>(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::<ChildOf>(parent).map(ChildOf::parent);
}
false
}
fn perform_prefab_unpack(world: &mut World, entity: Entity) -> Vec<Entity> {
let members: Vec<Entity> = world
.query::<(Entity, &HydratedPrefabMember)>()
.iter(world)
.filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member))
.collect();
let member_set: HashSet<Entity> = members.iter().copied().collect();
let roots = members
.iter()
.copied()
.filter(|member| {
world
.get::<ChildOf>(*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::<HydratedPrefabMember>();
if member_mut.contains::<LevelObject>() {
member_mut.insert(new_actor_id());
}
}
}
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut
.insert(ActorKind::Empty)
.remove::<PrefabInstance>()
.remove::<PrefabRef>()
.remove::<HydratedPrefabReady>()
.remove::<PrefabHydrationBlocked>()
.remove::<DynamicWorldRoot>()
.remove::<WorldInstance>();
}
roots
}
fn despawn_hydrated_prefab_members(world: &mut World, entity: Entity) {
let members: Vec<Entity> = world
.query::<(Entity, &HydratedPrefabMember)>()
.iter(world)
.filter_map(|(member, owner)| (owner.instance_root == entity).then_some(member))
.collect();
let member_set: HashSet<Entity> = members.iter().copied().collect();
let roots: Vec<Entity> = members
.into_iter()
.filter(|member| {
world
.get::<ChildOf>(*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::<PrefabInstance>(entity).cloned(),
prefab_ref: world.get::<PrefabRef>(entity).cloned(),
blocked_reason: world
.get::<PrefabHydrationBlocked>(entity)
.map(|blocked| blocked.reason.clone()),
transform: world.get::<Transform>(entity).copied(),
material: world.get::<MaterialDesc>(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::<Transform>(entity) {
*transform = new;
}
push_history(world, EditorCommand::SetTransform { entity, old, new });
}
pub fn set_transform_group_with_history(
world: &mut World,
changes: impl IntoIterator<Item = (Entity, Transform, Transform)>,
) {
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::<Transform>(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<Option<ReflectedComponentValue>, String> {
let entity_ref = world
.get_entity(entity)
.map_err(|_| format!("entity {entity:?} no longer exists"))?;
let app_registry = world
.get_resource::<AppTypeRegistry>()
.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::<ReflectComponent>()
.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<AppTypeRegistry>| {
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::<ReflectComponent>()
.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<AppTypeRegistry>| {
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::<ReflectComponent>()
.cloned()
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
let default = registration
.data::<ReflectDefault>()
.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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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::<Vec<_>>();
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::<EditorHistory>().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::<SceneComposition>().clone();
if old == new {
return;
}
world.insert_resource(new.clone());
push_history(world, EditorCommand::SetSceneComposition { old, new });
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::ReloadComposition);
}
pub fn apply_command_undo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(world, &candidate, true) {
world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}");
world
.resource_mut::<SceneIo>()
.set_status(format!("Undo blocked: {error}"));
return;
}
}
let Some(mut command) = world.resource_mut::<EditorHistory>().pop_undo() else {
return;
};
let label = command.label();
undo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_redo(command);
world.resource_mut::<EditorHistory>().set_redo_status(label);
mark_dirty(world);
}
pub fn apply_command_redo(world: &mut World) {
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
if let Some(candidate) = candidate {
if let Err(error) = prepare_prefab_source_history(world, &candidate, false) {
world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}");
world
.resource_mut::<SceneIo>()
.set_status(format!("Redo blocked: {error}"));
return;
}
}
let Some(mut command) = world.resource_mut::<EditorHistory>().pop_redo() else {
return;
};
let label = command.label();
redo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_undo(command);
world.resource_mut::<EditorHistory>().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::GroupSelection { group, changes, .. } => {
for change in changes.iter().rev() {
apply_sibling_change(world, change);
}
despawn_entity(world, *group);
clear_selection(world);
}
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::<Transform>(*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::SetLightGroup { entities, olds, .. } => {
for (entity, old) in entities.iter().zip(olds.iter()) {
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::<Transform>(*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::<Transform>(*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::<Transform>(*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::<SceneIo>().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::GroupSelection {
snapshot,
group,
changes,
} => {
let previous_group = *group;
let spawned = spawn_snapshot(world, snapshot);
*group = Some(spawned);
for change in changes.iter_mut() {
if Some(change.entity) == previous_group {
change.entity = spawned;
}
if change.new_parent == previous_group {
change.new_parent = Some(spawned);
}
apply_sibling_change_new(world, change);
}
select_one(world, spawned);
}
EditorCommand::Spawn { snapshot, entity } => {
let spawned = spawn_snapshot(world, snapshot);
*entity = Some(spawned);
select_one(world, spawned);
}
EditorCommand::SpawnMany {
snapshots,
entities,
} => {
let spawned: Vec<Entity> = 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<Entity> = 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::<Transform>(*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::SetLightGroup { entities, news, .. } => {
for (entity, new) in entities.iter().zip(news.iter()) {
apply_light(world, *entity, new);
}
}
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::<Transform>(*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::<SceneIo>().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::<PrefabInstance>()
.remove::<PrefabRef>()
.remove::<HydratedPrefabReady>()
.remove::<PrefabHydrationBlocked>()
.remove::<DynamicWorldRoot>()
.remove::<WorldInstance>();
}
*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<MaterialDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match material {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<MaterialDesc>();
}
}
}
}
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::<PrefabInstance>();
}
}
match &state.prefab_ref {
Some(prefab_ref) => {
entity_mut.insert(prefab_ref.clone());
}
None => {
entity_mut.remove::<PrefabRef>();
}
}
match &state.blocked_reason {
Some(reason) => {
entity_mut
.insert(PrefabHydrationBlocked {
reason: reason.clone(),
})
.remove::<HydratedPrefabReady>()
.remove::<DynamicWorldRoot>()
.remove::<WorldInstance>();
}
None => {
entity_mut.remove::<PrefabHydrationBlocked>();
}
}
if let Some(transform) = state.transform {
entity_mut.insert(transform);
}
match &state.material {
Some(material) => {
entity_mut.insert(material.clone());
}
None => {
entity_mut.remove::<MaterialDesc>();
}
}
}
crate::assets::prefab_overrides::apply_stored_overrides(world, entity);
}
fn apply_material_override(
world: &mut World,
entity: Entity,
material_override: &Option<MaterialOverride>,
) {
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::<MaterialOverride>();
}
}
}
}
fn apply_light(world: &mut World, entity: Entity, light: &Option<LightDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match light {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<LightDesc>();
}
}
}
}
fn apply_animation_controller(
world: &mut World,
entity: Entity,
controller: &Option<AnimationControllerDesc>,
) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match controller {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<AnimationControllerDesc>();
}
}
}
}
fn apply_audio_source(world: &mut World, entity: Entity, source: &Option<AudioSourceDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match source {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<AudioSourceDesc>();
}
}
}
}
fn apply_audio_listener(world: &mut World, entity: Entity, listener: &Option<AudioListenerDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match listener {
Some(value) => {
entity_mut.insert(*value);
}
None => {
entity_mut.remove::<AudioListenerDesc>();
}
}
}
}
fn apply_post_process_volume(
world: &mut World,
entity: Entity,
volume: &Option<PostProcessVolumeDesc>,
) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match volume {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<PostProcessVolumeDesc>();
}
}
}
}
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::<NavigationBounds>()
.remove::<NavigationObstacle>()
.remove::<NavigationArea>()
.remove::<NavigationLink>();
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<PhysicsBody>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match physics {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<PhysicsBody>();
}
}
}
}
fn apply_rigid_body(world: &mut World, entity: Entity, rigid_body: &Option<RigidBodyDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match rigid_body {
Some(value) => {
entity_mut.insert(*value);
}
None => {
entity_mut.remove::<RigidBodyDesc>();
}
}
}
}
fn apply_collider(world: &mut World, entity: Entity, collider: &Option<ColliderDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match collider {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<ColliderDesc>();
}
}
}
}
fn apply_primitive(world: &mut World, entity: Entity, primitive: &Option<Primitive>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match primitive {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<Primitive>();
}
}
}
}
fn apply_brush(world: &mut World, entity: Entity, brush: &Option<BrushDesc>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match brush {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<BrushDesc>();
}
}
}
}
fn apply_static_mesh_renderer(
world: &mut World,
entity: Entity,
renderer: &Option<StaticMeshRenderer>,
) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match renderer {
Some(value) => {
entity_mut.insert(value.clone());
}
None => {
entity_mut.remove::<StaticMeshRenderer>();
}
}
}
}
fn apply_inspector_order(
world: &mut World,
entity: Entity,
inspector_order: &Option<InspectorOrder>,
) {
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::<InspectorOrder>();
}
}
}
}
fn apply_parent(world: &mut World, entity: Entity, parent: Option<Entity>) {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
match parent {
Some(p) => {
entity_mut.insert(ChildOf(p));
}
None => {
entity_mut.remove::<ChildOf>();
}
}
}
}
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::<ActorKind>(entity).copied(),
Some(ActorKind::AudioSource) if snapshot.audio_source.is_some()
) || matches!(
world.get::<ActorKind>(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::<Primitive>();
}
if snapshot.brush.is_some() {
entity_mut.remove::<BrushDesc>();
}
if snapshot.static_mesh_renderer.is_some() {
entity_mut.remove::<StaticMeshRenderer>();
}
if snapshot.skinned_mesh_renderer.is_some() {
entity_mut.remove::<SkinnedMeshRenderer>();
}
if snapshot.material.is_some() {
entity_mut.remove::<MaterialDesc>();
}
if snapshot.material_override.is_some() {
entity_mut.remove::<MaterialOverride>();
}
if snapshot.rigid_body.is_some() {
entity_mut.remove::<RigidBodyDesc>();
}
if snapshot.collider.is_some() {
entity_mut.remove::<ColliderDesc>();
}
if snapshot.physics.is_some() {
entity_mut.remove::<PhysicsBody>();
}
if snapshot.light.is_some() {
entity_mut.remove::<LightDesc>();
}
if snapshot.animation_controller.is_some() {
entity_mut.remove::<AnimationControllerDesc>();
}
if snapshot.audio_source.is_some() {
entity_mut.remove::<AudioSourceDesc>();
}
if snapshot.audio_listener.is_some() {
entity_mut.remove::<AudioListenerDesc>();
}
if snapshot.player_spawn {
entity_mut.remove::<PlayerSpawn>();
}
if snapshot.model.is_some() {
entity_mut.remove::<ModelRef>();
}
if snapshot.prefab.is_some() {
entity_mut.remove::<PrefabRef>();
}
if snapshot.prefab_instance.is_some() {
entity_mut.remove::<PrefabInstance>();
}
if snapshot.weapon_spawn.is_some() {
entity_mut.remove::<WeaponSpawn>();
}
if snapshot.trigger_volume.is_some() {
entity_mut.remove::<TriggerVolume>();
}
if snapshot.post_process_volume.is_some() {
entity_mut.remove::<PostProcessVolumeDesc>();
}
if snapshot.team_spawn.is_some() {
entity_mut.remove::<TeamSpawn>();
}
if snapshot.objective.is_some() {
entity_mut.remove::<ObjectiveMarker>();
}
if snapshot.navigation_bounds.is_some() {
entity_mut.remove::<NavigationBounds>();
}
if snapshot.navigation_obstacle.is_some() {
entity_mut.remove::<NavigationObstacle>();
}
if snapshot.navigation_area.is_some() {
entity_mut.remove::<NavigationArea>();
}
if snapshot.navigation_link.is_some() {
entity_mut.remove::<NavigationLink>();
}
if snapshot.inspector_order.is_some() {
entity_mut.remove::<InspectorOrder>();
}
if snapshot.component_states.is_some() {
entity_mut.remove::<AuthoringComponentStates>();
}
}
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::<ButtonInput<KeyCode>>();
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<Entity> = world
.get_resource::<UiState>()
.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<Entity> = world
.get_resource::<UiState>()
.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 mut started_edit = false;
let (finished_edit, live_edit) = {
let mut tracker = world.resource_mut::<TransformEditTracker>();
if tracker.active.is_none() {
if let (Some((entity, transform)), Some(group)) = (active, start_group) {
tracker.active = Some((entity, transform, group));
started_edit = true;
}
}
if started_edit {
(None, None)
} else {
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 started_edit {
set_transform_operator_status(
world,
OperatorPhase::Preview,
"Transform gizmo drag in progress",
);
return;
}
if let Some((primary, old_primary, group)) = live_edit {
let Some(new_primary) = world.get::<Transform>(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::<Transform>(primary).copied() else {
for (entity, old) in group {
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = old;
}
}
set_transform_operator_status(
world,
OperatorPhase::Canceled,
"Transform target disappeared; surviving previews restored",
);
return;
};
let undo_depth = world.resource::<EditorHistory>().undo_depth();
if group.len() <= 1 {
set_transform_with_history(world, primary, old_primary, new_primary);
} else {
let changes = transform_group_changes(old_primary, new_primary, group);
set_transform_group_with_history(world, changes);
}
if world.resource::<EditorHistory>().undo_depth() > undo_depth {
set_transform_operator_status(
world,
OperatorPhase::Committed,
"Transform gizmo edit committed",
);
} else {
set_transform_operator_status(
world,
OperatorPhase::Canceled,
"Transform gizmo edit made no changes",
);
}
}
}
fn set_transform_operator_status(world: &mut World, phase: OperatorPhase, hint: impl Into<String>) {
if let Some(mut active) = world.get_resource_mut::<ActiveOperator>() {
active.status = Some(OperatorStatus {
id: "transform.gizmo".to_string(),
label: "Transform Gizmo".to_string(),
phase,
hint: hint.into(),
warnings: Vec::new(),
});
}
}
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::<Transform>(*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::<Transform>(*entity)
.copied()
.map(|transform| (*entity, transform))
})
.collect()
} else {
vec![(primary, primary_transform)]
}
}
fn push_history(world: &mut World, command: EditorCommand) {
world.resource_mut::<EditorHistory>().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::<SceneIo>() {
io.mark_dirty();
}
}
fn despawn_entity(world: &mut World, entity: Option<Entity>) {
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::<UiState>() {
ui_state.selected_entities.clear();
ui_state.selected_entities.select_replace(entity);
}
world.resource_mut::<SelectedEntity>().0 = Some(entity);
}
fn select_many(world: &mut World, entities: &[Entity]) {
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
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::<SelectedEntity>().0 = entities.first().copied();
}
pub fn clear_selection(world: &mut World) {
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
ui_state.selected_entities.clear();
}
world.resource_mut::<SelectedEntity>().0 = None;
}
pub fn is_level_object(world: &World, entity: Entity) -> bool {
world
.get_entity(entity)
.is_ok_and(|entity_ref| entity_ref.contains::<LevelObject>())
}
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};
#[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::<ActorKind>()
.register_type::<StaticExtensionFixture>();
let world = app.world_mut();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
let entity = world
.spawn((LevelObject, ActorKind::Light, Transform::default()))
.id();
let type_path = std::any::type_name::<StaticExtensionFixture>();
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::<StaticExtensionFixture>(entity).unwrap().value,
42
);
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
apply_command_undo(world);
assert!(world.get::<StaticExtensionFixture>(entity).is_none());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Light));
apply_command_redo(world);
assert_eq!(
world.get::<StaticExtensionFixture>(entity).unwrap().value,
42
);
assert_eq!(world.get::<ActorKind>(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::<InspectorOrder>(restored), Some(&order));
assert_eq!(
world.get::<AuthoringComponentStates>(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::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<AnimationControllerDesc>(entity), Some(&new));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
assert!(world.resource::<SceneIo>().dirty);
apply_command_undo(&mut world);
assert_eq!(world.get::<AnimationControllerDesc>(entity), Some(&old));
apply_command_redo(&mut world);
assert_eq!(world.get::<AnimationControllerDesc>(entity), Some(&new));
}
#[test]
fn navigation_component_history_round_trips_and_preserves_kind() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<NavigationBounds>(entity), Some(&bounds));
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
apply_command_undo(&mut world);
assert!(world.get::<NavigationBounds>(entity).is_none());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
apply_command_redo(&mut world);
assert_eq!(world.get::<NavigationBounds>(entity), Some(&bounds));
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
}
#[test]
fn undoing_a_new_animation_controller_removes_the_component() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<AnimationControllerDesc>(entity).is_none());
apply_command_redo(&mut world);
assert_eq!(
world.get::<AnimationControllerDesc>(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::<AnimationControllerDesc>(respawned),
Some(&controller)
);
assert_eq!(
world.get::<Name>(respawned).map(Name::as_str),
Some("Animated Robot")
);
assert_eq!(
world.get::<SkinnedMeshRenderer>(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::<AudioSourceDesc>(source).is_none());
assert_eq!(world.get::<ActorKind>(source), Some(&ActorKind::Empty));
apply_authored_components(&mut world, source, &source_snapshot);
assert!(world.get::<AudioSourceDesc>(source).is_some());
assert_eq!(
world.get::<ActorKind>(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::<AudioListenerDesc>(listener).is_none());
assert_eq!(world.get::<ActorKind>(listener), Some(&ActorKind::Empty));
apply_authored_components(&mut world, listener, &listener_snapshot);
assert!(world.get::<AudioListenerDesc>(listener).is_some());
assert_eq!(
world.get::<ActorKind>(listener),
Some(&ActorKind::AudioListener)
);
}
#[test]
fn continuous_multi_transform_finalizes_as_one_undo_group() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<TransformEditTracker>();
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);
world.resource_mut::<TransformEditTracker>().active = Some((
first,
Transform::default(),
vec![
(first, Transform::default()),
(second, Transform::from_translation(initial[1])),
],
));
*world.get_mut::<Transform>(first).unwrap() = Transform::from_translation(committed[0]);
capture_gizmo_transform_edits(&mut world);
harness.assert_committed(&mut world, 1, 0);
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Committed);
assert_undo_redo_round_trip(&mut world, initial, committed, |world| {
[first, second]
.into_iter()
.map(|entity| world.get::<Transform>(entity).unwrap().translation)
.collect::<Vec<_>>()
});
}
#[test]
fn no_op_transform_release_terminates_preview_without_history() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<TransformEditTracker>();
let transform = Transform::from_xyz(1.0, 2.0, 3.0);
let entity = world.spawn((LevelObject, transform)).id();
world.resource_mut::<TransformEditTracker>().active =
Some((entity, transform, vec![(entity, transform)]));
set_transform_operator_status(
&mut world,
OperatorPhase::Preview,
"Transform gizmo drag in progress",
);
let harness = OperatorInvariantHarness::capture(&mut world);
capture_gizmo_transform_edits(&mut world);
harness.assert_canceled(&mut world);
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Canceled);
assert_eq!(world.get::<Transform>(entity), Some(&transform));
}
#[test]
fn missing_primary_transform_rolls_back_surviving_preview_targets() {
let mut world = World::new();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<TransformEditTracker>();
let primary_old = Transform::from_xyz(1.0, 0.0, 0.0);
let survivor_old = Transform::from_xyz(2.0, 0.0, 0.0);
let primary = world.spawn((LevelObject, primary_old)).id();
let survivor = world.spawn((LevelObject, survivor_old)).id();
world.resource_mut::<TransformEditTracker>().active = Some((
primary,
primary_old,
vec![(primary, primary_old), (survivor, survivor_old)],
));
*world.get_mut::<Transform>(survivor).unwrap() = Transform::from_xyz(8.0, 0.0, 0.0);
world.despawn(primary);
set_transform_operator_status(
&mut world,
OperatorPhase::Preview,
"Transform gizmo drag in progress",
);
let harness = OperatorInvariantHarness::capture(&mut world);
capture_gizmo_transform_edits(&mut world);
harness.assert_canceled(&mut world);
harness.assert_status(&world, "transform.gizmo", OperatorPhase::Canceled);
assert_eq!(world.get::<Transform>(survivor), Some(&survivor_old));
}
#[test]
fn unpack_prefab_is_one_undoable_history_command() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<EditorHistory>().undo_depth(), 1);
assert!(world.get::<PrefabInstance>(root).is_none());
assert!(world.get::<PrefabRef>(root).is_none());
assert_eq!(world.get::<ActorKind>(root), Some(&ActorKind::Empty));
assert!(world.get::<HydratedPrefabMember>(generated).is_none());
let unpacked_actor_id = world.get::<ActorId>(generated).unwrap().0.clone();
assert_ne!(unpacked_actor_id, "generated-child");
apply_command_undo(&mut world);
assert_eq!(world.get::<PrefabInstance>(root), Some(&instance));
assert!(world.get::<PrefabRef>(root).is_some());
assert_eq!(world.get::<ActorKind>(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::<PrefabRef>();
apply_command_redo(&mut world);
assert!(world.get::<PrefabInstance>(root).is_none());
assert!(world.get::<PrefabRef>(root).is_none());
assert!(world.get::<PrefabHydrationBlocked>(root).is_none());
assert_eq!(world.get::<ActorKind>(root), Some(&ActorKind::Empty));
assert!(!world.resource::<EditorHistory>().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::<ChildOf>(restored).map(ChildOf::parent),
Some(root)
);
assert_eq!(world.get::<ActorId>(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::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<ActorId>(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::<Name>(restored).map(Name::as_str),
Some("Committed Source Actor")
);
assert_eq!(
world.get::<Transform>(restored).unwrap().translation,
Vec3::new(1.0, 2.0, 3.0)
);
assert_eq!(
world.get::<ActorKind>(restored),
Some(&ActorKind::StaticMesh)
);
assert!(world.get::<HydratedPrefabMember>(restored).is_none());
}
#[test]
fn unpack_redo_preserves_structurally_unparented_roots() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<ActorId>(generated).unwrap().0.clone();
assert!(world.get::<ChildOf>(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::<ChildOf>(restored).is_none());
}
#[test]
fn unpack_layer_preserves_nested_prefab_links() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<MaterialDesc>().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::<PrefabInstance>(root).is_none());
assert!(world.get::<PrefabInstance>(nested).is_some());
assert!(world.get::<HydratedPrefabMember>(nested).is_none());
assert_eq!(
world
.get::<HydratedPrefabMember>(nested_child)
.map(|owner| owner.instance_root),
Some(nested)
);
let nested_overrides =
shared::decode_prefab_overrides(world.get::<PrefabInstance>(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::<MaterialDesc>(&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::<EditorHistory>();
world.init_resource::<SceneIo>();
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<String> = world
.query_filtered::<&ActorId, With<LevelObject>>()
.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::<EditorHistory>().undo_depth(), 1);
apply_command_undo(&mut world);
assert_eq!(world.get::<PrefabInstance>(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::<ChildOf>(restored_local).map(ChildOf::parent),
Some(root)
);
apply_command_redo(&mut world);
assert!(world.get::<PrefabInstance>(root).is_none());
let mut prefab_instances = world.query::<&PrefabInstance>();
assert_eq!(prefab_instances.iter(&world).count(), 0);
let ids: Vec<String> = world
.query_filtered::<&ActorId, With<LevelObject>>()
.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::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<PrefabInstance>(root).is_some());
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 0);
assert!(world.resource::<SceneIo>().status.contains("still loading"));
}
#[test]
fn duplicate_remaps_actor_ids_and_excludes_generated_members() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
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<String> = world
.query_filtered::<&ActorId, With<LevelObject>>()
.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::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
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::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
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::<Name>(member).unwrap().as_str(), "Source Actor");
assert_eq!(
world.get::<Transform>(member).unwrap(),
&Transform::default()
);
assert!(world.get_entity(member).is_ok());
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 0);
}
#[test]
fn undoing_prefab_relink_restores_broken_hydration_guard() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<PrefabHydrationBlocked>()
.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::<PrefabInstance>(root), Some(&broken));
assert!(world.get::<PrefabRef>(root).is_none());
assert!(world
.get::<PrefabHydrationBlocked>(root)
.is_some_and(|blocked| blocked.reason == "missing prefab source"));
assert!(world.get::<DynamicWorldRoot>(root).is_none());
assert!(world.get::<WorldInstance>(root).is_none());
}
}