BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 42.4 KiB Download Raw
1//! Project window: start screen, body, equipment, and the render queue.
2
3use std::path::PathBuf;
4use std::sync::mpsc::Receiver;
5
6use eframe::egui;
7
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
11use crate::preview_gl::Viewport;
12use crate::project::open_project;
13use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
15
16pub struct EditorApp {
17 session: Session,
18 dialogs: Box<dyn Dialogs>,
19 controls: PreviewControls,
20 preview_male: Option<PreviewCharacter>,
21 preview_female: Option<PreviewCharacter>,
22 viewport: Viewport,
23 inventories: [Option<Result<ImportedModel, String>>; 2],
24 render_rx: Option<Receiver<RenderEvent>>,
25 render_ui: RenderUiState,
26 applied_shell: Option<[f32; 2]>,
27 width_text: String,
28 height_text: String,
29 fps_text: String,
30 allow_close: bool,
31 dialog_pending: bool,
32 equipment_part: usize,
33}
34
35impl EditorApp {
36 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
37 Self {
38 session: Session::new(),
39 dialogs,
40 controls: PreviewControls::default(),
41 preview_male: None,
42 preview_female: None,
43 viewport: Viewport::new(),
44 inventories: [None, None],
45 render_rx: None,
46 render_ui: RenderUiState::idle(),
47 applied_shell: None,
48 width_text: "128".to_string(),
49 height_text: "128".to_string(),
50 fps_text: "12".to_string(),
51 allow_close: false,
52 dialog_pending: false,
53 equipment_part: 0,
54 }
55 }
56
57 pub fn open_path(&mut self, path: PathBuf) {
58 let candidate = std::env::current_dir()
59 .map_err(|e| e.to_string())
60 .and_then(|base| {
61 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
62 });
63 let path = match candidate {
64 Ok(path) => path,
65 Err(error) => {
66 self.session.fail(error);
67 return;
68 }
69 };
70 match open_project(&path) {
71 Ok(opened) => {
72 self.session.install_loaded(opened);
73 self.reset_render();
74 self.sync_fields();
75 self.reload_bodies();
76 }
77 Err(error) => {
78 self.session.fail(error.to_string());
79 }
80 }
81 }
82
83 pub fn open_fixture_project(&mut self) {
84 let root = std::env::temp_dir().join(format!(
85 "paperdoll-renderer-fixture-{}-{}",
86 std::process::id(),
87 std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .unwrap_or_default()
90 .as_nanos()
91 ));
92 if let Err(error) = self.session.install_new_project(&root) {
93 self.session.fail(error.to_string());
94 return;
95 }
96 self.sync_fields();
97 self.reload_bodies();
98 self.session.show_body(Sex::Male);
99 self.controls.sex = PreviewSex::Male;
100 self.controls.select_clip("walk");
101 }
102
103 fn reset_render(&mut self) {
104 self.render_rx = None;
105 self.render_ui = RenderUiState::idle();
106 }
107
108 fn sync_fields(&mut self) {
109 if let Some(opened) = &self.session.opened {
110 self.width_text = opened.document.render.target_width.to_string();
111 self.height_text = opened.document.render.target_height.to_string();
112 self.fps_text = opened.document.render.fps.to_string();
113 }
114 }
115
116 fn reload_bodies(&mut self) {
117 self.preview_male = None;
118 self.preview_female = None;
119 self.inventories = [None, None];
120 let Some(root) = self.session.project_root() else {
121 return;
122 };
123 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
124 if let Some(relative) = self.session.body_path(sex) {
125 let imported =
126 import_project_model(&root, relative).map_err(|error| error.to_string());
127 // Validate the source and its dependencies before the viewport reads it.
128 if imported.is_ok()
129 && matches!(
130 std::path::Path::new(relative)
131 .extension()
132 .and_then(|e| e.to_str()),
133 Some("gltf" | "glb")
134 )
135 {
136 match load_gltf_file(&root.join(relative)) {
137 Ok(character) => match sex {
138 Sex::Male => self.preview_male = Some(character),
139 Sex::Female => self.preview_female = Some(character),
140 },
141 Err(error) => self.session.fail(format!("3D: {error}")),
142 }
143 }
144 self.inventories[index] = Some(imported);
145 }
146 }
147 }
148
149 fn character(&self) -> Option<&PreviewCharacter> {
150 match self.controls.sex {
151 PreviewSex::Male => self.preview_male.as_ref(),
152 PreviewSex::Female => self.preview_female.as_ref(),
153 }
154 }
155
156 fn poll_dialogs(&mut self) {
157 let poll = self.dialogs.poll();
158 self.dialog_pending = matches!(poll, DialogPoll::Pending);
159 if let DialogPoll::Ready { purpose, path } = poll {
160 let Some(path) = path else {
161 return;
162 };
163 match purpose {
164 DialogPurpose::NewProject => {
165 if let Err(error) = self.session.install_new_project(&path) {
166 self.session.fail(error.to_string());
167 } else {
168 self.reset_render();
169 self.sync_fields();
170 self.reload_bodies();
171 }
172 }
173 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
174 Ok(opened) => {
175 self.session.install_loaded(opened);
176 self.reset_render();
177 self.sync_fields();
178 self.reload_bodies();
179 }
180 Err(error) => self.session.fail(error.to_string()),
181 },
182 DialogPurpose::Body(sex) => self.take_model(sex, &path),
183 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
184 DialogPurpose::EquipmentShiny(index, part) => {
185 if let Some(preset) = self
186 .session
187 .equipment
188 .get_mut(index)
189 .and_then(|i| i.preset.as_mut())
190 {
191 if let Err(error) = crate::equipment::import_shiny(preset, part, &path) {
192 self.session.fail(error);
193 }
194 }
195 }
196 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
197 Ok(mut preset) => {
198 let base = preset.name.clone();
199 let mut n = 2;
200 while self.session.equipment.iter().any(|i| i.slug == preset.name) {
201 preset.name = format!("{base}-{n}");
202 n += 1;
203 }
204 let index = self.session.equipment.len();
205 self.session.equipment.push(crate::session::EquipmentItem {
206 slug: preset.name.clone(),
207 model: preset.attachments.first().map(|p| p.label.clone()),
208 preset: Some(preset),
209 legacy_path: None,
210 error: None,
211 });
212 self.equipment_part = 0;
213 self.session.show_equipment(index);
214 }
215 Err(error) => self.session.fail(error),
216 },
217 }
218 }
219 }
220
221 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
222 let Some(root) = self.session.project_root() else {
223 return;
224 };
225 match adopt_file(&root, path, "assets/body") {
226 Ok(relative) => {
227 self.session.set_body_path(sex, relative.clone());
228 self.reload_bodies();
229 self.reset_render();
230 let index = if sex == Sex::Male { 0 } else { 1 };
231 if let Some(Err(error)) = &self.inventories[index] {
232 self.session.fail(format!("{relative}: {error}"));
233 } else {
234 self.session.notice = format!("{relative} staat in het project.");
235 self.session.notice_ok = true;
236 }
237 }
238 Err(error) => self.session.fail(error),
239 }
240 }
241
242 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
243 let sex = if self.session.active_body == Some(Sex::Female) {
244 1
245 } else {
246 0
247 };
248 let body = if sex == 0 {
249 self.preview_male.as_ref()
250 } else {
251 self.preview_female.as_ref()
252 };
253 let Some(item) = self.session.equipment.get_mut(index) else {
254 return;
255 };
256 let mut preset = item
257 .preset
258 .clone()
259 .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
260 preset.name = item.slug.clone();
261 match crate::equipment::import_obj(&mut preset, path) {
262 Ok(part) => {
263 if let Some(body) = body {
264 if !body.joint_nodes.is_empty() {
265 let _ = preset.bind(sex, body, part, 0);
266 }
267 }
268 item.model = Some(preset.attachments[part].label.clone());
269 item.legacy_path = None;
270 item.error = None;
271 item.preset = Some(preset);
272 self.equipment_part = part;
273 self.session.notice =
274 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
275 .into();
276 self.session.notice_ok = true;
277 }
278 Err(error) => self.session.fail(error),
279 }
280 }
281
282 pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
283 self.session.active_body = Some(sex);
284 self.session.show_equipment(index);
285 self.equipment_part = 0;
286 }
287
288 fn begin(&mut self, intent: Intent) {
289 if self.session.gate(intent) {
290 self.run_intent(intent);
291 }
292 }
293
294 fn run_intent(&mut self, intent: Intent) {
295 match intent {
296 Intent::Quit => {}
297 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
298 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
299 }
300 }
301
302 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
303 match self.session.resolve(choice) {
304 AfterChoice::Stay => {}
305 AfterChoice::SaveThen(intent) => match self.session.save() {
306 Ok(()) => self.finish_intent(intent, ctx),
307 Err(error) => self.session.fail(error.to_string()),
308 },
309 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
310 }
311 }
312
313 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
314 if intent == Intent::Quit {
315 self.allow_close = true;
316 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
317 } else {
318 self.run_intent(intent);
319 }
320 }
321
322 fn save(&mut self) {
323 if let Err(error) = self.session.save() {
324 self.session.fail(error.to_string());
325 }
326 }
327
328 fn apply_render_fields(&mut self) {
329 let width = self.width_text.parse::<u32>().unwrap_or(0);
330 let height = self.height_text.parse::<u32>().unwrap_or(0);
331 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
332 self.session.set_render(width, height, fps);
333 }
334
335 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
336 pub fn render_current_pose(&mut self) {
337 self.start_render([480, 640]);
338 }
339
340 fn start_render(&mut self, preview: [u32; 2]) {
341 if let Some(opened) = &self.session.opened {
342 if let Err(error) = opened.document.validate_shape() {
343 self.session.fail(error.to_string());
344 return;
345 }
346 }
347 let Some(character) = self.character().cloned() else {
348 self.session
349 .fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
350 return;
351 };
352 let Some(root) = self.session.project_root() else {
353 return;
354 };
355 let Some(opened) = &self.session.opened else {
356 return;
357 };
358 if let Err(error) =
359 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
360 {
361 self.session.fail(error.to_string());
362 return;
363 }
364 let sex = match self.controls.sex {
365 PreviewSex::Male => "male",
366 PreviewSex::Female => "female",
367 };
368 let output = root
369 .join(&opened.document.output_directory)
370 .join(format!("body-{sex}-still.png"));
371 let job = RenderJob {
372 character,
373 controls: self.controls.clone(),
374 target_width: opened.document.render.target_width.max(1),
375 target_height: opened.document.render.target_height.max(1),
376 preview_width: preview[0].max(1),
377 preview_height: preview[1].max(1),
378 output,
379 };
380 let thread = spawn(job);
381 self.render_rx = Some(thread.rx);
382 self.render_ui = RenderUiState::idle();
383 self.render_ui.running = true;
384 let _ = thread.handle;
385 }
386
387 fn drain_render(&mut self) {
388 let Some(rx) = &self.render_rx else {
389 return;
390 };
391 loop {
392 let event = match rx.try_recv() {
393 Ok(event) => event,
394 Err(std::sync::mpsc::TryRecvError::Empty) => break,
395 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
396 let message = "De renderworker stopte zonder eindresultaat.".to_string();
397 self.session.fail(message.clone());
398 self.render_ui.apply(RenderEvent::Failed(message));
399 self.render_rx = None;
400 break;
401 }
402 };
403 let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
404 if let RenderEvent::Finished { saved: Some(path) } = &event {
405 self.session.notice = format!("Standbeeld opgeslagen: {path}");
406 self.session.notice_ok = true;
407 }
408 if let RenderEvent::Failed(message) = &event {
409 self.session.fail(message.clone());
410 }
411 self.render_ui.apply(event);
412 if finished {
413 self.render_rx = None;
414 break;
415 }
416 }
417 }
418}
419
420impl Default for EditorApp {
421 fn default() -> Self {
422 Self::new(Box::new(SystemDialogs::new()))
423 }
424}
425
426impl eframe::App for EditorApp {
427 fn on_exit(&mut self, gl: Option<&glow::Context>) {
428 if let Some(gl) = gl {
429 self.viewport.destroy(gl);
430 }
431 }
432 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
433 let shell = self.session.shell_size();
434 if self.applied_shell != Some(shell) {
435 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
436 shell[0], shell[1],
437 )));
438 let min = if shell == crate::session::START_WINDOW {
439 egui::vec2(420.0, 220.0)
440 } else {
441 egui::vec2(900.0, 560.0)
442 };
443 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
444 self.applied_shell = Some(shell);
445 }
446 if ctx.input(|input| input.viewport().close_requested())
447 && !self.allow_close
448 && self.session.confirm.is_none()
449 {
450 if !self.session.gate(Intent::Quit) {
451 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
452 }
453 } else if self.session.confirm.is_some()
454 && ctx.input(|input| input.viewport().close_requested())
455 {
456 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
457 }
458 self.poll_dialogs();
459 self.drain_render();
460 if self.render_ui.running {
461 ctx.request_repaint();
462 }
463 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
464 let color = if self.session.notice_ok {
465 egui::Color32::LIGHT_GREEN
466 } else {
467 egui::Color32::LIGHT_RED
468 };
469 ui.colored_label(color, &self.session.notice);
470 });
471 match self.session.screen {
472 Screen::Start => self.show_start(ctx),
473 Screen::Bodies => self.show_bodies(ctx),
474 Screen::Body(_) => self.show_body(ctx),
475 Screen::EquipmentList => self.show_equipment_list(ctx),
476 Screen::Equipment(_) => self.show_equipment(ctx),
477 }
478 self.show_confirm(ctx);
479 if self.dialog_pending {
480 ctx.request_repaint_after(std::time::Duration::from_millis(50));
481 }
482 }
483}
484
485impl EditorApp {
486 fn show_confirm(&mut self, ctx: &egui::Context) {
487 let Some(_intent) = self.session.confirm else {
488 return;
489 };
490 let mut choice = None;
491 egui::Window::new("Wijzigingen")
492 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
493 .collapsible(false)
494 .resizable(false)
495 .show(ctx, |ui| {
496 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
497 ui.horizontal(|ui| {
498 if ui.button("Opslaan").clicked() {
499 choice = Some(crate::session::Choice::Save);
500 }
501 if ui.button("Niet opslaan").clicked() {
502 choice = Some(crate::session::Choice::Discard);
503 }
504 if ui.button("Annuleren").clicked() {
505 choice = Some(crate::session::Choice::Cancel);
506 }
507 });
508 });
509 if let Some(choice) = choice {
510 self.apply_choice(choice, ctx);
511 }
512 }
513
514 fn show_start(&mut self, ctx: &egui::Context) {
515 egui::CentralPanel::default().show(ctx, |ui| {
516 if self.session.confirm.is_some() || self.dialog_pending {
517 ui.disable();
518 }
519 ui.heading("Paperdoll Renderer");
520 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
521 ui.add_space(12.0);
522 if ui.button("Project laden").clicked() {
523 self.begin(Intent::PickLoad);
524 }
525 if ui.button("Nieuw project").clicked() {
526 self.begin(Intent::PickNew);
527 }
528 });
529 }
530
531 fn show_bodies(&mut self, ctx: &egui::Context) {
532 self.toolbar(ctx, true);
533 egui::CentralPanel::default().show(ctx, |ui| {
534 if self.session.confirm.is_some() || self.dialog_pending {
535 ui.disable();
536 }
537 ui.heading("Body");
538 if let Some(opened) = &mut self.session.opened {
539 ui.horizontal(|ui| {
540 ui.label("Naam");
541 ui.text_edit_singleline(&mut opened.document.name);
542 });
543 }
544 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
545 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
546 ui.add_space(8.0);
547 ui.horizontal(|ui| {
548 ui.label("Breedte");
549 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
550 ui.label("Hoogte");
551 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
552 ui.label("fps");
553 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
554 });
555 self.apply_render_fields();
556 });
557 }
558
559 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
560 ui.horizontal(|ui| {
561 ui.label(title);
562 let path = self
563 .session
564 .body_path(sex)
565 .unwrap_or("geen model")
566 .to_string();
567 ui.label(path);
568 if ui.button(pick).clicked() {
569 self.dialogs.start(DialogPurpose::Body(sex));
570 }
571 if ui.button(open).clicked() {
572 self.controls.sex = match sex {
573 Sex::Male => PreviewSex::Male,
574 Sex::Female => PreviewSex::Female,
575 };
576 self.session.show_body(sex);
577 }
578 });
579 let index = if sex == Sex::Male { 0 } else { 1 };
580 if let Some(inventory) = &self.inventories[index] {
581 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
582 Ok(model) => {
583 for mesh in &model.meshes {
584 ui.label(format!(
585 "{}: {} vertices, {} driehoeken",
586 mesh.name, mesh.vertex_count, mesh.triangle_count
587 ));
588 }
589 for material in &model.materials {
590 ui.label(format!("materiaal {}", material.name));
591 }
592 for texture in &model.textures {
593 ui.label(format!(
594 "texture {}: {}×{}",
595 texture.source, texture.width, texture.height
596 ));
597 }
598 ui.label(format!("{} joints", model.joints.len()));
599 for clip in &model.clips {
600 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
601 }
602 for notice in &model.notices {
603 ui.label(notice);
604 }
605 }
606 Err(message) => {
607 ui.colored_label(egui::Color32::LIGHT_RED, message);
608 }
609 });
610 }
611 }
612
613 fn show_body(&mut self, ctx: &egui::Context) {
614 self.toolbar(ctx, true);
615 let sex = match self.session.screen {
616 Screen::Body(sex) => sex,
617 _ => Sex::Male,
618 };
619 self.controls.sex = match sex {
620 Sex::Male => PreviewSex::Male,
621 Sex::Female => PreviewSex::Female,
622 };
623 egui::CentralPanel::default().show(ctx, |ui| {
624 if self.session.confirm.is_some() || self.dialog_pending {
625 ui.disable();
626 }
627 ui.horizontal_wrapped(|ui| {
628 if ui.button("Terug").clicked() {
629 self.session.show_bodies();
630 }
631 if ui.button("Equipment").clicked() {
632 self.session.show_equipment_list();
633 }
634 if ui
635 .add_enabled(!self.render_ui.running, egui::Button::new("Render"))
636 .clicked()
637 {
638 self.start_render([480, 640]);
639 }
640 if ui.button("Live 3D").clicked() {
641 self.render_ui.preview = None;
642 }
643 });
644 if self.render_ui.progress.total > 0 {
645 let fraction = self.render_ui.progress.completed as f32
646 / self.render_ui.progress.total.max(1) as f32;
647 ui.add(egui::ProgressBar::new(fraction).text(format!(
648 "{} / {} {}",
649 self.render_ui.progress.completed,
650 self.render_ui.progress.total,
651 self.render_ui.progress.label
652 )));
653 }
654 if let Some(image) = &self.render_ui.preview {
655 self.paint_pass(ui, image);
656 } else {
657 let character = match self.controls.sex {
658 PreviewSex::Male => self.preview_male.as_ref(),
659 PreviewSex::Female => self.preview_female.as_ref(),
660 };
661 if character.is_some() {
662 self.viewport.show(ui, &mut self.controls, character, &[]);
663 } else {
664 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
665 }
666 }
667 });
668 }
669
670 fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
671 let color = egui::ColorImage::from_rgba_unmultiplied(
672 [image.width as usize, image.height as usize],
673 &image.rgba,
674 );
675 let texture = ui.ctx().load_texture(
676 "render-pass",
677 color,
678 egui::TextureOptions {
679 magnification: egui::TextureFilter::Nearest,
680 minification: egui::TextureFilter::Nearest,
681 wrap_mode: egui::TextureWrapMode::ClampToEdge,
682 ..Default::default()
683 },
684 );
685 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
686 let fit = crate::render_job::fit_preview(
687 [image.width, image.height],
688 [bounds.width(), bounds.height()],
689 );
690 let rect = egui::Rect::from_min_size(
691 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
692 egui::vec2(fit.size[0], fit.size[1]),
693 );
694 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
695 ui.painter().image(
696 texture.id(),
697 rect,
698 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
699 egui::Color32::WHITE,
700 );
701 }
702
703 fn show_equipment_list(&mut self, ctx: &egui::Context) {
704 self.toolbar(ctx, true);
705 egui::CentralPanel::default().show(ctx, |ui| {
706 if self.session.confirm.is_some() || self.dialog_pending {
707 ui.disable();
708 }
709 ui.heading(format!(
710 "Bodies → {} → Equipment",
711 match self.session.active_body {
712 Some(Sex::Male) => "Man",
713 Some(Sex::Female) => "Vrouw",
714 None => "Body",
715 }
716 ));
717 if ui.button("Terug naar body").clicked() {
718 self.session.back();
719 }
720 let mut open = None;
721 for (index, item) in self.session.equipment.iter().enumerate() {
722 let label = match &item.model {
723 Some(model) => format!("{} {model}", item.slug),
724 None => item.slug.clone(),
725 };
726 if ui.button(label).clicked() {
727 open = Some(index);
728 }
729 }
730 if ui.button("Nieuw").clicked() {
731 self.equipment_part = 0;
732 self.session.add_equipment();
733 }
734 if ui.button("Preset importeren").clicked() {
735 self.dialogs.start(DialogPurpose::ImportEquipment);
736 }
737 if let Some(index) = open {
738 self.session.show_equipment(index);
739 }
740 });
741 }
742
743 fn show_equipment(&mut self, ctx: &egui::Context) {
744 self.toolbar(ctx, true);
745 let Screen::Equipment(index) = self.session.screen else {
746 return;
747 };
748 let mut sex = if self.session.active_body == Some(Sex::Female) {
749 1
750 } else {
751 0
752 };
753 egui::SidePanel::left("equipment-config")
754 .default_width(330.0)
755 .resizable(true)
756 .show(ctx, |ui| {
757 if self.session.confirm.is_some() || self.dialog_pending {
758 ui.disable();
759 }
760 egui::ScrollArea::vertical().show(ui, |ui| {
761 ui.heading("Equipment-configuratie");
762 if ui.button("Terug naar equipment").clicked() {
763 self.session.show_equipment_list();
764 }
765 ui.horizontal(|ui| {
766 ui.selectable_value(&mut sex, 0, "Man");
767 ui.selectable_value(&mut sex, 1, "Vrouw");
768 });
769 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
770 let Some(item) = self.session.equipment.get_mut(index) else {
771 return;
772 };
773 ui.label("Naam (kleine letters/cijfers/-/_)");
774 ui.text_edit_singleline(&mut item.slug);
775 if !crate::equipment::valid_name(&item.slug) {
776 ui.colored_label(
777 egui::Color32::LIGHT_RED,
778 "Ongeldige naam; 'body' is gereserveerd.",
779 );
780 }
781 if let Some(error) = &item.error {
782 ui.colored_label(egui::Color32::LIGHT_RED, error);
783 }
784 if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
785 self.dialogs.start(DialogPurpose::EquipmentModel(index));
786 }
787 if let Some(preset) = &mut item.preset {
788 ui.horizontal(|ui| {
789 ui.label("Geldig voor:");
790 ui.checkbox(&mut preset.applies[0], "Man");
791 ui.checkbox(&mut preset.applies[1], "Vrouw");
792 });
793 if !preset.applies[0] && !preset.applies[1] {
794 ui.colored_label(
795 egui::Color32::LIGHT_RED,
796 "Kies minstens één geslacht.",
797 );
798 }
799 if !preset.attachments.is_empty() {
800 self.equipment_part =
801 self.equipment_part.min(preset.attachments.len() - 1);
802 egui::ComboBox::from_id_salt("equipment-part")
803 .selected_text(&preset.attachments[self.equipment_part].label)
804 .show_ui(ui, |ui| {
805 for (part, item) in preset.attachments.iter().enumerate() {
806 ui.selectable_value(
807 &mut self.equipment_part,
808 part,
809 &item.label,
810 );
811 }
812 });
813 let body = if sex == 0 {
814 self.preview_male.as_ref()
815 } else {
816 self.preview_female.as_ref()
817 };
818 let attachment = &mut preset.attachments[self.equipment_part];
819 ui.label("Positie relatief aan bot (XYZ)");
820 ui.horizontal(|ui| {
821 for value in &mut attachment.translation {
822 ui.add(
823 egui::DragValue::new(value)
824 .speed(0.005)
825 .range(-1000.0..=1000.0),
826 );
827 }
828 });
829 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
830 ui.horizontal(|ui| {
831 for value in &mut attachment.rotation_deg {
832 ui.add(
833 egui::DragValue::new(value)
834 .speed(0.5)
835 .range(-3600.0..=3600.0),
836 );
837 }
838 });
839 ui.add(
840 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
841 .text("Glanssterkte"),
842 );
843 let shiny = attachment
844 .shiny
845 .and_then(|i| preset.assets.get(i))
846 .map(|a| a.path.clone());
847 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
848 if ui.button("Kies shinymap (PNG)").clicked() {
849 self.dialogs.start(DialogPurpose::EquipmentShiny(
850 index,
851 self.equipment_part,
852 ));
853 }
854 if ui.button("Wis shinymap").clicked() {
855 attachment.shiny = None;
856 }
857 let selected = attachment.bones[sex].clone();
858 ui.label(format!(
859 "Bot: {}",
860 selected.as_deref().unwrap_or("nog niet gekoppeld")
861 ));
862 if let Some(body) = body {
863 if preset.rigs[sex].as_ref()
864 != Some(&crate::equipment::RigSignature::of(body))
865 {
866 ui.colored_label(
867 egui::Color32::LIGHT_RED,
868 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
869 );
870 }
871 ui.collapsing("Skeleton / botten", |ui| {
872 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
873 if let Err(error) =
874 preset.bind(sex, body, self.equipment_part, joint)
875 {
876 ui.colored_label(egui::Color32::LIGHT_RED, error);
877 }
878 }
879 });
880 } else {
881 ui.colored_label(
882 egui::Color32::LIGHT_RED,
883 "Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
884 );
885 }
886 if ui.button("Verwijder onderdeel").clicked() {
887 preset.attachments.remove(self.equipment_part);
888 self.equipment_part = self.equipment_part.saturating_sub(1);
889 }
890 }
891 }
892 if ui.button("Verwijder equipmentdefinitie").clicked() {
893 self.session.delete_equipment(index);
894 }
895 });
896 });
897 self.controls.sex = if sex == 0 {
898 PreviewSex::Male
899 } else {
900 PreviewSex::Female
901 };
902 egui::CentralPanel::default().show(ctx, |ui| {
903 if self.session.confirm.is_some() || self.dialog_pending {
904 ui.disable();
905 }
906 let body = if sex == 0 {
907 self.preview_male.as_ref()
908 } else {
909 self.preview_female.as_ref()
910 };
911 let preset = self
912 .session
913 .equipment
914 .get(index)
915 .and_then(|item| item.preset.as_ref());
916 let presets: Vec<_> = preset.into_iter().collect();
917 self.viewport.show(ui, &mut self.controls, body, &presets);
918 });
919 }
920
921 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
922 if !project {
923 return;
924 }
925 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
926 if self.session.confirm.is_some() || self.dialog_pending {
927 ui.disable();
928 }
929 ui.horizontal(|ui| {
930 if ui.button("Body").clicked() {
931 self.session.show_bodies();
932 }
933 if ui.button("Opslaan").clicked() {
934 self.save();
935 }
936 if ui
937 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
938 .clicked()
939 {
940 self.begin(Intent::PickNew);
941 }
942 if ui
943 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
944 .clicked()
945 {
946 self.begin(Intent::PickLoad);
947 }
948 });
949 });
950 }
951}
952
953fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
954 fn node(
955 ui: &mut egui::Ui,
956 body: &PreviewCharacter,
957 paths: &[String],
958 index: usize,
959 selected: Option<&str>,
960 choice: &mut Option<usize>,
961 depth: usize,
962 ) {
963 if depth > 64 {
964 return;
965 }
966 let joint = body.joint_nodes.iter().position(|i| *i == index);
967 let children: Vec<_> = body
968 .nodes
969 .iter()
970 .enumerate()
971 .filter(|(_, n)| n.parent == Some(index))
972 .map(|(i, _)| i)
973 .collect();
974 let label = if body.nodes[index].name.is_empty() {
975 format!("node-{index}")
976 } else {
977 body.nodes[index].name.clone()
978 };
979 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
980 if let Some(joint) = joint {
981 if ui
982 .selectable_label(
983 selected == Some(paths[joint].as_str()),
984 format!("Koppel {label}"),
985 )
986 .clicked()
987 {
988 *choice = Some(joint);
989 }
990 }
991 };
992 if children.is_empty() {
993 select(ui, choice);
994 } else {
995 egui::CollapsingHeader::new(&label)
996 .id_salt(index)
997 .default_open(depth < 2)
998 .show(ui, |ui| {
999 select(ui, choice);
1000 for child in children {
1001 node(ui, body, paths, child, selected, choice, depth + 1);
1002 }
1003 });
1004 }
1005 }
1006 let mut choice = None;
1007 let paths = crate::preview::joint_paths(body);
1008 for (index, item) in body.nodes.iter().enumerate() {
1009 if item.parent.is_none() {
1010 node(ui, body, &paths, index, selected, &mut choice, 0);
1011 }
1012 }
1013 choice
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use crate::dialogs::ScriptedDialogs;
1020 use crate::session::Choice;
1021
1022 fn root() -> PathBuf {
1023 std::env::temp_dir().join(format!(
1024 "paperdoll-editor-{}",
1025 std::time::SystemTime::now()
1026 .duration_since(std::time::UNIX_EPOCH)
1027 .unwrap()
1028 .as_nanos()
1029 ))
1030 }
1031
1032 #[test]
1033 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
1034 let root = root();
1035 let mut dialogs = ScriptedDialogs::default();
1036 dialogs.push(Some(root.clone()));
1037 dialogs.push(None);
1038 let mut app = EditorApp::new(Box::new(dialogs));
1039 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
1040 app.begin(Intent::PickNew);
1041 app.poll_dialogs();
1042 assert_eq!(app.session.screen, Screen::Bodies);
1043 assert!(app.preview_male.is_some());
1044 assert!(app.preview_female.is_some());
1045 app.session.set_name("Keep my edit".into());
1046 app.begin(Intent::PickLoad);
1047 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
1048 app.apply_choice(Choice::Discard, &egui::Context::default());
1049 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
1050 assert_eq!(
1051 app.session.opened.as_ref().unwrap().document.name,
1052 "Keep my edit"
1053 );
1054 assert!(app.session.is_dirty());
1055 let _ = std::fs::remove_dir_all(root);
1056 }
1057
1058 #[test]
1059 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
1060 let root = root();
1061 let mut app = EditorApp::default();
1062 app.session.install_new_project(&root).unwrap();
1063 app.session.set_render(0, 128, 12);
1064 app.begin(Intent::Quit);
1065 app.apply_choice(Choice::Save, &egui::Context::default());
1066 assert!(!app.allow_close);
1067 assert_eq!(app.session.confirm, Some(Intent::Quit));
1068 assert!(app.session.is_dirty());
1069 app.open_path(root.join("missing.ppd"));
1070 assert!(app.session.opened.is_some());
1071 assert_eq!(app.session.screen, Screen::Bodies);
1072 let _ = std::fs::remove_dir_all(root);
1073 }
1074
1075 #[test]
1076 fn open_with_project_path_loads_bodies_and_models() {
1077 let root = root().join("project with spaces");
1078 let opened = crate::session::create_seeded_project(&root).unwrap();
1079 let mut app = EditorApp::default();
1080 app.open_path(opened.manifest_path.clone());
1081 assert_eq!(app.session.screen, Screen::Bodies);
1082 assert!(app.preview_male.is_some());
1083 assert_eq!(
1084 app.session.opened.unwrap().manifest_path,
1085 opened.manifest_path
1086 );
1087 let _ = std::fs::remove_dir_all(root.parent().unwrap());
1088 }
1089
1090 #[test]
1091 fn renderer_disconnect_is_reported() {
1092 let mut app = EditorApp::default();
1093 let (tx, rx) = std::sync::mpsc::channel();
1094 app.render_rx = Some(rx);
1095 app.render_ui.running = true;
1096 drop(tx);
1097 app.drain_render();
1098 assert!(!app.render_ui.running);
1099 assert!(app.render_ui.error.is_some());
1100 }
1101}