BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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