BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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