BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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