//! `.ppd` project manifest: one JSON file in a project directory. //! //! Version 1 stores paths and render settings. It does not import models and //! it does not render paperdolls. Unknown JSON fields are rejected so a save //! cannot drop them. Schemas 1–3 migrate to 4 (version 4 adds the atlas trim settings, default on); unsupported `format_version` values are rejected first. use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::Value; pub const FORMAT_VERSION: u32 = 4; pub const MANIFEST_NAME: &str = "character.ppd"; pub const MAX_PROJECT_PRESETS: usize = 64; pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024; pub const MAX_DIMENSION: u32 = 4096; pub const MAX_FPS: u32 = 240; pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024; const PROJECT_DIRS: [&str; 5] = [ "assets/body", "assets/equipment", "presets", "cache", "renders", ]; /// Persisted orthographic camera; integers keep project equality exact. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CameraFraming { pub height_milli: u32, pub center_y_milli: i32, /// None resolves the rest-pose neck height, independently of image framing. #[serde(default)] pub pivot_y_milli: Option, /// Total depth range (mm) centred on the pivot; None = automatic /// (T-pose arm span + one arm length margin per side, t985u). #[serde(default, skip_serializing_if = "Option::is_none")] pub depth_range_milli: Option, } impl Default for CameraFraming { fn default() -> Self { Self { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: None, depth_range_milli: None, } } } impl CameraFraming { pub fn validate(self) -> Result<(), String> { if !(100..=10000).contains(&self.height_milli) || self.center_y_milli.unsigned_abs() > 10000 || self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000) || self.depth_range_milli.is_some_and(|d| { !(crate::camera::MIN_DEPTH_RANGE_MILLI..=crate::camera::MAX_DEPTH_RANGE_MILLI) .contains(&d) }) { Err( "renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m" .into(), ) } else { Ok(()) } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RenderSettings { pub target_width: u32, pub target_height: u32, pub fps: u32, #[serde(default)] pub framing: [CameraFraming; 2], /// Depth PNG bit depth: 8 (default) or 16 (t983u). #[serde(default = "default_depth_bits")] pub depth_bits: u8, /// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u). #[serde(default)] pub depth_encoding: crate::camera::DepthEncoding, /// Crop body atlas views to their content (t1030u; default on). #[serde(default = "default_true")] pub trim_body: bool, /// Crop equipment atlas views to their content (t1030u; default on). #[serde(default = "default_true")] pub trim_equipment: bool, /// Allow semi-transparent body pixels (t1031u; default off = binary alpha). #[serde(default)] pub semi_transparency_body: bool, } fn default_true() -> bool { true } fn default_depth_bits() -> u8 { 8 } impl Default for RenderSettings { fn default() -> Self { Self { target_width: DEFAULT_TARGET[0], target_height: DEFAULT_TARGET[1], fps: DEFAULT_FPS, framing: [CameraFraming::default(); 2], depth_bits: 8, depth_encoding: crate::camera::DepthEncoding::Gray, trim_body: true, trim_equipment: true, semi_transparency_body: false, } } } /// Framerate for new projects (t1022u). Existing `.ppd` files keep their stored fps. pub const DEFAULT_FPS: u32 = 8; /// Target size for new projects (t1022u). pub const DEFAULT_TARGET: [u32; 2] = [92, 128]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProjectFile { pub format_version: u32, pub id: String, pub name: String, pub male_model: Option, pub female_model: Option, pub equipment_presets: Vec, pub output_directory: String, pub render: RenderSettings, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectDraft { pub id: String, pub name: String, pub male_model: String, pub female_model: String, pub equipment_presets: Vec, pub output_directory: String, pub target_width: u32, pub target_height: u32, pub fps: u32, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenedProject { pub manifest_path: PathBuf, pub document: ProjectFile, pub disk_issues: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirEntry { pub name: String, pub relative: String, pub is_dir: bool, } #[derive(Debug)] pub enum ProjectError { Validation(Vec), UnsupportedVersion { found: u64, supported: u32 }, UnknownField(String), Json(String), Io { path: String, message: String }, } impl std::fmt::Display for ProjectError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Validation(issues) => { write!(formatter, "{}", issues.join("\n")) } Self::UnsupportedVersion { found, supported } => write!( formatter, "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}" ), Self::UnknownField(detail) => write!( formatter, "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven." ), Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"), Self::Io { path, message } => write!(formatter, "{message} ({path})"), } } } impl std::error::Error for ProjectError {} impl ProjectFile { pub fn new_empty(name: impl Into) -> Self { Self { format_version: FORMAT_VERSION, id: new_id(), name: name.into(), male_model: None, female_model: None, equipment_presets: Vec::new(), output_directory: "renders".to_string(), render: RenderSettings::default(), } } pub fn validate_shape(&self) -> Result<(), ProjectError> { let mut issues = Vec::new(); if self.render.depth_bits != 8 && self.render.depth_bits != 16 { issues.push("depth_bits moet 8 of 16 zijn".to_string()); } for camera in self.render.framing { if let Err(error) = camera.validate() { issues.push(error); } } if self.format_version != FORMAT_VERSION { issues.push(format!( "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}", self.format_version )); } if self.id.trim().is_empty() || !self .id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') || self.id.len() > 64 { issues.push( "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'" .to_string(), ); } let name = self.name.trim(); if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 { issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string()); } check_optional_path( "het mannelijke model", self.male_model.as_deref(), &mut issues, ); check_optional_path( "het vrouwelijke model", self.female_model.as_deref(), &mut issues, ); let mut seen = std::collections::BTreeSet::new(); if self.equipment_presets.len() > MAX_PROJECT_PRESETS { issues.push(format!( "maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project" )); } for preset in &self.equipment_presets { match portable_relative(preset) { Ok(clean) => { if clean != *preset { issues.push(format!("de preset '{preset}' is niet genormaliseerd")); } if !seen.insert(clean.clone()) { issues.push(format!("de preset '{clean}' staat dubbel in het project")); } match preset_stem(&clean) { Ok(stem) if stem == "body" => issues.push( "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam" .to_string(), ), Ok(_) => {} Err(message) => issues.push(message), } if clean == MANIFEST_NAME { issues.push("character.ppd is het projectbestand, geen preset".to_string()); } } Err(message) => issues.push(format!("preset '{preset}': {message}")), } } match portable_relative(&self.output_directory) { Ok(clean) => { if clean != self.output_directory { issues.push("de uitvoermap is niet genormaliseerd".to_string()); } if clean == "." { issues.push( "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(), ); } if clean == MANIFEST_NAME { issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string()); } for source in self.sources() { if source == clean || source.starts_with(&(clean.clone() + "/")) { issues.push(format!( "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten" )); } } } Err(message) => issues.push(format!("uitvoermap: {message}")), } match memory_estimate(self.render.target_width, self.render.target_height) { Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {} Some(bytes) => issues.push(format!( "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}" )), None => issues.push( "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(), ), } if self.render.target_width == 0 || self.render.target_height == 0 { issues.push("breedte en hoogte moeten groter zijn dan 0".to_string()); } if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION { issues.push(format!( "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn" )); } if self.render.fps == 0 { issues.push("fps moet groter zijn dan 0".to_string()); } if self.render.fps > MAX_FPS { issues.push(format!("fps mag hoogstens {MAX_FPS} zijn")); } if issues.is_empty() { Ok(()) } else { Err(ProjectError::Validation(issues)) } } fn sources(&self) -> Vec<&str> { self.male_model .iter() .chain(self.female_model.iter()) .map(String::as_str) .chain(self.equipment_presets.iter().map(String::as_str)) .collect() } } pub fn assemble(draft: ProjectDraft) -> Result { let document = ProjectFile { format_version: FORMAT_VERSION, id: draft.id, name: draft.name, male_model: blank_to_none(draft.male_model), female_model: blank_to_none(draft.female_model), equipment_presets: draft.equipment_presets, output_directory: draft.output_directory, render: RenderSettings { target_width: draft.target_width, target_height: draft.target_height, fps: draft.fps, ..RenderSettings::default() }, }; document.validate_shape()?; Ok(document) } pub fn parse_manifest(text: &str) -> Result { let value: Value = serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?; let Some(object) = value.as_object() else { return Err(ProjectError::Json( "de wortel moet een object zijn".to_string(), )); }; let Some(version_value) = object.get("format_version") else { return Err(ProjectError::Json("format_version ontbreekt".to_string())); }; let Some(version) = version_value.as_u64() else { return Err(ProjectError::Json( "format_version moet een geheel getal zijn".to_string(), )); }; if !(1..=u64::from(FORMAT_VERSION)).contains(&version) { return Err(ProjectError::UnsupportedVersion { found: version, supported: FORMAT_VERSION, }); } let mut value = value; value["format_version"] = serde_json::json!(FORMAT_VERSION); serde_json::from_value(value).map_err(|err| { let detail = err.to_string(); if detail.contains("unknown field") { ProjectError::UnknownField(detail) } else { ProjectError::Json(detail) } }) } /// Validate a typed project name and return `/` (Tim t998u.3). /// The folder must not exist yet, or be empty. pub fn new_project_dir(parent: &Path, name: &str) -> Result { let name = name.trim(); if name.is_empty() { return Err("geef het project een naam".into()); } if name.chars().count() > 100 { return Err("de projectnaam is te lang (maximaal 100 tekens)".into()); } if name.chars().any(|c| { c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') }) { return Err("de projectnaam mag geen / \\ < > : \" | ? * of stuurtekens bevatten".into()); } if name == "." || name == ".." || name.ends_with('.') { return Err("de projectnaam mag niet op een punt eindigen".into()); } if !parent.is_dir() { return Err("kies eerst een bestaande map waarin het project komt".into()); } let dir = parent.join(name); match fs::symlink_metadata(&dir) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(dir), Err(e) => Err(format!("{}: {e}", dir.display())), Ok(meta) if meta.is_dir() => { let empty = fs::read_dir(&dir) .map_err(|e| format!("{}: {e}", dir.display()))? .next() .is_none(); if empty { Ok(dir) } else { Err(format!( "de map '{name}' bestaat al en is niet leeg; kies een andere naam" )) } } Ok(_) => Err(format!( "er bestaat al een bestand '{name}'; kies een andere naam" )), } } pub fn create_project(root: &Path, name: &str) -> Result { if root.exists() && !root.is_dir() { return Err(io_error(root, "de projectmap is een bestand")); } let manifest = root.join(MANIFEST_NAME); if manifest.exists() { return Err(io_error( &manifest, "er staat al een character.ppd; open dat project in plaats van het te overschrijven", )); } fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?; for relative in PROJECT_DIRS { let path = root.join(relative); fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?; } let document = ProjectFile::new_empty(name); document.validate_shape()?; write_manifest(&manifest, &document)?; Ok(OpenedProject { manifest_path: manifest, document, disk_issues: Vec::new(), }) } pub fn open_project(manifest: &Path) -> Result { if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") { return Err(io_error(manifest, "kies een .ppd-bestand")); } use std::io::Read; let metadata = fs::metadata(manifest).map_err(|err| { io_error( manifest, &format!("openen is mislukt: {err}. Er is niets gewijzigd"), ) })?; if !metadata.is_file() { return Err(io_error(manifest, "kies een regulier .ppd-bestand")); } let mut bytes = Vec::new(); fs::File::open(manifest) .and_then(|file| { file.take((MAX_MANIFEST_BYTES + 1) as u64) .read_to_end(&mut bytes) }) .map_err(|err| { io_error( manifest, &format!("openen is mislukt: {err}. Er is niets gewijzigd"), ) })?; if bytes.len() > MAX_MANIFEST_BYTES { return Err(io_error(manifest, "projectmanifest groter dan 4 MiB")); } let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?; let document = parse_manifest(text)?; document.validate_shape()?; let root = manifest .parent() .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?; let disk_issues = disk_issues(root, &document); Ok(OpenedProject { manifest_path: manifest.to_path_buf(), document, disk_issues, }) } pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> { opened.document.validate_shape()?; let root = opened.manifest_path.parent().ok_or_else(|| { io_error( &opened.manifest_path, "het .ppd-bestand heeft geen projectmap", ) })?; let mut issues = Vec::new(); for relative in opened.document.sources() { match require_file_inside(root, relative) { Ok(()) => {} Err(ProjectError::Validation(extra)) => issues.extend(extra), Err(other) => return Err(other), } } if !issues.is_empty() { return Err(ProjectError::Validation(issues)); } prepare_output_dir(root, &opened.document.output_directory)?; write_manifest(&opened.manifest_path, &opened.document) } pub fn list_dir(root: &Path, relative: &str) -> Result, ProjectError> { let relative = if relative.is_empty() { String::new() } else { portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))? }; let path = if relative.is_empty() { root.to_path_buf() } else { root.join(&relative) }; let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?; let mut entries = Vec::new(); for item in read { let item = item.map_err(|err| io_error(&path, &err.to_string()))?; let name = item.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let child = if relative.is_empty() { name.clone() } else { format!("{relative}/{name}") }; let is_dir = item .file_type() .map_err(|err| io_error(&item.path(), &err.to_string()))? .is_dir(); entries.push(DirEntry { name, relative: child, is_dir, }); } entries.sort_by(|left, right| { right .is_dir .cmp(&left.is_dir) .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) }); Ok(entries) } pub fn memory_estimate(width: u32, height: u32) -> Option { // Shape heuristic from the original brief: 8× width and 8× height, // 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a // time, so the 60-view plan (t955u) does not change memory use; the // factor stays 36 so that 256×256 targets remain valid. u64::from(width) .checked_mul(u64::from(height))? .checked_mul(64)? .checked_mul(36)? .checked_mul(3)? .checked_mul(4) } fn new_id() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); format!("ppd-{nanos:x}") } fn blank_to_none(value: String) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec) { let Some(value) = value else { return; }; match portable_relative(value) { Ok(clean) if clean == value && clean != MANIFEST_NAME => {} Ok(_) if value == MANIFEST_NAME => { issues.push(format!("{label} mag niet character.ppd zijn")); } Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")), Err(message) => issues.push(format!("{label}: {message}")), } } pub fn portable_relative(value: &str) -> Result { let value = value.trim(); if value.is_empty() { return Err("het pad is leeg".to_string()); } if value.contains('\\') { return Err("gebruik '/' in projectpaden".to_string()); } if value.starts_with('/') || windows_drive(value) { return Err( "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap" .to_string(), ); } let mut parts = Vec::new(); for part in value.split('/') { if part.is_empty() || part == "." || part == ".." { return Err( "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben" .to_string(), ); } if part.contains('\0') { return Err("het pad bevat een leeg teken".to_string()); } parts.push(part); } Ok(parts.join("/")) } fn windows_drive(value: &str) -> bool { let mut chars = value.chars(); matches!( (chars.next(), chars.next(), chars.next()), (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic() ) } fn preset_stem(relative: &str) -> Result { let file_name = relative.rsplit('/').next().unwrap_or(relative); let stem = file_name.split('.').next().unwrap_or(file_name); if stem.is_empty() || !stem .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_') || stem.contains(' ') { return Err(format!( "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties" )); } Ok(stem.to_string()) } fn disk_issues(root: &Path, document: &ProjectFile) -> Vec { let mut issues = Vec::new(); for relative in document.sources() { if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) { issues.extend(extra); } } let output = root.join(&document.output_directory); if output.exists() && !output.is_dir() { issues.push(format!( "de uitvoermap '{}' is een bestand", document.output_directory )); } else if !output.exists() { issues.push(format!( "de uitvoermap '{}' bestaat nog niet", document.output_directory )); } issues } fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> { let path = root.join(relative); if !path.is_file() { return Err(ProjectError::Validation(vec![format!( "het bestand ontbreekt in de projectmap: {relative}" )])); } ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?; match (root.canonicalize(), path.canonicalize()) { (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()), (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!( "het pad '{relative}' wijst buiten de projectmap" )])), (Err(err), _) => Err(io_error(root, &err.to_string())), (_, Err(err)) => Err(io_error(&path, &err.to_string())), } } pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> { let path = root.join(relative); if path.exists() && !path.is_dir() { return Err(ProjectError::Validation(vec![format!( "de uitvoermap '{relative}' is een bestand" )])); } if !path.exists() { fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?; } ensure_lexically_inside(root, &path) .map_err(|message| ProjectError::Validation(vec![message]))?; Ok(()) } fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> { let root = root.components().collect::>(); let path = path.components().collect::>(); if path.starts_with(&root) { Ok(()) } else { Err("het pad wijst buiten de projectmap".to_string()) } } fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> { let parent = path .parent() .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?; let text = serde_json::to_string_pretty(document) .map_err(|err| ProjectError::Json(err.to_string()))? + "\n"; if text.len() > MAX_MANIFEST_BYTES { return Err(io_error(path, "projectmanifest groter dan 4 MiB")); } let mut temporary_name = path .file_name() .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))? .to_os_string(); temporary_name.push(".tmp"); let temporary = parent.join(temporary_name); let write_result = (|| { let mut file = File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?; file.write_all(text.as_bytes()) .map_err(|err| io_error(&temporary, &err.to_string()))?; file.sync_all() .map_err(|err| io_error(&temporary, &err.to_string()))?; Ok(()) })(); if let Err(err) = write_result { let _ = fs::remove_file(&temporary); return Err(err); } if let Err(err) = fs::rename(&temporary, path) { let _ = fs::remove_file(&temporary); return Err(io_error( path, &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"), )); } Ok(()) } fn io_error(path: &Path, message: &str) -> ProjectError { ProjectError::Io { path: path.display().to_string(), message: message.to_string(), } } #[cfg(test)] mod tests { use super::*; fn temp_root(label: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( "paperdoll-{label}-{}-{}", std::process::id(), new_id() )); let _ = fs::remove_dir_all(&path); path } fn draft_from(document: &ProjectFile) -> ProjectDraft { ProjectDraft { id: document.id.clone(), name: document.name.clone(), male_model: document.male_model.clone().unwrap_or_default(), female_model: document.female_model.clone().unwrap_or_default(), equipment_presets: document.equipment_presets.clone(), output_directory: document.output_directory.clone(), target_width: document.render.target_width, target_height: document.render.target_height, fps: document.render.fps, } } #[test] fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() { let root = temp_root("resource-limits"); let mut opened = create_project(&root, "Limits").unwrap(); let before = fs::read(&opened.manifest_path).unwrap(); opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1); assert!(save_project(&opened).is_err()); assert!(write_manifest(&opened.manifest_path, &opened.document) .unwrap_err() .to_string() .contains("4 MiB")); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); let oversized = root.join("oversized.ppd"); fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap(); assert!(open_project(&oversized) .unwrap_err() .to_string() .contains("4 MiB")); opened.document.name = "Limits".into(); opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS) .map(|i| format!("presets/item-{i}.ppe")) .collect(); assert!(opened .document .validate_shape() .unwrap_err() .to_string() .contains("64")); fs::remove_dir_all(root).unwrap(); } #[test] fn roundtrip_keeps_null_slots_and_relative_paths() { let root = temp_root("roundtrip"); let opened = create_project(&root, "Ridder").unwrap(); let text = fs::read_to_string(&opened.manifest_path).unwrap(); assert!(text.contains("\"male_model\": null")); assert!(text.contains("\"female_model\": null")); assert!(text.contains("\"output_directory\": \"renders\"")); assert!(!text.contains(root.to_string_lossy().as_ref())); let loaded = open_project(&opened.manifest_path).unwrap(); assert_eq!(loaded.document, opened.document); assert!(loaded.disk_issues.is_empty()); assert!(root.join("assets/body").is_dir()); assert!(root.join("presets").is_dir()); assert!(root.join("renders").is_dir()); let _ = fs::remove_dir_all(&root); } #[test] fn save_writes_model_and_preset_paths_and_reloads_them() { let root = temp_root("paths"); let mut opened = create_project(&root, "Ridder").unwrap(); fs::write(root.join("assets/body/male.glb"), b"glb").unwrap(); fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap(); opened.document.male_model = Some("assets/body/male.glb".to_string()); opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()]; save_project(&opened).unwrap(); let loaded = open_project(&opened.manifest_path).unwrap(); assert_eq!( loaded.document.male_model.as_deref(), Some("assets/body/male.glb") ); assert_eq!(loaded.document.female_model, None); assert_eq!( loaded.document.equipment_presets, vec!["presets/zwaard.bin"] ); assert!(loaded.disk_issues.is_empty()); let _ = fs::remove_dir_all(&root); } #[test] fn validation_rejects_escape_absolute_body_and_output_overlap() { let mut document = ProjectFile::new_empty("Ridder"); document.id = "ridder-1".to_string(); document.male_model = Some("../geheim.glb".to_string()); document.female_model = Some("/tmp/vrouw.glb".to_string()); document.equipment_presets = vec![ "presets/body.bin".to_string(), "presets/Zwaard.bin".to_string(), ]; document.output_directory = "presets".to_string(); let error = document.validate_shape().unwrap_err(); let text = error.to_string(); assert!(text.contains("buiten de projectmap"), "{text}"); assert!(text.contains("absoluut"), "{text}"); assert!(text.contains("body"), "{text}"); assert!(text.contains("kleine letters"), "{text}"); assert!(text.contains("uitvoermap"), "{text}"); } #[test] fn newer_version_and_unknown_field_are_recoverable_parse_errors() { let newer = r#"{"format_version":5,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#; let error = parse_manifest(newer).unwrap_err(); assert!(matches!( error, ProjectError::UnsupportedVersion { found: 5, supported: 4 } )); assert!(error.to_string().contains("format_version 5")); let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#; let error = parse_manifest(unknown).unwrap_err(); assert!(matches!(error, ProjectError::UnknownField(_))); assert!(error.to_string().contains("niet herschreven")); } #[test] fn failed_save_keeps_the_previous_manifest() { let root = temp_root("atomic"); let opened = create_project(&root, "Ridder").unwrap(); let before = fs::read(&opened.manifest_path).unwrap(); let mut broken = opened.clone(); broken.document.output_directory = "..".to_string(); let error = save_project(&broken).unwrap_err(); assert!(error.to_string().contains("buiten de projectmap")); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); broken.document.output_directory = "renders".to_string(); broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string()); let error = save_project(&broken).unwrap_err(); assert!(error.to_string().contains("ontbreekt"), "{error}"); assert_eq!(fs::read(&opened.manifest_path).unwrap(), before); let _ = fs::remove_dir_all(&root); } #[test] fn missing_manifest_is_a_recoverable_io_error() { let path = temp_root("missing").join(MANIFEST_NAME); let error = open_project(&path).unwrap_err(); let text = error.to_string(); assert!(text.contains("openen is mislukt"), "{text}"); assert!(matches!(error, ProjectError::Io { .. })); } #[test] fn create_refuses_to_overwrite_an_existing_project() { let root = temp_root("twice"); create_project(&root, "Eerste").unwrap(); let error = create_project(&root, "Tweede").unwrap_err(); assert!(error.to_string().contains("al een character.ppd")); let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap(); assert_eq!(loaded.document.name, "Eerste"); let _ = fs::remove_dir_all(&root); } #[test] fn render_bounds_and_assemble_roundtrip() { let mut document = ProjectFile::new_empty("Ridder"); document.id = "ridder-1".to_string(); document.render.target_width = 0; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("groter zijn dan 0")); document.render = RenderSettings { target_width: 4096, target_height: 4096, fps: 12, ..RenderSettings::default() }; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("geschatte renderbuffer")); document.render.fps = 0; document.render.target_width = 128; document.render.target_height = 128; assert!(document .validate_shape() .unwrap_err() .to_string() .contains("fps")); let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap(); let text = serde_json::to_string(&assembled).unwrap(); let parsed = parse_manifest(&text).unwrap(); assert_eq!(parsed.render, RenderSettings::default()); assert_eq!(parsed.male_model, None); } #[cfg(unix)] #[test] fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() { let root = temp_root("link"); let opened = create_project(&root, "Ridder").unwrap(); let outside = temp_root("outside-file"); fs::create_dir_all(&outside).unwrap(); let secret = outside.join("geheim.glb"); fs::write(&secret, b"nope").unwrap(); std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap(); let mut broken = opened.clone(); broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string()); let error = save_project(&broken).unwrap_err(); assert!( error.to_string().contains("buiten de projectmap"), "{error}" ); fs::write(root.join("assets/body/male.glb"), b"ok").unwrap(); let entries = list_dir(&root, "assets/body").unwrap(); assert!(entries .iter() .any(|entry| entry.name == "male.glb" && !entry.is_dir)); let error = list_dir(&root, "../").unwrap_err(); assert!(error.to_string().contains("buiten de projectmap")); let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&outside); } } #[cfg(test)] mod framing_tests { use super::*; #[test] fn new_projects_default_to_8_fps_92x128_and_stored_fps_is_kept() { assert_eq!(DEFAULT_FPS, 8); assert_eq!(RenderSettings::default().fps, 8); assert_eq!( ( RenderSettings::default().target_width, RenderSettings::default().target_height ), (92, 128) ); assert_eq!(RenderSettings::default().depth_bits, 8); assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 8); let mut stored = ProjectFile::new_empty("Oud"); stored.render.fps = 12; let text = serde_json::to_string(&stored).unwrap(); assert_eq!(parse_manifest(&text).unwrap().render.fps, 12); } #[test] fn per_sex_fixed_framing_roundtrip_and_v1_migration() { let mut p = assemble(ProjectDraft { id: "fixed-framing".into(), name: "Frames".into(), male_model: String::new(), female_model: String::new(), equipment_presets: Vec::new(), output_directory: "renders".into(), target_width: 64, target_height: 96, fps: 12, }) .unwrap(); p.render.framing = [ CameraFraming { height_milli: 3210, center_y_milli: 1100, pivot_y_milli: Some(1700), depth_range_milli: None, }, CameraFraming { height_milli: 2890, center_y_milli: 987, pivot_y_milli: Some(1650), depth_range_milli: None, }, ]; let root = std::env::temp_dir().join(format!( "paperdoll-framing-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let mut opened = create_project(&root, "Fixed framing").unwrap(); opened.document.render.framing = p.render.framing; save_project(&opened).unwrap(); assert_eq!( open_project(&opened.manifest_path) .unwrap() .document .render .framing, p.render.framing ); std::fs::remove_dir_all(root).unwrap(); let mut value = serde_json::to_value(&p).unwrap(); let text = serde_json::to_string(&value).unwrap(); assert_eq!( parse_manifest(&text).unwrap().render.framing, p.render.framing ); value["format_version"] = serde_json::json!(1); value["render"].as_object_mut().unwrap().remove("framing"); let migrated = parse_manifest(&value.to_string()).unwrap(); assert_eq!(migrated.format_version, 4); assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]); // t1030u: older files get both atlas trim settings on. assert!(migrated.render.trim_body && migrated.render.trim_equipment); assert!(!migrated.render.semi_transparency_body); value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]); assert!(parse_manifest(&value.to_string()) .unwrap() .validate_shape() .is_err()); value["format_version"] = serde_json::json!(2); value["render"]["framing"] = serde_json::json!([ {"height_milli":2600,"center_y_milli":1000}, {"height_milli":2600,"center_y_milli":1000} ]); let migrated_v2 = parse_manifest(&value.to_string()).unwrap(); assert_eq!(migrated_v2.format_version, 4); assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None); assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None); } }