BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 53.2 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 body_rel = match self.controls.sex {
418 PreviewSex::Male => opened.document.male_model.clone(),
419 PreviewSex::Female => opened.document.female_model.clone(),
420 };
421 let Some(body_rel) = body_rel else {
422 self.session.fail("dit lichaam heeft geen modelpad");
423 return;
424 };
425 if let Err(error) = crate::project::portable_relative(&body_rel) {
426 self.session.fail(error);
427 return;
428 }
429 let body_path = root.join(&body_rel);
430 let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
431 Ok(hash) => hash,
432 Err(error) => {
433 self.session.fail(format!("body-fingerprint: {error}"));
434 return;
435 }
436 };
437 let preset_sha256 = match &preset {
438 None => None,
439 Some(p) => match p.encode() {
440 Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
441 Err(error) => {
442 self.session.fail(format!("equipment-fingerprint: {error}"));
443 return;
444 }
445 },
446 };
447 let job = RenderJob {
448 character,
449 sex: if sex == 0 { "male" } else { "female" }.into(),
450 preset,
451 target_width: opened.document.render.target_width,
452 target_height: opened.document.render.target_height,
453 fps: opened.document.render.fps,
454 framing: opened.document.render.framing[sex],
455 output: root.join(&opened.document.output_directory),
456 project_id: opened.document.id.clone(),
457 project_name: opened.document.name.clone(),
458 body_model: crate::production::SourceFingerprint {
459 path: body_rel,
460 sha256: body_sha,
461 },
462 preset_sha256,
463 };
464 self.render_worker = Some(spawn(job, None));
465 self.render_ui = RenderUiState::idle();
466 self.pass_texture = None;
467 self.render_ui.running = true;
468 }
469 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
470 let request = self
471 .render_worker
472 .as_ref()
473 .and_then(|worker| worker.gpu.try_recv().ok());
474 if let Some(request) = request {
475 if let Some(gl) = gl {
476 unsafe {
477 request.service(&mut self.gpu_renderer, gl);
478 }
479 } else {
480 request.fail("Glow-context ontbreekt".into());
481 }
482 }
483 }
484 fn drain_render(&mut self) {
485 let Some(worker) = &self.render_worker else {
486 return;
487 };
488 let ended = worker
489 .handle
490 .as_ref()
491 .map(|h| h.is_finished())
492 .unwrap_or(true);
493 let events = worker.mailbox.drain();
494 let mut terminal = false;
495 for event in events {
496 terminal |= matches!(
497 event,
498 RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled
499 );
500 if let RenderEvent::Finished { saved: Some(path) } = &event {
501 self.session.notice = format!("Batch opgeslagen: {path}");
502 self.session.notice_ok = true;
503 }
504 if let RenderEvent::Failed(error) = &event {
505 self.session.fail(error.clone());
506 }
507 self.render_ui.apply(event);
508 }
509 if terminal {
510 self.render_worker = None;
511 } else if ended {
512 self.render_ui.apply(RenderEvent::Failed(
513 "De renderworker stopte zonder eindresultaat.".into(),
514 ));
515 self.render_worker = None;
516 }
517 }
518 fn cancel_render(&mut self) {
519 if let Some(worker) = &self.render_worker {
520 worker
521 .cancel
522 .store(true, std::sync::atomic::Ordering::Relaxed);
523 }
524 }
525 fn render_progress_ui(&mut self, ui: &mut egui::Ui) {
526 if self.render_ui.progress.total > 0 {
527 ui.add(
528 egui::ProgressBar::new(
529 self.render_ui.progress.completed as f32
530 / self.render_ui.progress.total.max(1) as f32,
531 )
532 .text(format!(
533 "{} / {} · {}",
534 self.render_ui.progress.completed,
535 self.render_ui.progress.total,
536 self.render_ui.progress.label
537 )),
538 );
539 }
540 if self.render_ui.running && ui.button("Annuleer render").clicked() {
541 self.cancel_render();
542 }
543 }
544 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
545 let diagnostic = {
546 let framing = self
547 .session
548 .opened
549 .as_ref()
550 .map(|opened| opened.document.render.framing[sex]);
551 let body = match sex {
552 0 => self.preview_male.as_ref(),
553 _ => self.preview_female.as_ref(),
554 };
555 match (body, framing) {
556 (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)),
557 _ => None,
558 }
559 };
560 if let Some(opened) = &mut self.session.opened {
561 let camera = &mut opened.document.render.framing[sex];
562 let mut height = camera.height_milli as f32 / 1000.;
563 let mut center = camera.center_y_milli as f32 / 1000.;
564 let mut pivot = diagnostic
565 .as_ref()
566 .map(|resolved| {
567 resolved
568 .framing
569 .pivot_y_milli
570 .unwrap_or(resolved.framing.center_y_milli) as f32
571 / 1000.
572 })
573 .unwrap_or_else(|| {
574 camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
575 });
576 ui.horizontal_wrapped(|ui| {
577 ui.label("Vaste renderhoogte (m)");
578 if ui
579 .add(
580 egui::DragValue::new(&mut height)
581 .speed(0.01)
582 .range(0.1..=10.),
583 )
584 .changed()
585 {
586 camera.height_milli = (height * 1000.).round() as u32;
587 }
588 ui.label("Centrum Y (m)");
589 if ui
590 .add(
591 egui::DragValue::new(&mut center)
592 .speed(0.01)
593 .range(-10.0..=10.0),
594 )
595 .changed()
596 {
597 camera.center_y_milli = (center * 1000.).round() as i32;
598 }
599 ui.label("Pivot Y (m)");
600 if ui
601 .add(
602 egui::DragValue::new(&mut pivot)
603 .speed(0.01)
604 .range(-10.0..=10.0),
605 )
606 .changed()
607 {
608 camera.pivot_y_milli = Some((pivot * 1000.).round() as i32);
609 }
610 if camera.pivot_y_milli.is_some()
611 && ui
612 .button("Auto nek")
613 .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
614 .clicked()
615 {
616 camera.pivot_y_milli = None;
617 }
618 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
619 });
620 if let Some(resolved) = &diagnostic {
621 ui.label(resolved.description());
622 } else {
623 ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");
624 }
625 }
626 }
627 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
628 if !self.show_render_frame {
629 return None;
630 }
631 let r = &self.session.opened.as_ref()?.document.render;
632 let sex = usize::from(self.controls.sex == PreviewSex::Female);
633 Some((r.framing[sex], [r.target_width, r.target_height]))
634 }
635}
636
637impl Default for EditorApp {
638 fn default() -> Self {
639 Self::new(Box::new(SystemDialogs::new()))
640 }
641}
642
643impl eframe::App for EditorApp {
644 fn on_exit(&mut self, gl: Option<&glow::Context>) {
645 self.render_worker = None;
646 if let Some(gl) = gl {
647 unsafe {
648 self.gpu_renderer.destroy(gl);
649 }
650 self.viewport.destroy(gl);
651 }
652 }
653 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
654 self.service_gpu(frame.gl());
655 let shell = self.session.shell_size();
656 if self.applied_shell != Some(shell) {
657 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
658 shell[0], shell[1],
659 )));
660 let min = if shell == crate::session::START_WINDOW {
661 egui::vec2(420.0, 220.0)
662 } else {
663 egui::vec2(900.0, 560.0)
664 };
665 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
666 self.applied_shell = Some(shell);
667 }
668 if ctx.input(|input| input.viewport().close_requested())
669 && !self.allow_close
670 && self.session.confirm.is_none()
671 {
672 if !self.session.gate(Intent::Quit) {
673 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
674 }
675 } else if self.session.confirm.is_some()
676 && ctx.input(|input| input.viewport().close_requested())
677 {
678 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
679 }
680 self.poll_dialogs();
681 self.drain_render();
682 if self.render_ui.running {
683 ctx.request_repaint();
684 }
685 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
686 let color = if self.session.notice_ok {
687 egui::Color32::LIGHT_GREEN
688 } else {
689 egui::Color32::LIGHT_RED
690 };
691 ui.colored_label(color, &self.session.notice);
692 });
693 match self.session.screen {
694 Screen::Start => self.show_start(ctx),
695 Screen::Bodies => self.show_bodies(ctx),
696 Screen::Body(_) => self.show_body(ctx),
697 Screen::EquipmentList => self.show_equipment_list(ctx),
698 Screen::Equipment(_) => self.show_equipment(ctx),
699 }
700 self.show_confirm(ctx);
701 if self.dialog_pending {
702 ctx.request_repaint_after(std::time::Duration::from_millis(50));
703 }
704 }
705}
706
707impl EditorApp {
708 fn show_confirm(&mut self, ctx: &egui::Context) {
709 let Some(_intent) = self.session.confirm else {
710 return;
711 };
712 let mut choice = None;
713 egui::Window::new("Wijzigingen")
714 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
715 .collapsible(false)
716 .resizable(false)
717 .show(ctx, |ui| {
718 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
719 ui.horizontal(|ui| {
720 if ui.button("Opslaan").clicked() {
721 choice = Some(crate::session::Choice::Save);
722 }
723 if ui.button("Niet opslaan").clicked() {
724 choice = Some(crate::session::Choice::Discard);
725 }
726 if ui.button("Annuleren").clicked() {
727 choice = Some(crate::session::Choice::Cancel);
728 }
729 });
730 });
731 if let Some(choice) = choice {
732 self.apply_choice(choice, ctx);
733 }
734 }
735
736 fn show_start(&mut self, ctx: &egui::Context) {
737 egui::CentralPanel::default().show(ctx, |ui| {
738 if self.session.confirm.is_some() || self.dialog_pending {
739 ui.disable();
740 }
741 ui.heading("Paperdoll Renderer");
742 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
743 ui.add_space(12.0);
744 if ui.button("Project laden").clicked() {
745 self.begin(Intent::PickLoad);
746 }
747 if ui.button("Nieuw project").clicked() {
748 self.begin(Intent::PickNew);
749 }
750 });
751 }
752
753 fn show_bodies(&mut self, ctx: &egui::Context) {
754 self.toolbar(ctx, true);
755 egui::CentralPanel::default().show(ctx, |ui| {
756 if self.session.confirm.is_some() || self.dialog_pending {
757 ui.disable();
758 }
759 ui.heading("Body");
760 if let Some(opened) = &mut self.session.opened {
761 ui.horizontal(|ui| {
762 ui.label("Naam");
763 ui.text_edit_singleline(&mut opened.document.name);
764 });
765 }
766 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
767 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
768 ui.add_space(8.0);
769 ui.horizontal(|ui| {
770 ui.label("Breedte");
771 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
772 ui.label("Hoogte");
773 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
774 ui.label("fps");
775 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
776 });
777 self.apply_render_fields();
778 });
779 }
780
781 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
782 ui.horizontal(|ui| {
783 ui.label(title);
784 let path = self
785 .session
786 .body_path(sex)
787 .unwrap_or("geen model")
788 .to_string();
789 ui.label(path);
790 if ui.button(pick).clicked() {
791 self.dialogs.start(DialogPurpose::Body(sex));
792 }
793 if ui.button(open).clicked() {
794 self.controls.sex = match sex {
795 Sex::Male => PreviewSex::Male,
796 Sex::Female => PreviewSex::Female,
797 };
798 self.session.show_body(sex);
799 }
800 });
801 let index = if sex == Sex::Male { 0 } else { 1 };
802 if let Some(inventory) = &self.inventories[index] {
803 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
804 Ok(model) => {
805 for mesh in &model.meshes {
806 ui.label(format!(
807 "{}: {} vertices, {} driehoeken",
808 mesh.name, mesh.vertex_count, mesh.triangle_count
809 ));
810 }
811 for material in &model.materials {
812 ui.label(format!("materiaal {}", material.name));
813 }
814 for texture in &model.textures {
815 ui.label(format!(
816 "texture {}: {}×{}",
817 texture.source, texture.width, texture.height
818 ));
819 }
820 ui.label(format!("{} joints", model.joints.len()));
821 for clip in &model.clips {
822 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
823 }
824 for notice in &model.notices {
825 ui.label(notice);
826 }
827 }
828 Err(message) => {
829 ui.colored_label(egui::Color32::LIGHT_RED, message);
830 }
831 });
832 }
833 }
834
835 fn show_body(&mut self, ctx: &egui::Context) {
836 self.toolbar(ctx, true);
837 let sex = match self.session.screen {
838 Screen::Body(sex) => sex,
839 _ => Sex::Male,
840 };
841 self.controls.sex = match sex {
842 Sex::Male => PreviewSex::Male,
843 Sex::Female => PreviewSex::Female,
844 };
845 egui::CentralPanel::default().show(ctx, |ui| {
846 if self.session.confirm.is_some() || self.dialog_pending {
847 ui.disable();
848 }
849 ui.horizontal_wrapped(|ui| {
850 if ui.button("Terug").clicked() {
851 self.session.show_bodies();
852 }
853 if ui.button("Equipment").clicked() {
854 self.session.show_equipment_list();
855 }
856 if ui
857 .add_enabled(
858 !self.render_ui.running,
859 egui::Button::new("Render alle hoeken"),
860 )
861 .clicked()
862 {
863 self.start_render();
864 }
865 if ui.button("Live 3D").clicked() {
866 self.render_ui.preview = None;
867 }
868 });
869 self.framing_ui(ui, usize::from(sex == Sex::Female));
870 self.render_progress_ui(ui);
871 if self.render_ui.preview.is_some() {
872 self.paint_pass(ui);
873 } else {
874 let character = match self.controls.sex {
875 PreviewSex::Male => self.preview_male.as_ref(),
876 PreviewSex::Female => self.preview_female.as_ref(),
877 };
878 if character.is_some() {
879 let framing = self.fixed_camera();
880 self.viewport
881 .show_framed(ui, &mut self.controls, character, &[], framing);
882 } else {
883 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
884 }
885 }
886 });
887 }
888
889 fn paint_pass(&mut self, ui: &mut egui::Ui) {
890 let Some(image) = &self.render_ui.preview else {
891 return;
892 };
893 ui.label(format!(
894 "Laatste voltooide pass: {}",
895 self.render_ui.preview_label
896 ));
897 if self.pass_texture.is_none()
898 || self.pass_texture_revision != self.render_ui.preview_revision
899 {
900 let color = egui::ColorImage::from_rgba_unmultiplied(
901 [image.width as usize, image.height as usize],
902 &image.rgba,
903 );
904 let options = egui::TextureOptions::NEAREST;
905 if let Some(texture) = &mut self.pass_texture {
906 texture.set(color, options);
907 } else {
908 self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
909 }
910 self.pass_texture_revision = self.render_ui.preview_revision;
911 }
912 let texture = self.pass_texture.as_ref().expect("pass texture installed");
913 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
914 let fit = crate::render_job::fit_preview(
915 [image.width, image.height],
916 [bounds.width(), bounds.height()],
917 );
918 let rect = egui::Rect::from_min_size(
919 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
920 egui::vec2(fit.size[0], fit.size[1]),
921 );
922 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
923 ui.painter().image(
924 texture.id(),
925 rect,
926 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
927 egui::Color32::WHITE,
928 );
929 }
930
931 fn show_equipment_list(&mut self, ctx: &egui::Context) {
932 self.toolbar(ctx, true);
933 egui::CentralPanel::default().show(ctx, |ui| {
934 if self.session.confirm.is_some() || self.dialog_pending {
935 ui.disable();
936 }
937 ui.heading(format!(
938 "Bodies → {} → Equipment",
939 match self.session.active_body {
940 Some(Sex::Male) => "Man",
941 Some(Sex::Female) => "Vrouw",
942 None => "Body",
943 }
944 ));
945 if ui.button("Terug naar body").clicked() {
946 self.session.back();
947 }
948 let mut open = None;
949 for (index, item) in self.session.equipment.iter().enumerate() {
950 let label = match &item.model {
951 Some(model) => format!("{} {model}", item.slug),
952 None => item.slug.clone(),
953 };
954 if ui.button(label).clicked() {
955 open = Some(index);
956 }
957 }
958 if ui
959 .add_enabled(
960 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
961 egui::Button::new("Nieuw"),
962 )
963 .clicked()
964 {
965 self.equipment_part = 0;
966 self.session.add_equipment();
967 }
968 if ui
969 .add_enabled(
970 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
971 egui::Button::new("Preset importeren"),
972 )
973 .clicked()
974 {
975 self.dialogs.start(DialogPurpose::ImportEquipment);
976 }
977 if let Some(index) = open {
978 self.session.show_equipment(index);
979 }
980 });
981 }
982
983 fn show_equipment(&mut self, ctx: &egui::Context) {
984 self.toolbar(ctx, true);
985 let Screen::Equipment(index) = self.session.screen else {
986 return;
987 };
988 let mut sex = if self.session.active_body == Some(Sex::Female) {
989 1
990 } else {
991 0
992 };
993 egui::SidePanel::left("equipment-config")
994 .default_width(330.0)
995 .resizable(true)
996 .show(ctx, |ui| {
997 if self.session.confirm.is_some() || self.dialog_pending {
998 ui.disable();
999 }
1000 egui::ScrollArea::vertical().show(ui, |ui| {
1001 ui.heading("Equipment-configuratie");
1002 if ui.button("Terug naar equipment").clicked() {
1003 self.session.show_equipment_list();
1004 }
1005 ui.horizontal(|ui| {
1006 ui.selectable_value(&mut sex, 0, "Man");
1007 ui.selectable_value(&mut sex, 1, "Vrouw");
1008 });
1009 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1010 let Some(item) = self.session.equipment.get_mut(index) else {
1011 return;
1012 };
1013 ui.label("Naam (kleine letters/cijfers/-/_)");
1014 ui.text_edit_singleline(&mut item.slug);
1015 if !crate::equipment::valid_name(&item.slug) {
1016 ui.colored_label(
1017 egui::Color32::LIGHT_RED,
1018 "Ongeldige naam; 'body' is gereserveerd.",
1019 );
1020 }
1021 if let Some(error) = &item.error {
1022 ui.colored_label(egui::Color32::LIGHT_RED, error);
1023 }
1024 if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
1025 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1026 }
1027 if let Some(preset) = &mut item.preset {
1028 ui.horizontal(|ui| {
1029 ui.label("Geldig voor:");
1030 ui.checkbox(&mut preset.applies[0], "Man");
1031 ui.checkbox(&mut preset.applies[1], "Vrouw");
1032 });
1033 if !preset.applies[0] && !preset.applies[1] {
1034 ui.colored_label(
1035 egui::Color32::LIGHT_RED,
1036 "Kies minstens één geslacht.",
1037 );
1038 }
1039 ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1040 ui.add(
1041 egui::TextEdit::multiline(&mut preset.provenance)
1042 .desired_rows(2)
1043 .char_limit(4096),
1044 );
1045 if preset.provenance.len() > 4096 {
1046 ui.colored_label(
1047 egui::Color32::LIGHT_RED,
1048 "Herkomst/licentie is te lang.",
1049 );
1050 }
1051 if !preset.attachments.is_empty() {
1052 self.equipment_part =
1053 self.equipment_part.min(preset.attachments.len() - 1);
1054 egui::ComboBox::from_id_salt("equipment-part")
1055 .selected_text(&preset.attachments[self.equipment_part].label)
1056 .show_ui(ui, |ui| {
1057 for (part, item) in preset.attachments.iter().enumerate() {
1058 ui.selectable_value(
1059 &mut self.equipment_part,
1060 part,
1061 &item.label,
1062 );
1063 }
1064 });
1065 let body = if sex == 0 {
1066 self.preview_male.as_ref()
1067 } else {
1068 self.preview_female.as_ref()
1069 };
1070 let attachment = &mut preset.attachments[self.equipment_part];
1071 ui.label("Positie relatief aan bot (XYZ)");
1072 ui.horizontal(|ui| {
1073 for value in &mut attachment.translation {
1074 ui.add(
1075 egui::DragValue::new(value)
1076 .speed(0.005)
1077 .range(-1000.0..=1000.0),
1078 );
1079 }
1080 });
1081 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1082 ui.horizontal(|ui| {
1083 for value in &mut attachment.rotation_deg {
1084 ui.add(
1085 egui::DragValue::new(value)
1086 .speed(0.5)
1087 .range(-3600.0..=3600.0),
1088 );
1089 }
1090 });
1091 ui.add(
1092 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1093 .text("Glanssterkte"),
1094 );
1095 let shiny = attachment
1096 .shiny
1097 .and_then(|i| preset.assets.get(i))
1098 .map(|a| a.path.clone());
1099 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1100 if ui.button("Kies shinymap (PNG)").clicked() {
1101 self.dialogs.start(DialogPurpose::EquipmentShiny(
1102 index,
1103 self.equipment_part,
1104 ));
1105 }
1106 let clear_shiny = ui.button("Wis shinymap").clicked();
1107 let selected = attachment.bones[sex].clone();
1108 if clear_shiny {
1109 if let Err(error) =
1110 crate::equipment::clear_shiny(preset, self.equipment_part)
1111 {
1112 item.error = Some(error);
1113 }
1114 }
1115 ui.label(format!(
1116 "Bot: {}",
1117 selected.as_deref().unwrap_or("nog niet gekoppeld")
1118 ));
1119 if let Some(body) = body {
1120 if preset.rigs[sex].as_ref()
1121 != Some(&crate::equipment::RigSignature::of(body))
1122 {
1123 ui.colored_label(
1124 egui::Color32::LIGHT_RED,
1125 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1126 );
1127 }
1128 ui.collapsing("Skeleton / botten", |ui| {
1129 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1130 if let Err(error) =
1131 preset.bind(sex, body, self.equipment_part, joint)
1132 {
1133 ui.colored_label(egui::Color32::LIGHT_RED, error);
1134 }
1135 }
1136 });
1137 } else {
1138 ui.colored_label(
1139 egui::Color32::LIGHT_RED,
1140 "Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
1141 );
1142 }
1143 if ui.button("Verwijder onderdeel").clicked() {
1144 if let Err(error) =
1145 crate::equipment::remove_attachment(preset, self.equipment_part)
1146 {
1147 item.error = Some(error);
1148 }
1149 self.equipment_part = self.equipment_part.saturating_sub(1);
1150 }
1151 }
1152 }
1153 if ui.button("Verwijder equipmentdefinitie").clicked() {
1154 self.session.delete_equipment(index);
1155 }
1156 });
1157 });
1158 self.controls.sex = if sex == 0 {
1159 PreviewSex::Male
1160 } else {
1161 PreviewSex::Female
1162 };
1163 egui::CentralPanel::default().show(ctx, |ui| {
1164 if self.session.confirm.is_some() || self.dialog_pending {
1165 ui.disable();
1166 }
1167 ui.horizontal_wrapped(|ui| {
1168 if ui
1169 .add_enabled(
1170 !self.render_ui.running,
1171 egui::Button::new("Render equipment · alle hoeken"),
1172 )
1173 .clicked()
1174 {
1175 self.start_render();
1176 }
1177 if ui.button("Live 3D").clicked() {
1178 self.render_ui.preview = None;
1179 }
1180 });
1181 self.render_progress_ui(ui);
1182 if self.render_ui.preview.is_some() {
1183 self.paint_pass(ui);
1184 return;
1185 }
1186 let framing = self.fixed_camera();
1187 let body = if sex == 0 {
1188 self.preview_male.as_ref()
1189 } else {
1190 self.preview_female.as_ref()
1191 };
1192 let preset = self
1193 .session
1194 .equipment
1195 .get(index)
1196 .and_then(|item| item.preset.as_ref());
1197 let presets: Vec<_> = preset.into_iter().collect();
1198 self.viewport
1199 .show_framed(ui, &mut self.controls, body, &presets, framing);
1200 });
1201 }
1202
1203 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1204 if !project {
1205 return;
1206 }
1207 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
1208 if self.session.confirm.is_some() || self.dialog_pending {
1209 ui.disable();
1210 }
1211 ui.horizontal(|ui| {
1212 if ui.button("Body").clicked() {
1213 self.session.show_bodies();
1214 }
1215 if ui.button("Opslaan").clicked() {
1216 self.save();
1217 }
1218 if ui
1219 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
1220 .clicked()
1221 {
1222 self.begin(Intent::PickNew);
1223 }
1224 if ui
1225 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
1226 .clicked()
1227 {
1228 self.begin(Intent::PickLoad);
1229 }
1230 });
1231 });
1232 }
1233}
1234
1235fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
1236 fn node(
1237 ui: &mut egui::Ui,
1238 body: &PreviewCharacter,
1239 paths: &[String],
1240 index: usize,
1241 selected: Option<&str>,
1242 choice: &mut Option<usize>,
1243 depth: usize,
1244 ) {
1245 if depth > 64 {
1246 return;
1247 }
1248 let joint = body.joint_nodes.iter().position(|i| *i == index);
1249 let children: Vec<_> = body
1250 .nodes
1251 .iter()
1252 .enumerate()
1253 .filter(|(_, n)| n.parent == Some(index))
1254 .map(|(i, _)| i)
1255 .collect();
1256 let label = if body.nodes[index].name.is_empty() {
1257 format!("node-{index}")
1258 } else {
1259 body.nodes[index].name.clone()
1260 };
1261 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
1262 if let Some(joint) = joint {
1263 if ui
1264 .selectable_label(
1265 selected == Some(paths[joint].as_str()),
1266 format!("Koppel {label}"),
1267 )
1268 .clicked()
1269 {
1270 *choice = Some(joint);
1271 }
1272 }
1273 };
1274 if children.is_empty() {
1275 select(ui, choice);
1276 } else {
1277 egui::CollapsingHeader::new(&label)
1278 .id_salt(index)
1279 .default_open(depth < 2)
1280 .show(ui, |ui| {
1281 select(ui, choice);
1282 for child in children {
1283 node(ui, body, paths, child, selected, choice, depth + 1);
1284 }
1285 });
1286 }
1287 }
1288 let mut choice = None;
1289 let paths = crate::preview::joint_paths(body);
1290 for (index, item) in body.nodes.iter().enumerate() {
1291 if item.parent.is_none() {
1292 node(ui, body, &paths, index, selected, &mut choice, 0);
1293 }
1294 }
1295 choice
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300 use super::*;
1301 use crate::dialogs::ScriptedDialogs;
1302 use crate::session::Choice;
1303
1304 fn root() -> PathBuf {
1305 std::env::temp_dir().join(format!(
1306 "paperdoll-editor-{}",
1307 std::time::SystemTime::now()
1308 .duration_since(std::time::UNIX_EPOCH)
1309 .unwrap()
1310 .as_nanos()
1311 ))
1312 }
1313
1314 #[test]
1315 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
1316 let root = root();
1317 let mut dialogs = ScriptedDialogs::default();
1318 dialogs.push(Some(root.clone()));
1319 dialogs.push(None);
1320 let mut app = EditorApp::new(Box::new(dialogs));
1321 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
1322 app.begin(Intent::PickNew);
1323 app.poll_dialogs();
1324 assert_eq!(app.session.screen, Screen::Bodies);
1325 assert!(app.preview_male.is_some());
1326 assert!(app.preview_female.is_some());
1327 app.session.set_name("Keep my edit".into());
1328 app.begin(Intent::PickLoad);
1329 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
1330 app.apply_choice(Choice::Discard, &egui::Context::default());
1331 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
1332 assert_eq!(
1333 app.session.opened.as_ref().unwrap().document.name,
1334 "Keep my edit"
1335 );
1336 assert!(app.session.is_dirty());
1337 let _ = std::fs::remove_dir_all(root);
1338 }
1339
1340 #[test]
1341 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
1342 let root = root();
1343 let mut app = EditorApp::default();
1344 app.session.install_new_project(&root).unwrap();
1345 app.session.set_render(0, 128, 12);
1346 app.begin(Intent::Quit);
1347 app.apply_choice(Choice::Save, &egui::Context::default());
1348 assert!(!app.allow_close);
1349 assert_eq!(app.session.confirm, Some(Intent::Quit));
1350 assert!(app.session.is_dirty());
1351 app.open_path(root.join("missing.ppd"));
1352 assert!(app.session.opened.is_some());
1353 assert_eq!(app.session.screen, Screen::Bodies);
1354 let _ = std::fs::remove_dir_all(root);
1355 }
1356
1357 #[test]
1358 fn open_with_project_path_loads_bodies_and_models() {
1359 let root = root().join("project with spaces");
1360 let opened = crate::session::create_seeded_project(&root).unwrap();
1361 let mut app = EditorApp::default();
1362 app.open_path(opened.manifest_path.clone());
1363 assert_eq!(app.session.screen, Screen::Bodies);
1364 assert!(app.preview_male.is_some());
1365 assert_eq!(
1366 app.session.opened.unwrap().manifest_path,
1367 opened.manifest_path
1368 );
1369 let _ = std::fs::remove_dir_all(root.parent().unwrap());
1370 }
1371
1372 #[test]
1373 fn renderer_disconnect_is_reported() {
1374 let mut app = EditorApp::default();
1375 let (_, gpu) = std::sync::mpsc::channel();
1376 app.render_worker = Some(crate::production::RenderThread {
1377 mailbox: Default::default(),
1378 gpu,
1379 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1380 handle: None,
1381 });
1382 app.render_ui.running = true;
1383 app.drain_render();
1384 assert!(!app.render_ui.running);
1385 assert!(app.render_ui.error.is_some());
1386 }
1387}