BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

project.rs 36.3 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 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
496 u64::from(width)
497 .checked_mul(u64::from(height))?
498 .checked_mul(64)?
499 .checked_mul(36)?
500 .checked_mul(3)?
501 .checked_mul(4)
502}
503
504fn new_id() -> String {
505 let nanos = SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .map(|duration| duration.as_nanos())
508 .unwrap_or(0);
509 format!("ppd-{nanos:x}")
510}
511
512fn blank_to_none(value: String) -> Option<String> {
513 let trimmed = value.trim();
514 if trimmed.is_empty() {
515 None
516 } else {
517 Some(trimmed.to_string())
518 }
519}
520
521fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
522 let Some(value) = value else {
523 return;
524 };
525 match portable_relative(value) {
526 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
527 Ok(_) if value == MANIFEST_NAME => {
528 issues.push(format!("{label} mag niet character.ppd zijn"));
529 }
530 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
531 Err(message) => issues.push(format!("{label}: {message}")),
532 }
533}
534
535pub fn portable_relative(value: &str) -> Result<String, String> {
536 let value = value.trim();
537 if value.is_empty() {
538 return Err("het pad is leeg".to_string());
539 }
540 if value.contains('\\') {
541 return Err("gebruik '/' in projectpaden".to_string());
542 }
543 if value.starts_with('/') || windows_drive(value) {
544 return Err(
545 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
546 .to_string(),
547 );
548 }
549 let mut parts = Vec::new();
550 for part in value.split('/') {
551 if part.is_empty() || part == "." || part == ".." {
552 return Err(
553 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
554 .to_string(),
555 );
556 }
557 if part.contains('\0') {
558 return Err("het pad bevat een leeg teken".to_string());
559 }
560 parts.push(part);
561 }
562 Ok(parts.join("/"))
563}
564
565fn windows_drive(value: &str) -> bool {
566 let mut chars = value.chars();
567 matches!(
568 (chars.next(), chars.next(), chars.next()),
569 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
570 )
571}
572
573fn preset_stem(relative: &str) -> Result<String, String> {
574 let file_name = relative.rsplit('/').next().unwrap_or(relative);
575 let stem = file_name.split('.').next().unwrap_or(file_name);
576 if stem.is_empty()
577 || !stem
578 .chars()
579 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
580 || stem.contains(' ')
581 {
582 return Err(format!(
583 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
584 ));
585 }
586 Ok(stem.to_string())
587}
588
589fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
590 let mut issues = Vec::new();
591 for relative in document.sources() {
592 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
593 issues.extend(extra);
594 }
595 }
596 let output = root.join(&document.output_directory);
597 if output.exists() && !output.is_dir() {
598 issues.push(format!(
599 "de uitvoermap '{}' is een bestand",
600 document.output_directory
601 ));
602 } else if !output.exists() {
603 issues.push(format!(
604 "de uitvoermap '{}' bestaat nog niet",
605 document.output_directory
606 ));
607 }
608 issues
609}
610
611fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
612 let path = root.join(relative);
613 if !path.is_file() {
614 return Err(ProjectError::Validation(vec![format!(
615 "het bestand ontbreekt in de projectmap: {relative}"
616 )]));
617 }
618 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
619 match (root.canonicalize(), path.canonicalize()) {
620 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
621 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
622 "het pad '{relative}' wijst buiten de projectmap"
623 )])),
624 (Err(err), _) => Err(io_error(root, &err.to_string())),
625 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
626 }
627}
628
629pub(crate) fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
630 let path = root.join(relative);
631 if path.exists() && !path.is_dir() {
632 return Err(ProjectError::Validation(vec![format!(
633 "de uitvoermap '{relative}' is een bestand"
634 )]));
635 }
636 if !path.exists() {
637 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
638 }
639 ensure_lexically_inside(root, &path)
640 .map_err(|message| ProjectError::Validation(vec![message]))?;
641 Ok(())
642}
643
644fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
645 let root = root.components().collect::<Vec<_>>();
646 let path = path.components().collect::<Vec<_>>();
647 if path.starts_with(&root) {
648 Ok(())
649 } else {
650 Err("het pad wijst buiten de projectmap".to_string())
651 }
652}
653
654fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
655 let parent = path
656 .parent()
657 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
658 let text = serde_json::to_string_pretty(document)
659 .map_err(|err| ProjectError::Json(err.to_string()))?
660 + "\n";
661 if text.len() > MAX_MANIFEST_BYTES {
662 return Err(io_error(path, "projectmanifest groter dan 4 MiB"));
663 }
664 let mut temporary_name = path
665 .file_name()
666 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
667 .to_os_string();
668 temporary_name.push(".tmp");
669 let temporary = parent.join(temporary_name);
670 let write_result = (|| {
671 let mut file =
672 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
673 file.write_all(text.as_bytes())
674 .map_err(|err| io_error(&temporary, &err.to_string()))?;
675 file.sync_all()
676 .map_err(|err| io_error(&temporary, &err.to_string()))?;
677 Ok(())
678 })();
679 if let Err(err) = write_result {
680 let _ = fs::remove_file(&temporary);
681 return Err(err);
682 }
683 if let Err(err) = fs::rename(&temporary, path) {
684 let _ = fs::remove_file(&temporary);
685 return Err(io_error(
686 path,
687 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
688 ));
689 }
690 Ok(())
691}
692
693fn io_error(path: &Path, message: &str) -> ProjectError {
694 ProjectError::Io {
695 path: path.display().to_string(),
696 message: message.to_string(),
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn temp_root(label: &str) -> PathBuf {
705 let path = std::env::temp_dir().join(format!(
706 "paperdoll-{label}-{}-{}",
707 std::process::id(),
708 new_id()
709 ));
710 let _ = fs::remove_dir_all(&path);
711 path
712 }
713
714 fn draft_from(document: &ProjectFile) -> ProjectDraft {
715 ProjectDraft {
716 id: document.id.clone(),
717 name: document.name.clone(),
718 male_model: document.male_model.clone().unwrap_or_default(),
719 female_model: document.female_model.clone().unwrap_or_default(),
720 equipment_presets: document.equipment_presets.clone(),
721 output_directory: document.output_directory.clone(),
722 target_width: document.render.target_width,
723 target_height: document.render.target_height,
724 fps: document.render.fps,
725 }
726 }
727
728 #[test]
729 fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {
730 let root = temp_root("resource-limits");
731 let mut opened = create_project(&root, "Limits").unwrap();
732 let before = fs::read(&opened.manifest_path).unwrap();
733 opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);
734 assert!(save_project(&opened).is_err());
735 assert!(write_manifest(&opened.manifest_path, &opened.document)
736 .unwrap_err()
737 .to_string()
738 .contains("4 MiB"));
739 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
740 let oversized = root.join("oversized.ppd");
741 fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();
742 assert!(open_project(&oversized)
743 .unwrap_err()
744 .to_string()
745 .contains("4 MiB"));
746 opened.document.name = "Limits".into();
747 opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)
748 .map(|i| format!("presets/item-{i}.ppe"))
749 .collect();
750 assert!(opened
751 .document
752 .validate_shape()
753 .unwrap_err()
754 .to_string()
755 .contains("64"));
756 fs::remove_dir_all(root).unwrap();
757 }
758
759 #[test]
760 fn roundtrip_keeps_null_slots_and_relative_paths() {
761 let root = temp_root("roundtrip");
762 let opened = create_project(&root, "Ridder").unwrap();
763 let text = fs::read_to_string(&opened.manifest_path).unwrap();
764 assert!(text.contains("\"male_model\": null"));
765 assert!(text.contains("\"female_model\": null"));
766 assert!(text.contains("\"output_directory\": \"renders\""));
767 assert!(!text.contains(root.to_string_lossy().as_ref()));
768 let loaded = open_project(&opened.manifest_path).unwrap();
769 assert_eq!(loaded.document, opened.document);
770 assert!(loaded.disk_issues.is_empty());
771 assert!(root.join("assets/body").is_dir());
772 assert!(root.join("presets").is_dir());
773 assert!(root.join("renders").is_dir());
774 let _ = fs::remove_dir_all(&root);
775 }
776
777 #[test]
778 fn save_writes_model_and_preset_paths_and_reloads_them() {
779 let root = temp_root("paths");
780 let mut opened = create_project(&root, "Ridder").unwrap();
781 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
782 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
783 opened.document.male_model = Some("assets/body/male.glb".to_string());
784 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
785 save_project(&opened).unwrap();
786 let loaded = open_project(&opened.manifest_path).unwrap();
787 assert_eq!(
788 loaded.document.male_model.as_deref(),
789 Some("assets/body/male.glb")
790 );
791 assert_eq!(loaded.document.female_model, None);
792 assert_eq!(
793 loaded.document.equipment_presets,
794 vec!["presets/zwaard.bin"]
795 );
796 assert!(loaded.disk_issues.is_empty());
797 let _ = fs::remove_dir_all(&root);
798 }
799
800 #[test]
801 fn validation_rejects_escape_absolute_body_and_output_overlap() {
802 let mut document = ProjectFile::new_empty("Ridder");
803 document.id = "ridder-1".to_string();
804 document.male_model = Some("../geheim.glb".to_string());
805 document.female_model = Some("/tmp/vrouw.glb".to_string());
806 document.equipment_presets = vec![
807 "presets/body.bin".to_string(),
808 "presets/Zwaard.bin".to_string(),
809 ];
810 document.output_directory = "presets".to_string();
811 let error = document.validate_shape().unwrap_err();
812 let text = error.to_string();
813 assert!(text.contains("buiten de projectmap"), "{text}");
814 assert!(text.contains("absoluut"), "{text}");
815 assert!(text.contains("body"), "{text}");
816 assert!(text.contains("kleine letters"), "{text}");
817 assert!(text.contains("uitvoermap"), "{text}");
818 }
819
820 #[test]
821 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
822 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}"#;
823 let error = parse_manifest(newer).unwrap_err();
824 assert!(matches!(
825 error,
826 ProjectError::UnsupportedVersion {
827 found: 4,
828 supported: 3
829 }
830 ));
831 assert!(error.to_string().contains("format_version 4"));
832 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}"#;
833 let error = parse_manifest(unknown).unwrap_err();
834 assert!(matches!(error, ProjectError::UnknownField(_)));
835 assert!(error.to_string().contains("niet herschreven"));
836 }
837
838 #[test]
839 fn failed_save_keeps_the_previous_manifest() {
840 let root = temp_root("atomic");
841 let opened = create_project(&root, "Ridder").unwrap();
842 let before = fs::read(&opened.manifest_path).unwrap();
843 let mut broken = opened.clone();
844 broken.document.output_directory = "..".to_string();
845 let error = save_project(&broken).unwrap_err();
846 assert!(error.to_string().contains("buiten de projectmap"));
847 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
848 broken.document.output_directory = "renders".to_string();
849 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
850 let error = save_project(&broken).unwrap_err();
851 assert!(error.to_string().contains("ontbreekt"), "{error}");
852 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
853 let _ = fs::remove_dir_all(&root);
854 }
855
856 #[test]
857 fn missing_manifest_is_a_recoverable_io_error() {
858 let path = temp_root("missing").join(MANIFEST_NAME);
859 let error = open_project(&path).unwrap_err();
860 let text = error.to_string();
861 assert!(text.contains("openen is mislukt"), "{text}");
862 assert!(matches!(error, ProjectError::Io { .. }));
863 }
864
865 #[test]
866 fn create_refuses_to_overwrite_an_existing_project() {
867 let root = temp_root("twice");
868 create_project(&root, "Eerste").unwrap();
869 let error = create_project(&root, "Tweede").unwrap_err();
870 assert!(error.to_string().contains("al een character.ppd"));
871 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
872 assert_eq!(loaded.document.name, "Eerste");
873 let _ = fs::remove_dir_all(&root);
874 }
875
876 #[test]
877 fn render_bounds_and_assemble_roundtrip() {
878 let mut document = ProjectFile::new_empty("Ridder");
879 document.id = "ridder-1".to_string();
880 document.render.target_width = 0;
881 assert!(document
882 .validate_shape()
883 .unwrap_err()
884 .to_string()
885 .contains("groter zijn dan 0"));
886 document.render = RenderSettings {
887 target_width: 4096,
888 target_height: 4096,
889 fps: 12,
890 framing: [CameraFraming::default(); 2],
891 };
892 assert!(document
893 .validate_shape()
894 .unwrap_err()
895 .to_string()
896 .contains("geschatte renderbuffer"));
897 document.render.fps = 0;
898 document.render.target_width = 128;
899 document.render.target_height = 128;
900 assert!(document
901 .validate_shape()
902 .unwrap_err()
903 .to_string()
904 .contains("fps"));
905 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
906 let text = serde_json::to_string(&assembled).unwrap();
907 let parsed = parse_manifest(&text).unwrap();
908 assert_eq!(parsed.render, RenderSettings::default());
909 assert_eq!(parsed.male_model, None);
910 }
911
912 #[cfg(unix)]
913 #[test]
914 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
915 let root = temp_root("link");
916 let opened = create_project(&root, "Ridder").unwrap();
917 let outside = temp_root("outside-file");
918 fs::create_dir_all(&outside).unwrap();
919 let secret = outside.join("geheim.glb");
920 fs::write(&secret, b"nope").unwrap();
921 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
922 let mut broken = opened.clone();
923 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
924 let error = save_project(&broken).unwrap_err();
925 assert!(
926 error.to_string().contains("buiten de projectmap"),
927 "{error}"
928 );
929 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
930 let entries = list_dir(&root, "assets/body").unwrap();
931 assert!(entries
932 .iter()
933 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
934 let error = list_dir(&root, "../").unwrap_err();
935 assert!(error.to_string().contains("buiten de projectmap"));
936 let _ = fs::remove_dir_all(&root);
937 let _ = fs::remove_dir_all(&outside);
938 }
939}
940
941#[cfg(test)]
942mod framing_tests {
943 use super::*;
944 #[test]
945 fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
946 let mut p = assemble(ProjectDraft {
947 id: "fixed-framing".into(),
948 name: "Frames".into(),
949 male_model: String::new(),
950 female_model: String::new(),
951 equipment_presets: Vec::new(),
952 output_directory: "renders".into(),
953 target_width: 64,
954 target_height: 96,
955 fps: 12,
956 })
957 .unwrap();
958 p.render.framing = [
959 CameraFraming {
960 height_milli: 3210,
961 center_y_milli: 1100,
962 pivot_y_milli: Some(1700),
963 },
964 CameraFraming {
965 height_milli: 2890,
966 center_y_milli: 987,
967 pivot_y_milli: Some(1650),
968 },
969 ];
970 let root = std::env::temp_dir().join(format!(
971 "paperdoll-framing-{}-{}",
972 std::process::id(),
973 std::time::SystemTime::now()
974 .duration_since(std::time::UNIX_EPOCH)
975 .unwrap()
976 .as_nanos()
977 ));
978 let mut opened = create_project(&root, "Fixed framing").unwrap();
979 opened.document.render.framing = p.render.framing;
980 save_project(&opened).unwrap();
981 assert_eq!(
982 open_project(&opened.manifest_path)
983 .unwrap()
984 .document
985 .render
986 .framing,
987 p.render.framing
988 );
989 std::fs::remove_dir_all(root).unwrap();
990 let mut value = serde_json::to_value(&p).unwrap();
991 let text = serde_json::to_string(&value).unwrap();
992 assert_eq!(
993 parse_manifest(&text).unwrap().render.framing,
994 p.render.framing
995 );
996 value["format_version"] = serde_json::json!(1);
997 value["render"].as_object_mut().unwrap().remove("framing");
998 let migrated = parse_manifest(&value.to_string()).unwrap();
999 assert_eq!(migrated.format_version, 3);
1000 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1001 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1002 assert!(parse_manifest(&value.to_string())
1003 .unwrap()
1004 .validate_shape()
1005 .is_err());
1006 value["format_version"] = serde_json::json!(2);
1007 value["render"]["framing"] = serde_json::json!([
1008 {"height_milli":2600,"center_y_milli":1000},
1009 {"height_milli":2600,"center_y_milli":1000}
1010 ]);
1011 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1012 assert_eq!(migrated_v2.format_version, 3);
1013 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1014 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
1015 }
1016}