BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 69.4 KiB Download Raw
1//! Project window: start screen, body, equipment, render queue, and 2D preview.
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::preview2d::{
11 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
12 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
13};
14use crate::preview_gl::Viewport;
15use crate::project::open_project;
16use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
17use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
18
19pub struct EditorApp {
20 session: Session,
21 dialogs: Box<dyn Dialogs>,
22 controls: PreviewControls,
23 preview_male: Option<PreviewCharacter>,
24 preview_female: Option<PreviewCharacter>,
25 viewport: Viewport,
26 inventories: [Option<Result<ImportedModel, String>>; 2],
27 render_worker: Option<crate::production::RenderThread>,
28 gpu_renderer: crate::production::GpuRenderer,
29 show_render_frame: bool,
30 render_ui: RenderUiState,
31 pass_texture: Option<egui::TextureHandle>,
32 pass_texture_revision: u64,
33 applied_shell: Option<[f32; 2]>,
34 width_text: String,
35 height_text: String,
36 fps_text: String,
37 allow_close: bool,
38 dialog_pending: bool,
39 equipment_part: usize,
40 preview2d: Preview2dState,
41 preview2d_catalog: Vec<CatalogEntry>,
42 preview2d_texture: Option<egui::TextureHandle>,
43 preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
44 preview2d_image: Option<ImageBuf>,
45 preview2d_speed_text: String,
46}
47
48impl EditorApp {
49 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
50 Self {
51 session: Session::new(),
52 dialogs,
53 controls: PreviewControls::default(),
54 preview_male: None,
55 preview_female: None,
56 viewport: Viewport::new(),
57 inventories: [None, None],
58 render_worker: None,
59 gpu_renderer: Default::default(),
60 show_render_frame: true,
61 render_ui: RenderUiState::idle(),
62 pass_texture: None,
63 pass_texture_revision: 0,
64 applied_shell: None,
65 width_text: "128".to_string(),
66 height_text: "128".to_string(),
67 fps_text: "12".to_string(),
68 allow_close: false,
69 dialog_pending: false,
70 equipment_part: 0,
71 preview2d: Preview2dState::default(),
72 preview2d_catalog: Vec::new(),
73 preview2d_texture: None,
74 preview2d_texture_key: None,
75 preview2d_image: None,
76 preview2d_speed_text: "1.0".to_string(),
77 }
78 }
79
80 pub fn open_path(&mut self, path: PathBuf) {
81 let candidate = std::env::current_dir()
82 .map_err(|e| e.to_string())
83 .and_then(|base| {
84 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
85 });
86 let path = match candidate {
87 Ok(path) => path,
88 Err(error) => {
89 self.session.fail(error);
90 return;
91 }
92 };
93 match open_project(&path) {
94 Ok(opened) => {
95 self.session.install_loaded(opened);
96 self.reset_render();
97 self.sync_fields();
98 self.reload_bodies();
99 }
100 Err(error) => {
101 self.session.fail(error.to_string());
102 }
103 }
104 }
105
106 pub fn open_fixture_project(&mut self) {
107 let root = std::env::temp_dir().join(format!(
108 "paperdoll-renderer-fixture-{}-{}",
109 std::process::id(),
110 std::time::SystemTime::now()
111 .duration_since(std::time::UNIX_EPOCH)
112 .unwrap_or_default()
113 .as_nanos()
114 ));
115 if let Err(error) = self.session.install_new_project(&root) {
116 self.session.fail(error.to_string());
117 return;
118 }
119 self.sync_fields();
120 self.reload_bodies();
121 self.session.show_body(Sex::Male);
122 self.controls.sex = PreviewSex::Male;
123 self.controls.select_clip("walk");
124 }
125
126 fn reset_render(&mut self) {
127 self.render_worker = None;
128 self.render_ui = RenderUiState::idle();
129 self.pass_texture = None;
130 }
131
132 fn sync_fields(&mut self) {
133 if let Some(opened) = &self.session.opened {
134 self.width_text = opened.document.render.target_width.to_string();
135 self.height_text = opened.document.render.target_height.to_string();
136 self.fps_text = opened.document.render.fps.to_string();
137 }
138 }
139
140 fn reload_bodies(&mut self) {
141 self.preview_male = None;
142 self.preview_female = None;
143 self.inventories = [None, None];
144 let Some(root) = self.session.project_root() else {
145 return;
146 };
147 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
148 if let Some(relative) = self.session.body_path(sex) {
149 let imported =
150 import_project_model(&root, relative).map_err(|error| error.to_string());
151 // Validate the source and its dependencies before the viewport reads it.
152 if imported.is_ok()
153 && matches!(
154 std::path::Path::new(relative)
155 .extension()
156 .and_then(|e| e.to_str()),
157 Some("gltf" | "glb")
158 )
159 {
160 match load_gltf_file(&root.join(relative)) {
161 Ok(character) => match sex {
162 Sex::Male => self.preview_male = Some(character),
163 Sex::Female => self.preview_female = Some(character),
164 },
165 Err(error) => self.session.fail(format!("3D: {error}")),
166 }
167 }
168 self.inventories[index] = Some(imported);
169 }
170 }
171 }
172
173 fn character(&self) -> Option<&PreviewCharacter> {
174 match self.controls.sex {
175 PreviewSex::Male => self.preview_male.as_ref(),
176 PreviewSex::Female => self.preview_female.as_ref(),
177 }
178 }
179
180 fn poll_dialogs(&mut self) {
181 let poll = self.dialogs.poll();
182 self.dialog_pending = matches!(poll, DialogPoll::Pending);
183 if let DialogPoll::Ready { purpose, path } = poll {
184 let Some(path) = path else {
185 return;
186 };
187 match purpose {
188 DialogPurpose::NewProject => {
189 if let Err(error) = self.session.install_new_project(&path) {
190 self.session.fail(error.to_string());
191 } else {
192 self.reset_render();
193 self.sync_fields();
194 self.reload_bodies();
195 }
196 }
197 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
198 Ok(opened) => {
199 self.session.install_loaded(opened);
200 self.reset_render();
201 self.sync_fields();
202 self.reload_bodies();
203 }
204 Err(error) => self.session.fail(error.to_string()),
205 },
206 DialogPurpose::Body(sex) => self.take_model(sex, &path),
207 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
208 DialogPurpose::EquipmentShiny(index, part) => {
209 if let Some(mut preset) = self
210 .session
211 .equipment
212 .get(index)
213 .and_then(|i| i.preset.clone())
214 {
215 let result = crate::equipment::import_shiny(&mut preset, part, &path)
216 .and_then(|_| self.session.ensure_preset_budget(Some(index), &preset));
217 match result {
218 Ok(()) => self.session.equipment[index].preset = Some(preset),
219 Err(error) => self.session.fail(error),
220 }
221 }
222 }
223 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
224 Ok(mut preset) => {
225 let result = self
226 .session
227 .available_equipment_name(&preset.name)
228 .and_then(|name| {
229 preset.name = name;
230 self.session.ensure_preset_budget(None, &preset)
231 });
232 if let Err(error) = result {
233 self.session.fail(error);
234 return;
235 }
236 let index = self.session.equipment.len();
237 self.session.equipment.push(crate::session::EquipmentItem {
238 slug: preset.name.clone(),
239 model: preset.attachments.first().map(|p| p.label.clone()),
240 preset: Some(preset),
241 legacy_path: None,
242 error: None,
243 });
244 self.equipment_part = 0;
245 self.session.show_equipment(index);
246 }
247 Err(error) => self.session.fail(error),
248 },
249 }
250 }
251 }
252
253 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
254 let Some(root) = self.session.project_root() else {
255 return;
256 };
257 match adopt_file(&root, path, "assets/body") {
258 Ok(relative) => {
259 self.session.set_body_path(sex, relative.clone());
260 self.reload_bodies();
261 self.reset_render();
262 let index = if sex == Sex::Male { 0 } else { 1 };
263 if let Some(Err(error)) = &self.inventories[index] {
264 self.session.fail(format!("{relative}: {error}"));
265 } else {
266 self.session.notice = format!("{relative} staat in het project.");
267 self.session.notice_ok = true;
268 }
269 }
270 Err(error) => self.session.fail(error),
271 }
272 }
273
274 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
275 let sex = if self.session.active_body == Some(Sex::Female) {
276 1
277 } else {
278 0
279 };
280 let body = if sex == 0 {
281 self.preview_male.as_ref()
282 } else {
283 self.preview_female.as_ref()
284 };
285 let Some(item) = self.session.equipment.get(index) else {
286 return;
287 };
288 let mut preset = item
289 .preset
290 .clone()
291 .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
292 preset.name = item.slug.clone();
293 match crate::equipment::import_obj(&mut preset, path) {
294 Ok(part) => {
295 if let Some(body) = body {
296 if !body.joint_nodes.is_empty() {
297 let _ = preset.bind(sex, body, part, 0);
298 }
299 }
300 if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) {
301 self.session.fail(error);
302 return;
303 }
304 let item = &mut self.session.equipment[index];
305 item.model = Some(preset.attachments[part].label.clone());
306 item.legacy_path = None;
307 item.error = None;
308 item.preset = Some(preset);
309 self.equipment_part = part;
310 self.session.notice =
311 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
312 .into();
313 self.session.notice_ok = true;
314 }
315 Err(error) => self.session.fail(error),
316 }
317 }
318
319 pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
320 self.session.active_body = Some(sex);
321 self.session.show_equipment(index);
322 self.equipment_part = 0;
323 }
324
325 fn begin(&mut self, intent: Intent) {
326 if self.session.gate(intent) {
327 self.run_intent(intent);
328 }
329 }
330
331 fn run_intent(&mut self, intent: Intent) {
332 match intent {
333 Intent::Quit => {}
334 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
335 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
336 }
337 }
338
339 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
340 match self.session.resolve(choice) {
341 AfterChoice::Stay => {}
342 AfterChoice::SaveThen(intent) => match self.session.save() {
343 Ok(()) => self.finish_intent(intent, ctx),
344 Err(error) => self.session.fail(error.to_string()),
345 },
346 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
347 }
348 }
349
350 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
351 if intent == Intent::Quit {
352 self.allow_close = true;
353 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
354 } else {
355 self.run_intent(intent);
356 }
357 }
358
359 fn save(&mut self) {
360 if let Err(error) = self.session.save() {
361 self.session.fail(error.to_string());
362 }
363 }
364
365 fn apply_render_fields(&mut self) {
366 let width = self.width_text.parse::<u32>().unwrap_or(0);
367 let height = self.height_text.parse::<u32>().unwrap_or(0);
368 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
369 self.session.set_render(width, height, fps);
370 }
371
372 /// Open the body editor, also used by the native smoke example.
373 pub fn open_body_configuration(&mut self, sex: Sex) {
374 self.session.show_body(sex);
375 self.controls.sex = match sex {
376 Sex::Male => PreviewSex::Male,
377 Sex::Female => PreviewSex::Female,
378 };
379 }
380 pub fn render_error(&self) -> Option<&str> {
381 self.render_ui.error.as_deref()
382 }
383 pub fn render_all_angles(&mut self) {
384 self.start_render();
385 }
386 /// Legacy smoke entry point, now starts the production batch as well.
387 pub fn render_current_pose(&mut self) {
388 self.render_all_angles();
389 }
390 pub fn render_is_running(&self) -> bool {
391 self.render_ui.running
392 }
393 pub fn render_progress(&self) -> (u32, u32, String) {
394 (
395 self.render_ui.progress.completed,
396 self.render_ui.progress.total,
397 self.render_ui.progress.label.clone(),
398 )
399 }
400 fn start_render(&mut self) {
401 if self.render_ui.running {
402 return;
403 }
404 let Some(opened) = &self.session.opened else {
405 return;
406 };
407 if let Err(error) = opened.document.validate_shape() {
408 self.session.fail(error.to_string());
409 return;
410 }
411 let Some(character) = self.character().cloned() else {
412 self.session.fail("dit lichaam heeft geen tekenbaar glTF");
413 return;
414 };
415 let Some(root) = self.session.project_root() else {
416 return;
417 };
418 if let Err(error) =
419 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
420 {
421 self.session.fail(error.to_string());
422 return;
423 }
424 let sex = usize::from(self.controls.sex == PreviewSex::Female);
425 let preset = match self.session.screen {
426 Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()),
427 _ => None,
428 };
429 if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {
430 self.session.fail("configureer eerst het equipment");
431 return;
432 }
433 let body_rel = match self.controls.sex {
434 PreviewSex::Male => opened.document.male_model.clone(),
435 PreviewSex::Female => opened.document.female_model.clone(),
436 };
437 let Some(body_rel) = body_rel else {
438 self.session.fail("dit lichaam heeft geen modelpad");
439 return;
440 };
441 if let Err(error) = crate::project::portable_relative(&body_rel) {
442 self.session.fail(error);
443 return;
444 }
445 let body_path = root.join(&body_rel);
446 let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
447 Ok(hash) => hash,
448 Err(error) => {
449 self.session.fail(format!("body-fingerprint: {error}"));
450 return;
451 }
452 };
453 let preset_sha256 = match &preset {
454 None => None,
455 Some(p) => match p.encode() {
456 Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
457 Err(error) => {
458 self.session.fail(format!("equipment-fingerprint: {error}"));
459 return;
460 }
461 },
462 };
463 let job = RenderJob {
464 character,
465 sex: if sex == 0 { "male" } else { "female" }.into(),
466 preset,
467 target_width: opened.document.render.target_width,
468 target_height: opened.document.render.target_height,
469 fps: opened.document.render.fps,
470 framing: opened.document.render.framing[sex],
471 output: root.join(&opened.document.output_directory),
472 project_id: opened.document.id.clone(),
473 project_name: opened.document.name.clone(),
474 body_model: crate::production::SourceFingerprint {
475 path: body_rel,
476 sha256: body_sha,
477 },
478 preset_sha256,
479 };
480 self.render_worker = Some(spawn(job, None));
481 self.render_ui = RenderUiState::idle();
482 self.pass_texture = None;
483 self.render_ui.running = true;
484 }
485 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
486 let request = self
487 .render_worker
488 .as_ref()
489 .and_then(|worker| worker.gpu.try_recv().ok());
490 if let Some(request) = request {
491 if let Some(gl) = gl {
492 unsafe {
493 request.service(&mut self.gpu_renderer, gl);
494 }
495 } else {
496 request.fail("Glow-context ontbreekt".into());
497 }
498 }
499 }
500 fn drain_render(&mut self) {
501 let Some(worker) = &self.render_worker else {
502 return;
503 };
504 let ended = worker
505 .handle
506 .as_ref()
507 .map(|h| h.is_finished())
508 .unwrap_or(true);
509 let events = worker.mailbox.drain();
510 let mut terminal = false;
511 for event in events {
512 terminal |= matches!(
513 event,
514 RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled
515 );
516 if let RenderEvent::Finished { saved: Some(path) } = &event {
517 self.session.notice = format!("Batch opgeslagen: {path}");
518 self.session.notice_ok = true;
519 }
520 if let RenderEvent::Failed(error) = &event {
521 self.session.fail(error.clone());
522 }
523 self.render_ui.apply(event);
524 }
525 if terminal {
526 self.render_worker = None;
527 } else if ended {
528 self.render_ui.apply(RenderEvent::Failed(
529 "De renderworker stopte zonder eindresultaat.".into(),
530 ));
531 self.render_worker = None;
532 }
533 }
534 fn cancel_render(&mut self) {
535 if let Some(worker) = &self.render_worker {
536 worker
537 .cancel
538 .store(true, std::sync::atomic::Ordering::Relaxed);
539 }
540 }
541 fn render_progress_ui(&mut self, ui: &mut egui::Ui) {
542 if self.render_ui.progress.total > 0 {
543 ui.add(
544 egui::ProgressBar::new(
545 self.render_ui.progress.completed as f32
546 / self.render_ui.progress.total.max(1) as f32,
547 )
548 .text(format!(
549 "{} / {} · {}",
550 self.render_ui.progress.completed,
551 self.render_ui.progress.total,
552 self.render_ui.progress.label
553 )),
554 );
555 }
556 if self.render_ui.running && ui.button("Annuleer render").clicked() {
557 self.cancel_render();
558 }
559 }
560 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
561 let diagnostic = {
562 let framing = self
563 .session
564 .opened
565 .as_ref()
566 .map(|opened| opened.document.render.framing[sex]);
567 let body = match sex {
568 0 => self.preview_male.as_ref(),
569 _ => self.preview_female.as_ref(),
570 };
571 match (body, framing) {
572 (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)),
573 _ => None,
574 }
575 };
576 if let Some(opened) = &mut self.session.opened {
577 let camera = &mut opened.document.render.framing[sex];
578 let mut height = camera.height_milli as f32 / 1000.;
579 let mut center = camera.center_y_milli as f32 / 1000.;
580 let mut pivot = diagnostic
581 .as_ref()
582 .map(|resolved| {
583 resolved
584 .framing
585 .pivot_y_milli
586 .unwrap_or(resolved.framing.center_y_milli) as f32
587 / 1000.
588 })
589 .unwrap_or_else(|| {
590 camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
591 });
592 ui.horizontal_wrapped(|ui| {
593 ui.label("Vaste renderhoogte (m)");
594 if ui
595 .add(
596 egui::DragValue::new(&mut height)
597 .speed(0.01)
598 .range(0.1..=10.),
599 )
600 .changed()
601 {
602 camera.height_milli = (height * 1000.).round() as u32;
603 }
604 ui.label("Centrum Y (m)");
605 if ui
606 .add(
607 egui::DragValue::new(&mut center)
608 .speed(0.01)
609 .range(-10.0..=10.0),
610 )
611 .changed()
612 {
613 camera.center_y_milli = (center * 1000.).round() as i32;
614 }
615 ui.label("Pivot Y (m)");
616 if ui
617 .add(
618 egui::DragValue::new(&mut pivot)
619 .speed(0.01)
620 .range(-10.0..=10.0),
621 )
622 .changed()
623 {
624 camera.pivot_y_milli = Some((pivot * 1000.).round() as i32);
625 }
626 if camera.pivot_y_milli.is_some()
627 && ui
628 .button("Auto nek")
629 .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
630 .clicked()
631 {
632 camera.pivot_y_milli = None;
633 }
634 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
635 });
636 if let Some(resolved) = &diagnostic {
637 ui.label(resolved.description());
638 } else {
639 ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");
640 }
641 }
642 }
643 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
644 if !self.show_render_frame {
645 return None;
646 }
647 let r = &self.session.opened.as_ref()?.document.render;
648 let sex = usize::from(self.controls.sex == PreviewSex::Female);
649 Some((r.framing[sex], [r.target_width, r.target_height]))
650 }
651}
652
653impl Default for EditorApp {
654 fn default() -> Self {
655 Self::new(Box::new(SystemDialogs::new()))
656 }
657}
658
659impl eframe::App for EditorApp {
660 fn on_exit(&mut self, gl: Option<&glow::Context>) {
661 self.render_worker = None;
662 if let Some(gl) = gl {
663 unsafe {
664 self.gpu_renderer.destroy(gl);
665 }
666 self.viewport.destroy(gl);
667 }
668 }
669 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
670 self.service_gpu(frame.gl());
671 let shell = self.session.shell_size();
672 if self.applied_shell != Some(shell) {
673 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
674 shell[0], shell[1],
675 )));
676 let min = if shell == crate::session::START_WINDOW {
677 egui::vec2(420.0, 220.0)
678 } else {
679 egui::vec2(900.0, 560.0)
680 };
681 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
682 self.applied_shell = Some(shell);
683 }
684 if ctx.input(|input| input.viewport().close_requested())
685 && !self.allow_close
686 && self.session.confirm.is_none()
687 {
688 if !self.session.gate(Intent::Quit) {
689 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
690 }
691 } else if self.session.confirm.is_some()
692 && ctx.input(|input| input.viewport().close_requested())
693 {
694 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
695 }
696 self.poll_dialogs();
697 self.drain_render();
698 if self.render_ui.running
699 || matches!(self.session.screen, Screen::Preview2d)
700 && (self.preview2d.playing
701 || self.preview2d.hold_azimuth.is_some()
702 || self.preview2d.hold_elevation.is_some())
703 {
704 ctx.request_repaint();
705 }
706 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
707 let color = if self.session.notice_ok {
708 egui::Color32::LIGHT_GREEN
709 } else {
710 egui::Color32::LIGHT_RED
711 };
712 ui.colored_label(color, &self.session.notice);
713 });
714 match self.session.screen {
715 Screen::Start => self.show_start(ctx),
716 Screen::Bodies => self.show_bodies(ctx),
717 Screen::Body(_) => self.show_body(ctx),
718 Screen::EquipmentList => self.show_equipment_list(ctx),
719 Screen::Equipment(_) => self.show_equipment(ctx),
720 Screen::Preview2d => self.show_preview2d(ctx),
721 }
722 self.show_confirm(ctx);
723 if self.dialog_pending {
724 ctx.request_repaint_after(std::time::Duration::from_millis(50));
725 }
726 }
727}
728
729impl EditorApp {
730 fn show_confirm(&mut self, ctx: &egui::Context) {
731 let Some(_intent) = self.session.confirm else {
732 return;
733 };
734 let mut choice = None;
735 egui::Window::new("Wijzigingen")
736 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
737 .collapsible(false)
738 .resizable(false)
739 .show(ctx, |ui| {
740 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
741 ui.horizontal(|ui| {
742 if ui.button("Opslaan").clicked() {
743 choice = Some(crate::session::Choice::Save);
744 }
745 if ui.button("Niet opslaan").clicked() {
746 choice = Some(crate::session::Choice::Discard);
747 }
748 if ui.button("Annuleren").clicked() {
749 choice = Some(crate::session::Choice::Cancel);
750 }
751 });
752 });
753 if let Some(choice) = choice {
754 self.apply_choice(choice, ctx);
755 }
756 }
757
758 fn show_start(&mut self, ctx: &egui::Context) {
759 egui::CentralPanel::default().show(ctx, |ui| {
760 if self.session.confirm.is_some() || self.dialog_pending {
761 ui.disable();
762 }
763 ui.heading("Paperdoll Renderer");
764 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
765 ui.add_space(12.0);
766 if ui.button("Project laden").clicked() {
767 self.begin(Intent::PickLoad);
768 }
769 if ui.button("Nieuw project").clicked() {
770 self.begin(Intent::PickNew);
771 }
772 });
773 }
774
775 fn show_bodies(&mut self, ctx: &egui::Context) {
776 self.toolbar(ctx, true);
777 egui::CentralPanel::default().show(ctx, |ui| {
778 if self.session.confirm.is_some() || self.dialog_pending {
779 ui.disable();
780 }
781 ui.heading("Body");
782 if let Some(opened) = &mut self.session.opened {
783 ui.horizontal(|ui| {
784 ui.label("Naam");
785 ui.text_edit_singleline(&mut opened.document.name);
786 });
787 }
788 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
789 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
790 ui.add_space(8.0);
791 ui.horizontal(|ui| {
792 ui.label("Breedte");
793 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
794 ui.label("Hoogte");
795 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
796 ui.label("fps");
797 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
798 });
799 self.apply_render_fields();
800 ui.add_space(8.0);
801 if ui.button("2D-preview").clicked() {
802 self.open_preview2d();
803 }
804 ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/.");
805 });
806 }
807
808 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
809 ui.horizontal(|ui| {
810 ui.label(title);
811 let path = self
812 .session
813 .body_path(sex)
814 .unwrap_or("geen model")
815 .to_string();
816 ui.label(path);
817 if ui.button(pick).clicked() {
818 self.dialogs.start(DialogPurpose::Body(sex));
819 }
820 if ui.button(open).clicked() {
821 self.controls.sex = match sex {
822 Sex::Male => PreviewSex::Male,
823 Sex::Female => PreviewSex::Female,
824 };
825 self.session.show_body(sex);
826 }
827 });
828 let index = if sex == Sex::Male { 0 } else { 1 };
829 if let Some(inventory) = &self.inventories[index] {
830 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
831 Ok(model) => {
832 for mesh in &model.meshes {
833 ui.label(format!(
834 "{}: {} vertices, {} driehoeken",
835 mesh.name, mesh.vertex_count, mesh.triangle_count
836 ));
837 }
838 for material in &model.materials {
839 ui.label(format!("materiaal {}", material.name));
840 }
841 for texture in &model.textures {
842 ui.label(format!(
843 "texture {}: {}×{}",
844 texture.source, texture.width, texture.height
845 ));
846 }
847 ui.label(format!("{} joints", model.joints.len()));
848 for clip in &model.clips {
849 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
850 }
851 for notice in &model.notices {
852 ui.label(notice);
853 }
854 }
855 Err(message) => {
856 ui.colored_label(egui::Color32::LIGHT_RED, message);
857 }
858 });
859 }
860 }
861
862 fn show_body(&mut self, ctx: &egui::Context) {
863 self.toolbar(ctx, true);
864 let sex = match self.session.screen {
865 Screen::Body(sex) => sex,
866 _ => Sex::Male,
867 };
868 self.controls.sex = match sex {
869 Sex::Male => PreviewSex::Male,
870 Sex::Female => PreviewSex::Female,
871 };
872 egui::CentralPanel::default().show(ctx, |ui| {
873 if self.session.confirm.is_some() || self.dialog_pending {
874 ui.disable();
875 }
876 ui.horizontal_wrapped(|ui| {
877 if ui.button("Terug").clicked() {
878 self.session.show_bodies();
879 }
880 if ui.button("Equipment").clicked() {
881 self.session.show_equipment_list();
882 }
883 if ui
884 .add_enabled(
885 !self.render_ui.running,
886 egui::Button::new("Render alle hoeken"),
887 )
888 .clicked()
889 {
890 self.start_render();
891 }
892 if ui.button("Live 3D").clicked() {
893 self.render_ui.preview = None;
894 }
895 if ui.button("2D-preview").clicked() {
896 self.open_preview2d();
897 }
898 });
899 self.framing_ui(ui, usize::from(sex == Sex::Female));
900 self.render_progress_ui(ui);
901 if self.render_ui.preview.is_some() {
902 self.paint_pass(ui);
903 } else {
904 let character = match self.controls.sex {
905 PreviewSex::Male => self.preview_male.as_ref(),
906 PreviewSex::Female => self.preview_female.as_ref(),
907 };
908 if character.is_some() {
909 let framing = self.fixed_camera();
910 self.viewport
911 .show_framed(ui, &mut self.controls, character, &[], framing);
912 } else {
913 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
914 }
915 }
916 });
917 }
918
919 fn paint_pass(&mut self, ui: &mut egui::Ui) {
920 let Some(image) = &self.render_ui.preview else {
921 return;
922 };
923 ui.label(format!(
924 "Laatste voltooide pass: {}",
925 self.render_ui.preview_label
926 ));
927 if self.pass_texture.is_none()
928 || self.pass_texture_revision != self.render_ui.preview_revision
929 {
930 let color = egui::ColorImage::from_rgba_unmultiplied(
931 [image.width as usize, image.height as usize],
932 &image.rgba,
933 );
934 let options = egui::TextureOptions::NEAREST;
935 if let Some(texture) = &mut self.pass_texture {
936 texture.set(color, options);
937 } else {
938 self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
939 }
940 self.pass_texture_revision = self.render_ui.preview_revision;
941 }
942 let texture = self.pass_texture.as_ref().expect("pass texture installed");
943 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
944 let fit = crate::render_job::fit_preview(
945 [image.width, image.height],
946 [bounds.width(), bounds.height()],
947 );
948 let rect = egui::Rect::from_min_size(
949 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
950 egui::vec2(fit.size[0], fit.size[1]),
951 );
952 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
953 ui.painter().image(
954 texture.id(),
955 rect,
956 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
957 egui::Color32::WHITE,
958 );
959 }
960
961 fn show_equipment_list(&mut self, ctx: &egui::Context) {
962 self.toolbar(ctx, true);
963 egui::CentralPanel::default().show(ctx, |ui| {
964 if self.session.confirm.is_some() || self.dialog_pending {
965 ui.disable();
966 }
967 ui.heading(format!(
968 "Bodies → {} → Equipment",
969 match self.session.active_body {
970 Some(Sex::Male) => "Man",
971 Some(Sex::Female) => "Vrouw",
972 None => "Body",
973 }
974 ));
975 if ui.button("Terug naar body").clicked() {
976 self.session.back();
977 }
978 let mut open = None;
979 for (index, item) in self.session.equipment.iter().enumerate() {
980 let label = match &item.model {
981 Some(model) => format!("{} {model}", item.slug),
982 None => item.slug.clone(),
983 };
984 if ui.button(label).clicked() {
985 open = Some(index);
986 }
987 }
988 if ui
989 .add_enabled(
990 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
991 egui::Button::new("Nieuw"),
992 )
993 .clicked()
994 {
995 self.equipment_part = 0;
996 self.session.add_equipment();
997 }
998 if ui
999 .add_enabled(
1000 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1001 egui::Button::new("Preset importeren"),
1002 )
1003 .clicked()
1004 {
1005 self.dialogs.start(DialogPurpose::ImportEquipment);
1006 }
1007 if let Some(index) = open {
1008 self.session.show_equipment(index);
1009 }
1010 });
1011 }
1012
1013 fn show_equipment(&mut self, ctx: &egui::Context) {
1014 self.toolbar(ctx, true);
1015 let Screen::Equipment(index) = self.session.screen else {
1016 return;
1017 };
1018 let mut sex = if self.session.active_body == Some(Sex::Female) {
1019 1
1020 } else {
1021 0
1022 };
1023 egui::SidePanel::left("equipment-config")
1024 .default_width(330.0)
1025 .resizable(true)
1026 .show(ctx, |ui| {
1027 if self.session.confirm.is_some() || self.dialog_pending {
1028 ui.disable();
1029 }
1030 egui::ScrollArea::vertical().show(ui, |ui| {
1031 ui.heading("Equipment-configuratie");
1032 if ui.button("Terug naar equipment").clicked() {
1033 self.session.show_equipment_list();
1034 }
1035 ui.horizontal(|ui| {
1036 ui.selectable_value(&mut sex, 0, "Man");
1037 ui.selectable_value(&mut sex, 1, "Vrouw");
1038 });
1039 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1040 let Some(item) = self.session.equipment.get_mut(index) else {
1041 return;
1042 };
1043 ui.label("Naam (kleine letters/cijfers/-/_)");
1044 ui.text_edit_singleline(&mut item.slug);
1045 if !crate::equipment::valid_name(&item.slug) {
1046 ui.colored_label(
1047 egui::Color32::LIGHT_RED,
1048 "Ongeldige naam; 'body' is gereserveerd.",
1049 );
1050 }
1051 if let Some(error) = &item.error {
1052 ui.colored_label(egui::Color32::LIGHT_RED, error);
1053 }
1054 if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
1055 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1056 }
1057 if let Some(preset) = &mut item.preset {
1058 ui.horizontal(|ui| {
1059 ui.label("Geldig voor:");
1060 ui.checkbox(&mut preset.applies[0], "Man");
1061 ui.checkbox(&mut preset.applies[1], "Vrouw");
1062 });
1063 if !preset.applies[0] && !preset.applies[1] {
1064 ui.colored_label(
1065 egui::Color32::LIGHT_RED,
1066 "Kies minstens één geslacht.",
1067 );
1068 }
1069 ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1070 ui.add(
1071 egui::TextEdit::multiline(&mut preset.provenance)
1072 .desired_rows(2)
1073 .char_limit(4096),
1074 );
1075 if preset.provenance.len() > 4096 {
1076 ui.colored_label(
1077 egui::Color32::LIGHT_RED,
1078 "Herkomst/licentie is te lang.",
1079 );
1080 }
1081 if !preset.attachments.is_empty() {
1082 self.equipment_part =
1083 self.equipment_part.min(preset.attachments.len() - 1);
1084 egui::ComboBox::from_id_salt("equipment-part")
1085 .selected_text(&preset.attachments[self.equipment_part].label)
1086 .show_ui(ui, |ui| {
1087 for (part, item) in preset.attachments.iter().enumerate() {
1088 ui.selectable_value(
1089 &mut self.equipment_part,
1090 part,
1091 &item.label,
1092 );
1093 }
1094 });
1095 let body = if sex == 0 {
1096 self.preview_male.as_ref()
1097 } else {
1098 self.preview_female.as_ref()
1099 };
1100 let attachment = &mut preset.attachments[self.equipment_part];
1101 ui.label("Positie relatief aan bot (XYZ)");
1102 ui.horizontal(|ui| {
1103 for value in &mut attachment.translation {
1104 ui.add(
1105 egui::DragValue::new(value)
1106 .speed(0.005)
1107 .range(-1000.0..=1000.0),
1108 );
1109 }
1110 });
1111 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1112 ui.horizontal(|ui| {
1113 for value in &mut attachment.rotation_deg {
1114 ui.add(
1115 egui::DragValue::new(value)
1116 .speed(0.5)
1117 .range(-3600.0..=3600.0),
1118 );
1119 }
1120 });
1121 ui.add(
1122 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1123 .text("Glanssterkte"),
1124 );
1125 let shiny = attachment
1126 .shiny
1127 .and_then(|i| preset.assets.get(i))
1128 .map(|a| a.path.clone());
1129 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1130 if ui.button("Kies shinymap (PNG)").clicked() {
1131 self.dialogs.start(DialogPurpose::EquipmentShiny(
1132 index,
1133 self.equipment_part,
1134 ));
1135 }
1136 let clear_shiny = ui.button("Wis shinymap").clicked();
1137 let selected = attachment.bones[sex].clone();
1138 if clear_shiny {
1139 if let Err(error) =
1140 crate::equipment::clear_shiny(preset, self.equipment_part)
1141 {
1142 item.error = Some(error);
1143 }
1144 }
1145 ui.label(format!(
1146 "Bot: {}",
1147 selected.as_deref().unwrap_or("nog niet gekoppeld")
1148 ));
1149 if let Some(body) = body {
1150 if preset.rigs[sex].as_ref()
1151 != Some(&crate::equipment::RigSignature::of(body))
1152 {
1153 ui.colored_label(
1154 egui::Color32::LIGHT_RED,
1155 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1156 );
1157 }
1158 ui.collapsing("Skeleton / botten", |ui| {
1159 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1160 if let Err(error) =
1161 preset.bind(sex, body, self.equipment_part, joint)
1162 {
1163 ui.colored_label(egui::Color32::LIGHT_RED, error);
1164 }
1165 }
1166 });
1167 } else {
1168 ui.colored_label(
1169 egui::Color32::LIGHT_RED,
1170 "Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
1171 );
1172 }
1173 if ui.button("Verwijder onderdeel").clicked() {
1174 if let Err(error) =
1175 crate::equipment::remove_attachment(preset, self.equipment_part)
1176 {
1177 item.error = Some(error);
1178 }
1179 self.equipment_part = self.equipment_part.saturating_sub(1);
1180 }
1181 }
1182 }
1183 if ui.button("Verwijder equipmentdefinitie").clicked() {
1184 self.session.delete_equipment(index);
1185 }
1186 });
1187 });
1188 self.controls.sex = if sex == 0 {
1189 PreviewSex::Male
1190 } else {
1191 PreviewSex::Female
1192 };
1193 egui::CentralPanel::default().show(ctx, |ui| {
1194 if self.session.confirm.is_some() || self.dialog_pending {
1195 ui.disable();
1196 }
1197 ui.horizontal_wrapped(|ui| {
1198 if ui
1199 .add_enabled(
1200 !self.render_ui.running,
1201 egui::Button::new("Render equipment · alle hoeken"),
1202 )
1203 .clicked()
1204 {
1205 self.start_render();
1206 }
1207 if ui.button("Live 3D").clicked() {
1208 self.render_ui.preview = None;
1209 }
1210 if ui.button("2D-preview").clicked() {
1211 self.open_preview2d();
1212 }
1213 });
1214 self.render_progress_ui(ui);
1215 if self.render_ui.preview.is_some() {
1216 self.paint_pass(ui);
1217 return;
1218 }
1219 let framing = self.fixed_camera();
1220 let body = if sex == 0 {
1221 self.preview_male.as_ref()
1222 } else {
1223 self.preview_female.as_ref()
1224 };
1225 let preset = self
1226 .session
1227 .equipment
1228 .get(index)
1229 .and_then(|item| item.preset.as_ref());
1230 let presets: Vec<_> = preset.into_iter().collect();
1231 self.viewport
1232 .show_framed(ui, &mut self.controls, body, &presets, framing);
1233 });
1234 }
1235
1236 pub fn open_preview2d(&mut self) {
1237 self.refresh_preview2d_catalog();
1238 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1239 self.preview2d_image = None;
1240 self.preview2d_texture = None;
1241 self.preview2d_texture_key = None;
1242 self.session.show_preview2d();
1243 }
1244
1245 pub fn preview2d_state(&self) -> &Preview2dState {
1246 &self.preview2d
1247 }
1248
1249 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1250 self.preview2d_image.as_ref()
1251 }
1252
1253 fn refresh_preview2d_catalog(&mut self) {
1254 self.preview2d_catalog = match self.session.project_root() {
1255 Some(root) => discover_completed_runs(&root),
1256 None => Vec::new(),
1257 };
1258 }
1259
1260 fn rebuild_preview2d_frame(&mut self) {
1261 let sex = self.preview2d.sex;
1262 let equipment = self.preview2d.selected_equipment.clone();
1263 let clip = self.preview2d.clip_token.clone();
1264 let frame = self.preview2d.frame_index();
1265 let pitch = self.preview2d.pitch_deg();
1266 let yaw = self.preview2d.yaw_deg();
1267 let light = self.preview2d.light();
1268 if clip.is_empty() {
1269 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1270 self.preview2d_image = None;
1271 return;
1272 }
1273 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1274 Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1275 body: &body,
1276 equipment: &gear,
1277 clip_token: &clip,
1278 frame,
1279 pitch,
1280 yaw,
1281 light,
1282 }) {
1283 Ok(image) => {
1284 self.preview2d.diagnostic = None;
1285 self.preview2d_image = Some(image);
1286 }
1287 Err(err) => {
1288 self.preview2d.diagnostic = Some(err.to_string());
1289 self.preview2d_image = None;
1290 }
1291 },
1292 Err(err) => {
1293 self.preview2d.diagnostic = Some(err.to_string());
1294 self.preview2d_image = None;
1295 }
1296 }
1297 }
1298
1299 fn show_preview2d(&mut self, ctx: &egui::Context) {
1300 self.toolbar(ctx, true);
1301 let dt = ctx.input(|i| i.stable_dt);
1302 let focused = ctx.input(|i| i.focused);
1303 let cancel_holds = ctx.input(|i| {
1304 !i.focused
1305 || i.pointer.any_released()
1306 || i.events.iter().any(|e| {
1307 matches!(
1308 e,
1309 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1310 )
1311 })
1312 });
1313 if cancel_holds || !focused {
1314 self.preview2d.on_interaction_cancel();
1315 }
1316 self.preview2d.tick(dt);
1317
1318 let mut need_rebuild = false;
1319 let mut refresh_catalog = false;
1320 let mut go_back = false;
1321 let mut set_sex = None;
1322 let mut toggle_set = None;
1323 let mut select_clip = None;
1324 let mut step_yaw = 0i32;
1325 let mut step_pitch = 0i32;
1326 let mut toggle_play = false;
1327 let mut apply_speed = false;
1328 let mut hold_az = None;
1329 let mut hold_el = None;
1330 let mut release_az = false;
1331 let mut release_el = false;
1332
1333 egui::SidePanel::left("preview2d_controls")
1334 .default_width(330.0)
1335 .show(ctx, |ui| {
1336 if self.session.confirm.is_some() || self.dialog_pending {
1337 ui.disable();
1338 }
1339 ui.heading("2D-preview");
1340 if ui.button("Terug").clicked() {
1341 go_back = true;
1342 }
1343 ui.separator();
1344 ui.label("Geslacht");
1345 ui.horizontal(|ui| {
1346 if ui
1347 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1348 .clicked()
1349 {
1350 set_sex = Some(Sex::Male);
1351 }
1352 if ui
1353 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1354 .clicked()
1355 {
1356 set_sex = Some(Sex::Female);
1357 }
1358 });
1359 if ui.button("Vernieuw exportsets").clicked() {
1360 refresh_catalog = true;
1361 }
1362 ui.label(format!(
1363 "{} voltooide exportset(s)",
1364 self.preview2d_catalog.len()
1365 ));
1366 ui.separator();
1367 ui.label("Equipment (zelfde project/rig/contract)");
1368 let allowed =
1369 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1370 if allowed.is_empty() {
1371 ui.weak("Geen compatibele equipment-export.");
1372 }
1373 for set in allowed {
1374 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1375 if ui.checkbox(&mut checked, &set).changed() {
1376 toggle_set = Some(set);
1377 }
1378 }
1379 ui.separator();
1380 ui.label("Clip");
1381 let sex_token = self.preview2d.sex_token();
1382 let clips: Vec<_> = self
1383 .preview2d_catalog
1384 .iter()
1385 .filter(|e| e.sex == sex_token && e.is_body())
1386 .max_by_key(|e| e.path.clone())
1387 .map(|e| e.clips.clone())
1388 .unwrap_or_default();
1389 if clips.is_empty() {
1390 ui.colored_label(
1391 egui::Color32::LIGHT_RED,
1392 "Geen body-export met clips voor dit geslacht.",
1393 );
1394 }
1395 for (token, name, frames, duration) in &clips {
1396 let label = if name.is_empty() {
1397 format!("{token} ({frames}f)")
1398 } else {
1399 format!("{name} ({token}, {frames}f)")
1400 };
1401 if ui
1402 .selectable_label(self.preview2d.clip_token == *token, label)
1403 .clicked()
1404 {
1405 select_clip = Some((token.clone(), *frames, *duration));
1406 }
1407 }
1408 ui.separator();
1409 ui.horizontal(|ui| {
1410 let play_label = if self.preview2d.playing {
1411 "Pauze"
1412 } else {
1413 "Speel"
1414 };
1415 if ui.button(play_label).clicked() {
1416 toggle_play = true;
1417 }
1418 ui.label(format!(
1419 "frame {}/{} t={:.2}s",
1420 self.preview2d.frame_index() + 1,
1421 self.preview2d.clip_frames.max(1),
1422 self.preview2d.elapsed_secs
1423 ));
1424 });
1425 ui.horizontal(|ui| {
1426 ui.label("Snelheid");
1427 ui.add(
1428 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1429 .desired_width(48.0),
1430 );
1431 if ui.button("Pas toe").clicked() {
1432 apply_speed = true;
1433 }
1434 ui.label(format!("export-fps {}", self.preview2d.fps));
1435 });
1436 ui.separator();
1437 ui.label("Hoek (export)");
1438 ui.horizontal(|ui| {
1439 if ui.button("Yaw −").clicked() {
1440 step_yaw = -1;
1441 }
1442 if ui.button("Yaw +").clicked() {
1443 step_yaw = 1;
1444 }
1445 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
1446 });
1447 ui.horizontal(|ui| {
1448 if ui.button("Pitch −").clicked() {
1449 step_pitch = -1;
1450 }
1451 if ui.button("Pitch +").clicked() {
1452 step_pitch = 1;
1453 }
1454 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1455 });
1456 ui.separator();
1457 ui.label("Licht (vasthouden; °/s)");
1458 ui.horizontal(|ui| {
1459 let west = ui.button("Azimut −");
1460 let east = ui.button("Azimut +");
1461 if west.is_pointer_button_down_on() {
1462 hold_az = Some(HoldSign::Negative);
1463 } else if east.is_pointer_button_down_on() {
1464 hold_az = Some(HoldSign::Positive);
1465 } else if self.preview2d.hold_azimuth.is_some()
1466 && !west.is_pointer_button_down_on()
1467 && !east.is_pointer_button_down_on()
1468 {
1469 release_az = true;
1470 }
1471 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1472 });
1473 ui.horizontal(|ui| {
1474 let down = ui.button("Elevatie −");
1475 let up = ui.button("Elevatie +");
1476 if down.is_pointer_button_down_on() {
1477 hold_el = Some(HoldSign::Negative);
1478 } else if up.is_pointer_button_down_on() {
1479 hold_el = Some(HoldSign::Positive);
1480 } else if self.preview2d.hold_elevation.is_some()
1481 && !down.is_pointer_button_down_on()
1482 && !up.is_pointer_button_down_on()
1483 {
1484 release_el = true;
1485 }
1486 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1487 });
1488 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1489 });
1490
1491 if go_back {
1492 self.session.back();
1493 return;
1494 }
1495 if refresh_catalog {
1496 self.refresh_preview2d_catalog();
1497 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1498 need_rebuild = true;
1499 }
1500 if let Some(sex) = set_sex {
1501 self.preview2d.set_sex(sex);
1502 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1503 need_rebuild = true;
1504 }
1505 if let Some(set) = toggle_set {
1506 self.preview2d.toggle_equipment(&set);
1507 need_rebuild = true;
1508 }
1509 if let Some((token, frames, duration)) = select_clip {
1510 self.preview2d.set_clip(token, frames, duration);
1511 need_rebuild = true;
1512 }
1513 if toggle_play {
1514 self.preview2d.playing = !self.preview2d.playing;
1515 }
1516 if apply_speed {
1517 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
1518 if speed.is_finite() && speed >= 0.0 {
1519 self.preview2d.playback_speed = speed;
1520 }
1521 }
1522 }
1523 if step_yaw != 0 {
1524 self.preview2d.step_yaw(step_yaw);
1525 need_rebuild = true;
1526 }
1527 if step_pitch != 0 {
1528 self.preview2d.step_pitch(step_pitch);
1529 need_rebuild = true;
1530 }
1531 if let Some(sign) = hold_az {
1532 self.preview2d.hold_light(LightAxis::Azimuth, sign);
1533 } else if release_az {
1534 self.preview2d.release_light(LightAxis::Azimuth);
1535 }
1536 if let Some(sign) = hold_el {
1537 self.preview2d.hold_light(LightAxis::Elevation, sign);
1538 } else if release_el {
1539 self.preview2d.release_light(LightAxis::Elevation);
1540 }
1541
1542 // Continuous light or playing changes the image every tick.
1543 if self.preview2d.playing
1544 || self.preview2d.hold_azimuth.is_some()
1545 || self.preview2d.hold_elevation.is_some()
1546 {
1547 need_rebuild = true;
1548 }
1549
1550 let key = (
1551 self.preview2d.clip_token.clone(),
1552 self.preview2d.frame_index(),
1553 self.preview2d.pitch_deg(),
1554 self.preview2d.yaw_deg(),
1555 self.preview2d.selected_equipment.len() as u32,
1556 format!(
1557 "{:.1}/{:.1}/{}",
1558 self.preview2d.light_azimuth_deg,
1559 self.preview2d.light_elevation_deg,
1560 self.preview2d.sex_token()
1561 ),
1562 );
1563 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
1564 self.rebuild_preview2d_frame();
1565 self.preview2d_texture_key = Some(key);
1566 self.preview2d_texture = None;
1567 }
1568
1569 egui::CentralPanel::default().show(ctx, |ui| {
1570 if self.session.confirm.is_some() || self.dialog_pending {
1571 ui.disable();
1572 }
1573 if let Some(diag) = &self.preview2d.diagnostic {
1574 ui.colored_label(egui::Color32::LIGHT_RED, diag);
1575 }
1576 let Some(image) = &self.preview2d_image else {
1577 ui.label(
1578 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
1579 );
1580 return;
1581 };
1582 if self.preview2d_texture.is_none() {
1583 let color = egui::ColorImage::from_rgba_unmultiplied(
1584 [image.width as usize, image.height as usize],
1585 &image.rgba,
1586 );
1587 self.preview2d_texture = Some(ui.ctx().load_texture(
1588 "preview2d-frame",
1589 color,
1590 egui::TextureOptions::NEAREST,
1591 ));
1592 }
1593 let texture = self.preview2d_texture.as_ref().expect("texture");
1594 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1595 let fit = crate::render_job::fit_preview(
1596 [image.width, image.height],
1597 [bounds.width(), bounds.height()],
1598 );
1599 let rect = egui::Rect::from_min_size(
1600 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1601 egui::vec2(fit.size[0], fit.size[1]),
1602 );
1603 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1604 ui.painter().image(
1605 texture.id(),
1606 rect,
1607 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1608 egui::Color32::WHITE,
1609 );
1610 });
1611 }
1612
1613 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1614 if !project {
1615 return;
1616 }
1617 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
1618 if self.session.confirm.is_some() || self.dialog_pending {
1619 ui.disable();
1620 }
1621 ui.horizontal(|ui| {
1622 if ui.button("Body").clicked() {
1623 self.session.show_bodies();
1624 }
1625 if ui.button("2D-preview").clicked() {
1626 self.open_preview2d();
1627 }
1628 if ui.button("Opslaan").clicked() {
1629 self.save();
1630 }
1631 if ui
1632 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
1633 .clicked()
1634 {
1635 self.begin(Intent::PickNew);
1636 }
1637 if ui
1638 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
1639 .clicked()
1640 {
1641 self.begin(Intent::PickLoad);
1642 }
1643 });
1644 });
1645 }
1646}
1647
1648fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
1649 fn node(
1650 ui: &mut egui::Ui,
1651 body: &PreviewCharacter,
1652 paths: &[String],
1653 index: usize,
1654 selected: Option<&str>,
1655 choice: &mut Option<usize>,
1656 depth: usize,
1657 ) {
1658 if depth > 64 {
1659 return;
1660 }
1661 let joint = body.joint_nodes.iter().position(|i| *i == index);
1662 let children: Vec<_> = body
1663 .nodes
1664 .iter()
1665 .enumerate()
1666 .filter(|(_, n)| n.parent == Some(index))
1667 .map(|(i, _)| i)
1668 .collect();
1669 let label = if body.nodes[index].name.is_empty() {
1670 format!("node-{index}")
1671 } else {
1672 body.nodes[index].name.clone()
1673 };
1674 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
1675 if let Some(joint) = joint {
1676 if ui
1677 .selectable_label(
1678 selected == Some(paths[joint].as_str()),
1679 format!("Koppel {label}"),
1680 )
1681 .clicked()
1682 {
1683 *choice = Some(joint);
1684 }
1685 }
1686 };
1687 if children.is_empty() {
1688 select(ui, choice);
1689 } else {
1690 egui::CollapsingHeader::new(&label)
1691 .id_salt(index)
1692 .default_open(depth < 2)
1693 .show(ui, |ui| {
1694 select(ui, choice);
1695 for child in children {
1696 node(ui, body, paths, child, selected, choice, depth + 1);
1697 }
1698 });
1699 }
1700 }
1701 let mut choice = None;
1702 let paths = crate::preview::joint_paths(body);
1703 for (index, item) in body.nodes.iter().enumerate() {
1704 if item.parent.is_none() {
1705 node(ui, body, &paths, index, selected, &mut choice, 0);
1706 }
1707 }
1708 choice
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::*;
1714 use crate::dialogs::ScriptedDialogs;
1715 use crate::session::Choice;
1716
1717 fn root() -> PathBuf {
1718 std::env::temp_dir().join(format!(
1719 "paperdoll-editor-{}",
1720 std::time::SystemTime::now()
1721 .duration_since(std::time::UNIX_EPOCH)
1722 .unwrap()
1723 .as_nanos()
1724 ))
1725 }
1726
1727 #[test]
1728 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
1729 let root = root();
1730 let mut dialogs = ScriptedDialogs::default();
1731 dialogs.push(Some(root.clone()));
1732 dialogs.push(None);
1733 let mut app = EditorApp::new(Box::new(dialogs));
1734 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
1735 app.begin(Intent::PickNew);
1736 app.poll_dialogs();
1737 assert_eq!(app.session.screen, Screen::Bodies);
1738 assert!(app.preview_male.is_some());
1739 assert!(app.preview_female.is_some());
1740 app.session.set_name("Keep my edit".into());
1741 app.begin(Intent::PickLoad);
1742 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
1743 app.apply_choice(Choice::Discard, &egui::Context::default());
1744 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
1745 assert_eq!(
1746 app.session.opened.as_ref().unwrap().document.name,
1747 "Keep my edit"
1748 );
1749 assert!(app.session.is_dirty());
1750 let _ = std::fs::remove_dir_all(root);
1751 }
1752
1753 #[test]
1754 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
1755 let root = root();
1756 let mut app = EditorApp::default();
1757 app.session.install_new_project(&root).unwrap();
1758 app.session.set_render(0, 128, 12);
1759 app.begin(Intent::Quit);
1760 app.apply_choice(Choice::Save, &egui::Context::default());
1761 assert!(!app.allow_close);
1762 assert_eq!(app.session.confirm, Some(Intent::Quit));
1763 assert!(app.session.is_dirty());
1764 app.open_path(root.join("missing.ppd"));
1765 assert!(app.session.opened.is_some());
1766 assert_eq!(app.session.screen, Screen::Bodies);
1767 let _ = std::fs::remove_dir_all(root);
1768 }
1769
1770 #[test]
1771 fn open_with_project_path_loads_bodies_and_models() {
1772 let root = root().join("project with spaces");
1773 let opened = crate::session::create_seeded_project(&root).unwrap();
1774 let mut app = EditorApp::default();
1775 app.open_path(opened.manifest_path.clone());
1776 assert_eq!(app.session.screen, Screen::Bodies);
1777 assert!(app.preview_male.is_some());
1778 assert_eq!(
1779 app.session.opened.unwrap().manifest_path,
1780 opened.manifest_path
1781 );
1782 let _ = std::fs::remove_dir_all(root.parent().unwrap());
1783 }
1784
1785 #[test]
1786 fn renderer_disconnect_is_reported() {
1787 let mut app = EditorApp::default();
1788 let (_, gpu) = std::sync::mpsc::channel();
1789 app.render_worker = Some(crate::production::RenderThread {
1790 mailbox: Default::default(),
1791 gpu,
1792 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1793 handle: None,
1794 });
1795 app.render_ui.running = true;
1796 app.drain_render();
1797 assert!(!app.render_ui.running);
1798 assert!(app.render_ui.error.is_some());
1799 }
1800}