BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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