BlueGit

golden-age paperdoll-renderer

feat: add .ppd project files and the project window

Store male and female model paths, equipment preset paths and the output
directory relative to a project folder. New, open and save validate the
manifest and keep the previous file when a save fails.

Co-authored-by: tim <tim@timfalken.com>

timfa Cursor Agent · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
c4b0a41fd32966a07a49cd491c604b27e88a0c27
Parent
eb7dd0ce
9 files changed +1268 −52
Cargo.lock +2 −0
@@ -1744,6 +1744,7 @@ version = "0.1.0"
1744 1744 dependencies = [
1745 1745 "eframe",
1746 1746 "libloading",
1747 "serde",
1747 1748 "serde_json",
1748 1749 ]
1749 1750
@@ -2048,6 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2048 2049 checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2049 2050 dependencies = [
2050 2051 "serde_core",
2052 "serde_derive",
2051 2053 ]
2052 2054
2053 2055 [[package]]
@@ -1744,6 +1744,7 @@ version = "0.1.0"
1744dependencies = [
1744dependencies = [
1745 "eframe",
1745 "eframe",
1746 "libloading",
1746 "libloading",
1747 "serde",
1747 "serde_json",
1748 "serde_json",
1748]
1749]
1749
1750
@@ -2048,6 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2048checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2049checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2049dependencies = [
2050dependencies = [
2050 "serde_core",
2051 "serde_core",
2052 "serde_derive",
2051]
2053]
2052
2054
2053[[package]]
2055[[package]]
Cargo.toml +2 −3
@@ -6,9 +6,8 @@ license = "MIT"
6 6 publish = false
7 7 description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
8 8
9 [dev-dependencies]
10 serde_json = "1"
11
12 9 [dependencies]
13 10 eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
14 11 libloading = "0.8"
12 serde = { version = "1", features = ["derive"] }
13 serde_json = "1"
@@ -6,9 +6,8 @@ license = "MIT"
6publish = false
6publish = false
7description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
7description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
8
8
9[dev-dependencies]
10serde_json = "1"
11
12[dependencies]
9[dependencies]
13eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
14libloading = "0.8"
11libloading = "0.8"
12serde = { version = "1", features = ["derive"] }
13serde_json = "1"
README.md +2 −2
@@ -1,6 +1,6 @@
1 1 # Paperdoll Renderer
2 2
3 Native Rust-projectbasis voor Tim Falkens Paperdoll Renderer. Het programma toont een statusvenster. Het importeert geen modellen en het rendert geen paperdolls. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3 Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster maakt en bewaart een projectmap met één `character.ppd`: paden voor een mannelijk en vrouwelijk model, paden naar equipment-presets, en een uitvoermap, allemaal relatief aan die projectmap. Het importeert die modellen niet en het rendert geen paperdolls. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
4 4
5 5 ## Eisen
6 6
@@ -17,7 +17,7 @@ cargo run -- --status
17 17 cargo run
18 18 ```
19 19
20 `--status` print de capability-lijst en opent geen venster. Een pad naar een model wordt geweigerd.
20 `--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.
21 21
22 22 ## Testlichamen
23 23
@@ -1,6 +1,6 @@
1# Paperdoll Renderer
1# Paperdoll Renderer
2
2
3Native Rust-projectbasis voor Tim Falkens Paperdoll Renderer. Het programma toont een statusvenster. Het importeert geen modellen en het rendert geen paperdolls. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster maakt en bewaart een projectmap met één `character.ppd`: paden voor een mannelijk en vrouwelijk model, paden naar equipment-presets, en een uitvoermap, allemaal relatief aan die projectmap. Het importeert die modellen niet en het rendert geen paperdolls. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
4
4
5## Eisen
5## Eisen
6
6
@@ -17,7 +17,7 @@ cargo run -- --status
17cargo run
17cargo run
18```
18```
19
19
20`--status` print de capability-lijst en opent geen venster. Een pad naar een model wordt geweigerd.
20`--status` print de capability-lijst en opent geen venster. Een modelpad op de opdrachtregel wordt geweigerd. In het venster vullen Nieuw, Open en Opslaan een projectmap. Kies daarna bestanden in die map; de opgeslagen paden blijven relatief.
21
21
22## Testlichamen
22## Testlichamen
23
23
design.md +4 −0
@@ -86,6 +86,10 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets
86 86
87 87 Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
88 88
89 ### format_version 1
90
91 Implemented project file: one UTF-8 JSON manifest per project directory, default name `character.ppd`. Fields are `format_version` (exactly 1), `id`, `name`, `male_model`, `female_model`, `equipment_presets`, `output_directory`, and `render` with `target_width`, `target_height` and `fps`. Sex slots are JSON null when empty. Paths use `/` and stay inside the project directory. A preset file stem is a lowercase slug and is not `body`. Unknown fields are rejected, so a rewrite cannot drop them. Any other `format_version` is rejected before the rest of the object is accepted. Save writes a temporary sibling in the same directory and renames it over the manifest only after the bytes are on disk. A failed save leaves the previous manifest in place. Naming a model file does not import or render it. Animation inventory and import transforms are not part of version 1.
92
89 93 ## 6. Camera, axes, alignment and animation sampling
90 94
91 95 Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
@@ -86,6 +86,10 @@ Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets
86
86
87Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
87Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
88
88
89### format_version 1
90
91Implemented project file: one UTF-8 JSON manifest per project directory, default name `character.ppd`. Fields are `format_version` (exactly 1), `id`, `name`, `male_model`, `female_model`, `equipment_presets`, `output_directory`, and `render` with `target_width`, `target_height` and `fps`. Sex slots are JSON null when empty. Paths use `/` and stay inside the project directory. A preset file stem is a lowercase slug and is not `body`. Unknown fields are rejected, so a rewrite cannot drop them. Any other `format_version` is rejected before the rest of the object is accepted. Save writes a temporary sibling in the same directory and renames it over the manifest only after the bytes are on disk. A failed save leaves the previous manifest in place. Naming a model file does not import or render it. Animation inventory and import transforms are not part of version 1.
92
89## 6. Camera, axes, alignment and animation sampling
93## 6. Camera, axes, alignment and animation sampling
90
94
91Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
95Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
src/editor.rs added +411 −0
@@ -0,0 +1,411 @@
1 //! Project window. It edits a `.ppd` manifest and does not import or render.
2
3 use std::path::PathBuf;
4
5 use eframe::egui;
6
7 use crate::project::{
8 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
10 };
11
12 pub struct EditorApp {
13 root_input: String,
14 open_input: String,
15 opened: Option<OpenedProject>,
16 name: String,
17 male: String,
18 female: String,
19 presets: Vec<String>,
20 preset_input: String,
21 output: String,
22 width: String,
23 height: String,
24 fps: String,
25 browser: String,
26 notice: String,
27 notice_ok: bool,
28 }
29
30 impl EditorApp {
31 pub fn new() -> Self {
32 Self {
33 root_input: String::new(),
34 open_input: String::new(),
35 opened: None,
36 name: "Nieuw project".to_string(),
37 male: String::new(),
38 female: String::new(),
39 presets: Vec::new(),
40 preset_input: String::new(),
41 output: "renders".to_string(),
42 width: "128".to_string(),
43 height: "128".to_string(),
44 fps: "12".to_string(),
45 browser: String::new(),
46 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
47 notice_ok: true,
48 }
49 }
50
51 fn load(&mut self, opened: OpenedProject, notice: &str) {
52 self.name = opened.document.name.clone();
53 self.male = opened.document.male_model.clone().unwrap_or_default();
54 self.female = opened.document.female_model.clone().unwrap_or_default();
55 self.presets = opened.document.equipment_presets.clone();
56 self.output = opened.document.output_directory.clone();
57 self.width = opened.document.render.target_width.to_string();
58 self.height = opened.document.render.target_height.to_string();
59 self.fps = opened.document.render.fps.to_string();
60 self.browser.clear();
61 self.opened = Some(opened.clone());
62 if opened.disk_issues.is_empty() {
63 self.ok(notice);
64 } else {
65 self.fail_text(opened.disk_issues.join("\n"));
66 }
67 }
68
69 fn ok(&mut self, message: &str) {
70 self.notice = message.to_string();
71 self.notice_ok = true;
72 }
73
74 fn fail(&mut self, error: ProjectError) {
75 self.notice = error.to_string();
76 self.notice_ok = false;
77 }
78
79 fn fail_text(&mut self, message: impl Into<String>) {
80 self.notice = message.into();
81 self.notice_ok = false;
82 }
83
84 fn draft(&self) -> Result<ProjectDraft, String> {
85 let opened = self
86 .opened
87 .as_ref()
88 .ok_or_else(|| "open of maak eerst een project".to_string())?;
89 Ok(ProjectDraft {
90 id: opened.document.id.clone(),
91 name: self.name.clone(),
92 male_model: self.male.clone(),
93 female_model: self.female.clone(),
94 equipment_presets: self.presets.clone(),
95 output_directory: self.output.clone(),
96 target_width: parse_u32("breedte", &self.width)?,
97 target_height: parse_u32("hoogte", &self.height)?,
98 fps: parse_u32("fps", &self.fps)?,
99 })
100 }
101
102 fn create(&mut self) {
103 let root = PathBuf::from(self.root_input.trim());
104 if self.root_input.trim().is_empty() {
105 self.fail_text("vul de projectmap in");
106 return;
107 }
108 match create_project(&root, "Nieuw project") {
109 Ok(opened) => self.load(
110 opened,
111 "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
112 ),
113 Err(error) => self.fail(error),
114 }
115 }
116
117 fn open(&mut self) {
118 let path = PathBuf::from(self.open_input.trim());
119 if self.open_input.trim().is_empty() {
120 self.fail_text("vul het pad naar een .ppd-bestand in");
121 return;
122 }
123 match open_project(&path) {
124 Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
125 Err(error) => self.fail(error),
126 }
127 }
128
129 fn save(&mut self) {
130 let Some(current) = self.opened.clone() else {
131 self.fail_text("open of maak eerst een project");
132 return;
133 };
134 let draft = match self.draft() {
135 Ok(draft) => draft,
136 Err(message) => {
137 self.fail_text(message);
138 return;
139 }
140 };
141 let document = match assemble(draft) {
142 Ok(document) => document,
143 Err(error) => {
144 self.fail(error);
145 return;
146 }
147 };
148 let candidate = OpenedProject {
149 manifest_path: current.manifest_path,
150 document,
151 disk_issues: Vec::new(),
152 };
153 match save_project(&candidate) {
154 Ok(()) => {
155 let path = candidate.manifest_path.display().to_string();
156 self.opened = Some(candidate);
157 self.ok(&format!(
158 "Opgeslagen naar {path}. Import en render zijn niet uitgevoerd."
159 ));
160 }
161 Err(error) => self.fail(error),
162 }
163 }
164
165 fn add_preset(&mut self, relative: String) {
166 if relative.trim().is_empty() {
167 return;
168 }
169 self.presets.push(relative);
170 if let Err(error) = self
171 .draft()
172 .and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
173 {
174 self.presets.pop();
175 self.fail_text(error);
176 } else {
177 self.preset_input.clear();
178 self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
179 }
180 }
181
182 fn assign_file(&mut self, relative: &str) {
183 if relative == MANIFEST_NAME {
184 self.fail_text("character.ppd is het projectbestand, geen model of preset");
185 return;
186 }
187 let lower = relative.to_ascii_lowercase();
188 if lower.contains("/preset") || lower.starts_with("presets/") {
189 self.add_preset(relative.to_string());
190 } else if self.male.trim().is_empty() {
191 self.male = relative.to_string();
192 self.ok("Pad ingevuld bij het mannelijke model. Het model wordt niet geladen.");
193 } else if self.female.trim().is_empty() {
194 self.female = relative.to_string();
195 self.ok("Pad ingevuld bij het vrouwelijke model. Het model wordt niet geladen.");
196 } else {
197 self.add_preset(relative.to_string());
198 }
199 }
200 }
201
202 impl eframe::App for EditorApp {
203 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
204 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
205 ui.horizontal_wrapped(|ui| {
206 if ui.button("Nieuw").clicked() {
207 self.create();
208 }
209 if ui.button("Open").clicked() {
210 self.open();
211 }
212 if ui.button("Opslaan").clicked() {
213 self.save();
214 }
215 if ui.button("Sluiten").clicked() {
216 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
217 }
218 ui.label(format!("format_version {FORMAT_VERSION}"));
219 });
220 });
221 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
222 let color = if self.notice_ok {
223 egui::Color32::from_rgb(20, 90, 40)
224 } else {
225 egui::Color32::from_rgb(150, 30, 30)
226 };
227 ui.colored_label(color, &self.notice);
228 });
229 egui::SidePanel::right("browser")
230 .min_width(240.0)
231 .show(ctx, |ui| {
232 self.show_browser(ui);
233 });
234 egui::CentralPanel::default().show(ctx, |ui| {
235 egui::ScrollArea::vertical().show(ui, |ui| {
236 self.show_form(ui);
237 });
238 });
239 }
240 }
241
242 impl EditorApp {
243 fn show_form(&mut self, ui: &mut egui::Ui) {
244 ui.heading("Paperdoll Renderer");
245 ui.label("Projectmap met één .ppd-bestand. Modellen kiezen slaat alleen het pad op.");
246 ui.add_space(8.0);
247 ui.horizontal(|ui| {
248 ui.label("Projectmap");
249 ui.add(
250 egui::TextEdit::singleline(&mut self.root_input)
251 .desired_width(360.0)
252 .hint_text("/pad/naar/nieuwe-map"),
253 );
254 });
255 ui.horizontal(|ui| {
256 ui.label(".ppd openen");
257 ui.add(
258 egui::TextEdit::singleline(&mut self.open_input)
259 .desired_width(360.0)
260 .hint_text("/pad/naar/character.ppd"),
261 );
262 });
263 ui.separator();
264 ui.horizontal(|ui| {
265 ui.label("Naam");
266 ui.text_edit_singleline(&mut self.name);
267 });
268 ui.horizontal(|ui| {
269 ui.label("Man");
270 ui.add(
271 egui::TextEdit::singleline(&mut self.male)
272 .desired_width(360.0)
273 .hint_text("assets/body/male.glb"),
274 );
275 if ui.button("Wis man").clicked() {
276 self.male.clear();
277 }
278 });
279 ui.horizontal(|ui| {
280 ui.label("Vrouw");
281 ui.add(
282 egui::TextEdit::singleline(&mut self.female)
283 .desired_width(360.0)
284 .hint_text("assets/body/female.glb"),
285 );
286 if ui.button("Wis vrouw").clicked() {
287 self.female.clear();
288 }
289 });
290 ui.label("Equipment-presets, relatief aan de projectmap");
291 let mut remove = None;
292 for (index, preset) in self.presets.iter().enumerate() {
293 ui.horizontal(|ui| {
294 ui.label(preset);
295 if ui.button("Verwijder").clicked() {
296 remove = Some(index);
297 }
298 });
299 }
300 if let Some(index) = remove {
301 self.presets.remove(index);
302 }
303 ui.horizontal(|ui| {
304 ui.add(
305 egui::TextEdit::singleline(&mut self.preset_input)
306 .desired_width(280.0)
307 .hint_text("presets/zwaard.bin"),
308 );
309 if ui.button("Voeg preset toe").clicked() {
310 let value = self.preset_input.trim().to_string();
311 self.add_preset(value);
312 }
313 });
314 ui.horizontal(|ui| {
315 ui.label("Uitvoer");
316 ui.text_edit_singleline(&mut self.output);
317 });
318 ui.horizontal(|ui| {
319 ui.label("Breedte");
320 ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
321 ui.label("Hoogte");
322 ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
323 ui.label("fps");
324 ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
325 });
326 ui.add_space(8.0);
327 ui.label(
328 "Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",
329 );
330 }
331
332 fn show_browser(&mut self, ui: &mut egui::Ui) {
333 ui.heading("Bestanden");
334 let Some(opened) = self.opened.clone() else {
335 ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
336 return;
337 };
338 let root = opened
339 .manifest_path
340 .parent()
341 .map(PathBuf::from)
342 .unwrap_or_default();
343 ui.label(if self.browser.is_empty() {
344 "projectmap".to_string()
345 } else {
346 self.browser.clone()
347 });
348 if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
349 self.browser = parent_relative(&self.browser);
350 }
351 if ui.button("Deze map als uitvoer").clicked() {
352 if self.browser.is_empty() {
353 self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
354 } else {
355 self.output = self.browser.clone();
356 self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
357 }
358 }
359 let entries = match list_dir(&root, &self.browser) {
360 Ok(entries) => entries,
361 Err(error) => {
362 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
363 return;
364 }
365 };
366 let mut enter = None;
367 let mut choose = None;
368 for entry in &entries {
369 let label = if entry.is_dir {
370 format!("{}/", entry.name)
371 } else {
372 entry.name.clone()
373 };
374 if ui.button(label).clicked() {
375 if entry.is_dir {
376 enter = Some(entry.relative.clone());
377 } else {
378 choose = Some(entry.relative.clone());
379 }
380 }
381 }
382 if let Some(relative) = enter {
383 self.browser = relative;
384 }
385 if let Some(relative) = choose {
386 self.assign_file(&relative);
387 }
388 }
389 }
390
391 fn parent_relative(relative: &str) -> String {
392 match relative.rfind('/') {
393 Some(index) => relative[..index].to_string(),
394 None => String::new(),
395 }
396 }
397
398 fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
399 let text = text.trim();
400 if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
401 return Err(format!("{label} moet een geheel getal zijn"));
402 }
403 text.parse::<u32>()
404 .map_err(|_| format!("{label} is te groot"))
405 }
406
407 impl Default for EditorApp {
408 fn default() -> Self {
409 Self::new()
410 }
411 }
@@ -0,0 +1,411 @@
1//! Project window. It edits a `.ppd` manifest and does not import or render.
2
3use std::path::PathBuf;
4
5use eframe::egui;
6
7use crate::project::{
8 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
10};
11
12pub struct EditorApp {
13 root_input: String,
14 open_input: String,
15 opened: Option<OpenedProject>,
16 name: String,
17 male: String,
18 female: String,
19 presets: Vec<String>,
20 preset_input: String,
21 output: String,
22 width: String,
23 height: String,
24 fps: String,
25 browser: String,
26 notice: String,
27 notice_ok: bool,
28}
29
30impl EditorApp {
31 pub fn new() -> Self {
32 Self {
33 root_input: String::new(),
34 open_input: String::new(),
35 opened: None,
36 name: "Nieuw project".to_string(),
37 male: String::new(),
38 female: String::new(),
39 presets: Vec::new(),
40 preset_input: String::new(),
41 output: "renders".to_string(),
42 width: "128".to_string(),
43 height: "128".to_string(),
44 fps: "12".to_string(),
45 browser: String::new(),
46 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
47 notice_ok: true,
48 }
49 }
50
51 fn load(&mut self, opened: OpenedProject, notice: &str) {
52 self.name = opened.document.name.clone();
53 self.male = opened.document.male_model.clone().unwrap_or_default();
54 self.female = opened.document.female_model.clone().unwrap_or_default();
55 self.presets = opened.document.equipment_presets.clone();
56 self.output = opened.document.output_directory.clone();
57 self.width = opened.document.render.target_width.to_string();
58 self.height = opened.document.render.target_height.to_string();
59 self.fps = opened.document.render.fps.to_string();
60 self.browser.clear();
61 self.opened = Some(opened.clone());
62 if opened.disk_issues.is_empty() {
63 self.ok(notice);
64 } else {
65 self.fail_text(opened.disk_issues.join("\n"));
66 }
67 }
68
69 fn ok(&mut self, message: &str) {
70 self.notice = message.to_string();
71 self.notice_ok = true;
72 }
73
74 fn fail(&mut self, error: ProjectError) {
75 self.notice = error.to_string();
76 self.notice_ok = false;
77 }
78
79 fn fail_text(&mut self, message: impl Into<String>) {
80 self.notice = message.into();
81 self.notice_ok = false;
82 }
83
84 fn draft(&self) -> Result<ProjectDraft, String> {
85 let opened = self
86 .opened
87 .as_ref()
88 .ok_or_else(|| "open of maak eerst een project".to_string())?;
89 Ok(ProjectDraft {
90 id: opened.document.id.clone(),
91 name: self.name.clone(),
92 male_model: self.male.clone(),
93 female_model: self.female.clone(),
94 equipment_presets: self.presets.clone(),
95 output_directory: self.output.clone(),
96 target_width: parse_u32("breedte", &self.width)?,
97 target_height: parse_u32("hoogte", &self.height)?,
98 fps: parse_u32("fps", &self.fps)?,
99 })
100 }
101
102 fn create(&mut self) {
103 let root = PathBuf::from(self.root_input.trim());
104 if self.root_input.trim().is_empty() {
105 self.fail_text("vul de projectmap in");
106 return;
107 }
108 match create_project(&root, "Nieuw project") {
109 Ok(opened) => self.load(
110 opened,
111 "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
112 ),
113 Err(error) => self.fail(error),
114 }
115 }
116
117 fn open(&mut self) {
118 let path = PathBuf::from(self.open_input.trim());
119 if self.open_input.trim().is_empty() {
120 self.fail_text("vul het pad naar een .ppd-bestand in");
121 return;
122 }
123 match open_project(&path) {
124 Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
125 Err(error) => self.fail(error),
126 }
127 }
128
129 fn save(&mut self) {
130 let Some(current) = self.opened.clone() else {
131 self.fail_text("open of maak eerst een project");
132 return;
133 };
134 let draft = match self.draft() {
135 Ok(draft) => draft,
136 Err(message) => {
137 self.fail_text(message);
138 return;
139 }
140 };
141 let document = match assemble(draft) {
142 Ok(document) => document,
143 Err(error) => {
144 self.fail(error);
145 return;
146 }
147 };
148 let candidate = OpenedProject {
149 manifest_path: current.manifest_path,
150 document,
151 disk_issues: Vec::new(),
152 };
153 match save_project(&candidate) {
154 Ok(()) => {
155 let path = candidate.manifest_path.display().to_string();
156 self.opened = Some(candidate);
157 self.ok(&format!(
158 "Opgeslagen naar {path}. Import en render zijn niet uitgevoerd."
159 ));
160 }
161 Err(error) => self.fail(error),
162 }
163 }
164
165 fn add_preset(&mut self, relative: String) {
166 if relative.trim().is_empty() {
167 return;
168 }
169 self.presets.push(relative);
170 if let Err(error) = self
171 .draft()
172 .and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
173 {
174 self.presets.pop();
175 self.fail_text(error);
176 } else {
177 self.preset_input.clear();
178 self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
179 }
180 }
181
182 fn assign_file(&mut self, relative: &str) {
183 if relative == MANIFEST_NAME {
184 self.fail_text("character.ppd is het projectbestand, geen model of preset");
185 return;
186 }
187 let lower = relative.to_ascii_lowercase();
188 if lower.contains("/preset") || lower.starts_with("presets/") {
189 self.add_preset(relative.to_string());
190 } else if self.male.trim().is_empty() {
191 self.male = relative.to_string();
192 self.ok("Pad ingevuld bij het mannelijke model. Het model wordt niet geladen.");
193 } else if self.female.trim().is_empty() {
194 self.female = relative.to_string();
195 self.ok("Pad ingevuld bij het vrouwelijke model. Het model wordt niet geladen.");
196 } else {
197 self.add_preset(relative.to_string());
198 }
199 }
200}
201
202impl eframe::App for EditorApp {
203 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
204 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
205 ui.horizontal_wrapped(|ui| {
206 if ui.button("Nieuw").clicked() {
207 self.create();
208 }
209 if ui.button("Open").clicked() {
210 self.open();
211 }
212 if ui.button("Opslaan").clicked() {
213 self.save();
214 }
215 if ui.button("Sluiten").clicked() {
216 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
217 }
218 ui.label(format!("format_version {FORMAT_VERSION}"));
219 });
220 });
221 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
222 let color = if self.notice_ok {
223 egui::Color32::from_rgb(20, 90, 40)
224 } else {
225 egui::Color32::from_rgb(150, 30, 30)
226 };
227 ui.colored_label(color, &self.notice);
228 });
229 egui::SidePanel::right("browser")
230 .min_width(240.0)
231 .show(ctx, |ui| {
232 self.show_browser(ui);
233 });
234 egui::CentralPanel::default().show(ctx, |ui| {
235 egui::ScrollArea::vertical().show(ui, |ui| {
236 self.show_form(ui);
237 });
238 });
239 }
240}
241
242impl EditorApp {
243 fn show_form(&mut self, ui: &mut egui::Ui) {
244 ui.heading("Paperdoll Renderer");
245 ui.label("Projectmap met één .ppd-bestand. Modellen kiezen slaat alleen het pad op.");
246 ui.add_space(8.0);
247 ui.horizontal(|ui| {
248 ui.label("Projectmap");
249 ui.add(
250 egui::TextEdit::singleline(&mut self.root_input)
251 .desired_width(360.0)
252 .hint_text("/pad/naar/nieuwe-map"),
253 );
254 });
255 ui.horizontal(|ui| {
256 ui.label(".ppd openen");
257 ui.add(
258 egui::TextEdit::singleline(&mut self.open_input)
259 .desired_width(360.0)
260 .hint_text("/pad/naar/character.ppd"),
261 );
262 });
263 ui.separator();
264 ui.horizontal(|ui| {
265 ui.label("Naam");
266 ui.text_edit_singleline(&mut self.name);
267 });
268 ui.horizontal(|ui| {
269 ui.label("Man");
270 ui.add(
271 egui::TextEdit::singleline(&mut self.male)
272 .desired_width(360.0)
273 .hint_text("assets/body/male.glb"),
274 );
275 if ui.button("Wis man").clicked() {
276 self.male.clear();
277 }
278 });
279 ui.horizontal(|ui| {
280 ui.label("Vrouw");
281 ui.add(
282 egui::TextEdit::singleline(&mut self.female)
283 .desired_width(360.0)
284 .hint_text("assets/body/female.glb"),
285 );
286 if ui.button("Wis vrouw").clicked() {
287 self.female.clear();
288 }
289 });
290 ui.label("Equipment-presets, relatief aan de projectmap");
291 let mut remove = None;
292 for (index, preset) in self.presets.iter().enumerate() {
293 ui.horizontal(|ui| {
294 ui.label(preset);
295 if ui.button("Verwijder").clicked() {
296 remove = Some(index);
297 }
298 });
299 }
300 if let Some(index) = remove {
301 self.presets.remove(index);
302 }
303 ui.horizontal(|ui| {
304 ui.add(
305 egui::TextEdit::singleline(&mut self.preset_input)
306 .desired_width(280.0)
307 .hint_text("presets/zwaard.bin"),
308 );
309 if ui.button("Voeg preset toe").clicked() {
310 let value = self.preset_input.trim().to_string();
311 self.add_preset(value);
312 }
313 });
314 ui.horizontal(|ui| {
315 ui.label("Uitvoer");
316 ui.text_edit_singleline(&mut self.output);
317 });
318 ui.horizontal(|ui| {
319 ui.label("Breedte");
320 ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
321 ui.label("Hoogte");
322 ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
323 ui.label("fps");
324 ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
325 });
326 ui.add_space(8.0);
327 ui.label(
328 "Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.",
329 );
330 }
331
332 fn show_browser(&mut self, ui: &mut egui::Ui) {
333 ui.heading("Bestanden");
334 let Some(opened) = self.opened.clone() else {
335 ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
336 return;
337 };
338 let root = opened
339 .manifest_path
340 .parent()
341 .map(PathBuf::from)
342 .unwrap_or_default();
343 ui.label(if self.browser.is_empty() {
344 "projectmap".to_string()
345 } else {
346 self.browser.clone()
347 });
348 if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
349 self.browser = parent_relative(&self.browser);
350 }
351 if ui.button("Deze map als uitvoer").clicked() {
352 if self.browser.is_empty() {
353 self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
354 } else {
355 self.output = self.browser.clone();
356 self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
357 }
358 }
359 let entries = match list_dir(&root, &self.browser) {
360 Ok(entries) => entries,
361 Err(error) => {
362 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
363 return;
364 }
365 };
366 let mut enter = None;
367 let mut choose = None;
368 for entry in &entries {
369 let label = if entry.is_dir {
370 format!("{}/", entry.name)
371 } else {
372 entry.name.clone()
373 };
374 if ui.button(label).clicked() {
375 if entry.is_dir {
376 enter = Some(entry.relative.clone());
377 } else {
378 choose = Some(entry.relative.clone());
379 }
380 }
381 }
382 if let Some(relative) = enter {
383 self.browser = relative;
384 }
385 if let Some(relative) = choose {
386 self.assign_file(&relative);
387 }
388 }
389}
390
391fn parent_relative(relative: &str) -> String {
392 match relative.rfind('/') {
393 Some(index) => relative[..index].to_string(),
394 None => String::new(),
395 }
396}
397
398fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
399 let text = text.trim();
400 if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
401 return Err(format!("{label} moet een geheel getal zijn"));
402 }
403 text.parse::<u32>()
404 .map_err(|_| format!("{label} is te groot"))
405}
406
407impl Default for EditorApp {
408 fn default() -> Self {
409 Self::new()
410 }
411}
src/foundation.rs +6 −6
@@ -107,14 +107,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107 107 }
108 108
109 109 pub fn dutch_summary() -> &'static str {
110 "Deze build opent alleen dit statusvenster. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Synthetische testassets, als ze in de repository staan, worden door dit programma niet geladen."
110 "Deze build bewaart een .ppd-projectmap. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Genoemde modelbestanden worden niet geladen."
111 111 }
112 112
113 113 /// Stable status text shared by `--status` and the window.
114 114 pub fn report() -> String {
115 115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116 116 lines.push("name: Paperdoll Renderer".to_string());
117 lines.push("phase: foundation".to_string());
117 lines.push("phase: project-files".to_string());
118 118 for capability in CAPABILITIES {
119 119 lines.push(format!(
120 120 "{}: {}",
@@ -158,14 +158,14 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
158 158
159 159 pub fn help_text() -> &'static str {
160 160 "\
161 Paperdoll Renderer — projectbasis
161 Paperdoll Renderer — projectbestanden
162 162
163 163 Gebruik:
164 paperdoll-renderer open het statusvenster
164 paperdoll-renderer open het projectvenster
165 165 paperdoll-renderer --status print de status en sluit
166 166 paperdoll-renderer --help toon deze tekst
167 167
168 Een modelpad wordt niet geaccepteerd. Import en render zijn niet gebouwd."
168 Een modelpad op de opdrachtregel wordt niet geaccepteerd. Import en render zijn niet gebouwd."
169 169 }
170 170
171 171 pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -214,7 +214,7 @@ mod tests {
214 214 #[test]
215 215 fn status_report_names_every_format_as_not_built() {
216 216 let report = report();
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: foundation\n"));
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-files\n"));
218 218 for key in [
219 219 "obj_mtl_textures",
220 220 "fbx",
@@ -107,14 +107,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107}
107}
108
108
109pub fn dutch_summary() -> &'static str {
109pub fn dutch_summary() -> &'static str {
110 "Deze build opent alleen dit statusvenster. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Synthetische testassets, als ze in de repository staan, worden door dit programma niet geladen."
110 "Deze build bewaart een .ppd-projectmap. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Genoemde modelbestanden worden niet geladen."
111}
111}
112
112
113/// Stable status text shared by `--status` and the window.
113/// Stable status text shared by `--status` and the window.
114pub fn report() -> String {
114pub fn report() -> String {
115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116 lines.push("name: Paperdoll Renderer".to_string());
116 lines.push("name: Paperdoll Renderer".to_string());
117 lines.push("phase: foundation".to_string());
117 lines.push("phase: project-files".to_string());
118 for capability in CAPABILITIES {
118 for capability in CAPABILITIES {
119 lines.push(format!(
119 lines.push(format!(
120 "{}: {}",
120 "{}: {}",
@@ -158,14 +158,14 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
158
158
159pub fn help_text() -> &'static str {
159pub fn help_text() -> &'static str {
160 "\
160 "\
161Paperdoll Renderer — projectbasis
161Paperdoll Renderer — projectbestanden
162
162
163Gebruik:
163Gebruik:
164 paperdoll-renderer open het statusvenster
164 paperdoll-renderer open het projectvenster
165 paperdoll-renderer --status print de status en sluit
165 paperdoll-renderer --status print de status en sluit
166 paperdoll-renderer --help toon deze tekst
166 paperdoll-renderer --help toon deze tekst
167
167
168Een modelpad wordt niet geaccepteerd. Import en render zijn niet gebouwd."
168Een modelpad op de opdrachtregel wordt niet geaccepteerd. Import en render zijn niet gebouwd."
169}
169}
170
170
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -214,7 +214,7 @@ mod tests {
214 #[test]
214 #[test]
215 fn status_report_names_every_format_as_not_built() {
215 fn status_report_names_every_format_as_not_built() {
216 let report = report();
216 let report = report();
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: foundation\n"));
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-files\n"));
218 for key in [
218 for key in [
219 "obj_mtl_textures",
219 "obj_mtl_textures",
220 "fbx",
220 "fbx",
src/lib.rs +2 −0
@@ -3,4 +3,6 @@
3 3 //! This crate reports which product capabilities exist. It does not import
4 4 //! models and it does not render paperdolls.
5 5
6 pub mod editor;
6 7 pub mod foundation;
8 pub mod project;
@@ -3,4 +3,6 @@
3//! This crate reports which product capabilities exist. It does not import
3//! This crate reports which product capabilities exist. It does not import
4//! models and it does not render paperdolls.
4//! models and it does not render paperdolls.
5
5
6pub mod editor;
6pub mod foundation;
7pub mod foundation;
8pub mod project;
src/main.rs +10 −41
@@ -1,15 +1,12 @@
1 //! Desktop status shell for the Paperdoll Renderer foundation.
1 //! Desktop shell for Paperdoll Renderer project files.
2 2 //!
3 //! The window lists what this build does not do. It is not a 3D viewport and
4 //! it is not a paperdoll preview.
3 //! The window creates and saves a `.ppd` project directory. It does not import
4 //! models and it does not render paperdolls.
5 5
6 6 use std::process::ExitCode;
7 7
8 use eframe::egui;
9 use paperdoll_renderer::foundation::{
10 availability, capabilities, dutch_label, dutch_state, dutch_summary, help_text, parse_cli,
11 report, CliError, CliRequest,
12 };
8 use paperdoll_renderer::editor::EditorApp;
9 use paperdoll_renderer::foundation::{help_text, parse_cli, report, CliError, CliRequest};
13 10
14 11 fn main() -> ExitCode {
15 12 let args: Vec<String> = std::env::args().skip(1).collect();
@@ -31,7 +28,7 @@ fn main() -> ExitCode {
31 28 match run_window() {
32 29 Ok(()) => ExitCode::SUCCESS,
33 30 Err(err) => {
34 eprintln!("paperdoll-renderer: het statusvenster openen is mislukt: {err}");
31 eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");
35 32 ExitCode::from(1)
36 33 }
37 34 }
@@ -64,43 +61,15 @@ fn window_preflight() -> Option<String> {
64 61
65 62 fn run_window() -> eframe::Result<()> {
66 63 let options = eframe::NativeOptions {
67 viewport: egui::ViewportBuilder::default()
64 viewport: eframe::egui::ViewportBuilder::default()
68 65 .with_title("Paperdoll Renderer")
69 .with_inner_size([480.0, 560.0])
70 .with_min_inner_size([320.0, 280.0]),
66 .with_inner_size([980.0, 680.0])
67 .with_min_inner_size([720.0, 480.0]),
71 68 ..Default::default()
72 69 };
73 70 eframe::run_native(
74 71 "Paperdoll Renderer",
75 72 options,
76 Box::new(|_creation| Ok(Box::new(StatusApp))),
73 Box::new(|_creation| Ok(Box::<EditorApp>::default())),
77 74 )
78 75 }
79
80 struct StatusApp;
81
82 impl eframe::App for StatusApp {
83 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
84 egui::CentralPanel::default().show(ctx, |ui| {
85 egui::ScrollArea::vertical().show(ui, |ui| {
86 ui.heading("Paperdoll Renderer");
87 ui.label("Projectbasis. Nog geen paperdoll-renderer.");
88 ui.add_space(8.0);
89 ui.label(dutch_summary());
90 ui.add_space(12.0);
91 ui.strong("Status van deze build");
92 for capability in capabilities() {
93 ui.label(format!(
94 "{}: {}",
95 dutch_label(*capability),
96 dutch_state(availability(*capability))
97 ));
98 }
99 ui.add_space(12.0);
100 if ui.button("Sluiten").clicked() {
101 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
102 }
103 });
104 });
105 }
106 }
@@ -1,15 +1,12 @@
1//! Desktop status shell for the Paperdoll Renderer foundation.
1//! Desktop shell for Paperdoll Renderer project files.
2//!
2//!
3//! The window lists what this build does not do. It is not a 3D viewport and
3//! The window creates and saves a `.ppd` project directory. It does not import
4//! it is not a paperdoll preview.
4//! models and it does not render paperdolls.
5
5
6use std::process::ExitCode;
6use std::process::ExitCode;
7
7
8use eframe::egui;
8use paperdoll_renderer::editor::EditorApp;
9use paperdoll_renderer::foundation::{
9use paperdoll_renderer::foundation::{help_text, parse_cli, report, CliError, CliRequest};
10 availability, capabilities, dutch_label, dutch_state, dutch_summary, help_text, parse_cli,
11 report, CliError, CliRequest,
12};
13
10
14fn main() -> ExitCode {
11fn main() -> ExitCode {
15 let args: Vec<String> = std::env::args().skip(1).collect();
12 let args: Vec<String> = std::env::args().skip(1).collect();
@@ -31,7 +28,7 @@ fn main() -> ExitCode {
31 match run_window() {
28 match run_window() {
32 Ok(()) => ExitCode::SUCCESS,
29 Ok(()) => ExitCode::SUCCESS,
33 Err(err) => {
30 Err(err) => {
34 eprintln!("paperdoll-renderer: het statusvenster openen is mislukt: {err}");
31 eprintln!("paperdoll-renderer: het projectvenster openen is mislukt: {err}");
35 ExitCode::from(1)
32 ExitCode::from(1)
36 }
33 }
37 }
34 }
@@ -64,43 +61,15 @@ fn window_preflight() -> Option<String> {
64
61
65fn run_window() -> eframe::Result<()> {
62fn run_window() -> eframe::Result<()> {
66 let options = eframe::NativeOptions {
63 let options = eframe::NativeOptions {
67 viewport: egui::ViewportBuilder::default()
64 viewport: eframe::egui::ViewportBuilder::default()
68 .with_title("Paperdoll Renderer")
65 .with_title("Paperdoll Renderer")
69 .with_inner_size([480.0, 560.0])
66 .with_inner_size([980.0, 680.0])
70 .with_min_inner_size([320.0, 280.0]),
67 .with_min_inner_size([720.0, 480.0]),
71 ..Default::default()
68 ..Default::default()
72 };
69 };
73 eframe::run_native(
70 eframe::run_native(
74 "Paperdoll Renderer",
71 "Paperdoll Renderer",
75 options,
72 options,
76 Box::new(|_creation| Ok(Box::new(StatusApp))),
73 Box::new(|_creation| Ok(Box::<EditorApp>::default())),
77 )
74 )
78}
75}
79
80struct StatusApp;
81
82impl eframe::App for StatusApp {
83 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
84 egui::CentralPanel::default().show(ctx, |ui| {
85 egui::ScrollArea::vertical().show(ui, |ui| {
86 ui.heading("Paperdoll Renderer");
87 ui.label("Projectbasis. Nog geen paperdoll-renderer.");
88 ui.add_space(8.0);
89 ui.label(dutch_summary());
90 ui.add_space(12.0);
91 ui.strong("Status van deze build");
92 for capability in capabilities() {
93 ui.label(format!(
94 "{}: {}",
95 dutch_label(*capability),
96 dutch_state(availability(*capability))
97 ));
98 }
99 ui.add_space(12.0);
100 if ui.button("Sluiten").clicked() {
101 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
102 }
103 });
104 });
105 }
106}
src/project.rs added +829 −0
@@ -0,0 +1,829 @@
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. A newer or older `format_version` is rejected first.
6
7 use std::fs::{self, File};
8 use std::io::Write;
9 use std::path::{Path, PathBuf};
10 use std::time::{SystemTime, UNIX_EPOCH};
11
12 use serde::{Deserialize, Serialize};
13 use serde_json::Value;
14
15 pub const FORMAT_VERSION: u32 = 1;
16 pub const MANIFEST_NAME: &str = "character.ppd";
17 pub const MAX_DIMENSION: u32 = 4096;
18 pub const MAX_FPS: u32 = 240;
19 pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
20
21 const PROJECT_DIRS: [&str; 5] = [
22 "assets/body",
23 "assets/equipment",
24 "presets",
25 "cache",
26 "renders",
27 ];
28
29 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30 #[serde(deny_unknown_fields)]
31 pub struct RenderSettings {
32 pub target_width: u32,
33 pub target_height: u32,
34 pub fps: u32,
35 }
36
37 impl Default for RenderSettings {
38 fn default() -> Self {
39 Self {
40 target_width: 128,
41 target_height: 128,
42 fps: 12,
43 }
44 }
45 }
46
47 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48 #[serde(deny_unknown_fields)]
49 pub struct ProjectFile {
50 pub format_version: u32,
51 pub id: String,
52 pub name: String,
53 pub male_model: Option<String>,
54 pub female_model: Option<String>,
55 pub equipment_presets: Vec<String>,
56 pub output_directory: String,
57 pub render: RenderSettings,
58 }
59
60 #[derive(Debug, Clone, PartialEq, Eq)]
61 pub struct ProjectDraft {
62 pub id: String,
63 pub name: String,
64 pub male_model: String,
65 pub female_model: String,
66 pub equipment_presets: Vec<String>,
67 pub output_directory: String,
68 pub target_width: u32,
69 pub target_height: u32,
70 pub fps: u32,
71 }
72
73 #[derive(Debug, Clone, PartialEq, Eq)]
74 pub struct OpenedProject {
75 pub manifest_path: PathBuf,
76 pub document: ProjectFile,
77 pub disk_issues: Vec<String>,
78 }
79
80 #[derive(Debug, Clone, PartialEq, Eq)]
81 pub struct DirEntry {
82 pub name: String,
83 pub relative: String,
84 pub is_dir: bool,
85 }
86
87 #[derive(Debug)]
88 pub enum ProjectError {
89 Validation(Vec<String>),
90 UnsupportedVersion { found: u64, supported: u32 },
91 UnknownField(String),
92 Json(String),
93 Io { path: String, message: String },
94 }
95
96 impl std::fmt::Display for ProjectError {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Validation(issues) => {
100 write!(formatter, "{}", issues.join("\n"))
101 }
102 Self::UnsupportedVersion { found, supported } => write!(
103 formatter,
104 "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}"
105 ),
106 Self::UnknownField(detail) => write!(
107 formatter,
108 "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven."
109 ),
110 Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"),
111 Self::Io { path, message } => write!(formatter, "{message} ({path})"),
112 }
113 }
114 }
115
116 impl std::error::Error for ProjectError {}
117
118 impl ProjectFile {
119 pub fn new_empty(name: impl Into<String>) -> Self {
120 Self {
121 format_version: FORMAT_VERSION,
122 id: new_id(),
123 name: name.into(),
124 male_model: None,
125 female_model: None,
126 equipment_presets: Vec::new(),
127 output_directory: "renders".to_string(),
128 render: RenderSettings::default(),
129 }
130 }
131
132 pub fn validate_shape(&self) -> Result<(), ProjectError> {
133 let mut issues = Vec::new();
134 if self.format_version != FORMAT_VERSION {
135 issues.push(format!(
136 "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
137 self.format_version
138 ));
139 }
140 if self.id.trim().is_empty()
141 || !self
142 .id
143 .chars()
144 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
145 || self.id.len() > 64
146 {
147 issues.push(
148 "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'"
149 .to_string(),
150 );
151 }
152 let name = self.name.trim();
153 if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 {
154 issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string());
155 }
156 check_optional_path(
157 "het mannelijke model",
158 self.male_model.as_deref(),
159 &mut issues,
160 );
161 check_optional_path(
162 "het vrouwelijke model",
163 self.female_model.as_deref(),
164 &mut issues,
165 );
166 let mut seen = std::collections::BTreeSet::new();
167 for preset in &self.equipment_presets {
168 match portable_relative(preset) {
169 Ok(clean) => {
170 if clean != *preset {
171 issues.push(format!("de preset '{preset}' is niet genormaliseerd"));
172 }
173 if !seen.insert(clean.clone()) {
174 issues.push(format!("de preset '{clean}' staat dubbel in het project"));
175 }
176 match preset_stem(&clean) {
177 Ok(stem) if stem == "body" => issues.push(
178 "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam"
179 .to_string(),
180 ),
181 Ok(_) => {}
182 Err(message) => issues.push(message),
183 }
184 if clean == MANIFEST_NAME {
185 issues.push("character.ppd is het projectbestand, geen preset".to_string());
186 }
187 }
188 Err(message) => issues.push(format!("preset '{preset}': {message}")),
189 }
190 }
191 match portable_relative(&self.output_directory) {
192 Ok(clean) => {
193 if clean != self.output_directory {
194 issues.push("de uitvoermap is niet genormaliseerd".to_string());
195 }
196 if clean == "." {
197 issues.push(
198 "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(),
199 );
200 }
201 if clean == MANIFEST_NAME {
202 issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string());
203 }
204 for source in self.sources() {
205 if source == clean || source.starts_with(&(clean.clone() + "/")) {
206 issues.push(format!(
207 "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten"
208 ));
209 }
210 }
211 }
212 Err(message) => issues.push(format!("uitvoermap: {message}")),
213 }
214 match memory_estimate(self.render.target_width, self.render.target_height) {
215 Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {}
216 Some(bytes) => issues.push(format!(
217 "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}"
218 )),
219 None => issues.push(
220 "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(),
221 ),
222 }
223 if self.render.target_width == 0 || self.render.target_height == 0 {
224 issues.push("breedte en hoogte moeten groter zijn dan 0".to_string());
225 }
226 if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION {
227 issues.push(format!(
228 "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn"
229 ));
230 }
231 if self.render.fps == 0 {
232 issues.push("fps moet groter zijn dan 0".to_string());
233 }
234 if self.render.fps > MAX_FPS {
235 issues.push(format!("fps mag hoogstens {MAX_FPS} zijn"));
236 }
237 if issues.is_empty() {
238 Ok(())
239 } else {
240 Err(ProjectError::Validation(issues))
241 }
242 }
243
244 fn sources(&self) -> Vec<&str> {
245 self.male_model
246 .iter()
247 .chain(self.female_model.iter())
248 .map(String::as_str)
249 .chain(self.equipment_presets.iter().map(String::as_str))
250 .collect()
251 }
252 }
253
254 pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
255 let document = ProjectFile {
256 format_version: FORMAT_VERSION,
257 id: draft.id,
258 name: draft.name,
259 male_model: blank_to_none(draft.male_model),
260 female_model: blank_to_none(draft.female_model),
261 equipment_presets: draft.equipment_presets,
262 output_directory: draft.output_directory,
263 render: RenderSettings {
264 target_width: draft.target_width,
265 target_height: draft.target_height,
266 fps: draft.fps,
267 },
268 };
269 document.validate_shape()?;
270 Ok(document)
271 }
272
273 pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
274 let value: Value =
275 serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?;
276 let Some(object) = value.as_object() else {
277 return Err(ProjectError::Json(
278 "de wortel moet een object zijn".to_string(),
279 ));
280 };
281 let Some(version_value) = object.get("format_version") else {
282 return Err(ProjectError::Json("format_version ontbreekt".to_string()));
283 };
284 let Some(version) = version_value.as_u64() else {
285 return Err(ProjectError::Json(
286 "format_version moet een geheel getal zijn".to_string(),
287 ));
288 };
289 if version != u64::from(FORMAT_VERSION) {
290 return Err(ProjectError::UnsupportedVersion {
291 found: version,
292 supported: FORMAT_VERSION,
293 });
294 }
295 serde_json::from_value(value).map_err(|err| {
296 let detail = err.to_string();
297 if detail.contains("unknown field") {
298 ProjectError::UnknownField(detail)
299 } else {
300 ProjectError::Json(detail)
301 }
302 })
303 }
304
305 pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
306 if root.exists() && !root.is_dir() {
307 return Err(io_error(root, "de projectmap is een bestand"));
308 }
309 let manifest = root.join(MANIFEST_NAME);
310 if manifest.exists() {
311 return Err(io_error(
312 &manifest,
313 "er staat al een character.ppd; open dat project in plaats van het te overschrijven",
314 ));
315 }
316 fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?;
317 for relative in PROJECT_DIRS {
318 let path = root.join(relative);
319 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
320 }
321 let document = ProjectFile::new_empty(name);
322 document.validate_shape()?;
323 write_manifest(&manifest, &document)?;
324 Ok(OpenedProject {
325 manifest_path: manifest,
326 document,
327 disk_issues: Vec::new(),
328 })
329 }
330
331 pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
332 if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
333 return Err(io_error(manifest, "kies een .ppd-bestand"));
334 }
335 let text = fs::read_to_string(manifest).map_err(|err| {
336 io_error(
337 manifest,
338 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
339 )
340 })?;
341 let document = parse_manifest(&text)?;
342 document.validate_shape()?;
343 let root = manifest
344 .parent()
345 .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?;
346 let disk_issues = disk_issues(root, &document);
347 Ok(OpenedProject {
348 manifest_path: manifest.to_path_buf(),
349 document,
350 disk_issues,
351 })
352 }
353
354 pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> {
355 opened.document.validate_shape()?;
356 let root = opened.manifest_path.parent().ok_or_else(|| {
357 io_error(
358 &opened.manifest_path,
359 "het .ppd-bestand heeft geen projectmap",
360 )
361 })?;
362 let mut issues = Vec::new();
363 for relative in opened.document.sources() {
364 match require_file_inside(root, relative) {
365 Ok(()) => {}
366 Err(ProjectError::Validation(extra)) => issues.extend(extra),
367 Err(other) => return Err(other),
368 }
369 }
370 if !issues.is_empty() {
371 return Err(ProjectError::Validation(issues));
372 }
373 prepare_output_dir(root, &opened.document.output_directory)?;
374 write_manifest(&opened.manifest_path, &opened.document)
375 }
376
377 pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectError> {
378 let relative = if relative.is_empty() {
379 String::new()
380 } else {
381 portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))?
382 };
383 let path = if relative.is_empty() {
384 root.to_path_buf()
385 } else {
386 root.join(&relative)
387 };
388 let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?;
389 let mut entries = Vec::new();
390 for item in read {
391 let item = item.map_err(|err| io_error(&path, &err.to_string()))?;
392 let name = item.file_name().to_string_lossy().to_string();
393 if name.starts_with('.') {
394 continue;
395 }
396 let child = if relative.is_empty() {
397 name.clone()
398 } else {
399 format!("{relative}/{name}")
400 };
401 let is_dir = item
402 .file_type()
403 .map_err(|err| io_error(&item.path(), &err.to_string()))?
404 .is_dir();
405 entries.push(DirEntry {
406 name,
407 relative: child,
408 is_dir,
409 });
410 }
411 entries.sort_by(|left, right| {
412 right
413 .is_dir
414 .cmp(&left.is_dir)
415 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
416 });
417 Ok(entries)
418 }
419
420 pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
421 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
422 u64::from(width)
423 .checked_mul(u64::from(height))?
424 .checked_mul(64)?
425 .checked_mul(36)?
426 .checked_mul(3)?
427 .checked_mul(4)
428 }
429
430 fn new_id() -> String {
431 let nanos = SystemTime::now()
432 .duration_since(UNIX_EPOCH)
433 .map(|duration| duration.as_nanos())
434 .unwrap_or(0);
435 format!("ppd-{nanos:x}")
436 }
437
438 fn blank_to_none(value: String) -> Option<String> {
439 let trimmed = value.trim();
440 if trimmed.is_empty() {
441 None
442 } else {
443 Some(trimmed.to_string())
444 }
445 }
446
447 fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
448 let Some(value) = value else {
449 return;
450 };
451 match portable_relative(value) {
452 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
453 Ok(_) if value == MANIFEST_NAME => {
454 issues.push(format!("{label} mag niet character.ppd zijn"));
455 }
456 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
457 Err(message) => issues.push(format!("{label}: {message}")),
458 }
459 }
460
461 pub fn portable_relative(value: &str) -> Result<String, String> {
462 let value = value.trim();
463 if value.is_empty() {
464 return Err("het pad is leeg".to_string());
465 }
466 if value.contains('\\') {
467 return Err("gebruik '/' in projectpaden".to_string());
468 }
469 if value.starts_with('/') || windows_drive(value) {
470 return Err(
471 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
472 .to_string(),
473 );
474 }
475 let mut parts = Vec::new();
476 for part in value.split('/') {
477 if part.is_empty() || part == "." || part == ".." {
478 return Err(
479 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
480 .to_string(),
481 );
482 }
483 if part.contains('\0') {
484 return Err("het pad bevat een leeg teken".to_string());
485 }
486 parts.push(part);
487 }
488 Ok(parts.join("/"))
489 }
490
491 fn windows_drive(value: &str) -> bool {
492 let mut chars = value.chars();
493 matches!(
494 (chars.next(), chars.next(), chars.next()),
495 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
496 )
497 }
498
499 fn preset_stem(relative: &str) -> Result<String, String> {
500 let file_name = relative.rsplit('/').next().unwrap_or(relative);
501 let stem = file_name.split('.').next().unwrap_or(file_name);
502 if stem.is_empty()
503 || !stem
504 .chars()
505 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
506 || stem.contains(' ')
507 {
508 return Err(format!(
509 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
510 ));
511 }
512 Ok(stem.to_string())
513 }
514
515 fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
516 let mut issues = Vec::new();
517 for relative in document.sources() {
518 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
519 issues.extend(extra);
520 }
521 }
522 let output = root.join(&document.output_directory);
523 if output.exists() && !output.is_dir() {
524 issues.push(format!(
525 "de uitvoermap '{}' is een bestand",
526 document.output_directory
527 ));
528 } else if !output.exists() {
529 issues.push(format!(
530 "de uitvoermap '{}' bestaat nog niet",
531 document.output_directory
532 ));
533 }
534 issues
535 }
536
537 fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
538 let path = root.join(relative);
539 if !path.is_file() {
540 return Err(ProjectError::Validation(vec![format!(
541 "het bestand ontbreekt in de projectmap: {relative}"
542 )]));
543 }
544 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
545 match (root.canonicalize(), path.canonicalize()) {
546 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
547 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
548 "het pad '{relative}' wijst buiten de projectmap"
549 )])),
550 (Err(err), _) => Err(io_error(root, &err.to_string())),
551 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
552 }
553 }
554
555 fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
556 let path = root.join(relative);
557 if path.exists() && !path.is_dir() {
558 return Err(ProjectError::Validation(vec![format!(
559 "de uitvoermap '{relative}' is een bestand"
560 )]));
561 }
562 if !path.exists() {
563 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
564 }
565 ensure_lexically_inside(root, &path)
566 .map_err(|message| ProjectError::Validation(vec![message]))?;
567 Ok(())
568 }
569
570 fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
571 let root = root.components().collect::<Vec<_>>();
572 let path = path.components().collect::<Vec<_>>();
573 if path.starts_with(&root) {
574 Ok(())
575 } else {
576 Err("het pad wijst buiten de projectmap".to_string())
577 }
578 }
579
580 fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
581 let parent = path
582 .parent()
583 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
584 let text = serde_json::to_string_pretty(document)
585 .map_err(|err| ProjectError::Json(err.to_string()))?
586 + "\n";
587 let mut temporary_name = path
588 .file_name()
589 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
590 .to_os_string();
591 temporary_name.push(".tmp");
592 let temporary = parent.join(temporary_name);
593 let write_result = (|| {
594 let mut file =
595 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
596 file.write_all(text.as_bytes())
597 .map_err(|err| io_error(&temporary, &err.to_string()))?;
598 file.sync_all()
599 .map_err(|err| io_error(&temporary, &err.to_string()))?;
600 Ok(())
601 })();
602 if let Err(err) = write_result {
603 let _ = fs::remove_file(&temporary);
604 return Err(err);
605 }
606 if let Err(err) = fs::rename(&temporary, path) {
607 let _ = fs::remove_file(&temporary);
608 return Err(io_error(
609 path,
610 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
611 ));
612 }
613 Ok(())
614 }
615
616 fn io_error(path: &Path, message: &str) -> ProjectError {
617 ProjectError::Io {
618 path: path.display().to_string(),
619 message: message.to_string(),
620 }
621 }
622
623 #[cfg(test)]
624 mod tests {
625 use super::*;
626
627 fn temp_root(label: &str) -> PathBuf {
628 let path = std::env::temp_dir().join(format!(
629 "paperdoll-{label}-{}-{}",
630 std::process::id(),
631 new_id()
632 ));
633 let _ = fs::remove_dir_all(&path);
634 path
635 }
636
637 fn draft_from(document: &ProjectFile) -> ProjectDraft {
638 ProjectDraft {
639 id: document.id.clone(),
640 name: document.name.clone(),
641 male_model: document.male_model.clone().unwrap_or_default(),
642 female_model: document.female_model.clone().unwrap_or_default(),
643 equipment_presets: document.equipment_presets.clone(),
644 output_directory: document.output_directory.clone(),
645 target_width: document.render.target_width,
646 target_height: document.render.target_height,
647 fps: document.render.fps,
648 }
649 }
650
651 #[test]
652 fn roundtrip_keeps_null_slots_and_relative_paths() {
653 let root = temp_root("roundtrip");
654 let opened = create_project(&root, "Ridder").unwrap();
655 let text = fs::read_to_string(&opened.manifest_path).unwrap();
656 assert!(text.contains("\"male_model\": null"));
657 assert!(text.contains("\"female_model\": null"));
658 assert!(text.contains("\"output_directory\": \"renders\""));
659 assert!(!text.contains(root.to_string_lossy().as_ref()));
660 let loaded = open_project(&opened.manifest_path).unwrap();
661 assert_eq!(loaded.document, opened.document);
662 assert!(loaded.disk_issues.is_empty());
663 assert!(root.join("assets/body").is_dir());
664 assert!(root.join("presets").is_dir());
665 assert!(root.join("renders").is_dir());
666 let _ = fs::remove_dir_all(&root);
667 }
668
669 #[test]
670 fn save_writes_model_and_preset_paths_and_reloads_them() {
671 let root = temp_root("paths");
672 let mut opened = create_project(&root, "Ridder").unwrap();
673 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
674 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
675 opened.document.male_model = Some("assets/body/male.glb".to_string());
676 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
677 save_project(&opened).unwrap();
678 let loaded = open_project(&opened.manifest_path).unwrap();
679 assert_eq!(
680 loaded.document.male_model.as_deref(),
681 Some("assets/body/male.glb")
682 );
683 assert_eq!(loaded.document.female_model, None);
684 assert_eq!(
685 loaded.document.equipment_presets,
686 vec!["presets/zwaard.bin"]
687 );
688 assert!(loaded.disk_issues.is_empty());
689 let _ = fs::remove_dir_all(&root);
690 }
691
692 #[test]
693 fn validation_rejects_escape_absolute_body_and_output_overlap() {
694 let mut document = ProjectFile::new_empty("Ridder");
695 document.id = "ridder-1".to_string();
696 document.male_model = Some("../geheim.glb".to_string());
697 document.female_model = Some("/tmp/vrouw.glb".to_string());
698 document.equipment_presets = vec![
699 "presets/body.bin".to_string(),
700 "presets/Zwaard.bin".to_string(),
701 ];
702 document.output_directory = "presets".to_string();
703 let error = document.validate_shape().unwrap_err();
704 let text = error.to_string();
705 assert!(text.contains("buiten de projectmap"), "{text}");
706 assert!(text.contains("absoluut"), "{text}");
707 assert!(text.contains("body"), "{text}");
708 assert!(text.contains("kleine letters"), "{text}");
709 assert!(text.contains("uitvoermap"), "{text}");
710 }
711
712 #[test]
713 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
714 let newer = r#"{"format_version":2,"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}"#;
715 let error = parse_manifest(newer).unwrap_err();
716 assert!(matches!(
717 error,
718 ProjectError::UnsupportedVersion {
719 found: 2,
720 supported: 1
721 }
722 ));
723 assert!(error.to_string().contains("format_version 2"));
724 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}"#;
725 let error = parse_manifest(unknown).unwrap_err();
726 assert!(matches!(error, ProjectError::UnknownField(_)));
727 assert!(error.to_string().contains("niet herschreven"));
728 }
729
730 #[test]
731 fn failed_save_keeps_the_previous_manifest() {
732 let root = temp_root("atomic");
733 let opened = create_project(&root, "Ridder").unwrap();
734 let before = fs::read(&opened.manifest_path).unwrap();
735 let mut broken = opened.clone();
736 broken.document.output_directory = "..".to_string();
737 let error = save_project(&broken).unwrap_err();
738 assert!(error.to_string().contains("buiten de projectmap"));
739 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
740 broken.document.output_directory = "renders".to_string();
741 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
742 let error = save_project(&broken).unwrap_err();
743 assert!(error.to_string().contains("ontbreekt"), "{error}");
744 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
745 let _ = fs::remove_dir_all(&root);
746 }
747
748 #[test]
749 fn missing_manifest_is_a_recoverable_io_error() {
750 let path = temp_root("missing").join(MANIFEST_NAME);
751 let error = open_project(&path).unwrap_err();
752 let text = error.to_string();
753 assert!(text.contains("openen is mislukt"), "{text}");
754 assert!(matches!(error, ProjectError::Io { .. }));
755 }
756
757 #[test]
758 fn create_refuses_to_overwrite_an_existing_project() {
759 let root = temp_root("twice");
760 create_project(&root, "Eerste").unwrap();
761 let error = create_project(&root, "Tweede").unwrap_err();
762 assert!(error.to_string().contains("al een character.ppd"));
763 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
764 assert_eq!(loaded.document.name, "Eerste");
765 let _ = fs::remove_dir_all(&root);
766 }
767
768 #[test]
769 fn render_bounds_and_assemble_roundtrip() {
770 let mut document = ProjectFile::new_empty("Ridder");
771 document.id = "ridder-1".to_string();
772 document.render.target_width = 0;
773 assert!(document
774 .validate_shape()
775 .unwrap_err()
776 .to_string()
777 .contains("groter zijn dan 0"));
778 document.render = RenderSettings {
779 target_width: 4096,
780 target_height: 4096,
781 fps: 12,
782 };
783 assert!(document
784 .validate_shape()
785 .unwrap_err()
786 .to_string()
787 .contains("geschatte renderbuffer"));
788 document.render.fps = 0;
789 document.render.target_width = 128;
790 document.render.target_height = 128;
791 assert!(document
792 .validate_shape()
793 .unwrap_err()
794 .to_string()
795 .contains("fps"));
796 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
797 let text = serde_json::to_string(&assembled).unwrap();
798 let parsed = parse_manifest(&text).unwrap();
799 assert_eq!(parsed.render, RenderSettings::default());
800 assert_eq!(parsed.male_model, None);
801 }
802
803 #[test]
804 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
805 let root = temp_root("link");
806 let opened = create_project(&root, "Ridder").unwrap();
807 let outside = temp_root("outside-file");
808 fs::create_dir_all(&outside).unwrap();
809 let secret = outside.join("geheim.glb");
810 fs::write(&secret, b"nope").unwrap();
811 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
812 let mut broken = opened.clone();
813 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
814 let error = save_project(&broken).unwrap_err();
815 assert!(
816 error.to_string().contains("buiten de projectmap"),
817 "{error}"
818 );
819 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
820 let entries = list_dir(&root, "assets/body").unwrap();
821 assert!(entries
822 .iter()
823 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
824 let error = list_dir(&root, "../").unwrap_err();
825 assert!(error.to_string().contains("buiten de projectmap"));
826 let _ = fs::remove_dir_all(&root);
827 let _ = fs::remove_dir_all(&outside);
828 }
829 }
@@ -0,0 +1,829 @@
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. A newer or older `format_version` is 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 = 1;
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_DIMENSION: u32 = 4096;
18pub const MAX_FPS: u32 = 240;
19pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
20
21const PROJECT_DIRS: [&str; 5] = [
22 "assets/body",
23 "assets/equipment",
24 "presets",
25 "cache",
26 "renders",
27];
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RenderSettings {
32 pub target_width: u32,
33 pub target_height: u32,
34 pub fps: u32,
35}
36
37impl Default for RenderSettings {
38 fn default() -> Self {
39 Self {
40 target_width: 128,
41 target_height: 128,
42 fps: 12,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ProjectFile {
50 pub format_version: u32,
51 pub id: String,
52 pub name: String,
53 pub male_model: Option<String>,
54 pub female_model: Option<String>,
55 pub equipment_presets: Vec<String>,
56 pub output_directory: String,
57 pub render: RenderSettings,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ProjectDraft {
62 pub id: String,
63 pub name: String,
64 pub male_model: String,
65 pub female_model: String,
66 pub equipment_presets: Vec<String>,
67 pub output_directory: String,
68 pub target_width: u32,
69 pub target_height: u32,
70 pub fps: u32,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct OpenedProject {
75 pub manifest_path: PathBuf,
76 pub document: ProjectFile,
77 pub disk_issues: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct DirEntry {
82 pub name: String,
83 pub relative: String,
84 pub is_dir: bool,
85}
86
87#[derive(Debug)]
88pub enum ProjectError {
89 Validation(Vec<String>),
90 UnsupportedVersion { found: u64, supported: u32 },
91 UnknownField(String),
92 Json(String),
93 Io { path: String, message: String },
94}
95
96impl std::fmt::Display for ProjectError {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Validation(issues) => {
100 write!(formatter, "{}", issues.join("\n"))
101 }
102 Self::UnsupportedVersion { found, supported } => write!(
103 formatter,
104 "dit project gebruikt format_version {found}; deze build leest alleen versie {supported}"
105 ),
106 Self::UnknownField(detail) => write!(
107 formatter,
108 "het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven."
109 ),
110 Self::Json(detail) => write!(formatter, "het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}"),
111 Self::Io { path, message } => write!(formatter, "{message} ({path})"),
112 }
113 }
114}
115
116impl std::error::Error for ProjectError {}
117
118impl ProjectFile {
119 pub fn new_empty(name: impl Into<String>) -> Self {
120 Self {
121 format_version: FORMAT_VERSION,
122 id: new_id(),
123 name: name.into(),
124 male_model: None,
125 female_model: None,
126 equipment_presets: Vec::new(),
127 output_directory: "renders".to_string(),
128 render: RenderSettings::default(),
129 }
130 }
131
132 pub fn validate_shape(&self) -> Result<(), ProjectError> {
133 let mut issues = Vec::new();
134 if self.format_version != FORMAT_VERSION {
135 issues.push(format!(
136 "dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}",
137 self.format_version
138 ));
139 }
140 if self.id.trim().is_empty()
141 || !self
142 .id
143 .chars()
144 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
145 || self.id.len() > 64
146 {
147 issues.push(
148 "het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'"
149 .to_string(),
150 );
151 }
152 let name = self.name.trim();
153 if name.is_empty() || name.chars().any(|ch| ch.is_control()) || name.len() > 80 {
154 issues.push("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens".to_string());
155 }
156 check_optional_path(
157 "het mannelijke model",
158 self.male_model.as_deref(),
159 &mut issues,
160 );
161 check_optional_path(
162 "het vrouwelijke model",
163 self.female_model.as_deref(),
164 &mut issues,
165 );
166 let mut seen = std::collections::BTreeSet::new();
167 for preset in &self.equipment_presets {
168 match portable_relative(preset) {
169 Ok(clean) => {
170 if clean != *preset {
171 issues.push(format!("de preset '{preset}' is niet genormaliseerd"));
172 }
173 if !seen.insert(clean.clone()) {
174 issues.push(format!("de preset '{clean}' staat dubbel in het project"));
175 }
176 match preset_stem(&clean) {
177 Ok(stem) if stem == "body" => issues.push(
178 "een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam"
179 .to_string(),
180 ),
181 Ok(_) => {}
182 Err(message) => issues.push(message),
183 }
184 if clean == MANIFEST_NAME {
185 issues.push("character.ppd is het projectbestand, geen preset".to_string());
186 }
187 }
188 Err(message) => issues.push(format!("preset '{preset}': {message}")),
189 }
190 }
191 match portable_relative(&self.output_directory) {
192 Ok(clean) => {
193 if clean != self.output_directory {
194 issues.push("de uitvoermap is niet genormaliseerd".to_string());
195 }
196 if clean == "." {
197 issues.push(
198 "de uitvoermap moet een submap zijn, niet de projectmap zelf".to_string(),
199 );
200 }
201 if clean == MANIFEST_NAME {
202 issues.push("de uitvoermap mag niet het .ppd-bestand zijn".to_string());
203 }
204 for source in self.sources() {
205 if source == clean || source.starts_with(&(clean.clone() + "/")) {
206 issues.push(format!(
207 "de uitvoermap '{clean}' mag de bron '{source}' niet bevatten"
208 ));
209 }
210 }
211 }
212 Err(message) => issues.push(format!("uitvoermap: {message}")),
213 }
214 match memory_estimate(self.render.target_width, self.render.target_height) {
215 Some(bytes) if bytes <= MAX_ESTIMATE_BYTES => {}
216 Some(bytes) => issues.push(format!(
217 "de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}"
218 )),
219 None => issues.push(
220 "breedte en hoogte lopen buiten de geheugenschatting van deze versie".to_string(),
221 ),
222 }
223 if self.render.target_width == 0 || self.render.target_height == 0 {
224 issues.push("breedte en hoogte moeten groter zijn dan 0".to_string());
225 }
226 if self.render.target_width > MAX_DIMENSION || self.render.target_height > MAX_DIMENSION {
227 issues.push(format!(
228 "breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn"
229 ));
230 }
231 if self.render.fps == 0 {
232 issues.push("fps moet groter zijn dan 0".to_string());
233 }
234 if self.render.fps > MAX_FPS {
235 issues.push(format!("fps mag hoogstens {MAX_FPS} zijn"));
236 }
237 if issues.is_empty() {
238 Ok(())
239 } else {
240 Err(ProjectError::Validation(issues))
241 }
242 }
243
244 fn sources(&self) -> Vec<&str> {
245 self.male_model
246 .iter()
247 .chain(self.female_model.iter())
248 .map(String::as_str)
249 .chain(self.equipment_presets.iter().map(String::as_str))
250 .collect()
251 }
252}
253
254pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
255 let document = ProjectFile {
256 format_version: FORMAT_VERSION,
257 id: draft.id,
258 name: draft.name,
259 male_model: blank_to_none(draft.male_model),
260 female_model: blank_to_none(draft.female_model),
261 equipment_presets: draft.equipment_presets,
262 output_directory: draft.output_directory,
263 render: RenderSettings {
264 target_width: draft.target_width,
265 target_height: draft.target_height,
266 fps: draft.fps,
267 },
268 };
269 document.validate_shape()?;
270 Ok(document)
271}
272
273pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
274 let value: Value =
275 serde_json::from_str(text).map_err(|err| ProjectError::Json(err.to_string()))?;
276 let Some(object) = value.as_object() else {
277 return Err(ProjectError::Json(
278 "de wortel moet een object zijn".to_string(),
279 ));
280 };
281 let Some(version_value) = object.get("format_version") else {
282 return Err(ProjectError::Json("format_version ontbreekt".to_string()));
283 };
284 let Some(version) = version_value.as_u64() else {
285 return Err(ProjectError::Json(
286 "format_version moet een geheel getal zijn".to_string(),
287 ));
288 };
289 if version != u64::from(FORMAT_VERSION) {
290 return Err(ProjectError::UnsupportedVersion {
291 found: version,
292 supported: FORMAT_VERSION,
293 });
294 }
295 serde_json::from_value(value).map_err(|err| {
296 let detail = err.to_string();
297 if detail.contains("unknown field") {
298 ProjectError::UnknownField(detail)
299 } else {
300 ProjectError::Json(detail)
301 }
302 })
303}
304
305pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
306 if root.exists() && !root.is_dir() {
307 return Err(io_error(root, "de projectmap is een bestand"));
308 }
309 let manifest = root.join(MANIFEST_NAME);
310 if manifest.exists() {
311 return Err(io_error(
312 &manifest,
313 "er staat al een character.ppd; open dat project in plaats van het te overschrijven",
314 ));
315 }
316 fs::create_dir_all(root).map_err(|err| io_error(root, &err.to_string()))?;
317 for relative in PROJECT_DIRS {
318 let path = root.join(relative);
319 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
320 }
321 let document = ProjectFile::new_empty(name);
322 document.validate_shape()?;
323 write_manifest(&manifest, &document)?;
324 Ok(OpenedProject {
325 manifest_path: manifest,
326 document,
327 disk_issues: Vec::new(),
328 })
329}
330
331pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
332 if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
333 return Err(io_error(manifest, "kies een .ppd-bestand"));
334 }
335 let text = fs::read_to_string(manifest).map_err(|err| {
336 io_error(
337 manifest,
338 &format!("openen is mislukt: {err}. Er is niets gewijzigd"),
339 )
340 })?;
341 let document = parse_manifest(&text)?;
342 document.validate_shape()?;
343 let root = manifest
344 .parent()
345 .ok_or_else(|| io_error(manifest, "het .ppd-bestand heeft geen projectmap"))?;
346 let disk_issues = disk_issues(root, &document);
347 Ok(OpenedProject {
348 manifest_path: manifest.to_path_buf(),
349 document,
350 disk_issues,
351 })
352}
353
354pub fn save_project(opened: &OpenedProject) -> Result<(), ProjectError> {
355 opened.document.validate_shape()?;
356 let root = opened.manifest_path.parent().ok_or_else(|| {
357 io_error(
358 &opened.manifest_path,
359 "het .ppd-bestand heeft geen projectmap",
360 )
361 })?;
362 let mut issues = Vec::new();
363 for relative in opened.document.sources() {
364 match require_file_inside(root, relative) {
365 Ok(()) => {}
366 Err(ProjectError::Validation(extra)) => issues.extend(extra),
367 Err(other) => return Err(other),
368 }
369 }
370 if !issues.is_empty() {
371 return Err(ProjectError::Validation(issues));
372 }
373 prepare_output_dir(root, &opened.document.output_directory)?;
374 write_manifest(&opened.manifest_path, &opened.document)
375}
376
377pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectError> {
378 let relative = if relative.is_empty() {
379 String::new()
380 } else {
381 portable_relative(relative).map_err(|message| ProjectError::Validation(vec![message]))?
382 };
383 let path = if relative.is_empty() {
384 root.to_path_buf()
385 } else {
386 root.join(&relative)
387 };
388 let read = fs::read_dir(&path).map_err(|err| io_error(&path, &err.to_string()))?;
389 let mut entries = Vec::new();
390 for item in read {
391 let item = item.map_err(|err| io_error(&path, &err.to_string()))?;
392 let name = item.file_name().to_string_lossy().to_string();
393 if name.starts_with('.') {
394 continue;
395 }
396 let child = if relative.is_empty() {
397 name.clone()
398 } else {
399 format!("{relative}/{name}")
400 };
401 let is_dir = item
402 .file_type()
403 .map_err(|err| io_error(&item.path(), &err.to_string()))?
404 .is_dir();
405 entries.push(DirEntry {
406 name,
407 relative: child,
408 is_dir,
409 });
410 }
411 entries.sort_by(|left, right| {
412 right
413 .is_dir
414 .cmp(&left.is_dir)
415 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
416 });
417 Ok(entries)
418}
419
420pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
421 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
422 u64::from(width)
423 .checked_mul(u64::from(height))?
424 .checked_mul(64)?
425 .checked_mul(36)?
426 .checked_mul(3)?
427 .checked_mul(4)
428}
429
430fn new_id() -> String {
431 let nanos = SystemTime::now()
432 .duration_since(UNIX_EPOCH)
433 .map(|duration| duration.as_nanos())
434 .unwrap_or(0);
435 format!("ppd-{nanos:x}")
436}
437
438fn blank_to_none(value: String) -> Option<String> {
439 let trimmed = value.trim();
440 if trimmed.is_empty() {
441 None
442 } else {
443 Some(trimmed.to_string())
444 }
445}
446
447fn check_optional_path(label: &str, value: Option<&str>, issues: &mut Vec<String>) {
448 let Some(value) = value else {
449 return;
450 };
451 match portable_relative(value) {
452 Ok(clean) if clean == value && clean != MANIFEST_NAME => {}
453 Ok(_) if value == MANIFEST_NAME => {
454 issues.push(format!("{label} mag niet character.ppd zijn"));
455 }
456 Ok(_) => issues.push(format!("{label} is niet genormaliseerd: '{value}'")),
457 Err(message) => issues.push(format!("{label}: {message}")),
458 }
459}
460
461pub fn portable_relative(value: &str) -> Result<String, String> {
462 let value = value.trim();
463 if value.is_empty() {
464 return Err("het pad is leeg".to_string());
465 }
466 if value.contains('\\') {
467 return Err("gebruik '/' in projectpaden".to_string());
468 }
469 if value.starts_with('/') || windows_drive(value) {
470 return Err(
471 "een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap"
472 .to_string(),
473 );
474 }
475 let mut parts = Vec::new();
476 for part in value.split('/') {
477 if part.is_empty() || part == "." || part == ".." {
478 return Err(
479 "het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben"
480 .to_string(),
481 );
482 }
483 if part.contains('\0') {
484 return Err("het pad bevat een leeg teken".to_string());
485 }
486 parts.push(part);
487 }
488 Ok(parts.join("/"))
489}
490
491fn windows_drive(value: &str) -> bool {
492 let mut chars = value.chars();
493 matches!(
494 (chars.next(), chars.next(), chars.next()),
495 (Some(letter), Some(':'), _) if letter.is_ascii_alphabetic()
496 )
497}
498
499fn preset_stem(relative: &str) -> Result<String, String> {
500 let file_name = relative.rsplit('/').next().unwrap_or(relative);
501 let stem = file_name.split('.').next().unwrap_or(file_name);
502 if stem.is_empty()
503 || !stem
504 .chars()
505 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
506 || stem.contains(' ')
507 {
508 return Err(format!(
509 "de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties"
510 ));
511 }
512 Ok(stem.to_string())
513}
514
515fn disk_issues(root: &Path, document: &ProjectFile) -> Vec<String> {
516 let mut issues = Vec::new();
517 for relative in document.sources() {
518 if let Err(ProjectError::Validation(extra)) = require_file_inside(root, relative) {
519 issues.extend(extra);
520 }
521 }
522 let output = root.join(&document.output_directory);
523 if output.exists() && !output.is_dir() {
524 issues.push(format!(
525 "de uitvoermap '{}' is een bestand",
526 document.output_directory
527 ));
528 } else if !output.exists() {
529 issues.push(format!(
530 "de uitvoermap '{}' bestaat nog niet",
531 document.output_directory
532 ));
533 }
534 issues
535}
536
537fn require_file_inside(root: &Path, relative: &str) -> Result<(), ProjectError> {
538 let path = root.join(relative);
539 if !path.is_file() {
540 return Err(ProjectError::Validation(vec![format!(
541 "het bestand ontbreekt in de projectmap: {relative}"
542 )]));
543 }
544 ensure_lexically_inside(root, &path).map_err(|err| ProjectError::Validation(vec![err]))?;
545 match (root.canonicalize(), path.canonicalize()) {
546 (Ok(root_real), Ok(path_real)) if path_real.starts_with(&root_real) => Ok(()),
547 (Ok(_), Ok(_)) => Err(ProjectError::Validation(vec![format!(
548 "het pad '{relative}' wijst buiten de projectmap"
549 )])),
550 (Err(err), _) => Err(io_error(root, &err.to_string())),
551 (_, Err(err)) => Err(io_error(&path, &err.to_string())),
552 }
553}
554
555fn prepare_output_dir(root: &Path, relative: &str) -> Result<(), ProjectError> {
556 let path = root.join(relative);
557 if path.exists() && !path.is_dir() {
558 return Err(ProjectError::Validation(vec![format!(
559 "de uitvoermap '{relative}' is een bestand"
560 )]));
561 }
562 if !path.exists() {
563 fs::create_dir_all(&path).map_err(|err| io_error(&path, &err.to_string()))?;
564 }
565 ensure_lexically_inside(root, &path)
566 .map_err(|message| ProjectError::Validation(vec![message]))?;
567 Ok(())
568}
569
570fn ensure_lexically_inside(root: &Path, path: &Path) -> Result<(), String> {
571 let root = root.components().collect::<Vec<_>>();
572 let path = path.components().collect::<Vec<_>>();
573 if path.starts_with(&root) {
574 Ok(())
575 } else {
576 Err("het pad wijst buiten de projectmap".to_string())
577 }
578}
579
580fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectError> {
581 let parent = path
582 .parent()
583 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen map"))?;
584 let text = serde_json::to_string_pretty(document)
585 .map_err(|err| ProjectError::Json(err.to_string()))?
586 + "\n";
587 let mut temporary_name = path
588 .file_name()
589 .ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
590 .to_os_string();
591 temporary_name.push(".tmp");
592 let temporary = parent.join(temporary_name);
593 let write_result = (|| {
594 let mut file =
595 File::create(&temporary).map_err(|err| io_error(&temporary, &err.to_string()))?;
596 file.write_all(text.as_bytes())
597 .map_err(|err| io_error(&temporary, &err.to_string()))?;
598 file.sync_all()
599 .map_err(|err| io_error(&temporary, &err.to_string()))?;
600 Ok(())
601 })();
602 if let Err(err) = write_result {
603 let _ = fs::remove_file(&temporary);
604 return Err(err);
605 }
606 if let Err(err) = fs::rename(&temporary, path) {
607 let _ = fs::remove_file(&temporary);
608 return Err(io_error(
609 path,
610 &format!("opslaan is mislukt: {err}. Het vorige bestand is blijven staan"),
611 ));
612 }
613 Ok(())
614}
615
616fn io_error(path: &Path, message: &str) -> ProjectError {
617 ProjectError::Io {
618 path: path.display().to_string(),
619 message: message.to_string(),
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn temp_root(label: &str) -> PathBuf {
628 let path = std::env::temp_dir().join(format!(
629 "paperdoll-{label}-{}-{}",
630 std::process::id(),
631 new_id()
632 ));
633 let _ = fs::remove_dir_all(&path);
634 path
635 }
636
637 fn draft_from(document: &ProjectFile) -> ProjectDraft {
638 ProjectDraft {
639 id: document.id.clone(),
640 name: document.name.clone(),
641 male_model: document.male_model.clone().unwrap_or_default(),
642 female_model: document.female_model.clone().unwrap_or_default(),
643 equipment_presets: document.equipment_presets.clone(),
644 output_directory: document.output_directory.clone(),
645 target_width: document.render.target_width,
646 target_height: document.render.target_height,
647 fps: document.render.fps,
648 }
649 }
650
651 #[test]
652 fn roundtrip_keeps_null_slots_and_relative_paths() {
653 let root = temp_root("roundtrip");
654 let opened = create_project(&root, "Ridder").unwrap();
655 let text = fs::read_to_string(&opened.manifest_path).unwrap();
656 assert!(text.contains("\"male_model\": null"));
657 assert!(text.contains("\"female_model\": null"));
658 assert!(text.contains("\"output_directory\": \"renders\""));
659 assert!(!text.contains(root.to_string_lossy().as_ref()));
660 let loaded = open_project(&opened.manifest_path).unwrap();
661 assert_eq!(loaded.document, opened.document);
662 assert!(loaded.disk_issues.is_empty());
663 assert!(root.join("assets/body").is_dir());
664 assert!(root.join("presets").is_dir());
665 assert!(root.join("renders").is_dir());
666 let _ = fs::remove_dir_all(&root);
667 }
668
669 #[test]
670 fn save_writes_model_and_preset_paths_and_reloads_them() {
671 let root = temp_root("paths");
672 let mut opened = create_project(&root, "Ridder").unwrap();
673 fs::write(root.join("assets/body/male.glb"), b"glb").unwrap();
674 fs::write(root.join("presets/zwaard.bin"), b"preset").unwrap();
675 opened.document.male_model = Some("assets/body/male.glb".to_string());
676 opened.document.equipment_presets = vec!["presets/zwaard.bin".to_string()];
677 save_project(&opened).unwrap();
678 let loaded = open_project(&opened.manifest_path).unwrap();
679 assert_eq!(
680 loaded.document.male_model.as_deref(),
681 Some("assets/body/male.glb")
682 );
683 assert_eq!(loaded.document.female_model, None);
684 assert_eq!(
685 loaded.document.equipment_presets,
686 vec!["presets/zwaard.bin"]
687 );
688 assert!(loaded.disk_issues.is_empty());
689 let _ = fs::remove_dir_all(&root);
690 }
691
692 #[test]
693 fn validation_rejects_escape_absolute_body_and_output_overlap() {
694 let mut document = ProjectFile::new_empty("Ridder");
695 document.id = "ridder-1".to_string();
696 document.male_model = Some("../geheim.glb".to_string());
697 document.female_model = Some("/tmp/vrouw.glb".to_string());
698 document.equipment_presets = vec![
699 "presets/body.bin".to_string(),
700 "presets/Zwaard.bin".to_string(),
701 ];
702 document.output_directory = "presets".to_string();
703 let error = document.validate_shape().unwrap_err();
704 let text = error.to_string();
705 assert!(text.contains("buiten de projectmap"), "{text}");
706 assert!(text.contains("absoluut"), "{text}");
707 assert!(text.contains("body"), "{text}");
708 assert!(text.contains("kleine letters"), "{text}");
709 assert!(text.contains("uitvoermap"), "{text}");
710 }
711
712 #[test]
713 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
714 let newer = r#"{"format_version":2,"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}"#;
715 let error = parse_manifest(newer).unwrap_err();
716 assert!(matches!(
717 error,
718 ProjectError::UnsupportedVersion {
719 found: 2,
720 supported: 1
721 }
722 ));
723 assert!(error.to_string().contains("format_version 2"));
724 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}"#;
725 let error = parse_manifest(unknown).unwrap_err();
726 assert!(matches!(error, ProjectError::UnknownField(_)));
727 assert!(error.to_string().contains("niet herschreven"));
728 }
729
730 #[test]
731 fn failed_save_keeps_the_previous_manifest() {
732 let root = temp_root("atomic");
733 let opened = create_project(&root, "Ridder").unwrap();
734 let before = fs::read(&opened.manifest_path).unwrap();
735 let mut broken = opened.clone();
736 broken.document.output_directory = "..".to_string();
737 let error = save_project(&broken).unwrap_err();
738 assert!(error.to_string().contains("buiten de projectmap"));
739 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
740 broken.document.output_directory = "renders".to_string();
741 broken.document.male_model = Some("assets/body/ontbreekt.glb".to_string());
742 let error = save_project(&broken).unwrap_err();
743 assert!(error.to_string().contains("ontbreekt"), "{error}");
744 assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
745 let _ = fs::remove_dir_all(&root);
746 }
747
748 #[test]
749 fn missing_manifest_is_a_recoverable_io_error() {
750 let path = temp_root("missing").join(MANIFEST_NAME);
751 let error = open_project(&path).unwrap_err();
752 let text = error.to_string();
753 assert!(text.contains("openen is mislukt"), "{text}");
754 assert!(matches!(error, ProjectError::Io { .. }));
755 }
756
757 #[test]
758 fn create_refuses_to_overwrite_an_existing_project() {
759 let root = temp_root("twice");
760 create_project(&root, "Eerste").unwrap();
761 let error = create_project(&root, "Tweede").unwrap_err();
762 assert!(error.to_string().contains("al een character.ppd"));
763 let loaded = open_project(&root.join(MANIFEST_NAME)).unwrap();
764 assert_eq!(loaded.document.name, "Eerste");
765 let _ = fs::remove_dir_all(&root);
766 }
767
768 #[test]
769 fn render_bounds_and_assemble_roundtrip() {
770 let mut document = ProjectFile::new_empty("Ridder");
771 document.id = "ridder-1".to_string();
772 document.render.target_width = 0;
773 assert!(document
774 .validate_shape()
775 .unwrap_err()
776 .to_string()
777 .contains("groter zijn dan 0"));
778 document.render = RenderSettings {
779 target_width: 4096,
780 target_height: 4096,
781 fps: 12,
782 };
783 assert!(document
784 .validate_shape()
785 .unwrap_err()
786 .to_string()
787 .contains("geschatte renderbuffer"));
788 document.render.fps = 0;
789 document.render.target_width = 128;
790 document.render.target_height = 128;
791 assert!(document
792 .validate_shape()
793 .unwrap_err()
794 .to_string()
795 .contains("fps"));
796 let assembled = assemble(draft_from(&ProjectFile::new_empty("Ridder"))).unwrap();
797 let text = serde_json::to_string(&assembled).unwrap();
798 let parsed = parse_manifest(&text).unwrap();
799 assert_eq!(parsed.render, RenderSettings::default());
800 assert_eq!(parsed.male_model, None);
801 }
802
803 #[test]
804 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
805 let root = temp_root("link");
806 let opened = create_project(&root, "Ridder").unwrap();
807 let outside = temp_root("outside-file");
808 fs::create_dir_all(&outside).unwrap();
809 let secret = outside.join("geheim.glb");
810 fs::write(&secret, b"nope").unwrap();
811 std::os::unix::fs::symlink(&secret, root.join("assets/body/ontsnapt.glb")).unwrap();
812 let mut broken = opened.clone();
813 broken.document.male_model = Some("assets/body/ontsnapt.glb".to_string());
814 let error = save_project(&broken).unwrap_err();
815 assert!(
816 error.to_string().contains("buiten de projectmap"),
817 "{error}"
818 );
819 fs::write(root.join("assets/body/male.glb"), b"ok").unwrap();
820 let entries = list_dir(&root, "assets/body").unwrap();
821 assert!(entries
822 .iter()
823 .any(|entry| entry.name == "male.glb" && !entry.is_dir));
824 let error = list_dir(&root, "../").unwrap_err();
825 assert!(error.to_string().contains("buiten de projectmap"));
826 let _ = fs::remove_dir_all(&root);
827 let _ = fs::remove_dir_all(&outside);
828 }
829}