BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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