BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

project.rs 38.1 KiB Download Raw
1//! `.ppd` project manifest: one JSON file in a project directory.
2//!
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5//! cannot drop them. Schemas 1/2 migrate to 3; unsupported `format_version` values are rejected first.
6
7use std::fs::{self, File};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15pub const FORMAT_VERSION: u32 = 3;
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
19pub const MAX_DIMENSION: u32 = 4096;
20pub const MAX_FPS: u32 = 240;
21pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
22
23const PROJECT_DIRS: [&str; 5] = [
24 "assets/body",
25 "assets/equipment",
26 "presets",
27 "cache",
28 "renders",
29];
30
31/// Persisted orthographic camera; integers keep project equality exact.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct CameraFraming {
35 pub height_milli: u32,
36 pub center_y_milli: i32,
37 /// None resolves the rest-pose neck height, independently of image framing.
38 #[serde(default)]
39 pub pivot_y_milli: Option<i32>,
40 /// Total depth range (mm) centred on the pivot; None = automatic
41 /// (T-pose arm span + one arm length margin per side, t985u).
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub depth_range_milli: Option<u32>,
44}
45impl Default for CameraFraming {
46 fn default() -> Self {
47 Self {
48 height_milli: 2600,
49 center_y_milli: 1000,
50 pivot_y_milli: None,
51 depth_range_milli: None,
52 }
53 }
54}
55impl CameraFraming {
56 pub fn validate(self) -> Result<(), String> {
57 if !(100..=10000).contains(&self.height_milli)
58 || self.center_y_milli.unsigned_abs() > 10000
59 || self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)
60 || self.depth_range_milli.is_some_and(|d| {
61 !(crate::camera::MIN_DEPTH_RANGE_MILLI..=crate::camera::MAX_DEPTH_RANGE_MILLI)
62 .contains(&d)
63 })
64 {
65 Err(
66 "renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m"
67 .into(),
68 )
69 } else {
70 Ok(())
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct RenderSettings {
78 pub target_width: u32,
79 pub target_height: u32,
80 pub fps: u32,
81 #[serde(default)]
82 pub framing: [CameraFraming; 2],
83 /// Depth PNG bit depth: 8 (default) or 16 (t983u).
84 #[serde(default = "default_depth_bits")]
85 pub depth_bits: u8,
86 /// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).
87 #[serde(default)]
88 pub depth_encoding: crate::camera::DepthEncoding,
89}
90
91fn default_depth_bits() -> u8 {
92 8
93}
94
95impl Default for RenderSettings {
96 fn default() -> Self {
97 Self {
98 target_width: 128,
99 target_height: 128,
100 fps: DEFAULT_FPS,
101 framing: [CameraFraming::default(); 2],
102 depth_bits: 8,
103 depth_encoding: crate::camera::DepthEncoding::Gray,
104 }
105 }
106}
107
108/// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
109pub const DEFAULT_FPS: u32 = 4;
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct ProjectFile {
114 pub format_version: u32,
115 pub id: String,
116 pub name: String,
117 pub male_model: Option<String>,
118 pub female_model: Option<String>,
119 pub equipment_presets: Vec<String>,
120 pub output_directory: String,
121 pub render: RenderSettings,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct ProjectDraft {
126 pub id: String,
127 pub name: String,
128 pub male_model: String,
129 pub female_model: String,
130 pub equipment_presets: Vec<String>,
131 pub output_directory: String,
132 pub target_width: u32,
133 pub target_height: u32,
134 pub fps: u32,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct OpenedProject {
139 pub manifest_path: PathBuf,
140 pub document: ProjectFile,
141 pub disk_issues: Vec<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct DirEntry {
146 pub name: String,
147 pub relative: String,
148 pub is_dir: bool,
149}
150
151#[derive(Debug)]
152pub enum ProjectError {
153 Validation(Vec<String>),
154 UnsupportedVersion { found: u64, supported: u32 },
155 UnknownField(String),
156 Json(String),
157 Io { path: String, message: String },
158}
159
160impl std::fmt::Display for ProjectError {
161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Validation(issues) => {
164 write!(formatter, "{}", issues.join("\n"))
165 }
166 Self::UnsupportedVersion { found, supported } => write!(
167 formatter,
168 "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}"
169 ),
170 Self::UnknownField(detail) => write!(
171 formatter,
172 "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven."
173 ),
174 Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"),
175 Self::Io { path, message } => write!(formatter, "{message} ({path})"),
176 }
177 }
178}
179
180impl std::error::Error for ProjectError {}
181
182impl ProjectFile {
183 pub fn new_empty(name: impl Into<String>) -> Self {
184 Self {
185 format_version: FORMAT_VERSION,
186 id: new_id(),
187 name: name.into(),
188 male_model: None,
189 female_model: None,
190 equipment_presets: Vec::new(),
191 output_directory: "renders".to_string(),
192 render: RenderSettings::default(),
193 }
194 }
195
196 pub fn validate_shape(&self) -> Result<(), ProjectError> {
197 let mut issues = Vec::new();
198 if self.render.depth_bits != 8 && self.render.depth_bits != 16 {
199 issues.push("depth_bits moet 8 of 16 zijn".to_string());
200 }
201 for camera in self.render.framing {
202 if let Err(error) = camera.validate() {
203 issues.push(error);
204 }
205 }
206
207 if self.format_version != FORMAT_VERSION {
208 issues.push(format!(
209 "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
210 self.format_version
211 ));
212 }
213 if self.id.trim().is_empty()
214 || !self
215 .id
216 .chars()
217 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
218 || self.id.len() > 64
219 {
220 issues.push(
221 "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'"
222 .to_string(),
223 );
224 }
225 let name = self.name.trim();
226 if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 {
227 issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string());
228 }
229 check_optional_path(
230 "het mannelijke model",
231 self.male_model.as_deref(),
232 &mut issues,
233 );
234 check_optional_path(
235 "het vrouwelijke model",
236 self.female_model.as_deref(),
237 &mut issues,
238 );
239 let mut seen = std::collections::BTreeSet::new();
240 if self.equipment_presets.len() > MAX_PROJECT_PRESETS {
241 issues.push(format!(
242 "maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"
243 ));
244 }
245 for preset in &self.equipment_presets {
246 match portable_relative(preset) {
247 Ok(clean) => {
248 if clean != *preset {
249 issues.push(format!("de preset '{preset}' is niet genormaliseerd"));
250 }
251 if !seen.insert(clean.clone()) {
252 issues.push(format!("de preset '{clean}' staat dubbel in het project"));
253 }
254 match preset_stem(&clean) {
255 Ok(stem) if stem == "body" => issues.push(
256 "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam"
257 .to_string(),
258 ),
259 Ok(_) => {}
260 Err(message) => issues.push(message),
261 }
262 if clean == MANIFEST_NAME {
263 issues.push("character.ppd is het projectbestand, geen preset".to_string());
264 }
265 }
266 Err(message) => issues.push(format!("preset '{preset}': {message}")),
267 }
268 }
269 match portable_relative(&self.output_directory) {
270 Ok(clean) => {
271 if clean != self.output_directory {
272 issues.push("de uitvoermap is niet genormaliseerd".to_string());
273 }
274 if clean == "." {
275 issues.push(
276 "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(),
277 );
278 }
279 if clean == MANIFEST_NAME {
280 issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string());
281 }
282 for source in self.sources() {
283 if source == clean || source.starts_with(&(clean.clone() + "/")) {
284 issues.push(format!(
285 "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten"
286 ));
287 }
288 }
289 }
290 Err(message) => issues.push(format!("uitvoermap: {message}")),
291 }
292 match memory_estimate(self.render.target_width, self.render.target_height) {
293 Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {}
294 Some(bytes) => issues.push(format!(
295 "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}"
296 )),
297 None => issues.push(
298 "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(),
299 ),
300 }
301 if self.render.target_width == 0 || self.render.target_height == 0 {
302 issues.push("breedte en hoogte moeten groter zijn dan 0".to_string());
303 }
304 if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION {
305 issues.push(format!(
306 "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn"
307 ));
308 }
309 if self.render.fps == 0 {
310 issues.push("fps moet groter zijn dan 0".to_string());
311 }
312 if self.render.fps > MAX_FPS {
313 issues.push(format!("fps mag hoogstens {MAX_FPS} zijn"));
314 }
315 if issues.is_empty() {
316 Ok(())
317 } else {
318 Err(ProjectError::Validation(issues))
319 }
320 }
321
322 fn sources(&self) -> Vec<&str> {
323 self.male_model
324 .iter()
325 .chain(self.female_model.iter())
326 .map(String::as_str)
327 .chain(self.equipment_presets.iter().map(String::as_str))
328 .collect()
329 }
330}
331
332pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
333 let document = ProjectFile {
334 format_version: FORMAT_VERSION,
335 id: draft.id,
336 name: draft.name,
337 male_model: blank_to_none(draft.male_model),
338 female_model: blank_to_none(draft.female_model),
339 equipment_presets: draft.equipment_presets,
340 output_directory: draft.output_directory,
341 render: RenderSettings {
342 target_width: draft.target_width,
343 target_height: draft.target_height,
344 fps: draft.fps,
345 ..RenderSettings::default()
346 },
347 };
348 document.validate_shape()?;
349 Ok(document)
350}
351
352pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
353 let value: Value =
354 serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?;
355 let Some(object) = value.as_object() else {
356 return Err(ProjectError::Json(
357 "de wortel moet een object zijn".to_string(),
358 ));
359 };
360 let Some(version_value) = object.get("format_version") else {
361 return Err(ProjectError::Json("format_version ontbreekt".to_string()));
362 };
363 let Some(version) = version_value.as_u64() else {
364 return Err(ProjectError::Json(
365 "format_version moet een geheel getal zijn".to_string(),
366 ));
367 };
368 if !(1..=u64::from(FORMAT_VERSION)).contains(&version) {
369 return Err(ProjectError::UnsupportedVersion {
370 found: version,
371 supported: FORMAT_VERSION,
372 });
373 }
374 let mut value = value;
375 value["format_version"] = serde_json::json!(FORMAT_VERSION);
376 serde_json::from_value(value).map_err(|err| {
377 let detail = err.to_string();
378 if detail.contains("unknown field") {
379 ProjectError::UnknownField(detail)
380 } else {
381 ProjectError::Json(detail)
382 }
383 })
384}
385
386pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
387 if root.exists() && !root.is_dir() {
388 return Err(io_error(root, "de projectmap is een bestand"));
389 }
390 let manifest = root.join(MANIFEST_NAME);
391 if manifest.exists() {
392 return Err(io_error(
393 &manifest,
394 "er staat al een character.ppd; open dat project in plaats van het te overschrijven",
395 ));
396 }
397 fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?;
398 for relative in PROJECT_DIRS {
399 let path = root.join(relative);
400 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
401 }
402 let document = ProjectFile::new_empty(name);
403 document.validate_shape()?;
404 write_manifest(&manifest, &document)?;
405 Ok(OpenedProject {
406 manifest_path: manifest,
407 document,
408 disk_issues: Vec::new(),
409 })
410}
411
412pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
413 if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
414 return Err(io_error(manifest, "kies een .ppd-bestand"));
415 }
416 use std::io::Read;
417 let metadata = fs::metadata(manifest).map_err(|err| {
418 io_error(
419 manifest,
420 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
421 )
422 })?;
423 if !metadata.is_file() {
424 return Err(io_error(manifest, "kies een regulier .ppd-bestand"));
425 }
426 let mut bytes = Vec::new();
427 fs::File::open(manifest)
428 .and_then(|file| {
429 file.take((MAX_MANIFEST_BYTES + 1) as u64)
430 .read_to_end(&mut bytes)
431 })
432 .map_err(|err| {
433 io_error(
434 manifest,
435 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
436 )
437 })?;
438 if bytes.len() > MAX_MANIFEST_BYTES {
439 return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));
440 }
441 let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;
442 let document = parse_manifest(text)?;
443 document.validate_shape()?;
444 let root = manifest
445 .parent()
446 .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?;
447 let disk_issues = disk_issues(root, &document);
448 Ok(OpenedProject {
449 manifest_path: manifest.to_path_buf(),
450 document,
451 disk_issues,
452 })
453}
454
455pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> {
456 opened.document.validate_shape()?;
457 let root = opened.manifest_path.parent().ok_or_else(|| {
458 io_error(
459 &opened.manifest_path,
460 "het .ppd-bestand heeft geen projectmap",
461 )
462 })?;
463 let mut issues = Vec::new();
464 for relative in opened.document.sources() {
465 match require_file_inside(root, relative) {
466 Ok(()) => {}
467 Err(ProjectError::Validation(extra)) => issues.extend(extra),
468 Err(other) => return Err(other),
469 }
470 }
471 if !issues.is_empty() {
472 return Err(ProjectError::Validation(issues));
473 }
474 prepare_output_dir(root, &opened.document.output_directory)?;
475 write_manifest(&opened.manifest_path, &opened.document)
476}
477
478pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectError> {
479 let relative = if relative.is_empty() {
480 String::new()
481 } else {
482 portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))?
483 };
484 let path = if relative.is_empty() {
485 root.to_path_buf()
486 } else {
487 root.join(&relative)
488 };
489 let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?;
490 let mut entries = Vec::new();
491 for item in read {
492 let item = item.map_err(|err| io_error(&path, &err.to_string()))?;
493 let name = item.file_name().to_string_lossy().to_string();
494 if name.starts_with('.') {
495 continue;
496 }
497 let child = if relative.is_empty() {
498 name.clone()
499 } else {
500 format!("{relative}/{name}")
501 };
502 let is_dir = item
503 .file_type()
504 .map_err(|err| io_error(&item.path(), &err.to_string()))?
505 .is_dir();
506 entries.push(DirEntry {
507 name,
508 relative: child,
509 is_dir,
510 });
511 }
512 entries.sort_by(|left, right| {
513 right
514 .is_dir
515 .cmp(&left.is_dir)
516 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
517 });
518 Ok(entries)
519}
520
521pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
522 // Shape heuristic from the original brief: 8× width and 8× height,
523 // 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a
524 // time, so the 60-view plan (t955u) does not change memory use; the
525 // factor stays 36 so that 256×256 targets remain valid.
526 u64::from(width)
527 .checked_mul(u64::from(height))?
528 .checked_mul(64)?
529 .checked_mul(36)?
530 .checked_mul(3)?
531 .checked_mul(4)
532}
533
534fn new_id() -> String {
535 let nanos = SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .map(|duration| duration.as_nanos())
538 .unwrap_or(0);
539 format!("ppd-{nanos:x}")
540}
541
542fn blank_to_none(value: String) -> Option<String> {
543 let trimmed = value.trim();
544 if trimmed.is_empty() {
545 None
546 } else {
547 Some(trimmed.to_string())
548 }
549}
550
551fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
552 let Some(value) = value else {
553 return;
554 };
555 match portable_relative(value) {
556 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
557 Ok(_) if value == MANIFEST_NAME => {
558 issues.push(format!("{label} mag niet character.ppd zijn"));
559 }
560 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
561 Err(message) => issues.push(format!("{label}: {message}")),
562 }
563}
564
565pub fn portable_relative(value: &str) -> Result<String, String> {
566 let value = value.trim();
567 if value.is_empty() {
568 return Err("het pad is leeg".to_string());
569 }
570 if value.contains('\\') {
571 return Err("gebruik '/' in projectpaden".to_string());
572 }
573 if value.starts_with('/') || windows_drive(value) {
574 return Err(
575 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
576 .to_string(),
577 );
578 }
579 let mut parts = Vec::new();
580 for part in value.split('/') {
581 if part.is_empty() || part == "." || part == ".." {
582 return Err(
583 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
584 .to_string(),
585 );
586 }
587 if part.contains('\0') {
588 return Err("het pad bevat een leeg teken".to_string());
589 }
590 parts.push(part);
591 }
592 Ok(parts.join("/"))
593}
594
595fn windows_drive(value: &str) -> bool {
596 let mut chars = value.chars();
597 matches!(
598 (chars.next(), chars.next(), chars.next()),
599 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
600 )
601}
602
603fn preset_stem(relative: &str) -> Result<String, String> {
604 let file_name = relative.rsplit('/').next().unwrap_or(relative);
605 let stem = file_name.split('.').next().unwrap_or(file_name);
606 if stem.is_empty()
607 || !stem
608 .chars()
609 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
610 || stem.contains(' ')
611 {
612 return Err(format!(
613 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
614 ));
615 }
616 Ok(stem.to_string())
617}
618
619fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
620 let mut issues = Vec::new();
621 for relative in document.sources() {
622 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
623 issues.extend(extra);
624 }
625 }
626 let output = root.join(&document.output_directory);
627 if output.exists() && !output.is_dir() {
628 issues.push(format!(
629 "de uitvoermap '{}' is een bestand",
630 document.output_directory
631 ));
632 } else if !output.exists() {
633 issues.push(format!(
634 "de uitvoermap '{}' bestaat nog niet",
635 document.output_directory
636 ));
637 }
638 issues
639}
640
641fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
642 let path = root.join(relative);
643 if !path.is_file() {
644 return Err(ProjectError::Validation(vec![format!(
645 "het bestand ontbreekt in de projectmap: {relative}"
646 )]));
647 }
648 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
649 match (root.canonicalize(), path.canonicalize()) {
650 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
651 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
652 "het pad '{relative}' wijst buiten de projectmap"
653 )])),
654 (Err(err), _) => Err(io_error(root, &err.to_string())),
655 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
656 }
657}
658
659pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
660 let path = root.join(relative);
661 if path.exists() && !path.is_dir() {
662 return Err(ProjectError::Validation(vec![format!(
663 "de uitvoermap '{relative}' is een bestand"
664 )]));
665 }
666 if !path.exists() {
667 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
668 }
669 ensure_lexically_inside(root, &path)
670 .map_err(|message| ProjectError::Validation(vec![message]))?;
671 Ok(())
672}
673
674fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
675 let root = root.components().collect::<Vec<_>>();
676 let path = path.components().collect::<Vec<_>>();
677 if path.starts_with(&root) {
678 Ok(())
679 } else {
680 Err("het pad wijst buiten de projectmap".to_string())
681 }
682}
683
684fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
685 let parent = path
686 .parent()
687 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
688 let text = serde_json::to_string_pretty(document)
689 .map_err(|err| ProjectError::Json(err.to_string()))?
690 + "\n";
691 if text.len() > MAX_MANIFEST_BYTES {
692 return Err(io_error(path, "projectmanifest groter dan 4 MiB"));
693 }
694 let mut temporary_name = path
695 .file_name()
696 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
697 .to_os_string();
698 temporary_name.push(".tmp");
699 let temporary = parent.join(temporary_name);
700 let write_result = (|| {
701 let mut file =
702 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
703 file.write_all(text.as_bytes())
704 .map_err(|err| io_error(&temporary, &err.to_string()))?;
705 file.sync_all()
706 .map_err(|err| io_error(&temporary, &err.to_string()))?;
707 Ok(())
708 })();
709 if let Err(err) = write_result {
710 let _ = fs::remove_file(&temporary);
711 return Err(err);
712 }
713 if let Err(err) = fs::rename(&temporary, path) {
714 let _ = fs::remove_file(&temporary);
715 return Err(io_error(
716 path,
717 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
718 ));
719 }
720 Ok(())
721}
722
723fn io_error(path: &Path, message: &str) -> ProjectError {
724 ProjectError::Io {
725 path: path.display().to_string(),
726 message: message.to_string(),
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 fn temp_root(label: &str) -> PathBuf {
735 let path = std::env::temp_dir().join(format!(
736 "paperdoll-{label}-{}-{}",
737 std::process::id(),
738 new_id()
739 ));
740 let _ = fs::remove_dir_all(&path);
741 path
742 }
743
744 fn draft_from(document: &ProjectFile) -> ProjectDraft {
745 ProjectDraft {
746 id: document.id.clone(),
747 name: document.name.clone(),
748 male_model: document.male_model.clone().unwrap_or_default(),
749 female_model: document.female_model.clone().unwrap_or_default(),
750 equipment_presets: document.equipment_presets.clone(),
751 output_directory: document.output_directory.clone(),
752 target_width: document.render.target_width,
753 target_height: document.render.target_height,
754 fps: document.render.fps,
755 }
756 }
757
758 #[test]
759 fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {
760 let root = temp_root("resource-limits");
761 let mut opened = create_project(&root, "Limits").unwrap();
762 let before = fs::read(&opened.manifest_path).unwrap();
763 opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);
764 assert!(save_project(&opened).is_err());
765 assert!(write_manifest(&opened.manifest_path, &opened.document)
766 .unwrap_err()
767 .to_string()
768 .contains("4 MiB"));
769 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
770 let oversized = root.join("oversized.ppd");
771 fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();
772 assert!(open_project(&oversized)
773 .unwrap_err()
774 .to_string()
775 .contains("4 MiB"));
776 opened.document.name = "Limits".into();
777 opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)
778 .map(|i| format!("presets/item-{i}.ppe"))
779 .collect();
780 assert!(opened
781 .document
782 .validate_shape()
783 .unwrap_err()
784 .to_string()
785 .contains("64"));
786 fs::remove_dir_all(root).unwrap();
787 }
788
789 #[test]
790 fn roundtrip_keeps_null_slots_and_relative_paths() {
791 let root = temp_root("roundtrip");
792 let opened = create_project(&root, "Ridder").unwrap();
793 let text = fs::read_to_string(&opened.manifest_path).unwrap();
794 assert!(text.contains("\"male_model\": null"));
795 assert!(text.contains("\"female_model\": null"));
796 assert!(text.contains("\"output_directory\": \"renders\""));
797 assert!(!text.contains(root.to_string_lossy().as_ref()));
798 let loaded = open_project(&opened.manifest_path).unwrap();
799 assert_eq!(loaded.document, opened.document);
800 assert!(loaded.disk_issues.is_empty());
801 assert!(root.join("assets/body").is_dir());
802 assert!(root.join("presets").is_dir());
803 assert!(root.join("renders").is_dir());
804 let _ = fs::remove_dir_all(&root);
805 }
806
807 #[test]
808 fn save_writes_model_and_preset_paths_and_reloads_them() {
809 let root = temp_root("paths");
810 let mut opened = create_project(&root, "Ridder").unwrap();
811 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
812 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
813 opened.document.male_model = Some("assets/body/male.glb".to_string());
814 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
815 save_project(&opened).unwrap();
816 let loaded = open_project(&opened.manifest_path).unwrap();
817 assert_eq!(
818 loaded.document.male_model.as_deref(),
819 Some("assets/body/male.glb")
820 );
821 assert_eq!(loaded.document.female_model, None);
822 assert_eq!(
823 loaded.document.equipment_presets,
824 vec!["presets/zwaard.bin"]
825 );
826 assert!(loaded.disk_issues.is_empty());
827 let _ = fs::remove_dir_all(&root);
828 }
829
830 #[test]
831 fn validation_rejects_escape_absolute_body_and_output_overlap() {
832 let mut document = ProjectFile::new_empty("Ridder");
833 document.id = "ridder-1".to_string();
834 document.male_model = Some("../geheim.glb".to_string());
835 document.female_model = Some("/tmp/vrouw.glb".to_string());
836 document.equipment_presets = vec![
837 "presets/body.bin".to_string(),
838 "presets/Zwaard.bin".to_string(),
839 ];
840 document.output_directory = "presets".to_string();
841 let error = document.validate_shape().unwrap_err();
842 let text = error.to_string();
843 assert!(text.contains("buiten de projectmap"), "{text}");
844 assert!(text.contains("absoluut"), "{text}");
845 assert!(text.contains("body"), "{text}");
846 assert!(text.contains("kleine letters"), "{text}");
847 assert!(text.contains("uitvoermap"), "{text}");
848 }
849
850 #[test]
851 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
852 let newer = r#"{"format_version":4,"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}"#;
853 let error = parse_manifest(newer).unwrap_err();
854 assert!(matches!(
855 error,
856 ProjectError::UnsupportedVersion {
857 found: 4,
858 supported: 3
859 }
860 ));
861 assert!(error.to_string().contains("format_version 4"));
862 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}"#;
863 let error = parse_manifest(unknown).unwrap_err();
864 assert!(matches!(error, ProjectError::UnknownField(_)));
865 assert!(error.to_string().contains("niet herschreven"));
866 }
867
868 #[test]
869 fn failed_save_keeps_the_previous_manifest() {
870 let root = temp_root("atomic");
871 let opened = create_project(&root, "Ridder").unwrap();
872 let before = fs::read(&opened.manifest_path).unwrap();
873 let mut broken = opened.clone();
874 broken.document.output_directory = "..".to_string();
875 let error = save_project(&broken).unwrap_err();
876 assert!(error.to_string().contains("buiten de projectmap"));
877 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
878 broken.document.output_directory = "renders".to_string();
879 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
880 let error = save_project(&broken).unwrap_err();
881 assert!(error.to_string().contains("ontbreekt"), "{error}");
882 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
883 let _ = fs::remove_dir_all(&root);
884 }
885
886 #[test]
887 fn missing_manifest_is_a_recoverable_io_error() {
888 let path = temp_root("missing").join(MANIFEST_NAME);
889 let error = open_project(&path).unwrap_err();
890 let text = error.to_string();
891 assert!(text.contains("openen is mislukt"), "{text}");
892 assert!(matches!(error, ProjectError::Io { .. }));
893 }
894
895 #[test]
896 fn create_refuses_to_overwrite_an_existing_project() {
897 let root = temp_root("twice");
898 create_project(&root, "Eerste").unwrap();
899 let error = create_project(&root, "Tweede").unwrap_err();
900 assert!(error.to_string().contains("al een character.ppd"));
901 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
902 assert_eq!(loaded.document.name, "Eerste");
903 let _ = fs::remove_dir_all(&root);
904 }
905
906 #[test]
907 fn render_bounds_and_assemble_roundtrip() {
908 let mut document = ProjectFile::new_empty("Ridder");
909 document.id = "ridder-1".to_string();
910 document.render.target_width = 0;
911 assert!(document
912 .validate_shape()
913 .unwrap_err()
914 .to_string()
915 .contains("groter zijn dan 0"));
916 document.render = RenderSettings {
917 target_width: 4096,
918 target_height: 4096,
919 fps: 12,
920 ..RenderSettings::default()
921 };
922 assert!(document
923 .validate_shape()
924 .unwrap_err()
925 .to_string()
926 .contains("geschatte renderbuffer"));
927 document.render.fps = 0;
928 document.render.target_width = 128;
929 document.render.target_height = 128;
930 assert!(document
931 .validate_shape()
932 .unwrap_err()
933 .to_string()
934 .contains("fps"));
935 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
936 let text = serde_json::to_string(&assembled).unwrap();
937 let parsed = parse_manifest(&text).unwrap();
938 assert_eq!(parsed.render, RenderSettings::default());
939 assert_eq!(parsed.male_model, None);
940 }
941
942 #[cfg(unix)]
943 #[test]
944 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
945 let root = temp_root("link");
946 let opened = create_project(&root, "Ridder").unwrap();
947 let outside = temp_root("outside-file");
948 fs::create_dir_all(&outside).unwrap();
949 let secret = outside.join("geheim.glb");
950 fs::write(&secret, b"nope").unwrap();
951 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
952 let mut broken = opened.clone();
953 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
954 let error = save_project(&broken).unwrap_err();
955 assert!(
956 error.to_string().contains("buiten de projectmap"),
957 "{error}"
958 );
959 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
960 let entries = list_dir(&root, "assets/body").unwrap();
961 assert!(entries
962 .iter()
963 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
964 let error = list_dir(&root, "../").unwrap_err();
965 assert!(error.to_string().contains("buiten de projectmap"));
966 let _ = fs::remove_dir_all(&root);
967 let _ = fs::remove_dir_all(&outside);
968 }
969}
970
971#[cfg(test)]
972mod framing_tests {
973 use super::*;
974 #[test]
975 fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
976 assert_eq!(DEFAULT_FPS, 4);
977 assert_eq!(RenderSettings::default().fps, 4);
978 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
979 let mut stored = ProjectFile::new_empty("Oud");
980 stored.render.fps = 12;
981 let text = serde_json::to_string(&stored).unwrap();
982 assert_eq!(parse_manifest(&text).unwrap().render.fps, 12);
983 }
984 #[test]
985 fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
986 let mut p = assemble(ProjectDraft {
987 id: "fixed-framing".into(),
988 name: "Frames".into(),
989 male_model: String::new(),
990 female_model: String::new(),
991 equipment_presets: Vec::new(),
992 output_directory: "renders".into(),
993 target_width: 64,
994 target_height: 96,
995 fps: 12,
996 })
997 .unwrap();
998 p.render.framing = [
999 CameraFraming {
1000 height_milli: 3210,
1001 center_y_milli: 1100,
1002 pivot_y_milli: Some(1700),
1003 depth_range_milli: None,
1004 },
1005 CameraFraming {
1006 height_milli: 2890,
1007 center_y_milli: 987,
1008 pivot_y_milli: Some(1650),
1009 depth_range_milli: None,
1010 },
1011 ];
1012 let root = std::env::temp_dir().join(format!(
1013 "paperdoll-framing-{}-{}",
1014 std::process::id(),
1015 std::time::SystemTime::now()
1016 .duration_since(std::time::UNIX_EPOCH)
1017 .unwrap()
1018 .as_nanos()
1019 ));
1020 let mut opened = create_project(&root, "Fixed framing").unwrap();
1021 opened.document.render.framing = p.render.framing;
1022 save_project(&opened).unwrap();
1023 assert_eq!(
1024 open_project(&opened.manifest_path)
1025 .unwrap()
1026 .document
1027 .render
1028 .framing,
1029 p.render.framing
1030 );
1031 std::fs::remove_dir_all(root).unwrap();
1032 let mut value = serde_json::to_value(&p).unwrap();
1033 let text = serde_json::to_string(&value).unwrap();
1034 assert_eq!(
1035 parse_manifest(&text).unwrap().render.framing,
1036 p.render.framing
1037 );
1038 value["format_version"] = serde_json::json!(1);
1039 value["render"].as_object_mut().unwrap().remove("framing");
1040 let migrated = parse_manifest(&value.to_string()).unwrap();
1041 assert_eq!(migrated.format_version, 3);
1042 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1043 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1044 assert!(parse_manifest(&value.to_string())
1045 .unwrap()
1046 .validate_shape()
1047 .is_err());
1048 value["format_version"] = serde_json::json!(2);
1049 value["render"]["framing"] = serde_json::json!([
1050 {"height_milli":2600,"center_y_milli":1000},
1051 {"height_milli":2600,"center_y_milli":1000}
1052 ]);
1053 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1054 assert_eq!(migrated_v2.format_version, 3);
1055 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1056 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
1057 }
1058}