diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index aef5e5ab..d38f4f89 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -163,6 +163,7 @@ struct EditorApp { show_game_settings: bool, missing_dependencies: Vec, show_dependency_prompt: bool, + scene_rename: Option, } impl EditorApp { @@ -270,6 +271,7 @@ impl EditorApp { show_game_settings: false, missing_dependencies, show_dependency_prompt, + scene_rename: None, }; app.log( if read_only { @@ -532,6 +534,136 @@ impl EditorApp { } } + fn set_start_scene(&mut self, path: &Path) { + if self.play.is_some() || self.pending_runtime_changes.is_some() { + return; + } + let was_modified = self.document.is_modified(); + let scene_existed = self.document.scene_path().is_file(); + match self.document.set_start_scene(path) { + Ok(()) => { + if (was_modified && !self.document.is_modified()) + || (!scene_existed && self.document.scene_path().is_file()) + { + self.scene_disk_snapshot = std::fs::read(self.document.scene_path()).ok(); + self.external_scene_change_pending = false; + } + self.log( + Severity::Info, + "scene", + format!("Start scene set to {}", path.display()), + ); + } + Err(error) => self.log(Severity::Error, "scene", error.to_string()), + } + } + + fn scene_rename_dialog(&mut self, context: &egui::Context) { + let Some(mut rename) = self.scene_rename.take() else { + return; + }; + let can_edit = !self.document.is_read_only() + && self.play.is_none() + && self.pending_runtime_changes.is_none(); + let mut cancel = false; + let mut submit = false; + egui::Window::new("Rename scene") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(context, |ui| { + ui.label(format!("Scene: {}", rename.source.display())); + ui.add_enabled_ui(can_edit, |ui| { + let input = ui + .add(egui::TextEdit::singleline(&mut rename.name).hint_text("Scene name")); + if rename.focus { + input.request_focus(); + rename.focus = false; + } + submit = (input.has_focus() || input.lost_focus()) + && ui.input(|input| input.key_pressed(egui::Key::Enter)); + if let Some(error) = &rename.error { + ui.colored_label(egui::Color32::LIGHT_RED, error); + } + submit |= ui.button("Rename").clicked(); + }); + cancel = ui.button("Cancel").clicked() + || ui.input(|input| input.key_pressed(egui::Key::Escape)); + }); + if cancel { + return; + } + if submit && can_edit { + let previous_path = self.document.scene_path().to_path_buf(); + let was_modified = self.document.is_modified(); + match self.document.rename_scene(&rename.source, &rename.name) { + Ok(path) => { + if self.document.scene_path() != previous_path + || (was_modified && !self.document.is_modified()) + { + self.scene_disk_snapshot = std::fs::read(self.document.scene_path()).ok(); + self.external_scene_change_pending = false; + self.last_scene_scan = Instant::now(); + } + self.project_scan = Some(spawn_project_scan( + self.document.project().root().to_path_buf(), + )); + self.log( + Severity::Info, + "scene", + format!("Renamed scene to {}", path.display()), + ); + return; + } + Err(error) => rename.error = Some(error.to_string()), + } + } + self.scene_rename = Some(rename); + } + + fn apply_deferred_project_action( + &mut self, + action: Option, + context: &egui::Context, + ) { + match action { + Some(ProjectFileAction::ChangeScene(path)) => self.change_scene(&path, context), + Some(ProjectFileAction::SetStartScene(path)) => self.set_start_scene(&path), + Some(ProjectFileAction::RenameScene(path)) => { + if !self.document.is_read_only() + && self.play.is_none() + && self.pending_runtime_changes.is_none() + { + self.scene_rename = Some(SceneRename { + name: path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + source: path, + error: None, + focus: true, + }); + } + } + Some(ProjectFileAction::Attach(path, scope)) => { + self.attach_project_script(&path, scope); + } + Some(ProjectFileAction::Refresh) => { + let project_root = self.document.project().root().to_path_buf(); + if let Ok(settings) = GameSettings::load(&project_root) { + self.game_settings = settings; + } + self.scene_disk_snapshot = std::fs::read(self.document.scene_path()).ok(); + self.model_assets = load_project_model_assets(&project_root); + self.project_scan = Some(spawn_project_scan(project_root)); + self.last_project_scan = Instant::now(); + self.refresh_preview(); + } + _ => {} + } + } + fn control_play(&mut self, request: ControlRequest) { let result = self .play @@ -1451,27 +1583,12 @@ impl eframe::App for EditorApp { viewer.flush_play_input(); let request_preview = viewer.request_preview; drop(viewer); - match project_file_action { - Some(ProjectFileAction::ChangeScene(path)) => self.change_scene(&path, ui.ctx()), - Some(ProjectFileAction::Attach(path, scope)) => { - self.attach_project_script(&path, scope); - } - Some(ProjectFileAction::Refresh) => { - if let Ok(settings) = GameSettings::load(&project_root) { - self.game_settings = settings; - } - self.scene_disk_snapshot = std::fs::read(self.document.scene_path()).ok(); - self.model_assets = load_project_model_assets(&project_root); - self.project_scan = Some(spawn_project_scan(project_root)); - self.last_project_scan = Instant::now(); - self.refresh_preview(); - } - _ => {} - } + self.apply_deferred_project_action(project_file_action, ui.ctx()); if request_preview { self.refresh_preview(); } self.runtime_changes_dialog(ui.ctx()); + self.scene_rename_dialog(ui.ctx()); self.game_settings_dialog(ui.ctx()); self.dependency_prompt(ui.ctx()); self.update_close_dialog(ui); @@ -2281,15 +2398,19 @@ impl EditorViewer<'_> { } for path in paths { let selected = active.as_ref() == Some(&path); - if ui - .selectable_label(selected, path.to_string_lossy()) - .clicked() - { - if !selected { - *self.project_file_action = - Some(ProjectFileAction::ChangeScene(path)); - } - ui.close(); + let is_start = self + .document + .project() + .start_scene_path() + .is_ok_and(|start| start == scenes.join(&path)); + if let Some(action) = scene_picker_entry( + ui, + &path, + selected, + is_start, + !self.document.is_read_only(), + ) { + *self.project_file_action = Some(action); } } }); @@ -4402,6 +4523,8 @@ struct ProjectFile { #[derive(Debug)] enum ProjectFileAction { ChangeScene(PathBuf), + RenameScene(PathBuf), + SetStartScene(PathBuf), Refresh, Open(PathBuf), Attach(PathBuf, ScriptScope), @@ -4416,6 +4539,75 @@ enum ProjectFileAction { }, } +struct SceneRename { + source: PathBuf, + name: String, + error: Option, + focus: bool, +} + +fn scene_picker_entry( + ui: &mut egui::Ui, + path: &Path, + selected: bool, + is_start: bool, + can_edit: bool, +) -> Option { + let mut action = None; + let row = ui + .horizontal(|ui| { + let response = ui.selectable_label(selected, path.to_string_lossy()); + if is_start { + ui.weak("Start"); + } + response + }) + .inner; + // Memory-backed context menus close the parent popup. Keep this menu in + // egui's submenu state so it remains usable inside the scene picker. + let popup_id = ui.make_persistent_id(("scene-actions", path)); + let secondary_clicked = row.secondary_clicked(); + let open = egui::menu::MenuState::from_ui(ui, |state, _| { + if secondary_clicked { + state.open_item = Some(popup_id); + } + state.open_item == Some(popup_id) + }); + let popup = egui::Popup::from_response(&row) + .id(popup_id) + .open(open) + .kind(egui::PopupKind::Menu) + .align(egui::RectAlign::RIGHT_START) + .layout(egui::Layout::top_down_justified(egui::Align::Min)) + .show(|ui| { + if ui + .add_enabled(can_edit, egui::Button::new("Rename")) + .clicked() + { + action = Some(ProjectFileAction::RenameScene(path.to_path_buf())); + ui.close(); + } + if ui + .add_enabled(can_edit, egui::Button::new("Set as start scene")) + .clicked() + { + action = Some(ProjectFileAction::SetStartScene(path.to_path_buf())); + ui.close(); + } + }); + if popup.is_some_and(|popup| popup.response.should_close()) { + egui::menu::MenuState::from_ui(ui, |state, _| state.open_item = None); + ui.close(); + } + if row.clicked() { + if !selected { + action = Some(ProjectFileAction::ChangeScene(path.to_path_buf())); + } + ui.close(); + } + action +} + impl ProjectDirectory { fn scene_paths(&self, scenes: &Path) -> Vec { let mut paths: Vec<_> = self @@ -5700,6 +5892,123 @@ fn edit_light(ui: &mut egui::Ui, light: &mut engine_world::Light) -> bool { mod explorer_tests { use super::*; + fn scene_context_frame( + context: &egui::Context, + time: f64, + events: Vec, + can_edit: bool, + ) -> (egui::FullOutput, Option) { + let mut action = None; + let mut output = context.run_ui( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ui| { + ui.menu_button("Settings", |ui| { + ui.menu_button("Change scene", |ui| { + action = scene_picker_entry( + ui, + Path::new("night.scene"), + false, + false, + can_edit, + ); + }); + }); + }, + ); + output.textures_delta.clear(); + (output, action) + } + + fn scene_context_click( + context: &egui::Context, + time: f64, + position: egui::Pos2, + button: egui::PointerButton, + can_edit: bool, + ) -> (egui::FullOutput, Option) { + scene_context_frame( + context, + time, + vec![ + egui::Event::PointerMoved(position), + pointer(position, button, true), + ], + can_edit, + ); + scene_context_frame( + context, + time + 0.01, + vec![pointer(position, button, false)], + can_edit, + ) + } + + #[test] + fn right_click_scene_offers_rename_and_start_without_loading_the_scene() { + for (choice, can_edit) in [ + ("Rename", true), + ("Set as start scene", true), + ("Rename", false), + ("Set as start scene", false), + ] { + let context = egui::Context::default(); + let (output, _) = scene_context_frame(&context, 0.0, vec![], can_edit); + let settings = text_position(&output, "Settings"); + scene_context_click( + &context, + 0.1, + settings, + egui::PointerButton::Primary, + can_edit, + ); + let (output, _) = scene_context_frame(&context, 0.2, vec![], can_edit); + let change = text_position(&output, "Change scene"); + scene_context_click( + &context, + 0.3, + change, + egui::PointerButton::Primary, + can_edit, + ); + let (output, _) = scene_context_frame(&context, 0.4, vec![], can_edit); + let night = text_position(&output, "night.scene"); + let (_, action) = scene_context_click( + &context, + 0.5, + night, + egui::PointerButton::Secondary, + can_edit, + ); + assert!(action.is_none(), "right click must not switch scenes"); + let (output, _) = scene_context_frame(&context, 0.6, vec![], can_edit); + text_position(&output, "Rename"); + text_position(&output, "Set as start scene"); + let position = text_position(&output, choice); + let (_, action) = scene_context_click( + &context, + 0.7, + position, + egui::PointerButton::Primary, + can_edit, + ); + if !can_edit { + assert!(action.is_none()); + } else if choice == "Rename" { + assert!( + matches!(action, Some(ProjectFileAction::RenameScene(path)) if path == Path::new("night.scene")) + ); + } else { + assert!( + matches!(action, Some(ProjectFileAction::SetStartScene(path)) if path == Path::new("night.scene")) + ); + } + } + } + #[test] fn project_settings_gear_opens_scene_picker_and_requests_selected_scene() { let temporary = TestProject::new(); @@ -6715,7 +7024,7 @@ mod explorer_tests { None } }) - .unwrap() + .unwrap_or_else(|| panic!("Missing UI text: {label}")) } fn modifier_pointer( diff --git a/Engine/crates/engine-editor/src/document.rs b/Engine/crates/engine-editor/src/document.rs index 50339e1b..cee3c72e 100644 --- a/Engine/crates/engine-editor/src/document.rs +++ b/Engine/crates/engine-editor/src/document.rs @@ -15,6 +15,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use thiserror::Error; +mod scene_files; + #[derive(Debug, Error)] pub enum AuthoringError { #[error(transparent)] @@ -35,6 +37,8 @@ pub enum AuthoringError { }, #[error("scene path must stay inside the project scenes folder")] UnsafeScenePath, + #[error("scene file operation failed: {0}")] + SceneFileOperation(String), } /// One source scene and its centralized, checkpoint-aware edit history. @@ -60,22 +64,16 @@ pub struct AuthoringDocument { } impl AuthoringDocument { - /// Opens the project's main scene, including backup recovery when required. + /// Opens the project's start scene, including backup recovery when required. /// /// # Errors /// /// Returns an error when project paths, scene recovery, migration, or world construction fails. pub fn open(project: Project, read_only: bool) -> Result { - let scene_directory = project.directory(VirtualDirectory::Scenes)?; - // New projects use scene/{name}.scene. Keep opening the original - // scenes/main.rscene location so existing projects need no migration. - let preferred = scene_directory.join("main.scene"); - let legacy = scene_directory.join("main.rscene"); - let scene_path = if preferred.is_file() || !legacy.is_file() { - preferred - } else { - legacy - }; + if let Some(relative) = project.descriptor().start_scene.clone() { + return Self::open_scene(project, relative, read_only); + } + let scene_path = project.start_scene_path()?; let (document, recovery_read_only, recovered_from_backup) = if scene_path.is_file() { let loaded = load_scene_recovering(&scene_path)?; ( diff --git a/Engine/crates/engine-editor/src/document/scene_files.rs b/Engine/crates/engine-editor/src/document/scene_files.rs new file mode 100644 index 00000000..b4970b2b --- /dev/null +++ b/Engine/crates/engine-editor/src/document/scene_files.rs @@ -0,0 +1,368 @@ +use super::{AuthoringDocument, AuthoringError}; +use engine_project::VirtualDirectory; +use std::path::{Component, Path, PathBuf}; + +impl AuthoringDocument { + /// Saves a scene as the project's start scene without changing the active scene. + /// # Errors + /// Returns an error for read-only access, invalid scene data, or failed persistence. + pub fn set_start_scene(&mut self, relative: &Path) -> Result<(), AuthoringError> { + self.ensure_writable()?; + let path = self.scene_file_path(relative)?; + if same_file(&path, &self.scene_path) && (!path.is_file() || self.is_modified()) { + self.save()?; + } + Self::open_scene(self.project.clone(), relative, self.project_read_only)?; + self.project.set_start_scene(relative)?; + Ok(()) + } + + /// Renames a scene file, retaining active edits, recovery files, asset metadata, + /// and its start-scene reference. A bare name retains the original extension. + /// # Errors + /// Returns an error for read-only access, invalid names, collisions, or I/O failure. + pub fn rename_scene(&mut self, relative: &Path, name: &str) -> Result { + self.ensure_writable()?; + let source = self.scene_file_path(relative)?; + let name = scene_filename(relative, name)?; + let renamed = relative.with_file_name(name); + let destination = + source.with_file_name(renamed.file_name().ok_or(AuthoringError::UnsafeScenePath)?); + if source == destination { + return Ok(renamed); + } + let moves: Vec<_> = ["", ".bak", ".rmeta"] + .into_iter() + .map(|suffix| { + ( + with_suffix(&source, suffix), + with_suffix(&destination, suffix), + ) + }) + .collect(); + for (_, target) in &moves { + match std::fs::symlink_metadata(target) { + Ok(_) => { + return Err(operation_error(format!( + "An entry already exists at {}", + target.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(operation_error(error.to_string())), + } + } + let active = same_file(&source, &self.scene_path); + let start = same_file(&source, &self.project.start_scene_path()?); + if active && (self.is_modified() || !source.is_file()) { + self.save()?; + } + // Reject links and directories for every file before beginning the transaction. + let mut existing = Vec::new(); + for (from, to) in moves { + match std::fs::symlink_metadata(&from) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + existing.push((from, to)); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && from != source => {} + _ => { + return Err(operation_error(format!( + "Not a regular scene file: {}", + from.display() + ))); + } + } + } + let mut completed = Vec::new(); + for (from, to) in existing { + if let Err(error) = std::fs::rename(&from, &to) { + return Err(rollback(&completed, &error.to_string())); + } + completed.push((from, to)); + } + if start && let Err(error) = self.project.set_start_scene(&renamed) { + return Err(rollback(&completed, &error.to_string())); + } + if active { + self.scene_path = destination; + } + Ok(renamed) + } + + fn scene_file_path(&self, relative: &Path) -> Result { + self.project.validate()?; + if relative.as_os_str().is_empty() + || relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || !relative + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("scene") || value.eq_ignore_ascii_case("rscene") + }) + { + return Err(AuthoringError::UnsafeScenePath); + } + let mut path = self.project.directory(VirtualDirectory::Scenes)?; + for component in relative.components() { + path.push(component); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(AuthoringError::UnsafeScenePath); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(operation_error(error.to_string())), + } + } + Ok(path) + } +} + +fn same_file(left: &Path, right: &Path) -> bool { + left == right + || left + .canonicalize() + .is_ok_and(|left| right.canonicalize().is_ok_and(|right| left == right)) +} + +fn scene_filename(relative: &Path, name: &str) -> Result { + if name.is_empty() + || name != name.trim() + || name.ends_with('.') + || name.contains(['/', '\\', ':', '<', '>', '"', '|', '?', '*']) + || name.chars().any(char::is_control) + { + return Err(operation_error("Enter a single valid scene name")); + } + let extension = relative + .extension() + .and_then(|value| value.to_str()) + .ok_or(AuthoringError::UnsafeScenePath)?; + let suffix = format!(".{extension}"); + if name + .to_ascii_lowercase() + .ends_with(&suffix.to_ascii_lowercase()) + { + if name.len() == suffix.len() { + return Err(operation_error("Enter a scene name before the extension")); + } + Ok(name.to_owned()) + } else if Path::new(name).extension().is_some_and(|value| { + value.eq_ignore_ascii_case("scene") || value.eq_ignore_ascii_case("rscene") + }) { + Err(operation_error("Keep the original scene extension")) + } else { + Ok(format!("{name}{suffix}")) + } +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut path = path.as_os_str().to_owned(); + path.push(suffix); + PathBuf::from(path) +} + +fn operation_error(message: impl Into) -> AuthoringError { + AuthoringError::SceneFileOperation(message.into()) +} + +fn rollback(moved: &[(PathBuf, PathBuf)], cause: &str) -> AuthoringError { + let mut message = cause.to_owned(); + for (from, to) in moved.iter().rev() { + if let Err(error) = std::fs::rename(to, from) { + use std::fmt::Write as _; + let _ = write!(message, "; could not restore {}: {error}", from.display()); + } + } + operation_error(message) +} + +#[cfg(test)] +mod tests { + use super::*; + use engine_platform::AtomicFileService; + use engine_project::{PROJECT_DESCRIPTOR_FILE, Project, ProjectTemplate}; + use engine_world::{SceneDocument, save_scene_atomic}; + + fn fixture() -> (tempfile::TempDir, AuthoringDocument) { + let temp = tempfile::tempdir().unwrap(); + let project = + Project::create(temp.path().join("game"), "Game", ProjectTemplate::Blank).unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + document.save().unwrap(); + (temp, document) + } + + #[test] + fn start_scene_persists_and_reopens_objects_and_environment_without_switching_now() { + let (_temp, mut document) = fixture(); + let root = document.project.root().to_path_buf(); + let mut night = SceneDocument::new("Night"); + night.environment.enabled = true; + night.environment.sun_intensity = 0.3; + night.entities.push(engine_world::EntitySnapshot::default()); + let scenes = document + .project + .directory(VirtualDirectory::Scenes) + .unwrap(); + save_scene_atomic(scenes.join("levels/night.scene"), &night).unwrap(); + let selected = document.add_folder(None).unwrap(); + document + .set_start_scene(Path::new("levels/night.scene")) + .unwrap(); + assert_eq!(document.selected(), Some(selected)); + assert!(document.is_modified()); + assert_eq!(document.scene_path(), scenes.join("main.scene")); + let reopened = AuthoringDocument::open(Project::open(root).unwrap(), false).unwrap(); + assert_eq!(reopened.scene_path(), scenes.join("levels/night.scene")); + assert_eq!(reopened.entity_snapshots().unwrap(), night.entities); + assert_eq!(reopened.world().environment(), &night.environment); + } + + #[test] + fn active_scene_rename_keeps_edits_history_backups_metadata_and_default_start_reference() { + let (_temp, mut document) = fixture(); + let source = document.scene_path().to_path_buf(); + let selected = document.add_folder(None).unwrap(); + std::fs::write(with_suffix(&source, ".rmeta"), "asset identity").unwrap(); + let renamed = document + .rename_scene(Path::new("main.scene"), "Renamed") + .unwrap(); + assert_eq!(renamed, Path::new("Renamed.scene")); + let destination = source.with_file_name(&renamed); + assert_eq!(document.scene_path(), destination); + assert_eq!(document.selected(), Some(selected)); + assert!(destination.is_file()); + assert!(!source.exists()); + assert!(with_suffix(&destination, ".bak").is_file()); + assert!(!with_suffix(&source, ".bak").exists()); + assert_eq!( + std::fs::read_to_string(with_suffix(&destination, ".rmeta")).unwrap(), + "asset identity" + ); + let reopened = + AuthoringDocument::open(Project::open(document.project.root()).unwrap(), false) + .unwrap(); + assert_eq!(reopened.scene_path(), destination); + assert!(reopened.world().contains(selected)); + document.undo().unwrap(); + assert!(!document.world().contains(selected)); + document.redo().unwrap(); + document.save().unwrap(); + assert!( + !source.exists(), + "saving after rename must not recreate the old scene" + ); + } + + #[test] + fn renaming_inactive_start_scene_updates_start_without_touching_active_edits() { + let (_temp, mut document) = fixture(); + let scenes = document + .project + .directory(VirtualDirectory::Scenes) + .unwrap(); + save_scene_atomic(scenes.join("night.rscene"), &SceneDocument::new("Night")).unwrap(); + document.set_start_scene(Path::new("night.rscene")).unwrap(); + let selected = document.add_folder(None).unwrap(); + document + .rename_scene(Path::new("night.rscene"), "Moon.rscene") + .unwrap(); + assert_eq!(document.selected(), Some(selected)); + assert!(document.is_modified()); + assert_eq!(document.scene_path(), scenes.join("main.scene")); + assert_eq!( + Project::open(document.project.root()) + .unwrap() + .start_scene_path() + .unwrap(), + scenes.join("Moon.rscene") + ); + } + + #[test] + fn invalid_rename_collision_and_read_only_actions_preserve_scene_and_configuration() { + let (_temp, mut document) = fixture(); + let source = document.scene_path().to_path_buf(); + let bytes = std::fs::read(&source).unwrap(); + let descriptor = + std::fs::read(document.project.root().join(PROJECT_DESCRIPTOR_FILE)).unwrap(); + save_scene_atomic( + source.with_file_name("occupied.scene"), + &SceneDocument::new("Occupied"), + ) + .unwrap(); + std::fs::write(source.with_file_name("recovery.scene.bak"), "backup").unwrap(); + std::fs::write(source.with_file_name("asset.scene.rmeta"), "metadata").unwrap(); + for name in [ + "", + "../escape", + "bad/name", + "occupied.scene", + "recovery.scene", + "asset.scene", + ".scene", + "main.rscene", + ] { + assert!( + document + .rename_scene(Path::new("main.scene"), name) + .is_err(), + "accepted {name}" + ); + } + assert!( + document + .set_start_scene(Path::new("missing.scene")) + .is_err() + ); + assert!( + document + .set_start_scene(Path::new("../main.scene")) + .is_err() + ); + document.read_only = true; + assert!( + document + .rename_scene(Path::new("main.scene"), "Renamed") + .is_err() + ); + assert!(document.set_start_scene(Path::new("main.scene")).is_err()); + assert_eq!(std::fs::read(source).unwrap(), bytes); + assert_eq!( + std::fs::read(document.project.root().join(PROJECT_DESCRIPTOR_FILE)).unwrap(), + descriptor + ); + } + + #[test] + fn failed_start_reference_save_rolls_back_renamed_files_and_keeps_active_path() { + let (_temp, mut document) = fixture(); + let source = document.scene_path().to_path_buf(); + let descriptor_path = document.project.root().join(PROJECT_DESCRIPTOR_FILE); + let descriptor = std::fs::read(&descriptor_path).unwrap(); + std::fs::create_dir(AtomicFileService::backup_path(&descriptor_path)).unwrap(); + let selected = document.add_folder(None).unwrap(); + assert!( + document + .rename_scene(Path::new("main.scene"), "Renamed") + .is_err() + ); + assert_eq!(document.scene_path(), source); + assert!(source.is_file()); + assert!(!source.with_file_name("Renamed.scene").exists()); + assert!(document.world().contains(selected)); + assert_eq!(document.project.descriptor().start_scene, None); + assert_eq!(std::fs::read(descriptor_path).unwrap(), descriptor); + } + + #[test] + fn missing_explicit_start_scene_does_not_silently_create_an_empty_scene() { + let (_temp, mut document) = fixture(); + document.set_start_scene(Path::new("main.scene")).unwrap(); + std::fs::remove_file(document.scene_path()).unwrap(); + assert!(AuthoringDocument::open(document.project.clone(), false).is_err()); + } +} diff --git a/Engine/crates/engine-project/src/lib.rs b/Engine/crates/engine-project/src/lib.rs index d25341ad..6acdd074 100644 --- a/Engine/crates/engine-project/src/lib.rs +++ b/Engine/crates/engine-project/src/lib.rs @@ -48,6 +48,8 @@ pub enum ProjectError { UnsupportedFormat { found: u32, expected: u32 }, #[error("invalid project name: {0}")] InvalidName(String), + #[error("start scene must be a .scene or .rscene path relative to the scenes folder: {0}")] + InvalidStartScene(PathBuf), #[error("unsafe path for virtual directory `{directory}`: {path}")] UnsafeVirtualPath { directory: VirtualDirectory, @@ -311,6 +313,9 @@ pub struct ProjectDescriptor { pub metadata: ProjectMetadata, #[serde(default)] pub directories: VirtualDirectoryMap, + /// Scene opened when the project starts, relative to the configured scenes folder. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_scene: Option, } impl ProjectDescriptor { @@ -328,6 +333,7 @@ impl ProjectDescriptor { template, metadata, directories, + start_scene: None, } } @@ -345,6 +351,9 @@ impl ProjectDescriptor { }); } validate_name(&self.metadata.name)?; + if let Some(path) = &self.start_scene { + validate_scene_path(path)?; + } for (directory, path) in self.directories.iter() { validate_relative_path(directory, path)?; } @@ -543,6 +552,37 @@ impl Project { self.descriptor.project_id } + /// Resolves the configured start scene, or the legacy main-scene default. + /// # Errors + /// Returns an error for invalid scene or virtual-directory paths. + pub fn start_scene_path(&self) -> Result { + let scenes = self.directory(VirtualDirectory::Scenes)?; + if let Some(path) = &self.descriptor.start_scene { + validate_scene_path(path)?; + return Ok(scenes.join(path)); + } + let preferred = scenes.join("main.scene"); + let legacy = scenes.join("main.rscene"); + Ok(if preferred.is_file() || !legacy.is_file() { + preferred + } else { + legacy + }) + } + + /// Persists a start-scene path relative to the configured scenes folder. + /// The authoring layer validates the scene's contents before calling this method. + /// # Errors + /// Returns an error for unsafe paths or failed descriptor persistence; memory is unchanged. + pub fn set_start_scene(&mut self, path: impl AsRef) -> Result<(), ProjectError> { + validate_scene_path(path.as_ref())?; + let mut updated = self.clone(); + updated.descriptor.start_scene = Some(path.as_ref().to_path_buf()); + updated.save()?; + *self = updated; + Ok(()) + } + /// Resolve a virtual directory after validating its configured relative path. /// /// # Errors @@ -597,6 +637,23 @@ impl Project { } } +fn validate_scene_path(path: &Path) -> Result<(), ProjectError> { + if path.as_os_str().is_empty() + || path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || !path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("scene") || value.eq_ignore_ascii_case("rscene") + }) + { + return Err(ProjectError::InvalidStartScene(path.to_path_buf())); + } + Ok(()) +} + /// Convenience wrapper around [`Project::create`]. /// /// # Errors diff --git a/Engine/crates/engine-project/src/template.rs b/Engine/crates/engine-project/src/template.rs index 23b8ce8f..f39ad602 100644 --- a/Engine/crates/engine-project/src/template.rs +++ b/Engine/crates/engine-project/src/template.rs @@ -16,7 +16,8 @@ use std::path::{Path, PathBuf}; pub const CURRENT_TEMPLATE_MANIFEST_VERSION: u32 = 1; pub const TEMPLATE_MANIFEST_FILE: &str = "project-template.ron"; -/// Versioned record of the materialized project seed and startup scene. +/// Versioned record of the materialized project seed and its initial scene. +/// The current start-scene choice is stored in `ProjectDescriptor::start_scene`. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct TemplateManifest { pub format_version: u32, diff --git a/Engine/crates/engine-project/tests/project.rs b/Engine/crates/engine-project/tests/project.rs index 44dfb432..b466d1a8 100644 --- a/Engine/crates/engine-project/tests/project.rs +++ b/Engine/crates/engine-project/tests/project.rs @@ -5,6 +5,36 @@ use engine_project::{ use std::fs; use tempfile::tempdir; +#[test] +fn start_scene_is_optional_backward_compatible_and_uses_custom_scene_directory() { + let temp = tempdir().unwrap(); + let mut options = CreateProjectOptions::new("Start", ProjectTemplate::Blank); + options.directories.scenes = "levels/worlds".into(); + let mut project = Project::create_with_options(temp.path().join("game"), options).unwrap(); + let descriptor = fs::read_to_string(project.root().join(PROJECT_DESCRIPTOR_FILE)).unwrap(); + assert!(!descriptor.contains("start_scene")); + assert_eq!( + Project::open(project.root()) + .unwrap() + .descriptor() + .start_scene, + None + ); + project.set_start_scene("nested/night.rscene").unwrap(); + let reopened = Project::open(project.root()).unwrap(); + assert_eq!( + reopened.start_scene_path().unwrap(), + project.root().join("levels/worlds/nested/night.rscene") + ); + for path in ["../outside.scene", "/outside.scene", "night.txt", ""] { + assert!(project.set_start_scene(path).is_err()); + assert_eq!( + project.descriptor().start_scene.as_deref(), + Some(std::path::Path::new("nested/night.rscene")) + ); + } +} + #[test] fn create_open_and_save_preserve_identity() { let temporary = tempdir().unwrap(); diff --git a/Engine/docs/ARCHITECTURE.md b/Engine/docs/ARCHITECTURE.md index bdfb87e7..787d7d34 100644 --- a/Engine/docs/ARCHITECTURE.md +++ b/Engine/docs/ARCHITECTURE.md @@ -498,8 +498,15 @@ engine resources. A descriptor matching the initial `engine-project` schema is: ) ``` -Startup scene, rendering defaults, input maps, logging defaults, and other evolving -settings are versioned resources below `config/`, referenced by asset/resource ID as +The optional `start_scene` field in `project.engine` stores the selected start scene +as a path relative to the configured scenes folder. The editor persists it through +**Change scene → right-click a scene → Set as start scene** and follows it on the +next project open. Without this field, the editor uses `main.scene`, with +`main.rscene` as the legacy fallback. The template manifest records the initial +project seed and does not override this choice. + +Rendering defaults, input maps, logging defaults, and other evolving settings are +versioned resources below `config/`, referenced by asset/resource ID as their schemas land; they are not added as an unversioned catch-all table. Machine overrides, trust decisions, detected toolchains, and hardware scores do not belong in the source-controlled descriptor. They live below `.rustic/local/` or the user's engine diff --git a/Engine/docs/SCENE_ENVIRONMENT.md b/Engine/docs/SCENE_ENVIRONMENT.md index 69f91a28..556048c8 100644 --- a/Engine/docs/SCENE_ENVIRONMENT.md +++ b/Engine/docs/SCENE_ENVIRONMENT.md @@ -9,7 +9,16 @@ Each scene can save its own sky image, ambient light, sun light and haze. The se 3. The editor saves pending changes to the scene you are leaving, even when autosave is off. It then replaces the hierarchy and viewport with the selected scene's objects, cameras, lights, script attachments and environment. Selection and undo history start fresh. Check Console for `Loaded scene`. 4. Edit **Scene sky & atmosphere** in Inspector and save. Switch back and forth to verify that each scene retains its own sky, ambient light, sun and haze settings. Play, Play Solo and Run use the scene currently selected in the editor. -Stop play and resolve **Review runtime changes** before switching; **Change scene** is disabled until then. Read-only projects can switch scenes for inspection, but cannot save edits. If loading the target or saving local edits fails, the current scene stays open with its edits intact; inspect Console for `Could not change scene` and fix the reported path, scene data or write-permission issue. Scene files outside the configured scenes folder are not listed. Reopening the project still starts with `main.scene` (or legacy `main.rscene`); the picker does not change the project's startup scene. +Stop play and resolve **Review runtime changes** before switching; **Change scene** is disabled until then. Read-only projects can switch scenes for inspection, but cannot save edits. If loading the target or saving local edits fails, the current scene stays open with its edits intact; inspect Console for `Could not change scene` and fix the reported path, scene data or write-permission issue. Scene files outside the configured scenes folder are not listed. Reopening the project loads its saved start scene. Projects without an explicit choice keep using `main.scene` (or legacy `main.rscene`). + +## Rename a scene or set the start scene + +1. Open **Project → settings gear → Change scene**, then right-click a scene's filename (or two-finger click on a trackpad). This opens a second menu with **Rename** and **Set as start scene**. Right-clicking does not load the scene. +2. Choose **Rename**, enter a new filename, then click **Rename** or press **Enter**. You may omit the extension; the original `.scene` or `.rscene` extension is retained. **Cancel** or **Escape** leaves it unchanged. Names cannot contain directory separators or overwrite another scene or its recovery/metadata files. Errors stay visible in the dialog so you can correct the name. +3. Renaming the active scene saves pending edits first and keeps its selection and undo history. Later saves use the new filename. Recovery backups and asset metadata move with the file. Renaming a different scene leaves your current edits untouched. If the renamed scene was the start scene, its saved reference follows the rename automatically, including the default main scene. +4. Choose **Set as start scene** to save that scene as the project default. The list marks it **Start**. This choice does not switch the scene you are editing; close and reopen the project to verify that its objects and environment load first. Play, Play Solo and Run continue to use the currently edited scene. + +Both actions require writable access and are unavailable during play or runtime-change review. The start choice is stored in `project.engine` as `start_scene: Some("night.scene")`, relative to the configured scenes folder. If a start scene is removed outside the editor, restore it at that path or correct the field in `project.engine`; removing the optional `start_scene` field restores the main-scene default. The editor reports a missing explicit start scene instead of silently creating an empty replacement. File renames are filesystem operations and are not reverted by scene Undo. ## Set up a sky