3877 lines
130 KiB
Rust
3877 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 visibility = if snapshot.editor_visibility.visible {
|
|
Visibility::Visible
|
|
} else {
|
|
Visibility::Hidden
|
|
};
|
|
let mut entity_mut = world.spawn((
|
|
LevelObject,
|
|
snapshot.actor_kind,
|
|
snapshot.transform,
|
|
snapshot.editor_visibility,
|
|
visibility,
|
|
InheritedVisibility::default(),
|
|
ViewVisibility::default(),
|
|
));
|
|
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));
|
|
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);
|
|
crate::scene_io::reconcile_active_dirty_with_checkpoint(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);
|
|
crate::scene_io::reconcile_active_dirty_with_checkpoint(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());
|
|
}
|
|
}
|