//! 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), Preview2d, } #[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)] #[serde(deny_unknown_fields)] 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 show_preview2d(&mut self) { if self.opened.is_some() { self.screen = Screen::Preview2d; } } 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, Screen::Preview2d => Screen::Bodies, 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 ensure_preset_budget( &self, replacement: Option, candidate: &Preset, ) -> Result<(), String> { if replacement.is_none() && self.equipment.len() >= crate::project::MAX_PROJECT_PRESETS { return Err("maximaal 64 equipmentpresets per project".into()); } let mut budget = MAX_PROJECT_PRESET_BYTES .checked_sub(candidate.encoded_len()?) .ok_or("equipmentpresets samen groter dan 64 MiB")?; for (i, item) in self.equipment.iter().enumerate() { if Some(i) != replacement { if let Some(p) = &item.preset { budget = budget .checked_sub(p.encoded_len()?) .ok_or("equipmentpresets samen groter dan 64 MiB")?; } } } Ok(()) } pub fn available_equipment_name(&self, base: &str) -> Result { if !valid_slug(base) { return Err("ongeldige equipmentnaam".into()); } let mut name = base.to_string(); let mut n = 2; while self.equipment.iter().any(|i| i.slug == name) { let suffix = format!("-{n}"); name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix); n += 1; } Ok(name) } 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(()) } /// Create `//` (validated) and open it as a new project. pub fn install_named_project(&mut self, parent: &Path, name: &str) -> Result<(), String> { let dir = crate::project::new_project_dir(parent, name)?; let opened = create_default_project_named(&dir, name.trim()).map_err(|e| e.to_string())?; // install_loaded also reads the bundled equipment preset(s). self.install_loaded(opened); self.ok("Project aangemaakt, met de standaardlichamen in assets/body/male en female."); 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 { create_seeded_project_named(root, "Nieuw project") } pub fn create_seeded_project_named(root: &Path, name: &str) -> Result { let mut opened = create_project(root, name)?; 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) } /// A new project with Tim's bundled starter bodies (Tim t1009u) in /// `assets/body/male/` and `female/`, each with its `license.txt`, set as the /// male and female body. pub fn create_default_project_named( root: &Path, name: &str, ) -> Result { let mut opened = create_project(root, name)?; for (relative, bytes) in crate::default_bodies::FILES { let path = checked_project_path(root, &format!("assets/body/{relative}")) .map_err(|error| ProjectError::Validation(vec![error]))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| ProjectError::Io { path: parent.display().to_string(), message: e.to_string(), })?; } if !path.exists() { write_bytes(&path, bytes)?; } } opened.document.male_model = Some(crate::default_bodies::MALE_MODEL.to_string()); opened.document.female_model = Some(crate::default_bodies::FEMALE_MODEL.to_string()); // Bundled female hair preset (t1019u), listed in the equipment presets. let path = checked_project_path(root, crate::default_bodies::FEMALE_HAIR_PRESET) .map_err(|error| ProjectError::Validation(vec![error]))?; if !path.exists() { write_bytes(&path, crate::default_bodies::FEMALE_HAIR_PPE)?; } opened .document .equipment_presets .push(crate::default_bodies::FEMALE_HAIR_PRESET.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(), } } pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024; const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024; fn read_limited(path: &Path, limit: usize) -> Result, String> { use std::io::Read; if !path.is_file() { return Err("kies een regulier bestand".into()); } let mut bytes = Vec::new(); fs::File::open(path) .and_then(|file| file.take((limit + 1) as u64).read_to_end(&mut bytes)) .map_err(|e| e.to_string())?; if bytes.len() > limit { return Err("presetbestand overschrijdt het bestands-/projectbudget".into()); } Ok(bytes) } fn read_equipment(root: &Path, presets: &[String]) -> Vec { read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES) } fn read_equipment_with_budget( root: &Path, presets: &[String], mut budget: usize, ) -> Vec { presets .iter() .map(|path| { let failure = |error: String| EquipmentItem { preset: None, error: Some(error), slug: Path::new(path) .file_stem() .unwrap_or_default() .to_string_lossy() .into(), model: None, legacy_path: Some(path.clone()), }; let file = match checked_project_path(root, path) { Ok(path) => path, Err(error) => return failure(error), }; let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe"); let limit = budget.min(if binary { crate::equipment::MAX_FILE } else { MAX_LEGACY_PRESET_BYTES }); let bytes = match read_limited(&file, limit) { Ok(bytes) => bytes, Err(error) => return failure(error), }; budget -= bytes.len(); if binary { return match Preset::decode(&bytes) { 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) => failure(error), }; } if let Ok(parsed) = serde_json::from_slice::(&bytes) { if valid_slug(&parsed.name) { return EquipmentItem { preset: None, error: None, slug: parsed.name, model: parsed.model, legacy_path: None, }; } } failure("legacy preset; kies een OBJ- of FBX-model om te configureren".into()) }) .collect() } fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result, String> { if items.len() > crate::project::MAX_PROJECT_PRESETS { return Err("maximaal 64 equipmentpresets per project".into()); } let mut budget = MAX_PROJECT_PRESET_BYTES; 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 let Some(legacy) = item.legacy_path.as_ref().filter(|_| item.model.is_none()) { let path = checked_project_path(root, legacy)?; let length = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0); budget = budget .checked_sub(usize::try_from(length).map_err(|_| "preset te groot")?) .ok_or("equipmentpresets samen groter dan 64 MiB")?; 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(); let length = preset.encode()?.len(); budget = budget .checked_sub(length) .ok_or("equipmentpresets samen groter dan 64 MiB")?; } else { if let Some(model) = &item.model { portable_relative(model)?; } let text = serde_json::to_vec_pretty(&EquipmentFile { name: item.slug.clone(), model: item.model.clone(), }) .map_err(|e| e.to_string())?; if text.len() > MAX_LEGACY_PRESET_BYTES { return Err("legacy preset groter dan 64 KiB".into()); } budget = budget .checked_sub(text.len()) .ok_or("equipmentpresets samen groter dan 64 MiB")?; } } 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) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) { if existing == text.as_bytes() { 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 { crate::equipment::valid_name(slug) } 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 editing_budget_and_import_names_are_bounded_before_install() { let mut session = Session::new(); let mut p = Preset::new("sword".into()); for i in 0..3 { p.assets.push(crate::equipment::EmbeddedAsset { path: format!("raw-{i}"), bytes: vec![0; 8 * 1024 * 1024], }); } for _ in 0..2 { let i = session.add_equipment(); session.equipment[i].preset = Some(p.clone()); } assert!(session .ensure_preset_budget(None, &p) .unwrap_err() .contains("64 MiB")); assert!(session.ensure_preset_budget(Some(0), &p).is_ok()); assert_eq!(session.equipment.len(), 2); let base = "a".repeat(64); session.equipment[0].slug = base.clone(); let unique = session.available_equipment_name(&base).unwrap(); assert_eq!(unique.len(), 64); assert_ne!(unique, base); assert!(crate::equipment::valid_name(&unique)); } #[test] fn preset_read_budget_preserves_unloaded_references() { let root = scratch(); fs::create_dir_all(root.join("presets")).unwrap(); let bytes = Preset::new("sword".into()).encode().unwrap(); for name in ["one", "two"] { fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap(); } let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()]; let items = read_equipment_with_budget(&root, &refs, bytes.len()); assert!(items[0].preset.is_some()); assert!(items[1].preset.is_none()); assert!(items[1].error.as_ref().unwrap().contains("budget")); assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1])); fs::write( root.join("presets/old.json"), vec![b' '; MAX_LEGACY_PRESET_BYTES + 1], ) .unwrap(); assert!(read_equipment(&root, &["presets/old.json".into()])[0] .error .is_some()); fs::remove_dir_all(root).unwrap(); } #[test] fn too_many_presets_do_not_write_snapshots_or_replace_manifest() { let root = scratch(); let mut session = Session::new(); session.install_new_project(&root).unwrap(); let before = fs::read(root.join(MANIFEST_NAME)).unwrap(); for _ in 0..=crate::project::MAX_PROJECT_PRESETS { session.add_equipment(); } assert!(session.save().unwrap_err().to_string().contains("64")); assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before); assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0); assert!(session.is_dirty()); fs::remove_dir_all(root).unwrap(); } #[test] fn named_new_project_contains_and_loads_the_default_bodies() { let parent = scratch(); fs::create_dir_all(&parent).unwrap(); let mut session = Session::new(); session.install_named_project(&parent, "Starter").unwrap(); let root = parent.join("Starter"); for (relative, bytes) in crate::default_bodies::FILES { let copied = fs::read(root.join("assets/body").join(relative)).unwrap(); assert!(copied == *bytes, "{relative} not byte-exact"); } assert!(root.join("assets/body/male/license.txt").is_file()); assert!(root.join("assets/body/female/license.txt").is_file()); let document = &session.opened.as_ref().unwrap().document; assert_eq!( document.male_model.as_deref(), Some(crate::default_bodies::MALE_MODEL) ); assert_eq!( document.female_model.as_deref(), Some(crate::default_bodies::FEMALE_MODEL) ); for model in [ crate::default_bodies::MALE_MODEL, crate::default_bodies::FEMALE_MODEL, ] { let body = crate::preview::load_body_file(&root.join(model)).unwrap(); assert!( !body.joint_names.is_empty() && !body.clips.is_empty(), "{model}" ); assert!( body.surfaces.iter().any(|s| s.albedo.is_some()), "{model} textures" ); } // The bundled female hair preset is listed, female-only, and at rest // sits on the head of the default female (t1019u). assert_eq!( fs::read(root.join(crate::default_bodies::FEMALE_HAIR_PRESET)).unwrap(), crate::default_bodies::FEMALE_HAIR_PPE ); let hair = session .equipment .iter() .find_map(|item| item.preset.as_ref().filter(|p| p.name == "female-hair")) .expect("female-hair preset in the equipment list"); assert_eq!(hair.applies, [false, true]); let female = crate::preview::load_body_file(&root.join(crate::default_bodies::FEMALE_MODEL)) .unwrap(); let controls = crate::preview::PreviewControls { clip_name: String::new(), ..Default::default() }; let layers = crate::equipment::layers(hair, 1, &female, &controls).unwrap(); assert_eq!(layers.len(), 1); let (mut lo, mut hi) = (f32::MAX, f32::MIN); for p in &layers[0].mesh.positions { let m = &layers[0].model; let y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; lo = lo.min(y); hi = hi.max(y); } // Tim's original female with hair has rest top Y 1.621 m (the hair); // the baked rigid hair must reach exactly that, the ponytail ~1.27 m. assert!( (hi - 1.621).abs() < 0.002 && (1.2..1.35).contains(&lo), "hair Y {lo}..{hi}" ); // The saved manifest reopens with the same bodies. let reopened = crate::project::open_project(&root.join("character.ppd")).unwrap(); assert_eq!( reopened.document.male_model.as_deref(), Some(crate::default_bodies::MALE_MODEL) ); } #[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); } }