//! Project navigation, dirty state and equipment records. //! //! The screen starts small. A project opens on Bodies. Equipment is one //! step down from Body. Chosen files are copied into the project so a later //! save stays inside the directory. use std::fs; use std::path::{Path, PathBuf}; use crate::equipment::Preset; use serde::{Deserialize, Serialize}; use crate::project::{ create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile, MANIFEST_NAME, }; const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb"); const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb"); pub const MALE_ASSET: &str = "assets/body/test-body-male.glb"; pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb"; pub const START_WINDOW: [f32; 2] = [480.0, 280.0]; pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Sex { Male, Female, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Start, Bodies, Body(Sex), EquipmentList, Equipment(usize), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Intent { Quit, PickNew, PickLoad, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Choice { Save, Discard, Cancel, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AfterChoice { Stay, SaveThen(Intent), Go(Intent), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct EquipmentFile { name: String, model: Option, } #[derive(Debug, Clone, PartialEq)] pub struct EquipmentItem { pub slug: String, pub model: Option, pub legacy_path: Option, pub preset: Option, pub error: Option, } #[derive(Debug, Clone, PartialEq)] pub struct Session { pub screen: Screen, pub active_body: Option, pub opened: Option, pub saved_document: Option, pub equipment: Vec, pub saved_equipment: Vec, pub confirm: Option, pub notice: String, pub notice_ok: bool, } impl Session { pub fn new() -> Self { Self { screen: Screen::Start, active_body: None, opened: None, saved_document: None, equipment: Vec::new(), saved_equipment: Vec::new(), confirm: None, notice: "Laad een project of maak een nieuwe projectmap.".to_string(), notice_ok: true, } } pub fn shell_size(&self) -> [f32; 2] { match self.screen { Screen::Start => START_WINDOW, _ => PROJECT_WINDOW, } } pub fn is_dirty(&self) -> bool { self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref() || self.equipment != self.saved_equipment } pub fn gate(&mut self, intent: Intent) -> bool { if self.is_dirty() { self.confirm = Some(intent); false } else { true } } pub fn resolve(&mut self, choice: Choice) -> AfterChoice { let Some(intent) = self.confirm.take() else { return AfterChoice::Stay; }; match choice { Choice::Cancel => { self.confirm = None; AfterChoice::Stay } Choice::Discard => AfterChoice::Go(intent), Choice::Save => { self.confirm = Some(intent); AfterChoice::SaveThen(intent) } } } pub fn note_saved(&mut self) { if let Some(opened) = &self.opened { self.saved_document = Some(opened.document.clone()); } self.saved_equipment = self.equipment.clone(); self.confirm = None; } pub fn show_bodies(&mut self) { if self.opened.is_some() { self.screen = Screen::Bodies; } } pub fn show_body(&mut self, sex: Sex) { if self.opened.is_some() { self.active_body = Some(sex); self.screen = Screen::Body(sex); } } pub fn show_equipment_list(&mut self) { if self.opened.is_some() { self.screen = Screen::EquipmentList; } } pub fn show_equipment(&mut self, index: usize) { if index < self.equipment.len() { self.screen = Screen::Equipment(index); } } pub fn back(&mut self) { self.screen = match self.screen { Screen::Body(_) => Screen::Bodies, Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies), Screen::Equipment(_) => Screen::EquipmentList, other => other, }; } pub fn set_name(&mut self, name: String) { if let Some(opened) = &mut self.opened { opened.document.name = name; } } pub fn set_render(&mut self, width: u32, height: u32, fps: u32) { if let Some(opened) = &mut self.opened { opened.document.render.target_width = width; opened.document.render.target_height = height; opened.document.render.fps = fps; } } pub fn body_path(&self, sex: Sex) -> Option<&str> { let document = &self.opened.as_ref()?.document; match sex { Sex::Male => document.male_model.as_deref(), Sex::Female => document.female_model.as_deref(), } } pub fn set_body_path(&mut self, sex: Sex, relative: String) { let Some(opened) = &mut self.opened else { return; }; let slot = match sex { Sex::Male => &mut opened.document.male_model, Sex::Female => &mut opened.document.female_model, }; *slot = Some(relative); } pub fn add_equipment(&mut self) -> usize { let slug = next_slug(&self.equipment); self.equipment.push(EquipmentItem { preset: Some(Preset::new(slug.clone())), error: None, slug, model: None, legacy_path: None, }); let index = self.equipment.len() - 1; self.screen = Screen::Equipment(index); index } pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> { let slug = slug.trim().to_string(); if !valid_slug(&slug) { return Err( "de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'" .to_string(), ); } if self .equipment .iter() .enumerate() .any(|(other, item)| other != index && item.slug == slug) { return Err("die equipmentnaam bestaat al".to_string()); } if let Some(item) = self.equipment.get_mut(index) { item.slug = slug; item.legacy_path = None; } Ok(()) } pub fn set_equipment_model(&mut self, index: usize, relative: String) { if let Some(item) = self.equipment.get_mut(index) { item.model = Some(relative); item.legacy_path = None; item.preset = None; item.error = None; } } pub fn delete_equipment(&mut self, index: usize) { if index < self.equipment.len() { self.equipment.remove(index); self.screen = Screen::EquipmentList; } } pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> { let opened = create_seeded_project(root)?; self.install_opened(opened); self.ok("Projectmap aangemaakt, met de testlichamen in assets/body."); Ok(()) } pub fn install_loaded(&mut self, opened: OpenedProject) { let root = opened .manifest_path .parent() .map(Path::to_path_buf) .unwrap_or_default(); let equipment = read_equipment(&root, &opened.document.equipment_presets); self.equipment = equipment.clone(); self.saved_equipment = equipment; self.saved_document = Some(opened.document.clone()); self.opened = Some(opened); self.screen = Screen::Bodies; self.active_body = None; self.confirm = None; self.ok("Project geopend."); } fn install_opened(&mut self, opened: OpenedProject) { self.equipment.clear(); self.saved_equipment.clear(); self.saved_document = Some(opened.document.clone()); self.opened = Some(opened); self.screen = Screen::Bodies; self.active_body = None; self.confirm = None; } pub fn save(&mut self) -> Result<(), ProjectError> { let Some(opened) = self.opened.clone() else { return Err(ProjectError::Validation(vec![ "er is geen project om op te slaan".to_string(), ])); }; let root = opened .manifest_path .parent() .ok_or_else(|| ProjectError::Io { path: opened.manifest_path.display().to_string(), message: "het .ppd-bestand heeft geen projectmap".to_string(), })?; opened.document.validate_shape()?; let presets = write_equipment(root, &self.equipment) .map_err(|message| ProjectError::Validation(vec![message]))?; let mut document = opened.document.clone(); document.equipment_presets = presets; document.validate_shape()?; let candidate = OpenedProject { manifest_path: opened.manifest_path.clone(), document, disk_issues: Vec::new(), }; save_project(&candidate)?; for item in &mut self.equipment { if let Some(preset) = &mut item.preset { preset.name = item.slug.clone(); } } self.opened = Some(candidate); self.note_saved(); self.ok("Opgeslagen."); Ok(()) } pub fn project_root(&self) -> Option { self.opened .as_ref()? .manifest_path .parent() .map(PathBuf::from) } fn ok(&mut self, message: &str) { self.notice = message.to_string(); self.notice_ok = true; } pub fn fail(&mut self, message: impl Into) { self.notice = message.into(); self.notice_ok = false; } } impl Default for Session { fn default() -> Self { Self::new() } } pub fn create_seeded_project(root: &Path) -> Result { let mut opened = create_project(root, "Nieuw project")?; for (relative, bytes) in [ (MALE_ASSET, MALE_GLB), (FEMALE_ASSET, FEMALE_GLB), ( "assets/body/test-bodies-LICENSE.txt", include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(), ), ] { let path = checked_project_path(root, relative) .map_err(|error| ProjectError::Validation(vec![error]))?; if !path.exists() { write_bytes(&path, bytes)?; } } opened.document.male_model = Some(MALE_ASSET.to_string()); opened.document.female_model = Some(FEMALE_ASSET.to_string()); save_project(&opened)?; Ok(opened) } pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result { if !source.is_file() { return Err("kies een bestand".to_string()); } let name = source .file_name() .and_then(|name| name.to_str()) .filter(|name| { !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0') }) .ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?; if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) { if let Ok(inside) = source_full.strip_prefix(&root_full) { let relative = inside.to_string_lossy().replace('\\', "/"); portable_relative(&relative)?; return Ok(relative); } } let mut relative = format!("{folder}/{name}"); let stem = Path::new(name).file_stem().unwrap().to_string_lossy(); let ext = Path::new(name) .extension() .map(|x| format!(".{}", x.to_string_lossy())) .unwrap_or_default(); let mut n = 2; while root.join(&relative).exists() { relative = format!("{folder}/{stem}-{n}{ext}"); n += 1; } portable_relative(&relative)?; let dest = checked_project_path(root, &relative)?; if let Some(parent) = dest.parent() { fs::create_dir_all(parent).map_err(|err| err.to_string())?; } fs::copy(source, &dest).map_err(|err| err.to_string())?; Ok(relative) } fn checked_project_path(root: &Path, relative: &str) -> Result { portable_relative(relative)?; let root_full = root.canonicalize().map_err(|e| e.to_string())?; let path = root.join(relative); let mut ancestor = path.as_path(); while std::fs::symlink_metadata(ancestor).is_err() { ancestor = ancestor.parent().ok_or("ongeldig projectpad")?; } let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?; if !canonical.starts_with(&root_full) { return Err("het bestand ligt buiten de projectmap".into()); } Ok(path) } fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?; } fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string())) } fn io_at(path: &Path, message: &str) -> ProjectError { ProjectError::Io { path: path.display().to_string(), message: message.to_string(), } } fn read_equipment(root: &Path, presets: &[String]) -> Vec { presets .iter() .map(|path| { let file = match checked_project_path(root, path) { Ok(path) => path, Err(_) => { return EquipmentItem { preset: None, error: Some("onveilig presetpad".into()), slug: "onveilig".into(), model: None, legacy_path: Some(path.clone()), } } }; if file.extension().and_then(|e| e.to_str()) == Some("ppe") { return match Preset::read(&file) { Ok(preset) => EquipmentItem { slug: preset.name.clone(), model: preset.attachments.first().map(|a| a.label.clone()), preset: Some(preset), error: None, legacy_path: None, }, Err(error) => EquipmentItem { slug: file .file_stem() .unwrap_or_default() .to_string_lossy() .into(), model: None, preset: None, error: Some(error), legacy_path: Some(path.clone()), }, }; } if let Ok(text) = fs::read_to_string(&file) { if let Ok(parsed) = serde_json::from_str::(&text) { if valid_slug(&parsed.name) { return EquipmentItem { preset: None, error: None, slug: parsed.name, model: parsed.model, legacy_path: None, }; } } } let slug = path .rsplit('/') .next() .unwrap_or(path) .split('.') .next() .unwrap_or("item") .to_string(); EquipmentItem { preset: None, error: Some("legacy preset; kies een OBJ om te configureren".into()), slug, model: None, legacy_path: Some(path.clone()), } }) .collect() } fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result, String> { let mut paths = Vec::new(); let mut names = std::collections::BTreeSet::new(); // Validate every record before writing. Config files are immutable snapshots: // a failed manifest save leaves its previous equipment bytes intact. for item in items { if !names.insert(&item.slug) { return Err("dubbele equipmentnaam".into()); } if item.legacy_path.is_some() && item.model.is_none() { continue; } if !valid_slug(&item.slug) { return Err(format!("equipment '{}' heeft geen geldige naam", item.slug)); } if let Some(preset) = &item.preset { let mut preset = preset.clone(); preset.name = item.slug.clone(); preset.validate()?; } else if let Some(model) = &item.model { portable_relative(model)?; } } for item in items { if let Some(preset) = &item.preset { let mut preset = preset.clone(); preset.name = item.slug.clone(); let bytes = preset.encode()?; let relative = format!( "presets/{}-{:08x}-{}.ppe", item.slug, crate::equipment::checksum(&bytes), bytes.len() ); let path = checked_project_path(root, &relative)?; fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; crate::equipment::write_snapshot(&path, &bytes)?; paths.push(relative); continue; } if item.model.is_none() { if let Some(legacy) = &item.legacy_path { paths.push(legacy.clone()); continue; } } let file = EquipmentFile { name: item.slug.clone(), model: item.model.clone(), }; let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?; let mut number = 1; loop { let relative = format!("presets/{}-{number}.json", item.slug); let path = checked_project_path(root, &relative)?; if let Ok(existing) = fs::read_to_string(&path) { if existing == text { paths.push(relative); break; } } else if !path.exists() { fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?; use std::io::Write; let mut out = fs::OpenOptions::new() .write(true) .create_new(true) .open(&path) .map_err(|err| err.to_string())?; out.write_all(text.as_bytes()) .map_err(|err| err.to_string())?; out.sync_all().map_err(|err| err.to_string())?; paths.push(relative); break; } number += 1; } } Ok(paths) } fn next_slug(items: &[EquipmentItem]) -> String { let mut number = 1u32; loop { let slug = if number == 1 { "nieuw".to_string() } else { format!("nieuw-{number}") }; if !items.iter().any(|item| item.slug == slug) { return slug; } number += 1; } } fn valid_slug(slug: &str) -> bool { !slug.is_empty() && slug != "body" && slug .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_') } pub fn manifest_in(root: &Path) -> PathBuf { root.join(MANIFEST_NAME) } #[cfg(test)] mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}")); let _ = fs::remove_dir_all(&path); path } #[test] fn new_project_copies_both_test_bodies_and_can_be_overwritten() { let root = scratch(); let mut session = Session::new(); session.install_new_project(&root).unwrap(); assert_eq!(session.screen, Screen::Bodies); assert!(!session.is_dirty()); assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB); assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB); let outside = root .parent() .unwrap() .join(format!("pd-src-{}", std::process::id())); let _ = fs::remove_dir_all(&outside); fs::create_dir_all(&outside).unwrap(); let source = outside.join("test-body-male.glb"); fs::write(&source, b"replacement-body").unwrap(); let relative = adopt_file(&root, &source, "assets/body").unwrap(); assert_ne!(relative, MALE_ASSET); assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body"); assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB); let extra = outside.join("extra.glb"); fs::write(&extra, b"extra-body").unwrap(); let relative = adopt_file(&root, &extra, "assets/body").unwrap(); session.set_body_path(Sex::Male, relative); assert!(session.is_dirty()); session.save().unwrap(); assert!(!session.is_dirty()); assert_eq!( fs::read(root.join("assets/body/extra.glb")).unwrap(), b"extra-body" ); let _ = fs::remove_dir_all(&outside); let _ = fs::remove_dir_all(&root); } #[test] fn navigation_and_equipment_crud_roundtrip() { let root = scratch(); let mut session = Session::new(); session.show_body(Sex::Male); assert_eq!(session.screen, Screen::Start); session.install_new_project(&root).unwrap(); session.show_body(Sex::Female); assert_eq!(session.screen, Screen::Body(Sex::Female)); session.show_equipment_list(); assert_eq!(session.screen, Screen::EquipmentList); let index = session.add_equipment(); assert_eq!(index, 0); session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string()); session.save().unwrap(); let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap(); assert!(text.contains("zwaard.glb")); let mut again = Session::new(); let opened = crate::project::open_project(&manifest_in(&root)).unwrap(); again.install_loaded(opened); assert_eq!( again.equipment[0].model.as_deref(), Some("assets/equipment/zwaard.glb") ); again.delete_equipment(0); again.save().unwrap(); assert!(again .opened .as_ref() .unwrap() .document .equipment_presets .is_empty()); // Old immutable record stays recoverable if an earlier save is restored. assert!(root.join("presets/nieuw-1.json").exists()); let _ = fs::remove_dir_all(&root); } #[test] fn failed_save_preserves_previous_equipment_and_manifest_bytes() { let root = scratch(); let mut session = Session::new(); session.install_new_project(&root).unwrap(); session.add_equipment(); session.save().unwrap(); let manifest = fs::read(manifest_in(&root)).unwrap(); let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone(); let preset = fs::read(root.join(&relative)).unwrap(); session.equipment[0].slug = "changed".into(); // Missing body fails after immutable equipment snapshots are written. session.set_body_path(Sex::Male, "assets/body/missing.glb".into()); assert!(session.save().is_err()); assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest); assert_eq!(fs::read(root.join(&relative)).unwrap(), preset); assert!(session.is_dirty()); let _ = fs::remove_dir_all(root); } #[test] fn dirty_confirm_save_discard_and_cancel_keep_bytes() { let root = scratch(); let mut session = Session::new(); session.install_new_project(&root).unwrap(); session.set_name("Gewijzigd".to_string()); assert!(session.is_dirty()); assert!(!session.gate(Intent::Quit)); assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay); assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd"); assert!(!session.gate(Intent::PickLoad)); assert_eq!( session.resolve(Choice::Discard), AfterChoice::Go(Intent::PickLoad) ); assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd"); assert!(session.is_dirty()); // canceled file picker must not discard the old project session.set_name("Bewaard".to_string()); assert!(!session.gate(Intent::PickNew)); assert_eq!( session.resolve(Choice::Save), AfterChoice::SaveThen(Intent::PickNew) ); session.save().unwrap(); assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard"); let opened = crate::project::open_project(&manifest_in(&root)).unwrap(); assert_eq!(opened.document.name, "Bewaard"); let _ = fs::remove_dir_all(&root); } }