BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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