BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

59dd3616 History
editor.rs 19.2 KiB Download Raw
1//! Project window. It edits a `.ppd` manifest and does not import or render.
2
3use std::path::PathBuf;
4
5use eframe::egui;
6
7use crate::import::{import_project_model, ImportedModel, ModelFormat};
8use crate::project::{
9 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
10 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
11};
12
13pub struct EditorApp {
14 root_input: String,
15 open_input: String,
16 opened: Option<OpenedProject>,
17 name: String,
18 male: String,
19 female: String,
20 presets: Vec<String>,
21 preset_input: String,
22 output: String,
23 width: String,
24 height: String,
25 fps: String,
26 browser: String,
27 male_import: Option<Result<ImportedModel, String>>,
28 female_import: Option<Result<ImportedModel, String>>,
29 notice: String,
30 notice_ok: bool,
31}
32
33impl EditorApp {
34 pub fn new() -> Self {
35 Self {
36 root_input: String::new(),
37 open_input: String::new(),
38 opened: None,
39 name: "Nieuw project".to_string(),
40 male: String::new(),
41 female: String::new(),
42 presets: Vec::new(),
43 preset_input: String::new(),
44 output: "renders".to_string(),
45 width: "128".to_string(),
46 height: "128".to_string(),
47 fps: "12".to_string(),
48 browser: String::new(),
49 male_import: None,
50 female_import: None,
51 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
52 notice_ok: true,
53 }
54 }
55
56 fn load(&mut self, opened: OpenedProject, notice: &str) {
57 self.name = opened.document.name.clone();
58 self.male = opened.document.male_model.clone().unwrap_or_default();
59 self.female = opened.document.female_model.clone().unwrap_or_default();
60 self.presets = opened.document.equipment_presets.clone();
61 self.output = opened.document.output_directory.clone();
62 self.width = opened.document.render.target_width.to_string();
63 self.height = opened.document.render.target_height.to_string();
64 self.fps = opened.document.render.fps.to_string();
65 self.browser.clear();
66 self.male_import = None;
67 self.female_import = None;
68 self.opened = Some(opened.clone());
69 let male = self.male.clone();
70 let female = self.female.clone();
71 if !male.trim().is_empty() {
72 self.inspect(true);
73 }
74 if !female.trim().is_empty() {
75 self.inspect(false);
76 }
77 if male.trim().is_empty() && female.trim().is_empty() {
78 if opened.disk_issues.is_empty() {
79 self.ok(notice);
80 } else {
81 self.fail_text(opened.disk_issues.join("\n"));
82 }
83 }
84 }
85
86 fn ok(&mut self, message: &str) {
87 self.notice = message.to_string();
88 self.notice_ok = true;
89 }
90
91 fn fail(&mut self, error: ProjectError) {
92 self.notice = error.to_string();
93 self.notice_ok = false;
94 }
95
96 fn fail_text(&mut self, message: impl Into<String>) {
97 self.notice = message.into();
98 self.notice_ok = false;
99 }
100
101 fn draft(&self) -> Result<ProjectDraft, String> {
102 let opened = self
103 .opened
104 .as_ref()
105 .ok_or_else(|| "open of maak eerst een project".to_string())?;
106 Ok(ProjectDraft {
107 id: opened.document.id.clone(),
108 name: self.name.clone(),
109 male_model: self.male.clone(),
110 female_model: self.female.clone(),
111 equipment_presets: self.presets.clone(),
112 output_directory: self.output.clone(),
113 target_width: parse_u32("breedte", &self.width)?,
114 target_height: parse_u32("hoogte", &self.height)?,
115 fps: parse_u32("fps", &self.fps)?,
116 })
117 }
118
119 fn create(&mut self) {
120 let root = PathBuf::from(self.root_input.trim());
121 if self.root_input.trim().is_empty() {
122 self.fail_text("vul de projectmap in");
123 return;
124 }
125 match create_project(&root, "Nieuw project") {
126 Ok(opened) => self.load(
127 opened,
128 "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.",
129 ),
130 Err(error) => self.fail(error),
131 }
132 }
133
134 fn open(&mut self) {
135 let path = PathBuf::from(self.open_input.trim());
136 if self.open_input.trim().is_empty() {
137 self.fail_text("vul het pad naar een .ppd-bestand in");
138 return;
139 }
140 match open_project(&path) {
141 Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."),
142 Err(error) => self.fail(error),
143 }
144 }
145
146 fn save(&mut self) {
147 let Some(current) = self.opened.clone() else {
148 self.fail_text("open of maak eerst een project");
149 return;
150 };
151 let draft = match self.draft() {
152 Ok(draft) => draft,
153 Err(message) => {
154 self.fail_text(message);
155 return;
156 }
157 };
158 let document = match assemble(draft) {
159 Ok(document) => document,
160 Err(error) => {
161 self.fail(error);
162 return;
163 }
164 };
165 let candidate = OpenedProject {
166 manifest_path: current.manifest_path,
167 document,
168 disk_issues: Vec::new(),
169 };
170 match save_project(&candidate) {
171 Ok(()) => {
172 let path = candidate.manifest_path.display().to_string();
173 self.opened = Some(candidate);
174 self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
175 }
176 Err(error) => self.fail(error),
177 }
178 }
179
180 fn add_preset(&mut self, relative: String) {
181 if relative.trim().is_empty() {
182 return;
183 }
184 self.presets.push(relative);
185 if let Err(error) = self
186 .draft()
187 .and_then(|draft| assemble(draft).map_err(|error| error.to_string()))
188 {
189 self.presets.pop();
190 self.fail_text(error);
191 } else {
192 self.preset_input.clear();
193 self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd.");
194 }
195 }
196
197 fn assign_file(&mut self, relative: &str) {
198 if relative == MANIFEST_NAME {
199 self.fail_text("character.ppd is het projectbestand, geen model of preset");
200 return;
201 }
202 let lower = relative.to_ascii_lowercase();
203 if lower.contains("/preset") || lower.starts_with("presets/") {
204 self.add_preset(relative.to_string());
205 } else if self.male.trim().is_empty() {
206 self.male = relative.to_string();
207 self.inspect(true);
208 } else if self.female.trim().is_empty() {
209 self.female = relative.to_string();
210 self.inspect(false);
211 } else {
212 self.add_preset(relative.to_string());
213 }
214 }
215
216 fn inspect(&mut self, male: bool) {
217 let Some(root) = self.opened.as_ref().and_then(|opened| {
218 opened
219 .manifest_path
220 .parent()
221 .map(std::path::Path::to_path_buf)
222 }) else {
223 self.fail_text("open of maak eerst een project");
224 return;
225 };
226 let relative = if male {
227 self.male.trim()
228 } else {
229 self.female.trim()
230 };
231 if relative.is_empty() {
232 if male {
233 self.male_import = None;
234 } else {
235 self.female_import = None;
236 }
237 self.fail_text("dit slot heeft nog geen modelpad");
238 return;
239 }
240 match import_project_model(&root, relative) {
241 Ok(model) => {
242 let message = format!(
243 "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
244 model.meshes.len(),
245 model.joints.len(),
246 model.clips.len()
247 );
248 if male {
249 self.male_import = Some(Ok(model));
250 } else {
251 self.female_import = Some(Ok(model));
252 }
253 self.ok(&message);
254 }
255 Err(error) => {
256 let text = error.to_string();
257 if male {
258 self.male_import = Some(Err(text.clone()));
259 } else {
260 self.female_import = Some(Err(text.clone()));
261 }
262 self.fail_text(text);
263 }
264 }
265 }
266}
267
268impl eframe::App for EditorApp {
269 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
270 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
271 ui.horizontal_wrapped(|ui| {
272 if ui.button("Nieuw").clicked() {
273 self.create();
274 }
275 if ui.button("Open").clicked() {
276 self.open();
277 }
278 if ui.button("Opslaan").clicked() {
279 self.save();
280 }
281 if ui.button("Sluiten").clicked() {
282 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
283 }
284 ui.label(format!("format_version {FORMAT_VERSION}"));
285 });
286 });
287 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
288 let color = if self.notice_ok {
289 egui::Color32::from_rgb(20, 90, 40)
290 } else {
291 egui::Color32::from_rgb(150, 30, 30)
292 };
293 ui.colored_label(color, &self.notice);
294 });
295 egui::SidePanel::right("browser")
296 .min_width(240.0)
297 .show(ctx, |ui| {
298 self.show_browser(ui);
299 });
300 egui::CentralPanel::default().show(ctx, |ui| {
301 egui::ScrollArea::vertical().show(ui, |ui| {
302 self.show_form(ui);
303 });
304 });
305 }
306}
307
308impl EditorApp {
309 fn show_form(&mut self, ui: &mut egui::Ui) {
310 ui.heading("Paperdoll Renderer");
311 ui.label(
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",
313 );
314 ui.add_space(8.0);
315 ui.horizontal(|ui| {
316 ui.label("Projectmap");
317 ui.add(
318 egui::TextEdit::singleline(&mut self.root_input)
319 .desired_width(360.0)
320 .hint_text("/pad/naar/nieuwe-map"),
321 );
322 });
323 ui.horizontal(|ui| {
324 ui.label(".ppd openen");
325 ui.add(
326 egui::TextEdit::singleline(&mut self.open_input)
327 .desired_width(360.0)
328 .hint_text("/pad/naar/character.ppd"),
329 );
330 });
331 ui.separator();
332 ui.horizontal(|ui| {
333 ui.label("Naam");
334 ui.text_edit_singleline(&mut self.name);
335 });
336 ui.horizontal(|ui| {
337 ui.label("Man");
338 let male_edit = ui.add(
339 egui::TextEdit::singleline(&mut self.male)
340 .desired_width(360.0)
341 .hint_text("assets/body/male.glb"),
342 );
343 if male_edit.changed() {
344 self.male_import = None;
345 }
346 if ui.button("Wis man").clicked() {
347 self.male.clear();
348 self.male_import = None;
349 }
350 if ui.button("Lees man").clicked() {
351 self.inspect(true);
352 }
353 });
354 show_import(ui, "Import man", self.male_import.as_ref());
355 ui.horizontal(|ui| {
356 ui.label("Vrouw");
357 let female_edit = ui.add(
358 egui::TextEdit::singleline(&mut self.female)
359 .desired_width(360.0)
360 .hint_text("assets/body/female.glb"),
361 );
362 if female_edit.changed() {
363 self.female_import = None;
364 }
365 if ui.button("Wis vrouw").clicked() {
366 self.female.clear();
367 self.female_import = None;
368 }
369 if ui.button("Lees vrouw").clicked() {
370 self.inspect(false);
371 }
372 });
373 show_import(ui, "Import vrouw", self.female_import.as_ref());
374 ui.label("Equipment-presets, relatief aan de projectmap");
375 let mut remove = None;
376 for (index, preset) in self.presets.iter().enumerate() {
377 ui.horizontal(|ui| {
378 ui.label(preset);
379 if ui.button("Verwijder").clicked() {
380 remove = Some(index);
381 }
382 });
383 }
384 if let Some(index) = remove {
385 self.presets.remove(index);
386 }
387 ui.horizontal(|ui| {
388 ui.add(
389 egui::TextEdit::singleline(&mut self.preset_input)
390 .desired_width(280.0)
391 .hint_text("presets/zwaard.bin"),
392 );
393 if ui.button("Voeg preset toe").clicked() {
394 let value = self.preset_input.trim().to_string();
395 self.add_preset(value);
396 }
397 });
398 ui.horizontal(|ui| {
399 ui.label("Uitvoer");
400 ui.text_edit_singleline(&mut self.output);
401 });
402 ui.horizontal(|ui| {
403 ui.label("Breedte");
404 ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0));
405 ui.label("Hoogte");
406 ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0));
407 ui.label("fps");
408 ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0));
409 });
410 ui.add_space(8.0);
411 ui.label(
412 "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.",
413 );
414 }
415
416 fn show_browser(&mut self, ui: &mut egui::Ui) {
417 ui.heading("Bestanden");
418 let Some(opened) = self.opened.clone() else {
419 ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen.");
420 return;
421 };
422 let root = opened
423 .manifest_path
424 .parent()
425 .map(PathBuf::from)
426 .unwrap_or_default();
427 ui.label(if self.browser.is_empty() {
428 "projectmap".to_string()
429 } else {
430 self.browser.clone()
431 });
432 if !self.browser.is_empty() && ui.button("Omhoog").clicked() {
433 self.browser = parent_relative(&self.browser);
434 }
435 if ui.button("Deze map als uitvoer").clicked() {
436 if self.browser.is_empty() {
437 self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap");
438 } else {
439 self.output = self.browser.clone();
440 self.ok("Uitvoermap gekozen. Nog niet opgeslagen.");
441 }
442 }
443 let entries = match list_dir(&root, &self.browser) {
444 Ok(entries) => entries,
445 Err(error) => {
446 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string());
447 return;
448 }
449 };
450 let mut enter = None;
451 let mut choose = None;
452 for entry in &entries {
453 let label = if entry.is_dir {
454 format!("{}/", entry.name)
455 } else {
456 entry.name.clone()
457 };
458 if ui.button(label).clicked() {
459 if entry.is_dir {
460 enter = Some(entry.relative.clone());
461 } else {
462 choose = Some(entry.relative.clone());
463 }
464 }
465 }
466 if let Some(relative) = enter {
467 self.browser = relative;
468 }
469 if let Some(relative) = choose {
470 self.assign_file(&relative);
471 }
472 }
473}
474
475fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
476 let Some(report) = report else {
477 return;
478 };
479 ui.group(|ui| {
480 ui.strong(title);
481 match report {
482 Ok(model) => {
483 let format = match model.format {
484 ModelFormat::Gltf => "glTF/GLB",
485 ModelFormat::Obj => "OBJ",
486 ModelFormat::Fbx => "FBX",
487 };
488 ui.label(format);
489 for mesh in &model.meshes {
490 ui.label(format!(
491 "mesh {}: {} vertices, {} driehoeken{}",
492 mesh.name,
493 mesh.vertex_count,
494 mesh.triangle_count,
495 if mesh.has_skin { ", skin" } else { "" }
496 ));
497 }
498 for material in &model.materials {
499 ui.label(format!(
500 "materiaal {}{}",
501 material.name,
502 material
503 .base_color_texture
504 .as_ref()
505 .map(|name| format!(", texture {name}"))
506 .unwrap_or_default()
507 ));
508 }
509 for texture in &model.textures {
510 ui.label(format!(
511 "texture {} ({}x{})",
512 texture.source, texture.width, texture.height
513 ));
514 }
515 if model.joints.is_empty() {
516 ui.label("geen skeleton");
517 } else {
518 ui.label(format!("{} joints", model.joints.len()));
519 for joint in model.joints.iter().take(8) {
520 match &joint.parent {
521 Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
522 None => ui.label(format!("{} (wortel)", joint.name)),
523 };
524 }
525 }
526 if model.clips.is_empty() {
527 ui.label("geen animation clips");
528 } else {
529 for clip in &model.clips {
530 ui.label(format!(
531 "clip {} ({:.2} s, {} kanalen)",
532 clip.name, clip.duration_seconds, clip.channel_count
533 ));
534 }
535 }
536 for notice in &model.notices {
537 ui.label(notice);
538 }
539 }
540 Err(message) => {
541 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
542 }
543 }
544 });
545}
546
547fn parent_relative(relative: &str) -> String {
548 match relative.rfind('/') {
549 Some(index) => relative[..index].to_string(),
550 None => String::new(),
551 }
552}
553
554fn parse_u32(label: &str, text: &str) -> Result<u32, String> {
555 let text = text.trim();
556 if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) {
557 return Err(format!("{label} moet een geheel getal zijn"));
558 }
559 text.parse::<u32>()
560 .map_err(|_| format!("{label} is te groot"))
561}
562
563impl Default for EditorApp {
564 fn default() -> Self {
565 Self::new()
566 }
567}