BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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