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>
@@ -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"1744
dependencies = [1744
dependencies = [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"2048
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"2049
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"2049
dependencies = [2050
dependencies = [2050
"serde_core",2051
"serde_core",2052
"serde_derive",2051
]2053
]2052
2054
2053
[[package]]2055
[[package]]@@ -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"6
publish = false6
publish = false7
description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."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
[dependencies]9
[dependencies]13
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }14
libloading = "0.8"11
libloading = "0.8"12
serde = { version = "1", features = ["derive"] }13
serde_json = "1"@@ -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 Renderer1
# Paperdoll Renderer2
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
## Eisen5
## Eisen6
6
@@ -17,7 +17,7 @@ cargo run -- --status17
cargo run17
cargo run18
```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
## Testlichamen22
## Testlichamen23
23
@@ -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/`, `presets86
86
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.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 190
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
## 6. Camera, axes, alignment and animation sampling93
## 6. Camera, axes, alignment and animation sampling90
94
91
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.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.@@ -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
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 = self86
.opened87
.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) = self171
.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 = opened339
.manifest_path340
.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
}@@ -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
109
pub fn dutch_summary() -> &'static str {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
/// Stable status text shared by `--status` and the window.113
/// Stable status text shared by `--status` and the window.114
pub fn report() -> String {114
pub 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
159
pub fn help_text() -> &'static str {159
pub fn help_text() -> &'static str {160
"\160
"\161
Paperdoll Renderer — projectbasis161
Paperdoll Renderer — projectbestanden162
162
163
Gebruik:163
Gebruik:164
paperdoll-renderer open het statusvenster164
paperdoll-renderer open het projectvenster165
paperdoll-renderer --status print de status en sluit165
paperdoll-renderer --status print de status en sluit166
paperdoll-renderer --help toon deze tekst166
paperdoll-renderer --help toon deze tekst167
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
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>171
pub 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",@@ -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 import3
//! This crate reports which product capabilities exist. It does not import4
//! models and it does not render paperdolls.4
//! models and it does not render paperdolls.5
5
6
pub mod editor;6
pub mod foundation;7
pub mod foundation;8
pub mod project;@@ -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 and3
//! The window creates and saves a `.ppd` project directory. It does not import4
//! it is not a paperdoll preview.4
//! models and it does not render paperdolls.5
5
6
use std::process::ExitCode;6
use std::process::ExitCode;7
7
8
use eframe::egui;8
use paperdoll_renderer::editor::EditorApp;9
use paperdoll_renderer::foundation::{9
use 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
14
fn main() -> ExitCode {11
fn 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
65
fn run_window() -> eframe::Result<()> {62
fn 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
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
}@@ -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 and4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save5
//! 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_version138
));139
}140
if self.id.trim().is_empty()141
|| !self142
.id143
.chars()144
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')145
|| self.id.len() > 64146
{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_model246
.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 = manifest344
.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 = item402
.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
right413
.is_dir414
.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
None442
} 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
|| !stem504
.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_directory527
));528
} else if !output.exists() {529
issues.push(format!(530
"de uitvoermap '{}' bestaat nog niet",531
document.output_directory532
));533
}534
issues535
}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 = path582
.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 = path588
.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
path635
}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: 1721
}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!(document774
.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!(document784
.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!(document792
.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!(entries822
.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
}