BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / project.rs

project.rs

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