use std::collections::{HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use bevy::ecs::entity::EntityHashMap; use bevy::ecs::system::SystemState; use bevy::prelude::*; use bevy::window::PrimaryWindow; use bevy::world_serialization::serde::WorldDeserializer; use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldFilter, WorldInstance}; use scene::{document::SceneDocument, strip_schema_version, validate_level_text}; use serde::de::DeserializeSeed; use shared::{ infer_actor_kind, validate_actor, ActorId, ActorKind, ActorName, ActorValidationError, AuthoringComponentStates, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, PrefabHydrationBlocked, PrefabInstance, PrefabRef, SceneComposition, }; #[cfg(test)] use shared::{ AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer, }; use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS}; use crate::history::{clear_level_objects, snapshot_entity, EditorHistory}; use crate::native_dialog::NativeDialogBroker; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; use crate::scene::recovery::{ default_state_root, discard_recovery_snapshots, latest_recovery_snapshot, write_recovery_snapshot, }; use crate::selection::SelectedEntity; use crate::ui::hierarchy_ops::{ backfill_missing_editor_visibility, backfill_missing_sibling_indices, }; use crate::ui::UiState; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SceneIoRequest { New, Open, Save, SaveAs, ImportAssets, ExportSelection, SaveSelectionAsPrefab, SwitchProject, RestoreRecovery, SaveRecoveryCopyAs, DiscardRecovery, OpenRecent(usize), OpenPath(PathBuf), SwitchTab(usize), CloseTab(usize), ReloadComposition, } #[derive(Debug, Clone)] pub struct SceneTab { pub id: u64, pub path: Option, pub dirty: bool, snapshot: String, recovery_snapshot: Option, disk_snapshot: Option, } impl SceneTab { pub fn label(&self) -> String { self.path .as_deref() .and_then(Path::file_name) .and_then(|name| name.to_str()) .unwrap_or("Untitled") .to_string() } } #[derive(Resource, Debug)] pub struct SceneIo { pub active_path: Option, pub recent_paths: Vec, pub request: Option, pub status: String, pub dirty: bool, /// Newest recovery snapshot that is newer than the active authored scene. pub recovery_snapshot: Option, /// Bounded in-session audit trail for scene persistence and recovery operations. pub events: VecDeque, pub tabs: Vec, pub active_tab: usize, change_revision: u64, next_event_id: u64, next_tab_id: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SceneIoEventSeverity { Info, Error, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SceneIoEvent { pub id: u64, pub severity: SceneIoEventSeverity, pub message: String, } const MAX_SCENE_IO_EVENTS: usize = 32; impl Default for SceneIo { fn default() -> Self { let active_path = None; Self { active_path: active_path.clone(), recent_paths: Vec::new(), request: None, status: "Ready".to_string(), dirty: false, recovery_snapshot: None, events: VecDeque::new(), tabs: vec![SceneTab { id: 1, path: active_path, dirty: false, snapshot: String::new(), recovery_snapshot: None, disk_snapshot: None, }], active_tab: 0, change_revision: 0, next_event_id: 1, next_tab_id: 2, } } } impl SceneIo { pub fn mark_dirty(&mut self) { self.dirty = true; self.change_revision = self.change_revision.wrapping_add(1); self.sync_active_tab_metadata(); } pub fn mark_clean(&mut self) { self.dirty = false; self.change_revision = self.change_revision.wrapping_add(1); self.sync_active_tab_metadata(); } pub fn change_revision(&self) -> u64 { self.change_revision } pub fn active_path_label(&self) -> String { self.active_path .as_ref() .map(|path| path.display().to_string()) .unwrap_or_else(|| "Unsaved scene".to_string()) } pub fn set_status(&mut self, status: impl Into) { let message = status.into(); let normalized = message.to_ascii_lowercase(); let severity = if normalized.contains("failed") || normalized.contains("error") || normalized.contains("could not") { SceneIoEventSeverity::Error } else { SceneIoEventSeverity::Info }; self.status.clone_from(&message); self.events.push_back(SceneIoEvent { id: self.next_event_id, severity, message, }); self.next_event_id = self.next_event_id.saturating_add(1); while self.events.len() > MAX_SCENE_IO_EVENTS { self.events.pop_front(); } } pub fn clear_events(&mut self) { self.events.clear(); } pub fn has_unsaved_tabs(&self) -> bool { self.dirty || self.tabs.iter().any(|tab| tab.dirty) } fn sync_active_tab_metadata(&mut self) { if let Some(tab) = self.tabs.get_mut(self.active_tab) { tab.path.clone_from(&self.active_path); tab.dirty = self.dirty; tab.recovery_snapshot.clone_from(&self.recovery_snapshot); } } fn allocate_tab_id(&mut self) -> u64 { let id = self.next_tab_id; self.next_tab_id = self.next_tab_id.saturating_add(1); id } } #[derive(Component)] struct LoadedSceneRoot; /// Runtime-only ownership marker for entities hydrated from composed subscenes. #[derive(Component, Debug, Clone)] pub struct ComposedSceneMember { pub reference_id: String, pub scene_id: String, pub source_path: PathBuf, } #[derive(Resource, Default)] struct RecoveryClock { elapsed_secs: f32, } pub struct SceneIoPlugin; impl Plugin for SceneIoPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( process_scene_io_requests, tick_scene_recovery, update_window_title, ) .chain(), ) .add_systems(PostStartup, load_startup_scene); } } fn load_startup_scene(world: &mut World) { let active_path = world.resource::().active_path.clone(); let default_path = PathBuf::from( world .resource::() .default_level .clone(), ); let path = active_path.unwrap_or(default_path); match load_level(world, &path) { Ok(disk_snapshot) => { { let mut io = world.resource_mut::(); io.active_path = Some(path.clone()); let active_tab = io.active_tab; io.tabs[active_tab].disk_snapshot = Some(disk_snapshot); io.mark_clean(); } remember_path(world, path.clone()); world.resource_mut::().clear(); refresh_recovery_notice(world, &path); if let Err(error) = capture_active_tab(world) { warn!("Failed to capture startup scene tab: {error}"); } let recovery = world.resource::().recovery_snapshot.clone(); let status = recovery.map_or_else( || format!("Loaded {}", path.display()), |snapshot| { format!( "Loaded {}; newer recovery available: {}", path.display(), snapshot.display() ) }, ); world.resource_mut::().set_status(status); } Err(err) => { world.resource_mut::().set_status(format!( "Startup scene load failed: {err}; using generated starter arena" )); } } } fn process_scene_io_requests(world: &mut World) { let Some(request) = world.resource_mut::().request.take() else { return; }; if matches!(request, SceneIoRequest::SwitchProject) && world.resource::().has_unsaved_tabs() { request_switch_project_confirmation(world); return; } let status = match request { SceneIoRequest::New => new_scene_tab(world), SceneIoRequest::Save => save_active_or_prompt(world), SceneIoRequest::SaveAs => save_with_dialog(world), SceneIoRequest::Open => open_with_dialog(world), SceneIoRequest::ImportAssets => import_with_dialog(world), SceneIoRequest::ExportSelection => export_selection_with_dialog(world), SceneIoRequest::SaveSelectionAsPrefab => save_selection_as_prefab(world), SceneIoRequest::SwitchProject => switch_project(world), SceneIoRequest::RestoreRecovery => restore_recovery(world), SceneIoRequest::SaveRecoveryCopyAs => save_recovery_copy_with_dialog(world), SceneIoRequest::DiscardRecovery => discard_recovery(world), SceneIoRequest::OpenRecent(index) => open_recent(world, index), SceneIoRequest::OpenPath(path) => open_path(world, path), SceneIoRequest::SwitchTab(index) => switch_scene_tab(world, index), SceneIoRequest::CloseTab(index) => close_scene_tab(world, index), SceneIoRequest::ReloadComposition => reload_active_composition(world), }; world.resource_mut::().set_status(status); } fn switch_project(world: &mut World) -> String { match crate::launcher::spawn_project_launcher_process() { Ok(()) => { world.write_message(AppExit::Success); "Opening Blacksite Project Browser".to_string() } Err(error) => format!("Switch project failed: {error}"), } } fn request_switch_project_confirmation(world: &mut World) { let result = world.resource::().request( || { rfd::MessageDialog::new() .set_title("Unsaved Changes") .set_description("The scene has unsaved changes. Save before continuing?") .set_level(rfd::MessageLevel::Warning) .set_buttons(rfd::MessageButtons::YesNoCancel) .show() }, |world, decision| match decision { rfd::MessageDialogResult::Yes => { let status = save_all_tabs(world); if status == "Saved all modified scene tabs" { let status = switch_project(world); world.resource_mut::().set_status(status); } else { world.resource_mut::().set_status(format!( "Project switch paused until every scene is saved: {status}" )); } } rfd::MessageDialogResult::No => { let status = switch_project(world); world.resource_mut::().set_status(status); } _ => world .resource_mut::() .set_status("Project switch cancelled"), }, ); world.resource_mut::().set_status(match result { Ok(()) => "Waiting for unsaved-scene confirmation".into(), Err(error) => format!("Project switch unavailable: {error}"), }); } fn open_recent(world: &mut World, index: usize) -> String { let path = world.resource::().recent_paths.get(index).cloned(); let Some(path) = path else { return "Recent scene not found".to_string(); }; open_path(world, path) } fn open_path(world: &mut World, path: PathBuf) -> String { if let Some(index) = world .resource::() .tabs .iter() .position(|tab| tab.path.as_ref() == Some(&path)) { return switch_scene_tab(world, index); } if let Err(error) = capture_active_tab(world) { return format!("Open failed: could not preserve active scene: {error}"); } match load_level(world, &path) { Ok(disk_snapshot) => { let id = world.resource_mut::().allocate_tab_id(); { let mut io = world.resource_mut::(); io.tabs.push(SceneTab { id, path: Some(path.clone()), dirty: false, snapshot: String::new(), recovery_snapshot: None, disk_snapshot: Some(disk_snapshot), }); io.active_tab = io.tabs.len() - 1; io.active_path = Some(path.clone()); io.recovery_snapshot = None; io.mark_clean(); } remember_path(world, path.clone()); world.resource_mut::().clear(); refresh_recovery_notice(world, &path); if let Err(error) = capture_active_tab(world) { warn!("Failed to capture opened scene tab: {error}"); } format!("Loading {}", path.display()) } Err(err) => format!("Open failed: {err}"), } } fn new_scene_tab(world: &mut World) -> String { if let Err(error) = capture_active_tab(world) { return format!("New scene failed: could not preserve active scene: {error}"); } clear_scene_world(world); world.insert_resource(SceneComposition { scene_id: uuid::Uuid::new_v4().to_string(), subscenes: Vec::new(), }); let id = world.resource_mut::().allocate_tab_id(); { let mut io = world.resource_mut::(); io.tabs.push(SceneTab { id, path: None, dirty: false, snapshot: String::new(), recovery_snapshot: None, disk_snapshot: None, }); io.active_tab = io.tabs.len() - 1; io.active_path = None; io.recovery_snapshot = None; io.mark_clean(); } world.resource_mut::().clear(); if let Err(error) = capture_active_tab(world) { return format!("New scene failed: {error}"); } "Created a new empty scene tab".to_string() } fn capture_active_tab(world: &mut World) -> Result<(), String> { if world.resource::().tabs.is_empty() { return Ok(()); } ensure_scene_composition_identity(world); let text = serialize_active_scene(world)?; let (active_tab, active_path, dirty, recovery_snapshot) = { let io = world.resource::(); ( io.active_tab, io.active_path.clone(), io.dirty, io.recovery_snapshot.clone(), ) }; let mut io = world.resource_mut::(); let Some(tab) = io.tabs.get_mut(active_tab) else { return Err(format!("active scene tab {active_tab} is missing")); }; tab.path = active_path; tab.dirty = dirty; tab.snapshot = text; tab.recovery_snapshot = recovery_snapshot; Ok(()) } fn switch_scene_tab(world: &mut World, index: usize) -> String { let current = world.resource::().active_tab; if index == current { return "Scene tab is already active".to_string(); } if index >= world.resource::().tabs.len() { return "Scene tab not found".to_string(); } if let Err(error) = capture_active_tab(world) { return format!("Scene switch failed: could not preserve active scene: {error}"); } let target = world.resource::().tabs[index].clone(); if target.snapshot.is_empty() { return "Scene switch failed: target tab has no recoverable snapshot".to_string(); } if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) { return format!("Scene switch failed: {error}"); } { let mut io = world.resource_mut::(); io.active_tab = index; io.active_path = target.path.clone(); io.dirty = target.dirty; io.recovery_snapshot = target.recovery_snapshot.clone(); io.sync_active_tab_metadata(); } world.resource_mut::().clear(); format!("Activated scene tab {}", target.label()) } fn close_scene_tab(world: &mut World, index: usize) -> String { if index >= world.resource::().tabs.len() { return "Scene tab not found".to_string(); } if index != world.resource::().active_tab { let status = switch_scene_tab(world, index); if status.starts_with("Scene switch failed") { return status; } } if world.resource::().dirty { request_close_scene_confirmation(world); return "Waiting for close-scene confirmation".to_string(); } finish_close_scene_tab(world) } fn finish_close_scene_tab(world: &mut World) -> String { if world.resource::().tabs.len() == 1 { clear_scene_world(world); world.insert_resource(SceneComposition { scene_id: uuid::Uuid::new_v4().to_string(), subscenes: Vec::new(), }); { let mut io = world.resource_mut::(); let id = io.allocate_tab_id(); io.tabs = vec![SceneTab { id, path: None, dirty: false, snapshot: String::new(), recovery_snapshot: None, disk_snapshot: None, }]; io.active_tab = 0; io.active_path = None; io.recovery_snapshot = None; io.mark_clean(); } world.resource_mut::().clear(); let _ = capture_active_tab(world); return "Closed scene; created an empty scene tab".to_string(); } let closing = world.resource::().active_tab; let target_index = if closing + 1 < world.resource::().tabs.len() { closing + 1 } else { closing - 1 }; let target = world.resource::().tabs[target_index].clone(); if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) { return format!("Close scene failed: {error}"); } { let mut io = world.resource_mut::(); io.tabs.remove(closing); io.active_tab = if target_index > closing { target_index - 1 } else { target_index }; io.active_path = target.path.clone(); io.dirty = target.dirty; io.recovery_snapshot = target.recovery_snapshot; io.sync_active_tab_metadata(); } world.resource_mut::().clear(); "Closed scene tab".to_string() } fn reload_active_composition(world: &mut World) -> String { if let Err(error) = capture_active_tab(world) { return format!("Composition reload failed: {error}"); } let target = world.resource::().tabs[world.resource::().active_tab].clone(); if let Err(error) = load_level_text(world, target.path.as_deref(), &target.snapshot) { return format!("Composition reload failed: {error}"); } { let mut io = world.resource_mut::(); io.active_path = target.path; io.dirty = target.dirty; io.recovery_snapshot = target.recovery_snapshot; io.sync_active_tab_metadata(); } world.resource_mut::().clear(); "Reloaded active scene composition".to_string() } fn request_close_scene_confirmation(world: &mut World) { let result = world.resource::().request( || { rfd::MessageDialog::new() .set_title("Close Scene") .set_description("This scene tab has unsaved changes. Save before closing it?") .set_level(rfd::MessageLevel::Warning) .set_buttons(rfd::MessageButtons::YesNoCancel) .show() }, |world, decision| { let status = match decision { rfd::MessageDialogResult::Yes => { let save_status = save_active_or_prompt(world); if save_status.starts_with("Saved ") { finish_close_scene_tab(world) } else { format!("Close scene paused until it is saved: {save_status}") } } rfd::MessageDialogResult::No => finish_close_scene_tab(world), _ => "Close scene cancelled".to_string(), }; world.resource_mut::().set_status(status); }, ); if let Err(error) = result { world .resource_mut::() .set_status(format!("Close scene unavailable: {error}")); } } fn save_all_tabs(world: &mut World) -> String { let original_id = world.resource::().tabs[world.resource::().active_tab].id; let dirty_ids: Vec = world .resource::() .tabs .iter() .filter(|tab| tab.dirty) .map(|tab| tab.id) .collect(); for id in dirty_ids { let Some(index) = world .resource::() .tabs .iter() .position(|tab| tab.id == id) else { continue; }; if index != world.resource::().active_tab { let status = switch_scene_tab(world, index); if status.starts_with("Scene switch failed") { return format!("Save failed: {status}"); } } let status = save_active_or_prompt(world); if status.starts_with("Save failed") || status.ends_with("cancelled") { return status; } } if let Some(index) = world .resource::() .tabs .iter() .position(|tab| tab.id == original_id) { if index != world.resource::().active_tab { let _ = switch_scene_tab(world, index); } } "Saved all modified scene tabs".to_string() } fn save_selection_as_prefab(world: &mut World) -> String { let selected_roots: Vec = world .get_resource::() .map(|ui| { ui.selected_entities .iter() .filter(|entity| { world .get_entity(*entity) .is_ok_and(|e| e.contains::()) }) .collect() }) .unwrap_or_default(); let selection = expand_authored_selection(world, &selected_roots); if selection.is_empty() { return "Select level objects to save as prefab".to_string(); } let request = world.resource::().request( || { rfd::FileDialog::new() .set_directory("assets/prefabs") .add_filter("Bevy prefab", &["scn.ron", "ron"]) .set_file_name("prefab.scn.ron") .save_file() }, move |world, path| { let status = finish_save_selection_as_prefab(world, selection, path); world.resource_mut::().set_status(status); }, ); match request { Ok(()) => "Choose a destination for the prefab".to_string(), Err(error) => format!("Save prefab unavailable: {error}"), } } fn finish_save_selection_as_prefab( world: &mut World, selection: Vec, path: Option, ) -> String { let Some(path) = path else { return "Save prefab cancelled".to_string(); }; let expected = match FileSnapshot::capture(&path) { Ok(expected) => expected, Err(error) => return format!("Save prefab failed: {error}"), }; if ensure_unique_actor_ids(world, &selection) > 0 { world.resource_mut::().mark_dirty(); } match save_prefab_entities( world, &path, selection, SceneWriteContext::Standalone { expected, description: "prefab copy", }, ) { Ok(count) => { world.resource_mut::().refresh(); format!( "Saved prefab with {count} root entity(s) to {}", path.display() ) } Err(err) => format!("Save prefab failed: {err}"), } } fn ensure_unique_actor_ids(world: &mut World, entities: &[Entity]) -> usize { let selected: HashSet = entities.iter().copied().collect(); let mut occupied: HashSet = world .query_filtered::<( Entity, &ActorId, Has, Has, ), With>() .iter(world) .filter(|(entity, id, hydrated, composed)| { !selected.contains(entity) && !id.0.trim().is_empty() && !hydrated && !composed }) .map(|(_, id, _, _)| id.0.clone()) .collect(); let mut changed = 0; for entity in entities { let current = world .get::(*entity) .map(|id| id.0.trim().to_string()) .filter(|id| !id.is_empty()); if current .as_ref() .is_some_and(|actor_id| occupied.insert(actor_id.clone())) { continue; } let actor_id = loop { let candidate = uuid::Uuid::new_v4().to_string(); if occupied.insert(candidate.clone()) { break candidate; } }; if let Ok(mut entity_mut) = world.get_entity_mut(*entity) { entity_mut.insert(ActorId::new(actor_id)); changed += 1; } } changed } fn expand_authored_selection(world: &World, roots: &[Entity]) -> Vec { let mut selected = HashSet::new(); let mut stack = roots.to_vec(); while let Some(entity) = stack.pop() { if !world .get_entity(entity) .is_ok_and(|entity_ref| entity_ref.contains::()) || world.get::(entity).is_some() || world.get::(entity).is_some() || !selected.insert(entity) { continue; } if let Some(children) = world.get::(entity) { stack.extend(children.iter()); } } let mut selected: Vec<_> = selected.into_iter().collect(); selected.sort_by_key(|entity| entity.to_bits()); selected } fn save_prefab_entities( world: &mut World, path: &Path, entities: Vec, write_context: SceneWriteContext, ) -> Result { let count = entities.len(); let original_actor_ids: Vec<_> = entities .iter() .map(|entity| (*entity, world.get::(*entity).cloned())) .collect(); let repaired = ensure_unique_actor_ids(world, &entities); let result = (|| { let text = serialize_standalone_entities(world, entities)?; let project_root = PathBuf::from( world .resource::() .root .clone(), ); scene::validate_prefab_graph_text(&text, path, &project_root)?; publish_scene_text(world, path, text.as_bytes(), count, write_context)?; Ok(count) })(); if result.is_err() { for (entity, actor_id) in original_actor_ids { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { match actor_id { Some(actor_id) => { entity_mut.insert(actor_id); } None => { entity_mut.remove::(); } } } } } else if repaired > 0 { world.resource_mut::().mark_dirty(); } result } /// Save helper for play-mode and other cross-module callers. pub fn save_active_or_prompt_world(world: &mut World) -> String { save_active_or_prompt(world) } fn save_active_or_prompt(world: &mut World) -> String { let path = world.resource::().active_path.clone(); match path { Some(path) => match save_level(world, &path, SceneWriteContext::Active) { Ok(count) => { world.resource_mut::().mark_clean(); remember_path(world, path.clone()); retire_scene_recovery(world, &path); format!("Saved {count} level entities to {}", path.display()) } Err(err) => format!("Save failed: {err}"), }, None => save_with_dialog(world), } } fn save_with_dialog(world: &mut World) -> String { let request = world.resource::().request( || { rfd::FileDialog::new() .set_directory("assets/levels") .add_filter("Bevy scene", &["scn.ron", "ron"]) .set_file_name("editor_scene.scn.ron") .save_file() }, |world, path| { let status = finish_save_with_dialog(world, path); world.resource_mut::().set_status(status); }, ); match request { Ok(()) => "Choose a scene destination".to_string(), Err(error) => format!("Save unavailable: {error}"), } } fn finish_save_with_dialog(world: &mut World, path: Option) -> String { let Some(path) = path else { return "Save cancelled".to_string(); }; let expected = match FileSnapshot::capture(&path) { Ok(expected) => expected, Err(error) => return format!("Save failed: {error}"), }; match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) { Ok(count) => { world.resource_mut::().active_path = Some(path.clone()); world.resource_mut::().mark_clean(); remember_path(world, path.clone()); retire_scene_recovery(world, &path); format!("Saved {count} level entities to {}", path.display()) } Err(err) => format!("Save failed: {err}"), } } fn open_with_dialog(world: &mut World) -> String { let request = world.resource::().request( || { rfd::FileDialog::new() .set_directory("assets/levels") .add_filter("Bevy scene", &["scn.ron", "ron"]) .pick_file() }, |world, path| { let status = path.map_or_else( || "Open cancelled".to_string(), |path| open_path(world, path), ); world.resource_mut::().set_status(status); }, ); match request { Ok(()) => "Choose a scene to open".to_string(), Err(error) => format!("Open unavailable: {error}"), } } fn import_with_dialog(world: &mut World) -> String { let request = world.resource::().request( || { rfd::FileDialog::new() .add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS) .pick_files() }, |world, paths| { let status = match paths { None => "Import cancelled".to_string(), Some(paths) => match import_external_assets(&paths) { Ok(count) => { world.resource_mut::().refresh(); format!("Imported {count} asset(s)") } Err(err) => format!("Import failed: {err}"), }, }; world.resource_mut::().set_status(status); }, ); match request { Ok(()) => "Choose assets to import".to_string(), Err(error) => format!("Import unavailable: {error}"), } } fn export_selection_with_dialog(world: &mut World) -> String { let Some(entity) = world.resource::().0 else { return "Nothing selected to export".to_string(); }; if snapshot_entity(world, entity).is_none() { return "Selected entity is not an authored level object".to_string(); } let request = world.resource::().request( || { rfd::FileDialog::new() .set_directory("assets/levels") .add_filter("Bevy prefab", &["scn.ron", "ron"]) .set_file_name("selection.scn.ron") .save_file() }, move |world, path| { let status = finish_export_selection(world, entity, path); world.resource_mut::().set_status(status); }, ); match request { Ok(()) => "Choose an export destination".to_string(), Err(error) => format!("Export unavailable: {error}"), } } fn finish_export_selection(world: &mut World, entity: Entity, path: Option) -> String { let Some(path) = path else { return "Export cancelled".to_string(); }; if snapshot_entity(world, entity).is_none() { return "Export failed: the initiating selection no longer exists".to_string(); } let expected = match FileSnapshot::capture(&path) { Ok(expected) => expected, Err(error) => return format!("Export failed: {error}"), }; match save_standalone_entities( world, &path, vec![entity], SceneWriteContext::Standalone { expected, description: "selection export", }, ) { Ok(count) => format!("Exported {count} selected entity to {}", path.display()), Err(err) => format!("Export failed: {err}"), } } fn clear_loaded_scene_roots(world: &mut World) { let mut query = world.query_filtered::>(); let entities: Vec = query.iter(world).collect(); for entity in entities { if let Ok(entity_mut) = world.get_entity_mut(entity) { entity_mut.despawn(); } } } fn clear_scene_world(world: &mut World) { clear_loaded_scene_roots(world); clear_level_objects(world); if let Some(mut hierarchy) = world.get_resource_mut::() { hierarchy.locked.clear(); } if let Some(mut ui) = world.get_resource_mut::() { ui.selected_entities.clear(); } if let Some(mut selected) = world.get_resource_mut::() { selected.0 = None; } } fn save_level( world: &mut World, path: &Path, write_context: SceneWriteContext, ) -> Result { let entities = authored_scene_entities(world); if is_prefab_document(path) { save_prefab_entities(world, path, entities, write_context) } else { save_entities(world, path, entities, write_context) } } pub(crate) fn serialize_active_scene(world: &mut World) -> Result { let entities = authored_scene_entities(world); if world .resource::() .active_path .as_deref() .is_some_and(is_prefab_document) { serialize_standalone_entities(world, entities) } else { serialize_entities(world, entities) } } pub(crate) fn is_prefab_document(path: &Path) -> bool { let normalized = path.to_string_lossy().replace('\\', "/"); normalized.starts_with("assets/prefabs/") || normalized.contains("/assets/prefabs/") } fn authored_scene_entities(world: &mut World) -> Vec { let mut query = world.query_filtered::, Without, Without, )>(); query.iter(world).collect() } fn format_actor_validation(err: ActorValidationError) -> String { match err { ActorValidationError::MissingActorKind => { "Save failed: level object missing ActorKind (re-open scene to migrate)".into() } ActorValidationError::MissingTransform => { "Save failed: level object missing Transform".into() } ActorValidationError::BrushMissingDesc => { "Save failed: Brush actor requires BrushDesc".into() } ActorValidationError::BrushHasPrimitive => { "Save failed: Brush actor cannot have Primitive".into() } ActorValidationError::BrushHasStaticMeshRenderer => { "Save failed: Brush actor cannot have StaticMeshRenderer".into() } ActorValidationError::BrushHasLight => { "Save failed: Brush actor cannot have LightDesc".into() } ActorValidationError::BrushHasModelRef => { "Save failed: Brush actor cannot have ModelRef".into() } ActorValidationError::InvalidBrushGeometry(message) => { format!("Save failed: invalid brush geometry: {message}") } ActorValidationError::InvalidTerrain(message) => { format!("Save failed: invalid terrain: {message}") } ActorValidationError::StaticMeshMissingPrimitive => { "Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot" .into() } ActorValidationError::StaticMeshHasLight => { "Save failed: StaticMesh actor cannot have LightDesc".into() } ActorValidationError::StaticMeshHasModelRef => { "Save failed: StaticMesh actor cannot have ModelRef".into() } ActorValidationError::ImportedModelMissingModelRef => { "Save failed: ImportedModel actor requires ModelRef".into() } ActorValidationError::ImportedModelHasPrimitive => { "Save failed: ImportedModel actor cannot have Primitive".into() } ActorValidationError::ImportedModelHasStaticMeshRenderer => { "Save failed: ImportedModel actor cannot have StaticMeshRenderer".into() } ActorValidationError::SkinnedMeshMissingRenderer => { "Save failed: SkinnedMesh actor requires SkinnedMeshRenderer".into() } ActorValidationError::SkinnedMeshInvalidRenderer => { "Save failed: SkinnedMeshRenderer requires a model source path".into() } ActorValidationError::SkinnedMeshHasPrimitive => { "Save failed: SkinnedMesh actor cannot have Primitive".into() } ActorValidationError::SkinnedMeshHasStaticMeshRenderer => { "Save failed: SkinnedMesh actor cannot have StaticMeshRenderer".into() } ActorValidationError::SkinnedMeshHasModelRef => { "Save failed: SkinnedMesh actor cannot have ModelRef".into() } ActorValidationError::SkinnedMeshRendererActorKindMismatch => { "Save failed: SkinnedMeshRenderer requires ActorKind::SkinnedMesh".into() } ActorValidationError::ConflictingGeometrySources => { "Save failed: actor has more than one primary geometry source component".into() } ActorValidationError::LightMissingLightDesc => { "Save failed: Light actor requires LightDesc".into() } ActorValidationError::LightHasPrimitive => { "Save failed: Light actor cannot have Primitive".into() } ActorValidationError::LightHasModelRef => { "Save failed: Light actor cannot have ModelRef".into() } ActorValidationError::LightHasStaticMeshRenderer => { "Save failed: Light actor cannot have StaticMeshRenderer".into() } ActorValidationError::AudioSourceMissingDesc => { "Save failed: AudioSource actor requires AudioSourceDesc".into() } ActorValidationError::AudioSourceMissingClip => { "Save failed: audio source requires an assigned clip".into() } ActorValidationError::AudioSourceInvalidClipReference => { "Save failed: audio source clip reference is unresolved or not an audio clip".into() } ActorValidationError::AudioSourceInvalidGain => { "Save failed: audio source gain is outside the supported dB range".into() } ActorValidationError::AudioSourceInvalidPitch => { "Save failed: audio source pitch must be positive and finite".into() } ActorValidationError::AudioSourceInvalidSpatialBlend => { "Save failed: audio source spatial blend must be between 0 and 1".into() } ActorValidationError::AudioSourceInvalidAttenuation => { "Save failed: audio source attenuation distances or rolloff are invalid".into() } ActorValidationError::AudioSourceMissingBus => { "Save failed: audio source requires a bus ID".into() } ActorValidationError::AudioListenerMissingDesc => { "Save failed: AudioListener actor requires AudioListenerDesc".into() } ActorValidationError::AudioListenerInvalidEarGap => { "Save failed: audio listener ear gap must be positive and finite".into() } ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer => { "Save failed: animation controller requires SkinnedMeshRenderer on the same actor" .into() } ActorValidationError::AnimationControllerMissingSkeleton => { "Save failed: animation controller requires an assigned skeleton".into() } ActorValidationError::AnimationControllerInvalidSkeletonReference => { "Save failed: animation controller skeleton reference is unresolved or invalid".into() } ActorValidationError::AnimationControllerEmptyStateId => { "Save failed: animation controller state IDs cannot be empty".into() } ActorValidationError::AnimationControllerDuplicateStateId => { "Save failed: animation controller state IDs must be unique".into() } ActorValidationError::AnimationControllerInvalidClipReference => { "Save failed: animation states require resolved glTF/GLB clip references".into() } ActorValidationError::AnimationControllerInvalidStateSpeed => { "Save failed: animation state speed must be finite and nonzero".into() } ActorValidationError::AnimationControllerInvalidStateRange => { "Save failed: animation state playback range is invalid".into() } ActorValidationError::AnimationControllerInvalidCrossfade => { "Save failed: animation controller crossfade must be finite and non-negative".into() } ActorValidationError::AnimationControllerMissingDefaultState => { "Save failed: animation controller requires a default state".into() } ActorValidationError::AnimationControllerUnknownDefaultState => { "Save failed: animation controller default state does not exist".into() } ActorValidationError::PostProcessVolumeMissingDesc => { "Save failed: PostProcessVolume actor requires PostProcessVolumeDesc".into() } ActorValidationError::PostProcessVolumeInvalidHalfExtents => { "Save failed: post-process volume half_extents must be positive".into() } ActorValidationError::PostProcessVolumeInvalidBlendDistance => { "Save failed: post-process volume blend_distance must be non-negative".into() } ActorValidationError::PostProcessVolumeInvalidOverrideScalar => { "Save failed: post-process volume override contains invalid scalar".into() } ActorValidationError::InvalidNavigation(message) => { format!("Save failed: invalid navigation authoring: {message}") } } } fn save_entities( world: &mut World, path: &Path, entities: Vec, write_context: SceneWriteContext, ) -> Result { let count = entities.len(); let text = serialize_entities(world, entities)?; publish_scene_text(world, path, text.as_bytes(), count, write_context)?; Ok(count) } fn save_standalone_entities( world: &mut World, path: &Path, entities: Vec, write_context: SceneWriteContext, ) -> Result { let count = entities.len(); let text = serialize_standalone_entities(world, entities)?; publish_scene_text(world, path, text.as_bytes(), count, write_context)?; Ok(count) } #[derive(Clone)] enum SceneWriteContext { Active, ActiveSaveAs { expected: FileSnapshot, }, Standalone { expected: FileSnapshot, description: &'static str, }, } fn publish_scene_text( world: &mut World, path: &Path, bytes: &[u8], entity_count: usize, context: SceneWriteContext, ) -> Result<(), String> { match context { SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => { let (tab_id, expected) = match context { SceneWriteContext::Active => active_scene_write_baseline(world, path)?, SceneWriteContext::ActiveSaveAs { expected } => { (active_scene_tab_id(world)?, expected) } SceneWriteContext::Standalone { .. } => unreachable!(), }; let snapshot = publish_authored_file( world, path, bytes, &expected, FileWriteIntent::Scene { tab_id, entity_count, }, )?; let mut io = world.resource_mut::(); let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else { return Err("saved scene tab no longer exists".into()); }; tab.disk_snapshot = Some(snapshot); } SceneWriteContext::Standalone { expected, description, } => { publish_authored_file( world, path, bytes, &expected, FileWriteIntent::Standalone { description: description.into(), }, )?; } } Ok(()) } fn active_scene_write_baseline( world: &World, destination: &Path, ) -> Result<(u64, FileSnapshot), String> { let io = world.resource::(); let tab = io .tabs .get(io.active_tab) .ok_or_else(|| "active scene tab is missing".to_string())?; if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) { return Err(format!( "{} is not the active scene destination; use Save As", destination.display() )); } let expected = tab.disk_snapshot.clone().ok_or_else(|| { format!( "scene disk baseline is unavailable for {}; reload it or use Save As", destination.display() ) })?; Ok((tab.id, expected)) } fn active_scene_tab_id(world: &World) -> Result { let io = world.resource::(); io.tabs .get(io.active_tab) .map(|tab| tab.id) .ok_or_else(|| "active scene tab is missing".to_string()) } fn serialize_entities(world: &mut World, entities: Vec) -> Result { serialize_entities_inner_with_resources(world, entities, true) } #[cfg(test)] fn serialize_entities_inner(world: &mut World, entities: Vec) -> Result { serialize_entities_inner_with_resources(world, entities, true) } fn serialize_standalone_entities( world: &mut World, entities: Vec, ) -> Result { serialize_entities_inner_with_resources(world, entities, false) } fn serialize_entities_inner_with_resources( world: &mut World, entities: Vec, include_scene_resources: bool, ) -> Result { for entity in &entities { let entity_ref = world.entity(*entity); if let Err(err) = validate_actor(entity_ref) { return Err(format_actor_validation(err)); } } let entity_set: HashSet = entities.iter().copied().collect(); let detached_parents: Vec<(Entity, Entity, Transform)> = if include_scene_resources { Vec::new() } else { entities .iter() .filter_map(|entity| { world .get::(*entity) .map(ChildOf::parent) .filter(|parent| !entity_set.contains(parent)) .and_then(|parent| { computed_world_transform(world, *entity) .map(|world_transform| (*entity, parent, world_transform)) }) }) .collect() }; let original_local_transforms: Vec<(Entity, Transform)> = detached_parents .iter() .filter_map(|(entity, _, _)| { world .get::(*entity) .copied() .map(|transform| (*entity, transform)) }) .collect(); for (entity, _, world_transform) in &detached_parents { world .entity_mut(*entity) .remove::() .insert(*world_transform); } let result = (|| { let ron = { let registry = world.resource::().read(); let mut component_filter = WorldFilter::deny_all() .allow::() .allow::() .allow::() .allow::() .allow::() .allow::() .allow::() .allow::(); let component_registry = world .get_resource::() .cloned() .unwrap_or_default(); for descriptor in &component_registry.descriptors { if let Some(registration) = registry.get_with_type_path(descriptor.type_name) { component_filter = component_filter.allow_by_id(registration.type_id()); } } let builder = DynamicWorldBuilder::from_world(world, ®istry) .with_component_filter(component_filter) .extract_entities(entities.into_iter()); let scene = if include_scene_resources { builder .allow_resource::() .extract_resources() .remove_empty_entities() .build() } else { builder.remove_empty_entities().build() }; scene .serialize(®istry) .map_err(|err| format!("could not serialize scene: {err}"))? }; let document = SceneDocument::from_ron_text(&ron)?; document.to_ron_text() })(); for (entity, transform) in original_local_transforms { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(transform); } } for (entity, parent, _) in detached_parents { if let Ok(mut entity_mut) = world.get_entity_mut(entity) { entity_mut.insert(ChildOf(parent)); } } result } fn computed_world_transform(world: &World, entity: Entity) -> Option { let mut chain = Vec::new(); let mut current = entity; let mut visited = HashSet::new(); while visited.insert(current) { chain.push(*world.get::(current)?); let Some(parent) = world.get::(current).map(ChildOf::parent) else { break; }; current = parent; } if world.get::(current).is_some() { return None; } let mut chain = chain.into_iter().rev(); let root = chain.next()?; Some( chain .fold(GlobalTransform::from(root), |global, local| { global.mul_transform(local) }) .compute_transform(), ) } fn tick_scene_recovery(world: &mut World) { let (enabled, interval_secs, max_generations) = { let prefs = world.resource::(); ( prefs.scene_autosave_enabled, recovery_interval_secs(prefs), prefs.scene_recovery_generations.max(1), ) }; if !enabled { return; } let dirty = world.resource::().has_unsaved_tabs(); let delta = world.resource::