BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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