BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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