diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index c0c34348..bc2f1bb3 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -453,7 +453,13 @@ impl EditorApp { let selected = self.document.selected(); let project = self.document.project().clone(); let read_only = self.project_lock.is_none(); - match AuthoringDocument::open(project, read_only) { + let Ok(scenes) = project.directory(VirtualDirectory::Scenes) else { + return; + }; + let Ok(relative_path) = path.strip_prefix(&scenes) else { + return; + }; + match AuthoringDocument::open_scene(project, relative_path, read_only) { Ok(mut document) => { document.select(selected); self.document = document; @@ -495,6 +501,36 @@ impl EditorApp { } } + fn change_scene(&mut self, relative_path: &Path, context: &egui::Context) { + if self.play.is_some() || self.pending_runtime_changes.is_some() { + return; + } + match self.document.switch_scene(relative_path) { + Ok(()) => { + 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.last_autosave = Some(Instant::now()); + self.gizmo_drag = None; + context.data_mut(|data| { + data.remove::(egui::Id::new("scene-clipboard")); + }); + self.preview = None; + self.refresh_preview(); + self.log( + Severity::Info, + "scene", + format!("Loaded scene {}", relative_path.display()), + ); + } + Err(error) => self.log( + Severity::Error, + "scene", + format!("Could not change scene: {error}"), + ), + } + } + fn control_play(&mut self, request: ControlRequest) { let result = self .play @@ -1393,6 +1429,7 @@ impl eframe::App for EditorApp { preview_status: &self.preview_status, request_preview: false, playing: self.play.is_some(), + can_switch_scene: self.play.is_none() && self.pending_runtime_changes.is_none(), play: self.play.as_mut(), play_keys: BTreeSet::new(), camera: &mut self.camera, @@ -1407,6 +1444,7 @@ impl eframe::App for EditorApp { 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); } @@ -1445,6 +1483,7 @@ struct EditorViewer<'a> { preview_status: &'a str, request_preview: bool, playing: bool, + can_switch_scene: bool, play: Option<&'a mut EditorPlaySession>, play_keys: BTreeSet, camera: &'a mut EditorCamera, @@ -2179,7 +2218,14 @@ impl EditorViewer<'_> { reason = "the project explorer keeps its compact tree and toolbar interaction together" )] fn project_explorer(&mut self, ui: &mut egui::Ui) { - ui.heading(egui::RichText::new("Project").color(ui_theme::TEXT)); + ui.horizontal(|ui| { + ui.heading(egui::RichText::new("Project").color(ui_theme::TEXT)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.menu_button("⚙", |ui| self.project_settings(ui)) + .response + .on_hover_text("Project settings"); + }); + }); ui.add_space(6.0); ui.horizontal(|ui| { ui.selectable_value(self.project_panel_mode, ProjectPanelMode::Scene, "Scene"); @@ -2197,6 +2243,54 @@ impl EditorViewer<'_> { } } + fn project_settings(&mut self, ui: &mut egui::Ui) { + ui.add_enabled_ui(self.can_switch_scene, |ui| { + ui.menu_button("Change scene", |ui| { + let Ok(scenes) = self.document.project().directory(VirtualDirectory::Scenes) else { + ui.label("Scenes folder unavailable"); + return; + }; + let relative_scenes = scenes.strip_prefix(self.project_root).unwrap_or(&scenes); + let mut paths = self + .project_tree + .map_or_else(Vec::new, |tree| tree.scene_paths(relative_scenes)); + let active = self + .document + .scene_path() + .strip_prefix(&scenes) + .ok() + .map(Path::to_path_buf); + if let Some(active) = &active { + paths.push(active.clone()); + } + paths.sort(); + paths.dedup(); + egui::ScrollArea::vertical() + .max_height(320.0) + .show(ui, |ui| { + if self.project_tree.is_none() { + ui.label("Scanning project scenes…"); + } + 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(); + } + } + }); + }); + }) + .response + .on_disabled_hover_text("Stop play and review runtime changes before changing scenes"); + } + #[allow( clippy::too_many_lines, reason = "scene tree toolbar and recursive hierarchy share the current selection" @@ -4299,6 +4393,7 @@ struct ProjectFile { #[derive(Debug)] enum ProjectFileAction { + ChangeScene(PathBuf), Refresh, Open(PathBuf), Attach(PathBuf, ScriptScope), @@ -4314,6 +4409,24 @@ enum ProjectFileAction { } impl ProjectDirectory { + fn scene_paths(&self, scenes: &Path) -> Vec { + let mut paths: Vec<_> = self + .files + .iter() + .filter_map(|file| { + let path = file.relative_path.strip_prefix(scenes).ok()?; + let extension = path.extension()?.to_str()?; + (extension.eq_ignore_ascii_case("scene") + || extension.eq_ignore_ascii_case("rscene")) + .then(|| path.to_path_buf()) + }) + .collect(); + for directory in &self.directories { + paths.extend(directory.scene_paths(scenes)); + } + paths + } + fn file_count(&self) -> usize { self.files.len() + self.directories.iter().map(Self::file_count).sum::() } @@ -5591,6 +5704,140 @@ fn edit_light(ui: &mut egui::Ui, light: &mut engine_world::Light) -> bool { mod explorer_tests { use super::*; + #[test] + fn project_settings_gear_opens_scene_picker_and_requests_selected_scene() { + let temporary = TestProject::new(); + let project = Project::create( + temporary.0.join("game"), + "Game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + document.save().unwrap(); + let other = document.scene_path().with_file_name("night.scene"); + engine_world::save_scene_atomic(&other, &engine_world::SceneDocument::new("Night")) + .unwrap(); + let root = document.project().root().to_path_buf(); + let tree = scan_project_directory(&root, &root); + let context = egui::Context::default(); + ui_theme::apply(&context); + let mut action = None; + { + let mut viewer = EditorViewer { + document: &mut document, + project_root: &root, + console: &mut RuntimeConsole::default(), + project_tree: Some(&tree), + project_panel_mode: &mut ProjectPanelMode::Scene, + project_filter: &mut String::new(), + preview: None, + preview_status: "", + request_preview: false, + playing: false, + can_switch_scene: true, + play: None, + play_keys: BTreeSet::new(), + camera: &mut EditorCamera::default(), + viewport_extent: &mut [640, 480], + gizmo: &mut GizmoSettings::default(), + gizmo_drag: &mut None, + model_assets: &mut BTreeMap::new(), + project_file_action: &mut action, + }; + let mut frame = |time, events| { + let mut output = context.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(426.0, 586.0), + )), + time: Some(time), + events, + ..Default::default() + }, + |ui| viewer.project_explorer(ui), + ); + output.textures_delta.clear(); + output + }; + let gear = text_position(&frame(0.0, vec![]), "⚙"); + assert!( + gear.x > 350.0 && gear.y < 40.0, + "gear must sit at the top right: {gear:?}" + ); + frame( + 0.1, + vec![ + egui::Event::PointerMoved(gear), + pointer(gear, egui::PointerButton::Primary, true), + ], + ); + frame( + 0.2, + vec![pointer(gear, egui::PointerButton::Primary, false)], + ); + let change = text_position(&frame(0.3, vec![]), "Change scene"); + frame( + 0.4, + vec![ + egui::Event::PointerMoved(change), + pointer(change, egui::PointerButton::Primary, true), + ], + ); + frame( + 0.5, + vec![pointer(change, egui::PointerButton::Primary, false)], + ); + let night = text_position(&frame(0.6, vec![]), "night.scene"); + frame( + 0.7, + vec![ + egui::Event::PointerMoved(night), + pointer(night, egui::PointerButton::Primary, true), + ], + ); + frame( + 0.8, + vec![pointer(night, egui::PointerButton::Primary, false)], + ); + } + assert!( + matches!(action, Some(ProjectFileAction::ChangeScene(path)) if path == Path::new("night.scene")) + ); + } + + #[test] + fn scene_picker_lists_nested_and_legacy_scenes_in_the_configured_folder() { + let project = TestProject::new(); + for path in [ + "levels/main.scene", + "levels/area/night.scene", + "levels/old.rscene", + "levels/upper.SCENE", + "levels/readme.txt", + "assets/unrelated.scene", + "scene/unused.scene", + ] { + let path = project.0.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + let tree = scan_project_directory(&project.0, &project.0); + let mut paths = tree.scene_paths(Path::new("levels")); + paths.sort(); + assert_eq!( + paths, + [ + "area/night.scene", + "main.scene", + "old.rscene", + "upper.SCENE" + ] + .map(PathBuf::from) + ); + } + struct TestProject(PathBuf); impl TestProject { fn new() -> Self { diff --git a/Engine/crates/engine-editor/src/document.rs b/Engine/crates/engine-editor/src/document.rs index 242616cf..50339e1b 100644 --- a/Engine/crates/engine-editor/src/document.rs +++ b/Engine/crates/engine-editor/src/document.rs @@ -33,12 +33,16 @@ pub enum AuthoringError { #[source] source: std::io::Error, }, - #[error("scene insertion path must stay inside the project scenes folder")] + #[error("scene path must stay inside the project scenes folder")] UnsafeScenePath, } /// One source scene and its centralized, checkpoint-aware edit history. #[derive(Debug)] +#[allow( + clippy::struct_excessive_bools, + reason = "project access, scene recovery, and unsaved script links are independent states" +)] pub struct AuthoringDocument { project: Project, scene_path: PathBuf, @@ -49,6 +53,7 @@ pub struct AuthoringDocument { undo: UndoStack, selected: Option, read_only: bool, + project_read_only: bool, recovered_from_backup: bool, removed_scripts: BTreeSet, script_links_modified: bool, @@ -81,6 +86,96 @@ impl AuthoringDocument { } else { (SceneDocument::new("Main"), false, false) }; + Self::from_scene( + project, + scene_path, + document, + read_only, + recovery_read_only, + recovered_from_backup, + ) + } + + /// Opens an existing `.scene` or legacy `.rscene` relative to the project's scenes folder. + /// Backup recovery and scene environment settings are loaded with the objects. + /// + /// # Errors + /// Returns an error for paths outside the scenes folder, unsupported extensions, + /// missing files, invalid scene data, or failed world construction. + pub fn open_scene( + project: Project, + relative_path: impl AsRef, + read_only: bool, + ) -> Result { + let relative_path = relative_path.as_ref(); + if relative_path.is_absolute() + || relative_path.components().any(|part| { + !matches!( + part, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) + || !relative_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(AuthoringError::UnsafeScenePath); + } + let scenes = project.directory(VirtualDirectory::Scenes)?; + let scene_path = scenes.join(relative_path); + let canonical = |path: &Path| { + path.canonicalize().map_err(|source| AuthoringError::Io { + path: path.to_path_buf(), + source, + }) + }; + if !canonical(&scene_path)?.starts_with(canonical(&scenes)?) { + return Err(AuthoringError::UnsafeScenePath); + } + let loaded = load_scene_recovering(&scene_path)?; + Self::from_scene( + project, + scene_path, + loaded.document, + read_only, + loaded.read_only, + loaded.source == RecoverySource::Backup, + ) + } + + /// Loads another scene, saving local edits before replacing the current document. + /// Selection and undo history belong to the newly loaded scene. + /// + /// # Errors + /// Returns an error without replacing the current document if loading or saving fails. + pub fn switch_scene(&mut self, relative_path: impl AsRef) -> Result<(), AuthoringError> { + let next = Self::open_scene(self.project.clone(), relative_path, self.project_read_only)?; + let same_file = self.scene_path.canonicalize().is_ok_and(|current| { + next.scene_path + .canonicalize() + .is_ok_and(|path| path == current) + }); + if next.scene_path == self.scene_path || same_file { + return Ok(()); + } + if self.is_modified() { + self.save()?; + } + *self = next; + Ok(()) + } + + fn from_scene( + project: Project, + scene_path: PathBuf, + document: SceneDocument, + read_only: bool, + recovery_read_only: bool, + recovered_from_backup: bool, + ) -> Result { let world = document.create_world()?; let mut undo = UndoStack::new(); undo.mark_saved(); @@ -94,6 +189,7 @@ impl AuthoringDocument { undo, selected: None, read_only: read_only || recovery_read_only, + project_read_only: read_only, recovered_from_backup, removed_scripts: BTreeSet::new(), script_links_modified: false, @@ -893,6 +989,201 @@ mod tests { use glam::Vec3; use tempfile::tempdir; + #[test] + fn switching_scenes_saves_edits_and_replaces_objects_environment_and_history() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("switch"), + "Switch", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project.clone(), false).unwrap(); + let scenes = project.directory(VirtualDirectory::Scenes).unwrap(); + std::fs::create_dir_all(scenes.join("levels")).unwrap(); + let mut other = SceneDocument::new("Night"); + other.environment.enabled = true; + other.environment.sky_image = "assets/sky/night.hdr".into(); + other.environment.sun_intensity = 0.4; + other.environment.haze_density = 0.02; + let entity = engine_world::EntitySnapshot { + name: Some("Night object".into()), + camera: Some(engine_world::Camera::default()), + light: Some(engine_world::Light::default()), + scripts: vec![ScriptComponent::new(ScriptId::new())], + ..Default::default() + }; + other.entities.push(entity.clone()); + other + .startup_scripts + .push(ScriptReference::new(ScriptId::new())); + save_scene_atomic(scenes.join("levels/night.scene"), &other).unwrap(); + + let original = document + .add_primitive("Day object", Primitive::Cube { size: 1.0 }, None) + .unwrap(); + let day_environment = engine_world::SceneEnvironment { + enabled: true, + sun_intensity: 2.0, + ..Default::default() + }; + document.set_environment(day_environment.clone()).unwrap(); + let main_path = document.scene_path().to_path_buf(); + document.switch_scene("levels/night.scene").unwrap(); + assert_eq!(document.scene_path(), scenes.join("levels/night.scene")); + assert_eq!(document.entity_snapshots().unwrap(), vec![entity.clone()]); + assert_eq!(document.world().environment(), &other.environment); + assert_eq!(document.scene_startup_scripts(), &other.startup_scripts); + assert_eq!(document.selected(), None); + assert!(!document.is_modified()); + document.undo().unwrap(); + assert_eq!(document.entity_snapshots().unwrap(), vec![entity]); + assert_eq!( + load_scene_recovering(&main_path) + .unwrap() + .document + .environment, + day_environment + ); + + document + .switch_scene(main_path.file_name().unwrap()) + .unwrap(); + assert!(document.world().contains(original)); + assert_eq!(document.world().environment(), &day_environment); + // External reload must reopen the active scene rather than the main scene. + let reopened = AuthoringDocument::open_scene(project, "levels/night.scene", false).unwrap(); + assert_eq!(reopened.world().environment(), &other.environment); + } + + #[test] + fn selecting_the_current_scene_through_an_alias_keeps_unsaved_edits() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("same-scene"), + "Same scene", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + document.save().unwrap(); + let entity = document.add_folder(None).unwrap(); + document.switch_scene("./main.scene").unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + "main.scene", + document.scene_path().with_file_name("alias.scene"), + ) + .unwrap(); + document.switch_scene("alias.scene").unwrap(); + } + #[cfg(windows)] + document.switch_scene("MAIN.scene").unwrap(); + assert_eq!(document.selected(), Some(entity)); + assert!(document.is_modified()); + assert!(document.world().contains(entity)); + } + + #[test] + fn failed_scene_load_keeps_unsaved_edits_and_history() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("failure"), + "Failure", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + document.save().unwrap(); + let entity = document.add_folder(None).unwrap(); + let before = document.snapshot_bytes().unwrap(); + let saved = std::fs::read(document.scene_path()).unwrap(); + let broken = document.scene_path().with_file_name("broken.scene"); + std::fs::write(broken, "invalid scene").unwrap(); + for path in [ + "missing.scene", + "broken.scene", + "../main.scene", + "/outside.scene", + "main.txt", + ] { + assert!(document.switch_scene(path).is_err(), "accepted {path}"); + assert_eq!(document.snapshot_bytes().unwrap(), before); + assert_eq!(document.selected(), Some(entity)); + assert!(document.is_modified()); + assert_eq!(std::fs::read(document.scene_path()).unwrap(), saved); + } + document.undo().unwrap(); + assert!(!document.world().contains(entity)); + } + + #[test] + fn failed_scene_save_does_not_switch() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("save-failure"), + "Failure", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + document.save().unwrap(); + let path = document.scene_path().to_path_buf(); + save_scene_atomic( + path.with_file_name("other.scene"), + &SceneDocument::new("Other"), + ) + .unwrap(); + let entity = document.add_folder(None).unwrap(); + // A file where the destination's parent directory is expected forces + // a persistence failure on both Windows and Linux, even when run as root. + document.scene_path = path.join("blocked.scene"); + let blocked = document.scene_path().to_path_buf(); + assert!(document.switch_scene("other.scene").is_err()); + assert_eq!(document.scene_path(), blocked); + assert_eq!(document.selected(), Some(entity)); + assert!(document.is_modified()); + } + + #[test] + fn read_only_scene_switch_supports_legacy_scenes_and_keeps_project_read_only() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("read-only"), + "Read only", + ProjectTemplate::Blank, + ) + .unwrap(); + let scenes = project.directory(VirtualDirectory::Scenes).unwrap(); + save_scene_atomic(scenes.join("legacy.rscene"), &SceneDocument::new("Legacy")).unwrap(); + let mut document = AuthoringDocument::open(project, true).unwrap(); + document.switch_scene("legacy.rscene").unwrap(); + assert!(document.is_read_only()); + assert!(document.save().is_err()); + assert_eq!(document.scene_path(), scenes.join("legacy.rscene")); + } + + #[cfg(unix)] + #[test] + fn scene_open_rejects_symlinks_outside_scenes() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("symlink"), + "Symlink", + ProjectTemplate::Blank, + ) + .unwrap(); + let outside = temporary.path().join("outside.scene"); + save_scene_atomic(&outside, &SceneDocument::new("Outside")).unwrap(); + let scenes = project.directory(VirtualDirectory::Scenes).unwrap(); + std::os::unix::fs::symlink(outside, scenes.join("link.scene")).unwrap(); + assert!(matches!( + AuthoringDocument::open_scene(project, "link.scene", false), + Err(AuthoringError::UnsafeScenePath) + )); + } + #[test] fn explorer_tree_copy_delete_are_atomic_and_undoable() { let temporary = tempdir().unwrap(); diff --git a/Engine/docs/SCENE_ENVIRONMENT.md b/Engine/docs/SCENE_ENVIRONMENT.md index b61c8eaa..69f91a28 100644 --- a/Engine/docs/SCENE_ENVIRONMENT.md +++ b/Engine/docs/SCENE_ENVIRONMENT.md @@ -2,6 +2,15 @@ Each scene can save its own sky image, ambient light, sun light and haze. The settings affect the 3D editor viewport and the game renderer in Play, Play Solo and Run. They are scene settings, so you do not attach a sky to an entity. Inserted scene instances use the containing scene's environment. +## Change scenes + +1. Keep your scene files in the project's scenes folder: `scene/` for new projects, or the configured `scenes/` folder in older projects. Both `.scene` and legacy `.rscene` files are supported, including files in subfolders. To try a second scene, save the current scene, then copy `scene/main.scene` to `scene/night.scene` using your file manager. +2. In the **Project** panel, click the **settings gear** at the top right, open **Change scene**, and select a scene. The list shows paths relative to the scenes folder and highlights the current scene. New files appear after the background project scan, normally within a few seconds. +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. + ## Set up a sky 1. Copy a **2:1 equirectangular panorama** into your project, for example `assets/sky/sunset.hdr`. Supported formats are Radiance HDR (`.hdr`), PNG, JPEG and TGA. A 4096 × 2048 image is a typical choice; each dimension must be at most 8192 pixels. Six separate cubemap faces, EXR and model files are not supported.