BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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