BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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