//! Versioned machine-local editor session persistence and crash boundary. use std::path::{Path, PathBuf}; use bevy::app::AppExit; use bevy::prelude::*; use bevy_egui::egui; use serde::{Deserialize, Serialize}; use crate::project_io::{ProjectWorkspace, UserPreferences}; use crate::scene::recovery::{atomic_write, default_state_root}; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::settings_ui::ProjectSettingsPanel; use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel}; use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel; use crate::viewport::CameraBookmarks; const SESSION_FILE: &str = "session.ron"; const SESSION_SCHEMA_VERSION: u32 = 1; const SESSION_WRITE_INTERVAL_SECS: f32 = 2.0; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionPanelState { #[serde(default)] pub diagnostics: bool, #[serde(default)] pub brush_diagnostics: bool, #[serde(default)] pub rendering: bool, #[serde(default)] pub project_settings: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CameraBookmarkRecord { pub scene: String, pub translation: [f32; 3], pub rotation: [f32; 4], pub scale: [f32; 3], } impl CameraBookmarkRecord { fn from_transform(scene: String, transform: Transform) -> Self { Self { scene, translation: transform.translation.to_array(), rotation: transform.rotation.to_array(), scale: transform.scale.to_array(), } } fn to_transform(&self) -> Transform { Transform { translation: Vec3::from_array(self.translation), rotation: Quat::from_array(self.rotation).normalize(), scale: Vec3::from_array(self.scale), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EditorSessionDocument { #[serde(default)] pub schema_version: u32, #[serde(default)] pub clean_shutdown: bool, #[serde(default)] pub project_root: String, #[serde(default)] pub active_scene: Option, #[serde(default)] pub dock_layout: Option, #[serde(default)] pub hierarchy_expanded_paths: Vec, #[serde(default)] pub panels: SessionPanelState, #[serde(default)] pub camera_bookmarks: Vec, } impl Default for EditorSessionDocument { fn default() -> Self { Self { schema_version: SESSION_SCHEMA_VERSION, clean_shutdown: true, project_root: String::new(), active_scene: None, dock_layout: None, hierarchy_expanded_paths: Vec::new(), panels: SessionPanelState::default(), camera_bookmarks: Vec::new(), } } } #[derive(Resource, Debug, Default)] pub struct EditorSessionRuntime { pub prior_abnormal_shutdown: bool, pub resume_scene: Option, pub resume_prompt_open: bool, save_elapsed_secs: f32, } pub struct EditorSessionPlugin; impl Plugin for EditorSessionPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, initialize_session) .add_systems(Update, persist_running_session) .add_systems( Last, persist_clean_session_on_exit.after(bevy::window::ExitSystems), ); } } pub fn session_resume_window(world: &mut World, ctx: &egui::Context) { let (open, scene) = { let runtime = world.resource::(); (runtime.resume_prompt_open, runtime.resume_scene.clone()) }; if !open { return; } let Some(scene) = scene else { world .resource_mut::() .resume_prompt_open = false; return; }; egui::Window::new("Recover Editor Session") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .show(ctx, |ui| { ui.label("The previous editor session did not close cleanly."); ui.small("The safe startup scene is active. Resume only the last authored scene; modal tools and dirty preview state are never restored."); ui.monospace(scene.display().to_string()); ui.separator(); ui.horizontal(|ui| { if ui.button("Resume Last Scene").clicked() { world.resource_mut::().request = Some(SceneIoRequest::OpenPath(scene.clone())); world .resource_mut::() .resume_prompt_open = false; } if ui.button("Continue Safe").clicked() { world .resource_mut::() .resume_prompt_open = false; world .resource_mut::() .set_status("Continued with safe startup after abnormal shutdown"); } }); }); } fn session_path() -> Option { default_state_root().map(|root| root.join(SESSION_FILE)) } fn read_session(path: &Path) -> Result { let text = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; let document: EditorSessionDocument = ron::from_str(&text).map_err(|error| format!("invalid session document: {error}"))?; normalize_session(document) } fn normalize_session(mut document: EditorSessionDocument) -> Result { match document.schema_version { 0 => document.schema_version = SESSION_SCHEMA_VERSION, SESSION_SCHEMA_VERSION => {} version => { return Err(format!( "session schema {version} is newer than supported {SESSION_SCHEMA_VERSION}" )); } } document.camera_bookmarks.retain(valid_bookmark); Ok(document) } fn valid_bookmark(bookmark: &CameraBookmarkRecord) -> bool { bookmark .translation .iter() .chain(bookmark.rotation.iter()) .chain(bookmark.scale.iter()) .all(|value| value.is_finite()) } fn write_session(path: &Path, document: &EditorSessionDocument) -> Result<(), String> { let text = ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default()) .map_err(|error| format!("could not serialize session: {error}"))?; atomic_write(path, text.as_bytes()) } fn initialize_session(world: &mut World) { let previous = session_path().and_then(|path| read_session(&path).ok()); let prior_abnormal = previous .as_ref() .is_some_and(|document| !document.clean_shutdown); if let Some(document) = previous.as_ref() { restore_non_destructive_session_state(world, document); if !prior_abnormal { restore_active_scene(world, document); } } let resume_scene = previous .as_ref() .and_then(|document| document.active_scene.as_deref()) .map(PathBuf::from) .filter(|path| path.exists()); world.insert_resource(EditorSessionRuntime { prior_abnormal_shutdown: prior_abnormal, resume_prompt_open: prior_abnormal && resume_scene.is_some(), resume_scene, save_elapsed_secs: 0.0, }); if let Some(path) = session_path() { let document = capture_session(world, false); if let Err(error) = write_session(&path, &document) { warn!("Failed to write running session marker: {error}"); } } } fn restore_active_scene(world: &mut World, document: &EditorSessionDocument) { let Some(path) = document.active_scene.as_deref().map(PathBuf::from) else { return; }; if path.exists() { world.resource_mut::().active_path = Some(path); } } fn restore_non_destructive_session_state(world: &mut World, document: &EditorSessionDocument) { let poses = document .camera_bookmarks .iter() .map(|bookmark| (bookmark.scene.clone(), bookmark.to_transform())) .collect(); world.resource_mut::().poses = poses; world.resource_mut::().open = document.panels.diagnostics; world.resource_mut::().open = document.panels.brush_diagnostics; world.resource_mut::().open = document.panels.rendering; world.resource_mut::().open = document.panels.project_settings; } fn capture_session(world: &World, clean_shutdown: bool) -> EditorSessionDocument { let prefs = world.resource::(); EditorSessionDocument { schema_version: SESSION_SCHEMA_VERSION, clean_shutdown, project_root: world.resource::().root.clone(), active_scene: world .resource::() .active_path .as_ref() .map(|path| path.display().to_string()), dock_layout: prefs.dock_layout.clone(), hierarchy_expanded_paths: prefs.hierarchy_expanded_paths.clone(), panels: SessionPanelState { diagnostics: world.resource::().open, brush_diagnostics: world.resource::().open, rendering: world.resource::().open, project_settings: world.resource::().open, }, camera_bookmarks: world .resource::() .poses .iter() .map(|(scene, transform)| { CameraBookmarkRecord::from_transform(scene.clone(), *transform) }) .collect(), } } fn persist_running_session(world: &mut World) { let delta = world.resource::