BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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