BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 100.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::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::import::{import_project_model, ImportedModel};
9use crate::preview::{
10 is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,
11};
12use crate::preview2d::{
13 compatible_equipment_sets, compose_preview_frame, discover_exports, open_selection,
14 selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis,
15 Preview2dState, ViewMode,
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 /// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
46 preview2d_skipped: Vec<(std::path::PathBuf, String)>,
47 /// Decoded `.ppda` atlases of the current selection, keyed by run paths.
48 preview2d_loaded: Option<PreviewSelection>,
49 preview2d_texture: Option<egui::TextureHandle>,
50 preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
51 preview2d_image: Option<ImageBuf>,
52 preview2d_speed_text: String,
53 resume_candidates: Vec<crate::resume::ResumeCandidate>,
54 resume_scanned: bool,
55 resume_scan_error: Option<String>,
56 render_all: Option<RenderAll>,
57 render_all_summary: Option<RenderAllSummary>,
58}
59
60/// One queued item of **Render alles**: a body (0 = male, 1 = female) with
61/// optional equipment item index.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RenderAllItem {
64 pub sex: usize,
65 pub equipment: Option<usize>,
66 pub label: String,
67}
68
69/// What **Render alles** did, per item.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct RenderAllSummary {
72 pub rendered: Vec<String>,
73 pub skipped: Vec<(String, String)>,
74 pub failed: Vec<(String, String)>,
75 pub not_started: Vec<String>,
76 pub cancelled: bool,
77}
78
79impl RenderAllSummary {
80 pub fn text(&self) -> String {
81 let mut out = format!(
82 "Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt",
83 if self.cancelled { " (geannuleerd)" } else { "" },
84 self.rendered.len(),
85 self.skipped.len(),
86 self.failed.len()
87 );
88 if !self.not_started.is_empty() {
89 out += &format!(", {} niet gestart", self.not_started.len());
90 }
91 out
92 }
93}
94
95struct RenderAll {
96 queue: std::collections::VecDeque<RenderAllItem>,
97 total: usize,
98 started: usize,
99 current: Option<String>,
100 summary: RenderAllSummary,
101}
102
103/// Decoded atlases for the 2D preview selection (body first).
104struct PreviewSelection {
105 paths: Vec<std::path::PathBuf>,
106 body: crate::atlas::LoadedAtlas,
107 gear: Vec<crate::atlas::LoadedAtlas>,
108}
109
110impl EditorApp {
111 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
112 Self {
113 session: Session::new(),
114 dialogs,
115 controls: PreviewControls::default(),
116 preview_male: None,
117 preview_female: None,
118 viewport: Viewport::new(),
119 inventories: [None, None],
120 render_worker: None,
121 gpu_renderer: Default::default(),
122 show_render_frame: true,
123 render_ui: RenderUiState::idle(),
124 pass_texture: None,
125 pass_texture_revision: 0,
126 applied_shell: None,
127 width_text: "128".to_string(),
128 height_text: "128".to_string(),
129 fps_text: crate::project::DEFAULT_FPS.to_string(),
130 allow_close: false,
131 dialog_pending: false,
132 equipment_part: 0,
133 preview2d: Preview2dState::default(),
134 preview2d_catalog: Vec::new(),
135 preview2d_skipped: Vec::new(),
136 preview2d_loaded: None,
137 preview2d_texture: None,
138 preview2d_texture_key: None,
139 preview2d_image: None,
140 preview2d_speed_text: "1.0".to_string(),
141 resume_candidates: Vec::new(),
142 resume_scanned: false,
143 resume_scan_error: None,
144 render_all: None,
145 render_all_summary: None,
146 }
147 }
148
149 pub fn open_path(&mut self, path: PathBuf) {
150 let candidate = std::env::current_dir()
151 .map_err(|e| e.to_string())
152 .and_then(|base| {
153 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
154 });
155 let path = match candidate {
156 Ok(path) => path,
157 Err(error) => {
158 self.session.fail(error);
159 return;
160 }
161 };
162 match open_project(&path) {
163 Ok(opened) => {
164 self.session.install_loaded(opened);
165 self.reset_render();
166 self.sync_fields();
167 self.reload_bodies();
168 }
169 Err(error) => {
170 self.session.fail(error.to_string());
171 }
172 }
173 }
174
175 pub fn open_fixture_project(&mut self) {
176 let root = std::env::temp_dir().join(format!(
177 "paperdoll-renderer-fixture-{}-{}",
178 std::process::id(),
179 std::time::SystemTime::now()
180 .duration_since(std::time::UNIX_EPOCH)
181 .unwrap_or_default()
182 .as_nanos()
183 ));
184 if let Err(error) = self.session.install_new_project(&root) {
185 self.session.fail(error.to_string());
186 return;
187 }
188 self.sync_fields();
189 self.reload_bodies();
190 self.session.show_body(Sex::Male);
191 self.controls.sex = PreviewSex::Male;
192 self.controls.select_clip("walk");
193 }
194
195 fn reset_render(&mut self) {
196 self.render_worker = None;
197 self.render_ui = RenderUiState::idle();
198 self.pass_texture = None;
199 self.resume_candidates.clear();
200 self.resume_scanned = false;
201 self.resume_scan_error = None;
202 }
203
204 fn sync_fields(&mut self) {
205 if let Some(opened) = &self.session.opened {
206 self.width_text = opened.document.render.target_width.to_string();
207 self.height_text = opened.document.render.target_height.to_string();
208 self.fps_text = opened.document.render.fps.to_string();
209 }
210 }
211
212 fn reload_bodies(&mut self) {
213 self.preview_male = None;
214 self.preview_female = None;
215 self.inventories = [None, None];
216 let Some(root) = self.session.project_root() else {
217 return;
218 };
219 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
220 if let Some(relative) = self.session.body_path(sex) {
221 let imported =
222 import_project_model(&root, relative).map_err(|error| error.to_string());
223 // Validate the source and its dependencies before the viewport reads it.
224 if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {
225 match load_body_file(&root.join(relative)) {
226 Ok(character) => match sex {
227 Sex::Male => self.preview_male = Some(character),
228 Sex::Female => self.preview_female = Some(character),
229 },
230 Err(error) => self.session.fail(format!("3D: {error}")),
231 }
232 }
233 self.inventories[index] = Some(imported);
234 }
235 }
236 }
237
238 fn character(&self) -> Option<&PreviewCharacter> {
239 match self.controls.sex {
240 PreviewSex::Male => self.preview_male.as_ref(),
241 PreviewSex::Female => self.preview_female.as_ref(),
242 }
243 }
244
245 fn poll_dialogs(&mut self) {
246 let poll = self.dialogs.poll();
247 self.dialog_pending = matches!(poll, DialogPoll::Pending);
248 if let DialogPoll::Ready { purpose, path } = poll {
249 let Some(path) = path else {
250 return;
251 };
252 match purpose {
253 DialogPurpose::NewProject => {
254 if let Err(error) = self.session.install_new_project(&path) {
255 self.session.fail(error.to_string());
256 } else {
257 self.reset_render();
258 self.sync_fields();
259 self.reload_bodies();
260 }
261 }
262 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
263 Ok(opened) => {
264 self.session.install_loaded(opened);
265 self.reset_render();
266 self.sync_fields();
267 self.reload_bodies();
268 }
269 Err(error) => self.session.fail(error.to_string()),
270 },
271 DialogPurpose::Body(sex) => self.take_model(sex, &path),
272 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
273 DialogPurpose::EquipmentShiny(index, part) => {
274 if let Some(mut preset) = self
275 .session
276 .equipment
277 .get(index)
278 .and_then(|i| i.preset.clone())
279 {
280 let result = crate::equipment::import_shiny(&mut preset, part, &path)
281 .and_then(|_| self.session.ensure_preset_budget(Some(index), &preset));
282 match result {
283 Ok(()) => self.session.equipment[index].preset = Some(preset),
284 Err(error) => self.session.fail(error),
285 }
286 }
287 }
288 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
289 Ok(mut preset) => {
290 let result = self
291 .session
292 .available_equipment_name(&preset.name)
293 .and_then(|name| {
294 preset.name = name;
295 self.session.ensure_preset_budget(None, &preset)
296 });
297 if let Err(error) = result {
298 self.session.fail(error);
299 return;
300 }
301 let index = self.session.equipment.len();
302 self.session.equipment.push(crate::session::EquipmentItem {
303 slug: preset.name.clone(),
304 model: preset.attachments.first().map(|p| p.label.clone()),
305 preset: Some(preset),
306 legacy_path: None,
307 error: None,
308 });
309 self.equipment_part = 0;
310 self.session.show_equipment(index);
311 }
312 Err(error) => self.session.fail(error),
313 },
314 }
315 }
316 }
317
318 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
319 let Some(root) = self.session.project_root() else {
320 return;
321 };
322 match adopt_file(&root, path, "assets/body") {
323 Ok(relative) => {
324 self.session.set_body_path(sex, relative.clone());
325 self.reload_bodies();
326 self.reset_render();
327 let index = if sex == Sex::Male { 0 } else { 1 };
328 if let Some(Err(error)) = &self.inventories[index] {
329 self.session.fail(format!("{relative}: {error}"));
330 } else {
331 self.session.notice = format!("{relative} staat in het project.");
332 self.session.notice_ok = true;
333 }
334 }
335 Err(error) => self.session.fail(error),
336 }
337 }
338
339 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
340 let sex = if self.session.active_body == Some(Sex::Female) {
341 1
342 } else {
343 0
344 };
345 let body = if sex == 0 {
346 self.preview_male.as_ref()
347 } else {
348 self.preview_female.as_ref()
349 };
350 let Some(item) = self.session.equipment.get(index) else {
351 return;
352 };
353 let mut preset = item
354 .preset
355 .clone()
356 .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
357 preset.name = item.slug.clone();
358 match crate::equipment::import_model(&mut preset, path) {
359 Ok(part) => {
360 if let Some(body) = body {
361 if !body.joint_nodes.is_empty() {
362 let _ = preset.bind(sex, body, part, 0);
363 }
364 }
365 if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) {
366 self.session.fail(error);
367 return;
368 }
369 let item = &mut self.session.equipment[index];
370 item.model = Some(preset.attachments[part].label.clone());
371 item.legacy_path = None;
372 item.error = None;
373 item.preset = Some(preset);
374 self.equipment_part = part;
375 self.session.notice =
376 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
377 .into();
378 self.session.notice_ok = true;
379 }
380 Err(error) => self.session.fail(error),
381 }
382 }
383
384 pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
385 self.session.active_body = Some(sex);
386 self.session.show_equipment(index);
387 self.equipment_part = 0;
388 }
389
390 fn begin(&mut self, intent: Intent) {
391 if self.session.gate(intent) {
392 self.run_intent(intent);
393 }
394 }
395
396 fn run_intent(&mut self, intent: Intent) {
397 match intent {
398 Intent::Quit => {}
399 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
400 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
401 }
402 }
403
404 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
405 match self.session.resolve(choice) {
406 AfterChoice::Stay => {}
407 AfterChoice::SaveThen(intent) => match self.session.save() {
408 Ok(()) => self.finish_intent(intent, ctx),
409 Err(error) => self.session.fail(error.to_string()),
410 },
411 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
412 }
413 }
414
415 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
416 if intent == Intent::Quit {
417 self.allow_close = true;
418 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
419 } else {
420 self.run_intent(intent);
421 }
422 }
423
424 fn save(&mut self) {
425 if let Err(error) = self.session.save() {
426 self.session.fail(error.to_string());
427 }
428 }
429
430 fn apply_render_fields(&mut self) {
431 let width = self.width_text.parse::<u32>().unwrap_or(0);
432 let height = self.height_text.parse::<u32>().unwrap_or(0);
433 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
434 self.session.set_render(width, height, fps);
435 }
436
437 /// Open the body editor, also used by the native smoke example.
438 pub fn open_body_configuration(&mut self, sex: Sex) {
439 self.session.show_body(sex);
440 self.controls.sex = match sex {
441 Sex::Male => PreviewSex::Male,
442 Sex::Female => PreviewSex::Female,
443 };
444 }
445 pub fn render_error(&self) -> Option<&str> {
446 self.render_ui.error.as_deref()
447 }
448 /// Same as the **Live 3D** button: leave the pass preview, show the viewport.
449 pub fn show_live_3d(&mut self) {
450 self.render_ui.preview = None;
451 }
452 pub fn render_all_angles(&mut self) {
453 self.start_render();
454 }
455 /// Legacy smoke entry point, now starts the production batch as well.
456 pub fn render_current_pose(&mut self) {
457 self.render_all_angles();
458 }
459 pub fn render_is_running(&self) -> bool {
460 self.render_ui.running
461 }
462 pub fn render_progress(&self) -> (u32, u32, String) {
463 (
464 self.render_ui.progress.completed,
465 self.render_ui.progress.total,
466 self.render_ui.progress.label.clone(),
467 )
468 }
469 /// The job the Render action would start now (current project, body, equipment).
470 fn build_job(&mut self) -> Option<RenderJob> {
471 let sex = usize::from(self.controls.sex == PreviewSex::Female);
472 let equipment = match self.session.screen {
473 Screen::Equipment(i) => Some(i),
474 _ => None,
475 };
476 match self.job_for(sex, equipment) {
477 Ok(job) => Some(job),
478 Err(error) => {
479 self.session.fail(error);
480 None
481 }
482 }
483 }
484 /// Render job for one body (sex 0 = male, 1 = female) and optional equipment item.
485 fn job_for(&self, sex: usize, equipment: Option<usize>) -> Result<RenderJob, String> {
486 let opened = self.session.opened.as_ref().ok_or("geen project geopend")?;
487 opened
488 .document
489 .validate_shape()
490 .map_err(|e| e.to_string())?;
491 let character = match sex {
492 0 => self.preview_male.as_ref(),
493 _ => self.preview_female.as_ref(),
494 }
495 .cloned()
496 .ok_or("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model")?;
497 let root = self.session.project_root().ok_or("geen projectmap")?;
498 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
499 .map_err(|e| e.to_string())?;
500 let preset = match equipment {
501 Some(i) => Some(
502 self.session
503 .equipment
504 .get(i)
505 .and_then(|i| i.preset.clone())
506 .ok_or("configureer eerst het equipment")?,
507 ),
508 None => None,
509 };
510 let body_rel = match sex {
511 0 => opened.document.male_model.clone(),
512 _ => opened.document.female_model.clone(),
513 }
514 .ok_or("dit lichaam heeft geen modelpad")?;
515 crate::project::portable_relative(&body_rel)?;
516 let body_path = root.join(&body_rel);
517 let body_sha = crate::digest::sha256_file(&body_path, 32 * 1024 * 1024)
518 .map_err(|e| format!("body-fingerprint: {e}"))?;
519 let preset_sha256 = match &preset {
520 None => None,
521 Some(p) => Some(crate::digest::sha256_hex(
522 &p.encode()
523 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
524 )),
525 };
526 Ok(RenderJob {
527 character,
528 sex: if sex == 0 { "male" } else { "female" }.into(),
529 preset,
530 target_width: opened.document.render.target_width,
531 target_height: opened.document.render.target_height,
532 fps: opened.document.render.fps,
533 framing: opened.document.render.framing[sex],
534 output: root.join(&opened.document.output_directory),
535 project_id: opened.document.id.clone(),
536 project_name: opened.document.name.clone(),
537 body_model: crate::production::SourceFingerprint {
538 path: body_rel,
539 sha256: body_sha,
540 },
541 preset_sha256,
542 depth_format: crate::camera::DepthFormat::new(
543 opened.document.render.depth_bits,
544 opened.document.render.depth_encoding,
545 )?,
546 })
547 }
548 /// Every body (male, female) and then every equipment item per applicable
549 /// sex, plus items skipped before rendering (with the reason).
550 pub fn render_all_plan(&self) -> (Vec<RenderAllItem>, Vec<(String, String)>) {
551 let mut items = Vec::new();
552 let mut skipped = Vec::new();
553 let names = ["man", "vrouw"];
554 for (sex, name) in names.iter().enumerate() {
555 let label = format!("{name} · body");
556 if self
557 .session
558 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
559 .is_none()
560 {
561 skipped.push((label, "geen lichaam ingesteld".into()));
562 } else {
563 items.push(RenderAllItem {
564 sex,
565 equipment: None,
566 label,
567 });
568 }
569 }
570 for (index, item) in self.session.equipment.iter().enumerate() {
571 let Some(preset) = &item.preset else {
572 skipped.push((
573 format!("{} · equipment", item.slug),
574 item.error
575 .clone()
576 .unwrap_or_else(|| "equipment is niet geconfigureerd".into()),
577 ));
578 continue;
579 };
580 for (sex, name) in names.iter().enumerate() {
581 let label = format!("{name} · {}", item.slug);
582 if !preset.applies[sex] {
583 continue; // not applicable to this sex by design
584 }
585 if self
586 .session
587 .body_path(if sex == 0 { Sex::Male } else { Sex::Female })
588 .is_none()
589 {
590 skipped.push((label, "geen lichaam ingesteld voor dit geslacht".into()));
591 } else {
592 items.push(RenderAllItem {
593 sex,
594 equipment: Some(index),
595 label,
596 });
597 }
598 }
599 }
600 (items, skipped)
601 }
602 /// **Render alles**: queue every body and equipment export.
603 pub fn start_render_all(&mut self) {
604 if self.render_ui.running || self.render_all.is_some() {
605 return;
606 }
607 let (items, skipped) = self.render_all_plan();
608 self.render_all_summary = None;
609 self.render_all = Some(RenderAll {
610 total: items.len(),
611 queue: items.into(),
612 started: 0,
613 current: None,
614 summary: RenderAllSummary {
615 skipped,
616 ..Default::default()
617 },
618 });
619 self.render_all_next();
620 }
621 pub fn render_all_running(&self) -> bool {
622 self.render_all.is_some()
623 }
624 pub fn render_all_summary(&self) -> Option<&RenderAllSummary> {
625 self.render_all_summary.as_ref()
626 }
627 /// Start the next renderable queue item; items that cannot be built or fail
628 /// the render contract are skipped with their diagnostic. A matching
629 /// interrupted run of the item is resumed instead of starting over.
630 fn render_all_next(&mut self) {
631 loop {
632 let Some(state) = self.render_all.as_mut() else {
633 return;
634 };
635 let Some(item) = state.queue.pop_front() else {
636 let mut done = self.render_all.take().unwrap().summary;
637 done.cancelled = false;
638 self.finish_render_all(done);
639 return;
640 };
641 state.started += 1;
642 let job = self.job_for(item.sex, item.equipment).and_then(|job| {
643 let output = job.output.clone();
644 let contract = crate::production::contract(job.clone())?;
645 let resume = crate::resume::candidates(&output, &contract.render_doc)
646 .ok()
647 .and_then(|c| c.into_iter().find(|c| c.blocked.is_none()))
648 .map(|c| c.dir);
649 Ok((job, resume))
650 });
651 let state = self.render_all.as_mut().unwrap();
652 match job {
653 Err(reason) => state.summary.skipped.push((item.label, reason)),
654 Ok((job, resume)) => {
655 state.current = Some(item.label);
656 let worker = match resume {
657 Some(dir) => crate::render_job::spawn_resume(job, dir, None),
658 None => spawn(job, None),
659 };
660 self.render_worker = Some(worker);
661 self.begin_worker();
662 return;
663 }
664 }
665 }
666 }
667 /// Record the outcome of the current item and continue (or stop on cancel).
668 fn render_all_item_done(&mut self) {
669 let Some(state) = self.render_all.as_mut() else {
670 return;
671 };
672 let label = state.current.take().unwrap_or_default();
673 if self.render_ui.saved.is_some() {
674 state.summary.rendered.push(label);
675 } else if let Some(error) = &self.render_ui.error {
676 state.summary.failed.push((label, error.clone()));
677 } else {
678 // Cancelled: stop the queue, the item stays resumable.
679 let state = self.render_all.take().unwrap();
680 let mut summary = state.summary;
681 summary
682 .failed
683 .push((label, "geannuleerd (hervatbaar)".into()));
684 summary.cancelled = true;
685 summary
686 .not_started
687 .extend(state.queue.into_iter().map(|i| i.label));
688 self.finish_render_all(summary);
689 return;
690 }
691 self.render_all_next();
692 }
693 fn finish_render_all(&mut self, summary: RenderAllSummary) {
694 self.session.notice = summary.text();
695 self.session.notice_ok = summary.failed.is_empty() && !summary.cancelled;
696 self.render_all_summary = Some(summary);
697 }
698 fn render_all_ui(&mut self, ui: &mut egui::Ui) {
699 if let Some(state) = &self.render_all {
700 ui.label(format!(
701 "Render alles: item {}/{} · {}",
702 state.started,
703 state.total,
704 state.current.as_deref().unwrap_or("")
705 ));
706 }
707 if let Some(summary) = &self.render_all_summary {
708 ui.label(summary.text());
709 for label in &summary.rendered {
710 ui.label(format!("✔ {label}"));
711 }
712 for (label, reason) in &summary.skipped {
713 ui.weak(format!("overgeslagen: {label} — {reason}"));
714 }
715 for (label, reason) in &summary.failed {
716 ui.colored_label(
717 egui::Color32::LIGHT_RED,
718 format!("mislukt: {label} — {reason}"),
719 );
720 }
721 for label in &summary.not_started {
722 ui.weak(format!("niet gestart: {label}"));
723 }
724 }
725 }
726 fn start_render(&mut self) {
727 if self.render_ui.running {
728 return;
729 }
730 let Some(job) = self.build_job() else {
731 return;
732 };
733 self.render_worker = Some(spawn(job, None));
734 self.begin_worker();
735 }
736 fn begin_worker(&mut self) {
737 self.render_ui = RenderUiState::idle();
738 self.pass_texture = None;
739 self.render_ui.running = true;
740 self.resume_candidates.clear();
741 self.resume_scanned = false;
742 }
743 /// Find incomplete runs of the current body/equipment set and check their contract.
744 pub fn scan_resumable_runs(&mut self) {
745 self.resume_scanned = true;
746 self.resume_candidates.clear();
747 self.resume_scan_error = None;
748 let Some(job) = self.build_job() else {
749 self.resume_scan_error = Some("geen renderbare job voor deze weergave".into());
750 return;
751 };
752 let output = job.output.clone();
753 let result = crate::production::contract(job)
754 .and_then(|c| crate::resume::candidates(&output, &c.render_doc));
755 match result {
756 Ok(found) => self.resume_candidates = found,
757 Err(error) => self.resume_scan_error = Some(error),
758 }
759 }
760 pub fn resumable_runs(&self) -> &[crate::resume::ResumeCandidate] {
761 &self.resume_candidates
762 }
763 /// Resume one incomplete run; the worker re-checks contract, lock and checksums.
764 pub fn resume_run(&mut self, dir: PathBuf) {
765 if self.render_ui.running {
766 return;
767 }
768 let Some(job) = self.build_job() else {
769 return;
770 };
771 self.render_worker = Some(crate::render_job::spawn_resume(job, dir, None));
772 self.begin_worker();
773 }
774 /// Request cancellation of the running batch (same as the GUI button).
775 pub fn request_render_cancel(&mut self) {
776 self.cancel_render();
777 }
778 fn resume_ui(&mut self, ui: &mut egui::Ui) {
779 ui.horizontal_wrapped(|ui| {
780 ui.label("Onderbroken renders:");
781 if ui
782 .add_enabled(
783 !self.render_ui.running,
784 egui::Button::new("Zoek onderbroken runs"),
785 )
786 .on_hover_text(
787 "Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",
788 )
789 .clicked()
790 {
791 self.scan_resumable_runs();
792 }
793 if self.resume_scanned && self.resume_candidates.is_empty() {
794 ui.weak("geen gevonden voor deze set");
795 }
796 });
797 if let Some(error) = &self.resume_scan_error {
798 ui.colored_label(egui::Color32::LIGHT_RED, error);
799 }
800 let mut chosen = None;
801 for candidate in &self.resume_candidates {
802 ui.horizontal_wrapped(|ui| {
803 ui.label(format!(
804 "{} · {} · {} van {} PNG's vastgelegd",
805 candidate.name, candidate.state, candidate.journaled, candidate.planned
806 ));
807 match &candidate.blocked {
808 None => {
809 if ui
810 .add_enabled(!self.render_ui.running, egui::Button::new("Hervat"))
811 .clicked()
812 {
813 chosen = Some(candidate.dir.clone());
814 }
815 }
816 Some(reason) => {
817 ui.weak(reason);
818 }
819 }
820 });
821 }
822 if let Some(dir) = chosen {
823 self.resume_run(dir);
824 }
825 }
826
827 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
828 let request = self
829 .render_worker
830 .as_ref()
831 .and_then(|worker| worker.gpu.try_recv().ok());
832 if let Some(request) = request {
833 if let Some(gl) = gl {
834 unsafe {
835 request.service(&mut self.gpu_renderer, gl);
836 }
837 } else {
838 request.fail("Glow-context ontbreekt".into());
839 }
840 }
841 }
842 fn drain_render(&mut self) {
843 let Some(worker) = &self.render_worker else {
844 return;
845 };
846 let ended = worker
847 .handle
848 .as_ref()
849 .map(|h| h.is_finished())
850 .unwrap_or(true);
851 let events = worker.mailbox.drain();
852 let mut terminal = false;
853 for event in events {
854 terminal |= matches!(
855 event,
856 RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled
857 );
858 if let RenderEvent::Finished { saved: Some(path) } = &event {
859 self.session.notice = format!("Batch opgeslagen: {path}");
860 self.session.notice_ok = true;
861 }
862 if let RenderEvent::Failed(error) = &event {
863 self.session.fail(error.clone());
864 }
865 self.render_ui.apply(event);
866 }
867 if terminal {
868 self.render_worker = None;
869 if self.render_all.is_some() {
870 self.render_all_item_done();
871 } else if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
872 // Cancelled: offer the run for resumption right away.
873 self.scan_resumable_runs();
874 }
875 } else if ended {
876 self.render_ui.apply(RenderEvent::Failed(
877 "De renderworker stopte zonder eindresultaat.".into(),
878 ));
879 self.render_worker = None;
880 self.render_all_item_done();
881 }
882 }
883 fn cancel_render(&mut self) {
884 if let Some(worker) = &self.render_worker {
885 worker
886 .cancel
887 .store(true, std::sync::atomic::Ordering::Relaxed);
888 }
889 }
890 fn render_progress_ui(&mut self, ui: &mut egui::Ui) {
891 if self.render_ui.progress.total > 0 {
892 ui.add(
893 egui::ProgressBar::new(
894 self.render_ui.progress.completed as f32
895 / self.render_ui.progress.total.max(1) as f32,
896 )
897 .text(format!(
898 "{} / {} · {}",
899 self.render_ui.progress.completed,
900 self.render_ui.progress.total,
901 self.render_ui.progress.label
902 )),
903 );
904 }
905 if self.render_ui.running && ui.button("Annuleer render").clicked() {
906 self.cancel_render();
907 }
908 }
909 fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) {
910 let diagnostic = {
911 let framing = self
912 .session
913 .opened
914 .as_ref()
915 .map(|opened| opened.document.render.framing[sex]);
916 let body = match sex {
917 0 => self.preview_male.as_ref(),
918 _ => self.preview_female.as_ref(),
919 };
920 match (body, framing) {
921 (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)),
922 _ => None,
923 }
924 };
925 if let Some(opened) = &mut self.session.opened {
926 let camera = &mut opened.document.render.framing[sex];
927 let mut height = camera.height_milli as f32 / 1000.;
928 let mut center = camera.center_y_milli as f32 / 1000.;
929 let mut pivot = diagnostic
930 .as_ref()
931 .map(|resolved| {
932 resolved
933 .framing
934 .pivot_y_milli
935 .unwrap_or(resolved.framing.center_y_milli) as f32
936 / 1000.
937 })
938 .unwrap_or_else(|| {
939 camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
940 });
941 ui.horizontal_wrapped(|ui| {
942 ui.label("Vaste renderhoogte (m)");
943 if ui
944 .add(
945 egui::DragValue::new(&mut height)
946 .speed(0.01)
947 .range(0.1..=10.),
948 )
949 .changed()
950 {
951 camera.height_milli = (height * 1000.).round() as u32;
952 }
953 ui.label("Centrum Y (m)");
954 if ui
955 .add(
956 egui::DragValue::new(&mut center)
957 .speed(0.01)
958 .range(-10.0..=10.0),
959 )
960 .changed()
961 {
962 camera.center_y_milli = (center * 1000.).round() as i32;
963 }
964 ui.label("Pivot Y (m)");
965 if ui
966 .add(
967 egui::DragValue::new(&mut pivot)
968 .speed(0.01)
969 .range(-10.0..=10.0),
970 )
971 .changed()
972 {
973 camera.pivot_y_milli = Some((pivot * 1000.).round() as i32);
974 }
975 if camera.pivot_y_milli.is_some()
976 && ui
977 .button("Auto nek")
978 .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
979 .clicked()
980 {
981 camera.pivot_y_milli = None;
982 }
983 ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
984 });
985 let auto_range = diagnostic
986 .as_ref()
987 .and_then(|r| r.framing.depth_range_milli)
988 .unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);
989 ui.horizontal_wrapped(|ui| {
990 ui.label("Dieptebereik (m)");
991 let mut auto = camera.depth_range_milli.is_none();
992 if ui
993 .checkbox(&mut auto, "automatisch")
994 .on_hover_text(
995 "T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot",
996 )
997 .changed()
998 {
999 camera.depth_range_milli = if auto { None } else { Some(auto_range) };
1000 }
1001 let mut meters =
1002 camera.depth_range_milli.unwrap_or(auto_range) as f32 / 1000.;
1003 if ui
1004 .add_enabled(
1005 camera.depth_range_milli.is_some(),
1006 egui::DragValue::new(&mut meters)
1007 .speed(0.01)
1008 .range(0.1..=19.0),
1009 )
1010 .changed()
1011 {
1012 camera.depth_range_milli = Some((meters * 1000.).round() as u32);
1013 }
1014 let (near, far) = crate::camera::depth_range(crate::project::CameraFraming {
1015 depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),
1016 ..*camera
1017 });
1018 ui.weak(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"));
1019 });
1020 if let Some(resolved) = &diagnostic {
1021 ui.label(resolved.description());
1022 } else {
1023 ui.label("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.");
1024 }
1025 }
1026 }
1027 /// Depth PNG settings (bit depth 8/16, encoding), as set by the GUI.
1028 pub fn set_depth_format(&mut self, bits: u8, encoding: crate::camera::DepthEncoding) {
1029 if let Some(opened) = &mut self.session.opened {
1030 opened.document.render.depth_bits = bits;
1031 opened.document.render.depth_encoding = encoding;
1032 }
1033 }
1034 pub fn depth_format(&self) -> Option<(u8, crate::camera::DepthEncoding)> {
1035 let r = &self.session.opened.as_ref()?.document.render;
1036 Some((r.depth_bits, r.depth_encoding))
1037 }
1038 fn depth_format_ui(&mut self, ui: &mut egui::Ui) {
1039 let Some((mut bits, mut encoding)) = self.depth_format() else {
1040 return;
1041 };
1042 ui.horizontal_wrapped(|ui| {
1043 ui.label("Depth-map");
1044 egui::ComboBox::from_id_salt("depth_bits")
1045 .selected_text(format!("{bits}-bit"))
1046 .show_ui(ui, |ui| {
1047 ui.selectable_value(&mut bits, 8, "8-bit");
1048 ui.selectable_value(&mut bits, 16, "16-bit");
1049 });
1050 egui::ComboBox::from_id_salt("depth_encoding")
1051 .selected_text(encoding.label())
1052 .show_ui(ui, |ui| {
1053 for e in crate::camera::DepthEncoding::ALL {
1054 ui.selectable_value(&mut encoding, e, e.label());
1055 }
1056 });
1057 if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {
1058 ui.weak(format!("{} niveaus", fmt.levels() + 1));
1059 }
1060 });
1061 if encoding == crate::camera::DepthEncoding::RgbPacked {
1062 ui.weak("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).");
1063 }
1064 self.set_depth_format(bits, encoding);
1065 }
1066 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
1067 if !self.show_render_frame {
1068 return None;
1069 }
1070 let r = &self.session.opened.as_ref()?.document.render;
1071 let sex = usize::from(self.controls.sex == PreviewSex::Female);
1072 Some((r.framing[sex], [r.target_width, r.target_height]))
1073 }
1074}
1075
1076impl Default for EditorApp {
1077 fn default() -> Self {
1078 Self::new(Box::new(SystemDialogs::new()))
1079 }
1080}
1081
1082impl eframe::App for EditorApp {
1083 fn on_exit(&mut self, gl: Option<&glow::Context>) {
1084 self.render_worker = None;
1085 if let Some(gl) = gl {
1086 unsafe {
1087 self.gpu_renderer.destroy(gl);
1088 }
1089 self.viewport.destroy(gl);
1090 }
1091 }
1092 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
1093 self.service_gpu(frame.gl());
1094 let shell = self.session.shell_size();
1095 if self.applied_shell != Some(shell) {
1096 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
1097 shell[0], shell[1],
1098 )));
1099 let min = if shell == crate::session::START_WINDOW {
1100 egui::vec2(420.0, 220.0)
1101 } else {
1102 egui::vec2(900.0, 560.0)
1103 };
1104 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
1105 self.applied_shell = Some(shell);
1106 }
1107 if ctx.input(|input| input.viewport().close_requested())
1108 && !self.allow_close
1109 && self.session.confirm.is_none()
1110 {
1111 if !self.session.gate(Intent::Quit) {
1112 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
1113 }
1114 } else if self.session.confirm.is_some()
1115 && ctx.input(|input| input.viewport().close_requested())
1116 {
1117 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
1118 }
1119 self.poll_dialogs();
1120 self.drain_render();
1121 if self.render_ui.running
1122 || matches!(self.session.screen, Screen::Preview2d)
1123 && (self.preview2d.playing
1124 || self.preview2d.hold_azimuth.is_some()
1125 || self.preview2d.hold_elevation.is_some())
1126 {
1127 ctx.request_repaint();
1128 }
1129 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
1130 let color = if self.session.notice_ok {
1131 egui::Color32::LIGHT_GREEN
1132 } else {
1133 egui::Color32::LIGHT_RED
1134 };
1135 ui.colored_label(color, &self.session.notice);
1136 });
1137 match self.session.screen {
1138 Screen::Start => self.show_start(ctx),
1139 Screen::Bodies => self.show_bodies(ctx),
1140 Screen::Body(_) => self.show_body(ctx),
1141 Screen::EquipmentList => self.show_equipment_list(ctx),
1142 Screen::Equipment(_) => self.show_equipment(ctx),
1143 Screen::Preview2d => self.show_preview2d(ctx),
1144 }
1145 self.show_confirm(ctx);
1146 if self.dialog_pending {
1147 ctx.request_repaint_after(std::time::Duration::from_millis(50));
1148 }
1149 }
1150}
1151
1152impl EditorApp {
1153 fn show_confirm(&mut self, ctx: &egui::Context) {
1154 let Some(_intent) = self.session.confirm else {
1155 return;
1156 };
1157 let mut choice = None;
1158 egui::Window::new("Wijzigingen")
1159 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
1160 .collapsible(false)
1161 .resizable(false)
1162 .show(ctx, |ui| {
1163 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
1164 ui.horizontal(|ui| {
1165 if ui.button("Opslaan").clicked() {
1166 choice = Some(crate::session::Choice::Save);
1167 }
1168 if ui.button("Niet opslaan").clicked() {
1169 choice = Some(crate::session::Choice::Discard);
1170 }
1171 if ui.button("Annuleren").clicked() {
1172 choice = Some(crate::session::Choice::Cancel);
1173 }
1174 });
1175 });
1176 if let Some(choice) = choice {
1177 self.apply_choice(choice, ctx);
1178 }
1179 }
1180
1181 fn show_start(&mut self, ctx: &egui::Context) {
1182 egui::CentralPanel::default().show(ctx, |ui| {
1183 if self.session.confirm.is_some() || self.dialog_pending {
1184 ui.disable();
1185 }
1186 ui.heading("Paperdoll Renderer");
1187 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
1188 ui.add_space(12.0);
1189 if ui.button("Project laden").clicked() {
1190 self.begin(Intent::PickLoad);
1191 }
1192 if ui.button("Nieuw project").clicked() {
1193 self.begin(Intent::PickNew);
1194 }
1195 });
1196 }
1197
1198 fn show_bodies(&mut self, ctx: &egui::Context) {
1199 self.toolbar(ctx, true);
1200 egui::CentralPanel::default().show(ctx, |ui| {
1201 if self.session.confirm.is_some() || self.dialog_pending {
1202 ui.disable();
1203 }
1204 ui.heading("Body");
1205 if let Some(opened) = &mut self.session.opened {
1206 ui.horizontal(|ui| {
1207 ui.label("Naam");
1208 ui.text_edit_singleline(&mut opened.document.name);
1209 });
1210 }
1211 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
1212 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
1213 ui.add_space(8.0);
1214 ui.horizontal(|ui| {
1215 ui.label("Breedte");
1216 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
1217 ui.label("Hoogte");
1218 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
1219 ui.label("fps");
1220 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
1221 });
1222 self.apply_render_fields();
1223 self.depth_format_ui(ui);
1224 ui.add_space(8.0);
1225 ui.horizontal_wrapped(|ui| {
1226 if ui
1227 .add_enabled(
1228 !self.render_ui.running && self.render_all.is_none(),
1229 egui::Button::new("Render alles"),
1230 )
1231 .on_hover_text(
1232 "Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.",
1233 )
1234 .clicked()
1235 {
1236 self.start_render_all();
1237 }
1238 });
1239 self.render_all_ui(ui);
1240 self.render_progress_ui(ui);
1241 // Same live pass preview as a single body/equipment render (t990u).
1242 if self.render_ui.preview.is_some() {
1243 self.paint_pass(ui);
1244 }
1245 ui.add_space(8.0);
1246 if ui.button("2D-preview").clicked() {
1247 self.open_preview2d();
1248 }
1249 ui.label("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.");
1250 });
1251 }
1252
1253 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
1254 ui.horizontal(|ui| {
1255 ui.label(title);
1256 let path = self
1257 .session
1258 .body_path(sex)
1259 .unwrap_or("geen model")
1260 .to_string();
1261 ui.label(path);
1262 if ui.button(pick).clicked() {
1263 self.dialogs.start(DialogPurpose::Body(sex));
1264 }
1265 if ui.button(open).clicked() {
1266 self.controls.sex = match sex {
1267 Sex::Male => PreviewSex::Male,
1268 Sex::Female => PreviewSex::Female,
1269 };
1270 self.session.show_body(sex);
1271 }
1272 });
1273 let index = if sex == Sex::Male { 0 } else { 1 };
1274 if let Some(inventory) = &self.inventories[index] {
1275 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
1276 Ok(model) => {
1277 for mesh in &model.meshes {
1278 ui.label(format!(
1279 "{}: {} vertices, {} driehoeken",
1280 mesh.name, mesh.vertex_count, mesh.triangle_count
1281 ));
1282 }
1283 for material in &model.materials {
1284 ui.label(format!("materiaal {}", material.name));
1285 }
1286 for texture in &model.textures {
1287 ui.label(format!(
1288 "texture {}: {}×{}",
1289 texture.source, texture.width, texture.height
1290 ));
1291 }
1292 ui.label(format!("{} joints", model.joints.len()));
1293 for clip in &model.clips {
1294 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
1295 }
1296 for notice in &model.notices {
1297 ui.label(notice);
1298 }
1299 }
1300 Err(message) => {
1301 ui.colored_label(egui::Color32::LIGHT_RED, message);
1302 }
1303 });
1304 }
1305 }
1306
1307 fn show_body(&mut self, ctx: &egui::Context) {
1308 self.toolbar(ctx, true);
1309 let sex = match self.session.screen {
1310 Screen::Body(sex) => sex,
1311 _ => Sex::Male,
1312 };
1313 self.controls.sex = match sex {
1314 Sex::Male => PreviewSex::Male,
1315 Sex::Female => PreviewSex::Female,
1316 };
1317 egui::CentralPanel::default().show(ctx, |ui| {
1318 if self.session.confirm.is_some() || self.dialog_pending {
1319 ui.disable();
1320 }
1321 ui.horizontal_wrapped(|ui| {
1322 if ui.button("Terug").clicked() {
1323 self.session.show_bodies();
1324 }
1325 if ui.button("Equipment").clicked() {
1326 self.session.show_equipment_list();
1327 }
1328 if ui
1329 .add_enabled(
1330 !self.render_ui.running,
1331 egui::Button::new("Render alle hoeken"),
1332 )
1333 .clicked()
1334 {
1335 self.start_render();
1336 }
1337 if ui.button("Live 3D").clicked() {
1338 self.render_ui.preview = None;
1339 }
1340 if ui.button("2D-preview").clicked() {
1341 self.open_preview2d();
1342 }
1343 });
1344 if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {
1345 ui.colored_label(
1346 egui::Color32::from_rgb(230, 190, 90),
1347 format!(
1348 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."
1349 ),
1350 );
1351 }
1352 self.framing_ui(ui, usize::from(sex == Sex::Female));
1353 self.render_progress_ui(ui);
1354 self.resume_ui(ui);
1355 if self.render_ui.preview.is_some() {
1356 self.paint_pass(ui);
1357 } else {
1358 let character = match self.controls.sex {
1359 PreviewSex::Male => self.preview_male.as_ref(),
1360 PreviewSex::Female => self.preview_female.as_ref(),
1361 };
1362 if character.is_some() {
1363 let framing = self.fixed_camera();
1364 self.viewport
1365 .show_framed(ui, &mut self.controls, character, &[], framing);
1366 } else {
1367 ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");
1368 }
1369 }
1370 });
1371 }
1372
1373 fn paint_pass(&mut self, ui: &mut egui::Ui) {
1374 let Some(image) = &self.render_ui.preview else {
1375 return;
1376 };
1377 ui.label(format!(
1378 "Laatste voltooide pass: {}",
1379 self.render_ui.preview_label
1380 ));
1381 if self.pass_texture.is_none()
1382 || self.pass_texture_revision != self.render_ui.preview_revision
1383 {
1384 let color = egui::ColorImage::from_rgba_unmultiplied(
1385 [image.width as usize, image.height as usize],
1386 &image.rgba,
1387 );
1388 let options = egui::TextureOptions::NEAREST;
1389 if let Some(texture) = &mut self.pass_texture {
1390 texture.set(color, options);
1391 } else {
1392 self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
1393 }
1394 self.pass_texture_revision = self.render_ui.preview_revision;
1395 }
1396 let texture = self.pass_texture.as_ref().expect("pass texture installed");
1397 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1398 let fit = crate::render_job::fit_preview(
1399 [image.width, image.height],
1400 [bounds.width(), bounds.height()],
1401 );
1402 let rect = egui::Rect::from_min_size(
1403 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1404 egui::vec2(fit.size[0], fit.size[1]),
1405 );
1406 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1407 ui.painter().image(
1408 texture.id(),
1409 rect,
1410 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1411 egui::Color32::WHITE,
1412 );
1413 }
1414
1415 fn show_equipment_list(&mut self, ctx: &egui::Context) {
1416 self.toolbar(ctx, true);
1417 egui::CentralPanel::default().show(ctx, |ui| {
1418 if self.session.confirm.is_some() || self.dialog_pending {
1419 ui.disable();
1420 }
1421 ui.heading(format!(
1422 "Bodies → {} → Equipment",
1423 match self.session.active_body {
1424 Some(Sex::Male) => "Man",
1425 Some(Sex::Female) => "Vrouw",
1426 None => "Body",
1427 }
1428 ));
1429 if ui.button("Terug naar body").clicked() {
1430 self.session.back();
1431 }
1432 let mut open = None;
1433 for (index, item) in self.session.equipment.iter().enumerate() {
1434 let label = match &item.model {
1435 Some(model) => format!("{} {model}", item.slug),
1436 None => item.slug.clone(),
1437 };
1438 if ui.button(label).clicked() {
1439 open = Some(index);
1440 }
1441 }
1442 if ui
1443 .add_enabled(
1444 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1445 egui::Button::new("Nieuw"),
1446 )
1447 .clicked()
1448 {
1449 self.equipment_part = 0;
1450 self.session.add_equipment();
1451 }
1452 if ui
1453 .add_enabled(
1454 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1455 egui::Button::new("Preset importeren"),
1456 )
1457 .clicked()
1458 {
1459 self.dialogs.start(DialogPurpose::ImportEquipment);
1460 }
1461 if let Some(index) = open {
1462 self.session.show_equipment(index);
1463 }
1464 });
1465 }
1466
1467 fn show_equipment(&mut self, ctx: &egui::Context) {
1468 self.toolbar(ctx, true);
1469 let Screen::Equipment(index) = self.session.screen else {
1470 return;
1471 };
1472 let mut sex = if self.session.active_body == Some(Sex::Female) {
1473 1
1474 } else {
1475 0
1476 };
1477 egui::SidePanel::left("equipment-config")
1478 .default_width(330.0)
1479 .resizable(true)
1480 .show(ctx, |ui| {
1481 if self.session.confirm.is_some() || self.dialog_pending {
1482 ui.disable();
1483 }
1484 egui::ScrollArea::vertical().show(ui, |ui| {
1485 ui.heading("Equipment-configuratie");
1486 if ui.button("Terug naar equipment").clicked() {
1487 self.session.show_equipment_list();
1488 }
1489 ui.horizontal(|ui| {
1490 ui.selectable_value(&mut sex, 0, "Man");
1491 ui.selectable_value(&mut sex, 1, "Vrouw");
1492 });
1493 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1494 let Some(item) = self.session.equipment.get_mut(index) else {
1495 return;
1496 };
1497 ui.label("Naam (kleine letters/cijfers/-/_)");
1498 ui.text_edit_singleline(&mut item.slug);
1499 if !crate::equipment::valid_name(&item.slug) {
1500 ui.colored_label(
1501 egui::Color32::LIGHT_RED,
1502 "Ongeldige naam; 'body' is gereserveerd.",
1503 );
1504 }
1505 if let Some(error) = &item.error {
1506 ui.colored_label(egui::Color32::LIGHT_RED, error);
1507 }
1508 if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {
1509 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1510 }
1511 if let Some(preset) = &mut item.preset {
1512 ui.horizontal(|ui| {
1513 ui.label("Geldig voor:");
1514 ui.checkbox(&mut preset.applies[0], "Man");
1515 ui.checkbox(&mut preset.applies[1], "Vrouw");
1516 });
1517 if !preset.applies[0] && !preset.applies[1] {
1518 ui.colored_label(
1519 egui::Color32::LIGHT_RED,
1520 "Kies minstens één geslacht.",
1521 );
1522 }
1523 ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1524 ui.add(
1525 egui::TextEdit::multiline(&mut preset.provenance)
1526 .desired_rows(2)
1527 .char_limit(4096),
1528 );
1529 if preset.provenance.len() > 4096 {
1530 ui.colored_label(
1531 egui::Color32::LIGHT_RED,
1532 "Herkomst/licentie is te lang.",
1533 );
1534 }
1535 if !preset.attachments.is_empty() {
1536 self.equipment_part =
1537 self.equipment_part.min(preset.attachments.len() - 1);
1538 egui::ComboBox::from_id_salt("equipment-part")
1539 .selected_text(&preset.attachments[self.equipment_part].label)
1540 .show_ui(ui, |ui| {
1541 for (part, item) in preset.attachments.iter().enumerate() {
1542 ui.selectable_value(
1543 &mut self.equipment_part,
1544 part,
1545 &item.label,
1546 );
1547 }
1548 });
1549 let body = if sex == 0 {
1550 self.preview_male.as_ref()
1551 } else {
1552 self.preview_female.as_ref()
1553 };
1554 let attachment = &mut preset.attachments[self.equipment_part];
1555 ui.label("Positie relatief aan bot (XYZ)");
1556 ui.horizontal(|ui| {
1557 for value in &mut attachment.translation {
1558 ui.add(
1559 egui::DragValue::new(value)
1560 .speed(0.005)
1561 .range(-1000.0..=1000.0),
1562 );
1563 }
1564 });
1565 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1566 ui.horizontal(|ui| {
1567 for value in &mut attachment.rotation_deg {
1568 ui.add(
1569 egui::DragValue::new(value)
1570 .speed(0.5)
1571 .range(-3600.0..=3600.0),
1572 );
1573 }
1574 });
1575 ui.add(
1576 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1577 .text("Glanssterkte"),
1578 );
1579 let shiny = attachment
1580 .shiny
1581 .and_then(|i| preset.assets.get(i))
1582 .map(|a| a.path.clone());
1583 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1584 if ui.button("Kies shinymap (PNG)").clicked() {
1585 self.dialogs.start(DialogPurpose::EquipmentShiny(
1586 index,
1587 self.equipment_part,
1588 ));
1589 }
1590 let clear_shiny = ui.button("Wis shinymap").clicked();
1591 let selected = attachment.bones[sex].clone();
1592 if clear_shiny {
1593 if let Err(error) =
1594 crate::equipment::clear_shiny(preset, self.equipment_part)
1595 {
1596 item.error = Some(error);
1597 }
1598 }
1599 ui.label(format!(
1600 "Bot: {}",
1601 selected.as_deref().unwrap_or("nog niet gekoppeld")
1602 ));
1603 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1604 ui.add_enabled_ui(false, |ui| {
1605 ui.collapsing("Skeleton / botten", |ui| {
1606 ui.label("Geen botten");
1607 });
1608 });
1609 ui.colored_label(
1610 egui::Color32::LIGHT_RED,
1611 format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),
1612 );
1613 } else if let Some(body) = body {
1614 if preset.rigs[sex].as_ref()
1615 != Some(&crate::equipment::RigSignature::of(body))
1616 {
1617 ui.colored_label(
1618 egui::Color32::LIGHT_RED,
1619 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1620 );
1621 }
1622 ui.collapsing("Skeleton / botten", |ui| {
1623 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1624 if let Err(error) =
1625 preset.bind(sex, body, self.equipment_part, joint)
1626 {
1627 ui.colored_label(egui::Color32::LIGHT_RED, error);
1628 }
1629 }
1630 });
1631 } else {
1632 ui.colored_label(
1633 egui::Color32::LIGHT_RED,
1634 "Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",
1635 );
1636 }
1637 if ui.button("Verwijder onderdeel").clicked() {
1638 if let Err(error) =
1639 crate::equipment::remove_attachment(preset, self.equipment_part)
1640 {
1641 item.error = Some(error);
1642 }
1643 self.equipment_part = self.equipment_part.saturating_sub(1);
1644 }
1645 }
1646 }
1647 if ui.button("Verwijder equipmentdefinitie").clicked() {
1648 self.session.delete_equipment(index);
1649 }
1650 });
1651 });
1652 self.controls.sex = if sex == 0 {
1653 PreviewSex::Male
1654 } else {
1655 PreviewSex::Female
1656 };
1657 egui::CentralPanel::default().show(ctx, |ui| {
1658 if self.session.confirm.is_some() || self.dialog_pending {
1659 ui.disable();
1660 }
1661 ui.horizontal_wrapped(|ui| {
1662 let static_body = self.character().is_some_and(PreviewCharacter::is_static);
1663 if ui
1664 .add_enabled(
1665 !self.render_ui.running && !static_body,
1666 egui::Button::new("Render equipment · alle hoeken"),
1667 )
1668 .on_disabled_hover_text(
1669 "Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",
1670 )
1671 .clicked()
1672 {
1673 self.start_render();
1674 }
1675 if ui.button("Live 3D").clicked() {
1676 self.render_ui.preview = None;
1677 }
1678 if ui.button("2D-preview").clicked() {
1679 self.open_preview2d();
1680 }
1681 });
1682 self.render_progress_ui(ui);
1683 self.resume_ui(ui);
1684 if self.render_ui.preview.is_some() {
1685 self.paint_pass(ui);
1686 return;
1687 }
1688 let framing = self.fixed_camera();
1689 let body = if sex == 0 {
1690 self.preview_male.as_ref()
1691 } else {
1692 self.preview_female.as_ref()
1693 };
1694 let preset = self
1695 .session
1696 .equipment
1697 .get(index)
1698 .and_then(|item| item.preset.as_ref());
1699 // A static body has no bones; equipment layers are not attached
1700 // to an invented skeleton, so the live view shows only the body.
1701 let presets: Vec<_> = preset
1702 .into_iter()
1703 .filter(|_| !body.is_some_and(PreviewCharacter::is_static))
1704 .collect();
1705 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1706 ui.colored_label(
1707 egui::Color32::from_rgb(230, 190, 90),
1708 format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),
1709 );
1710 }
1711 self.viewport
1712 .show_framed(ui, &mut self.controls, body, &presets, framing);
1713 });
1714 }
1715
1716 pub fn open_preview2d(&mut self) {
1717 self.refresh_preview2d_catalog();
1718 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1719 self.preview2d_image = None;
1720 self.preview2d_texture = None;
1721 self.preview2d_texture_key = None;
1722 self.session.show_preview2d();
1723 }
1724
1725 pub fn preview2d_state(&self) -> &Preview2dState {
1726 &self.preview2d
1727 }
1728
1729 /// Mutable 2D-preview state (view/playback), e.g. for GUI smokes.
1730 pub fn preview2d_state_mut(&mut self) -> &mut Preview2dState {
1731 &mut self.preview2d
1732 }
1733
1734 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1735 self.preview2d_image.as_ref()
1736 }
1737
1738 fn refresh_preview2d_catalog(&mut self) {
1739 (self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {
1740 Some(root) => discover_exports(&root),
1741 None => (Vec::new(), Vec::new()),
1742 };
1743 self.preview2d_loaded = None;
1744 }
1745
1746 fn rebuild_preview2d_frame(&mut self) {
1747 let sex = self.preview2d.sex;
1748 let equipment = self.preview2d.selected_equipment.clone();
1749 let clip = self.preview2d.clip_token.clone();
1750 let frame = self.preview2d.frame_index();
1751 let pitch = self.preview2d.pitch_deg();
1752 let yaw = self.preview2d.yaw_deg();
1753 let light = self.preview2d.light();
1754 let view_mode = self.preview2d.view_mode;
1755 if clip.is_empty() {
1756 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1757 self.preview2d_image = None;
1758 return;
1759 }
1760 let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {
1761 Ok(paths) => paths,
1762 Err(err) => {
1763 self.preview2d.diagnostic = Some(err.to_string());
1764 self.preview2d_image = None;
1765 return;
1766 }
1767 };
1768 if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {
1769 self.preview2d_loaded = None;
1770 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1771 Ok((body, gear)) => {
1772 self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })
1773 }
1774 Err(err) => {
1775 self.preview2d.diagnostic = Some(err.to_string());
1776 self.preview2d_image = None;
1777 return;
1778 }
1779 }
1780 }
1781 match self.preview2d_loaded.as_ref().ok_or("geen selectie") {
1782 Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {
1783 body,
1784 equipment: gear,
1785 clip_token: &clip,
1786 frame,
1787 pitch,
1788 yaw,
1789 light,
1790 view_mode,
1791 }) {
1792 Ok(image) => {
1793 self.preview2d.diagnostic = None;
1794 self.preview2d_image = Some(image);
1795 }
1796 Err(err) => {
1797 self.preview2d.diagnostic = Some(err.to_string());
1798 self.preview2d_image = None;
1799 }
1800 },
1801 Err(err) => {
1802 self.preview2d.diagnostic = Some(err.to_string());
1803 self.preview2d_image = None;
1804 }
1805 }
1806 }
1807
1808 fn show_preview2d(&mut self, ctx: &egui::Context) {
1809 self.toolbar(ctx, true);
1810 let dt = ctx.input(|i| i.stable_dt);
1811 let focused = ctx.input(|i| i.focused);
1812 let cancel_holds = ctx.input(|i| {
1813 !i.focused
1814 || i.pointer.any_released()
1815 || i.events.iter().any(|e| {
1816 matches!(
1817 e,
1818 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1819 )
1820 })
1821 });
1822 if cancel_holds || !focused {
1823 self.preview2d.on_interaction_cancel();
1824 }
1825 self.preview2d.tick(dt);
1826
1827 let mut need_rebuild = false;
1828 let mut refresh_catalog = false;
1829 let mut go_back = false;
1830 let mut set_sex = None;
1831 let mut toggle_set = None;
1832 let mut select_clip = None;
1833 let mut step_yaw = 0i32;
1834 let mut step_pitch = 0i32;
1835 let mut toggle_play = false;
1836 let mut apply_speed = false;
1837 let mut hold_az = None;
1838 let mut hold_el = None;
1839 let mut release_az = false;
1840 let mut release_el = false;
1841 let mut set_view_mode = None;
1842
1843 egui::SidePanel::left("preview2d_controls")
1844 .default_width(330.0)
1845 .show(ctx, |ui| {
1846 if self.session.confirm.is_some() || self.dialog_pending {
1847 ui.disable();
1848 }
1849 ui.heading("2D-preview");
1850 if ui.button("Terug").clicked() {
1851 go_back = true;
1852 }
1853 ui.separator();
1854 ui.label("Geslacht");
1855 ui.horizontal(|ui| {
1856 if ui
1857 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1858 .clicked()
1859 {
1860 set_sex = Some(Sex::Male);
1861 }
1862 if ui
1863 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1864 .clicked()
1865 {
1866 set_sex = Some(Sex::Female);
1867 }
1868 });
1869 if ui.button("Vernieuw exportsets").clicked() {
1870 refresh_catalog = true;
1871 }
1872 ui.label(format!(
1873 "{} voltooide exportset(s) (.ppda + atlassen)",
1874 self.preview2d_catalog.len()
1875 ));
1876 if !self.preview2d_skipped.is_empty() {
1877 let reasons = self
1878 .preview2d_skipped
1879 .iter()
1880 .map(|(path, why)| {
1881 let name = path.file_name().unwrap_or_default().to_string_lossy();
1882 format!("{name}: {why}")
1883 })
1884 .collect::<Vec<_>>()
1885 .join("\n");
1886 ui.weak(format!(
1887 "{} run(s) zonder geldige .ppda overgeslagen",
1888 self.preview2d_skipped.len()
1889 ))
1890 .on_hover_text(reasons);
1891 }
1892 ui.separator();
1893 ui.label("Equipment (zelfde project/rig/contract)");
1894 let allowed =
1895 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1896 if allowed.is_empty() {
1897 ui.weak("Geen compatibele equipment-export.");
1898 }
1899 for set in allowed {
1900 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1901 if ui.checkbox(&mut checked, &set).changed() {
1902 toggle_set = Some(set);
1903 }
1904 }
1905 ui.separator();
1906 ui.label("Clip");
1907 let sex_token = self.preview2d.sex_token();
1908 let clips: Vec<_> = self
1909 .preview2d_catalog
1910 .iter()
1911 .filter(|e| e.sex == sex_token && e.is_body())
1912 .max_by_key(|e| e.path.clone())
1913 .map(|e| e.clips.clone())
1914 .unwrap_or_default();
1915 if clips.is_empty() {
1916 ui.colored_label(
1917 egui::Color32::LIGHT_RED,
1918 "Geen body-export met clips voor dit geslacht.",
1919 );
1920 }
1921 for (token, name, frames, duration) in &clips {
1922 let label = if name.is_empty() {
1923 format!("{token} ({frames}f)")
1924 } else {
1925 format!("{name} ({token}, {frames}f)")
1926 };
1927 if ui
1928 .selectable_label(self.preview2d.clip_token == *token, label)
1929 .clicked()
1930 {
1931 select_clip = Some((token.clone(), *frames, *duration));
1932 }
1933 }
1934 ui.separator();
1935 ui.horizontal(|ui| {
1936 let play_label = if self.preview2d.playing {
1937 "Pauze"
1938 } else {
1939 "Speel"
1940 };
1941 if ui.button(play_label).clicked() {
1942 toggle_play = true;
1943 }
1944 ui.label(format!(
1945 "frame {}/{} t={:.2}s",
1946 self.preview2d.frame_index() + 1,
1947 self.preview2d.clip_frames.max(1),
1948 self.preview2d.elapsed_secs
1949 ));
1950 });
1951 ui.horizontal(|ui| {
1952 ui.label("Snelheid");
1953 ui.add(
1954 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1955 .desired_width(48.0),
1956 );
1957 if ui.button("Pas toe").clicked() {
1958 apply_speed = true;
1959 }
1960 ui.label(format!("export-fps {}", self.preview2d.fps));
1961 });
1962 ui.separator();
1963 ui.label("Weergave");
1964 for mode in ViewMode::all() {
1965 if ui
1966 .selectable_label(self.preview2d.view_mode == mode, mode.label_nl())
1967 .clicked()
1968 {
1969 set_view_mode = Some(mode);
1970 }
1971 }
1972 ui.weak("Resultaat = belichte compositie; andere standen tonen de ongebelichte map.");
1973 ui.separator();
1974 ui.label("Hoek (export)");
1975 ui.horizontal(|ui| {
1976 if ui.button("Yaw −").clicked() {
1977 step_yaw = -1;
1978 }
1979 if ui.button("Yaw +").clicked() {
1980 step_yaw = 1;
1981 }
1982 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
1983 });
1984 ui.horizontal(|ui| {
1985 if ui.button("Pitch −").clicked() {
1986 step_pitch = -1;
1987 }
1988 if ui.button("Pitch +").clicked() {
1989 step_pitch = 1;
1990 }
1991 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1992 });
1993 ui.separator();
1994 ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {
1995 let held_ctx = ui.ctx().clone();
1996 let held = |r: &egui::Response| {
1997 held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i))
1998 };
1999 ui.label("Licht (vasthouden; °/s — alleen Resultaat)");
2000 ui.horizontal(|ui| {
2001 let west = ui.button("Azimut −");
2002 let east = ui.button("Azimut +");
2003 if self.preview2d.view_mode.uses_light() {
2004 if held(&west) {
2005 hold_az = Some(HoldSign::Negative);
2006 } else if held(&east) {
2007 hold_az = Some(HoldSign::Positive);
2008 } else if self.preview2d.hold_azimuth.is_some()
2009 && !held(&west)
2010 && !held(&east)
2011 {
2012 release_az = true;
2013 }
2014 }
2015 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
2016 });
2017 ui.horizontal(|ui| {
2018 let down = ui.button("Elevatie −");
2019 let up = ui.button("Elevatie +");
2020 if self.preview2d.view_mode.uses_light() {
2021 if held(&down) {
2022 hold_el = Some(HoldSign::Negative);
2023 } else if held(&up) {
2024 hold_el = Some(HoldSign::Positive);
2025 } else if self.preview2d.hold_elevation.is_some()
2026 && !held(&down)
2027 && !held(&up)
2028 {
2029 release_el = true;
2030 }
2031 }
2032 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
2033 });
2034 ui.weak(
2035 "Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",
2036 );
2037 });
2038 });
2039
2040 if go_back {
2041 self.session.back();
2042 return;
2043 }
2044 if refresh_catalog {
2045 self.refresh_preview2d_catalog();
2046 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
2047 need_rebuild = true;
2048 }
2049 if let Some(sex) = set_sex {
2050 self.preview2d.set_sex(sex);
2051 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
2052 need_rebuild = true;
2053 }
2054 if let Some(set) = toggle_set {
2055 self.preview2d.toggle_equipment(&set);
2056 need_rebuild = true;
2057 }
2058 if let Some((token, frames, duration)) = select_clip {
2059 self.preview2d.set_clip(token, frames, duration);
2060 need_rebuild = true;
2061 }
2062 if toggle_play {
2063 self.preview2d.playing = !self.preview2d.playing;
2064 }
2065 if apply_speed {
2066 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
2067 if speed.is_finite() && speed >= 0.0 {
2068 self.preview2d.playback_speed = speed;
2069 }
2070 }
2071 }
2072 if let Some(mode) = set_view_mode {
2073 if self.preview2d.view_mode != mode {
2074 self.preview2d.view_mode = mode;
2075 if !mode.uses_light() {
2076 self.preview2d.release_all_holds();
2077 }
2078 need_rebuild = true;
2079 }
2080 }
2081 if step_yaw != 0 {
2082 self.preview2d.step_yaw(step_yaw);
2083 need_rebuild = true;
2084 }
2085 if step_pitch != 0 {
2086 self.preview2d.step_pitch(step_pitch);
2087 need_rebuild = true;
2088 }
2089 if let Some(sign) = hold_az {
2090 self.preview2d.hold_light(LightAxis::Azimuth, sign);
2091 } else if release_az {
2092 self.preview2d.release_light(LightAxis::Azimuth);
2093 }
2094 if let Some(sign) = hold_el {
2095 self.preview2d.hold_light(LightAxis::Elevation, sign);
2096 } else if release_el {
2097 self.preview2d.release_light(LightAxis::Elevation);
2098 }
2099
2100 // Playing always refreshes; light holds only matter in Resultaat.
2101 if self.preview2d.playing
2102 || (self.preview2d.view_mode.uses_light()
2103 && (self.preview2d.hold_azimuth.is_some()
2104 || self.preview2d.hold_elevation.is_some()))
2105 {
2106 need_rebuild = true;
2107 }
2108
2109 let key = (
2110 self.preview2d.clip_token.clone(),
2111 self.preview2d.frame_index(),
2112 self.preview2d.pitch_deg(),
2113 self.preview2d.yaw_deg(),
2114 self.preview2d.selected_equipment.len() as u32,
2115 format!(
2116 "{:.1}/{:.1}/{:?}/{}",
2117 self.preview2d.light_azimuth_deg,
2118 self.preview2d.light_elevation_deg,
2119 self.preview2d.view_mode,
2120 self.preview2d.sex_token()
2121 ),
2122 );
2123 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
2124 self.rebuild_preview2d_frame();
2125 self.preview2d_texture_key = Some(key);
2126 self.preview2d_texture = None;
2127 }
2128
2129 egui::CentralPanel::default().show(ctx, |ui| {
2130 if self.session.confirm.is_some() || self.dialog_pending {
2131 ui.disable();
2132 }
2133 if let Some(diag) = &self.preview2d.diagnostic {
2134 ui.colored_label(egui::Color32::LIGHT_RED, diag);
2135 }
2136 let Some(image) = &self.preview2d_image else {
2137 ui.label(
2138 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
2139 );
2140 return;
2141 };
2142 if self.preview2d_texture.is_none() {
2143 let color = egui::ColorImage::from_rgba_unmultiplied(
2144 [image.width as usize, image.height as usize],
2145 &image.rgba,
2146 );
2147 self.preview2d_texture = Some(ui.ctx().load_texture(
2148 "preview2d-frame",
2149 color,
2150 egui::TextureOptions::NEAREST,
2151 ));
2152 }
2153 let texture = self.preview2d_texture.as_ref().expect("texture");
2154 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
2155 let fit = crate::render_job::fit_preview(
2156 [image.width, image.height],
2157 [bounds.width(), bounds.height()],
2158 );
2159 let rect = egui::Rect::from_min_size(
2160 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
2161 egui::vec2(fit.size[0], fit.size[1]),
2162 );
2163 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
2164 ui.painter().image(
2165 texture.id(),
2166 rect,
2167 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
2168 egui::Color32::WHITE,
2169 );
2170 });
2171 }
2172
2173 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
2174 if !project {
2175 return;
2176 }
2177 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
2178 if self.session.confirm.is_some() || self.dialog_pending {
2179 ui.disable();
2180 }
2181 ui.horizontal(|ui| {
2182 if ui.button("Body").clicked() {
2183 self.session.show_bodies();
2184 }
2185 if ui.button("2D-preview").clicked() {
2186 self.open_preview2d();
2187 }
2188 if ui.button("Opslaan").clicked() {
2189 self.save();
2190 }
2191 if ui
2192 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
2193 .clicked()
2194 {
2195 self.begin(Intent::PickNew);
2196 }
2197 if ui
2198 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
2199 .clicked()
2200 {
2201 self.begin(Intent::PickLoad);
2202 }
2203 });
2204 });
2205 }
2206}
2207
2208fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
2209 fn node(
2210 ui: &mut egui::Ui,
2211 body: &PreviewCharacter,
2212 paths: &[String],
2213 index: usize,
2214 selected: Option<&str>,
2215 choice: &mut Option<usize>,
2216 depth: usize,
2217 ) {
2218 if depth > 64 {
2219 return;
2220 }
2221 let joint = body.joint_nodes.iter().position(|i| *i == index);
2222 let children: Vec<_> = body
2223 .nodes
2224 .iter()
2225 .enumerate()
2226 .filter(|(_, n)| n.parent == Some(index))
2227 .map(|(i, _)| i)
2228 .collect();
2229 let label = if body.nodes[index].name.is_empty() {
2230 format!("node-{index}")
2231 } else {
2232 body.nodes[index].name.clone()
2233 };
2234 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
2235 if let Some(joint) = joint {
2236 if ui
2237 .selectable_label(
2238 selected == Some(paths[joint].as_str()),
2239 format!("Koppel {label}"),
2240 )
2241 .clicked()
2242 {
2243 *choice = Some(joint);
2244 }
2245 }
2246 };
2247 if children.is_empty() {
2248 select(ui, choice);
2249 } else {
2250 egui::CollapsingHeader::new(&label)
2251 .id_salt(index)
2252 .default_open(depth < 2)
2253 .show(ui, |ui| {
2254 select(ui, choice);
2255 for child in children {
2256 node(ui, body, paths, child, selected, choice, depth + 1);
2257 }
2258 });
2259 }
2260 }
2261 let mut choice = None;
2262 let paths = crate::preview::joint_paths(body);
2263 for (index, item) in body.nodes.iter().enumerate() {
2264 if item.parent.is_none() {
2265 node(ui, body, &paths, index, selected, &mut choice, 0);
2266 }
2267 }
2268 choice
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273 use super::*;
2274 use crate::dialogs::ScriptedDialogs;
2275 use crate::session::Choice;
2276
2277 fn root() -> PathBuf {
2278 std::env::temp_dir().join(format!(
2279 "paperdoll-editor-{}",
2280 std::time::SystemTime::now()
2281 .duration_since(std::time::UNIX_EPOCH)
2282 .unwrap()
2283 .as_nanos()
2284 ))
2285 }
2286
2287 #[test]
2288 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2289 let root = root();
2290 let mut dialogs = ScriptedDialogs::default();
2291 dialogs.push(Some(root.clone()));
2292 dialogs.push(None);
2293 let mut app = EditorApp::new(Box::new(dialogs));
2294 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
2295 app.begin(Intent::PickNew);
2296 app.poll_dialogs();
2297 assert_eq!(app.session.screen, Screen::Bodies);
2298 assert!(app.preview_male.is_some());
2299 assert!(app.preview_female.is_some());
2300 app.session.set_name("Keep my edit".into());
2301 app.begin(Intent::PickLoad);
2302 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
2303 app.apply_choice(Choice::Discard, &egui::Context::default());
2304 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
2305 assert_eq!(
2306 app.session.opened.as_ref().unwrap().document.name,
2307 "Keep my edit"
2308 );
2309 assert!(app.session.is_dirty());
2310 let _ = std::fs::remove_dir_all(root);
2311 }
2312
2313 #[test]
2314 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
2315 let root = root();
2316 let mut app = EditorApp::default();
2317 app.session.install_new_project(&root).unwrap();
2318 app.session.set_render(0, 128, 12);
2319 app.begin(Intent::Quit);
2320 app.apply_choice(Choice::Save, &egui::Context::default());
2321 assert!(!app.allow_close);
2322 assert_eq!(app.session.confirm, Some(Intent::Quit));
2323 assert!(app.session.is_dirty());
2324 app.open_path(root.join("missing.ppd"));
2325 assert!(app.session.opened.is_some());
2326 assert_eq!(app.session.screen, Screen::Bodies);
2327 let _ = std::fs::remove_dir_all(root);
2328 }
2329
2330 #[test]
2331 fn fbx_body_and_fbx_equipment_reach_live_view_and_render_job() {
2332 let root = root();
2333 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
2334 let mut app = EditorApp::default();
2335 app.session.install_new_project(&root).unwrap();
2336 let relative = crate::session::adopt_file(
2337 &root,
2338 &fixtures.join("bodies/test-body-male.fbx"),
2339 "assets/body",
2340 )
2341 .unwrap();
2342 app.session.set_body_path(Sex::Male, relative);
2343 app.session.save().unwrap();
2344 app.open_path(root.join("character.ppd"));
2345 let male = app.preview_male.clone().expect("FBX-lichaam in live view");
2346 assert_eq!(male.joint_names.len(), 19);
2347 assert!(male.clips.iter().any(|c| c.name.contains("walk")));
2348 assert!(matches!(&app.inventories[0], Some(Ok(_))));
2349 // Rigid FBX equipment through the same picker path, auto-bound to the FBX rig.
2350 let index = app.session.add_equipment();
2351 app.session.equipment[index].slug = "fbxsword".into();
2352 app.session.active_body = Some(Sex::Male);
2353 app.take_equipment(index, &fixtures.join("equipment/sword-fbx.fbx"));
2354 let preset = app.session.equipment[index].preset.clone().expect("preset");
2355 assert_eq!(preset.attachments[0].label, "sword-fbx.fbx");
2356 assert!(preset.attachments[0].bones[0].is_some());
2357 assert_eq!(
2358 preset.rigs[0].as_ref(),
2359 Some(&crate::equipment::RigSignature::of(&male))
2360 );
2361 app.open_body_configuration(Sex::Male);
2362 let job = app.build_job().expect("render job for FBX body");
2363 assert_eq!(job.body_model.path, "assets/body/test-body-male.fbx");
2364 assert_eq!(job.character.positions, male.positions);
2365 let _ = std::fs::remove_dir_all(root);
2366 }
2367
2368 #[test]
2369 fn static_obj_body_reaches_live_view_and_disables_bone_functions() {
2370 let root = root();
2371 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
2372 let mut app = EditorApp::default();
2373 app.session.install_new_project(&root).unwrap();
2374 let mut relative = String::new();
2375 for name in [
2376 "static-body-male.obj",
2377 "static-body-male.mtl",
2378 "static-body-albedo.png",
2379 ] {
2380 let r = crate::session::adopt_file(
2381 &root,
2382 &fixtures.join("bodies/static").join(name),
2383 "assets/body",
2384 )
2385 .unwrap();
2386 if name.ends_with(".obj") {
2387 relative = r;
2388 }
2389 }
2390 app.session.set_body_path(Sex::Male, relative.clone());
2391 app.session.save().unwrap();
2392 app.open_path(root.join("character.ppd"));
2393 let male = app
2394 .preview_male
2395 .clone()
2396 .expect("statisch OBJ-lichaam in live view");
2397 assert!(male.is_static());
2398 assert!(male.clips.is_empty() && male.joint_names.is_empty());
2399 let inventory = app.inventories[0].as_ref().unwrap().as_ref().unwrap();
2400 assert!(inventory.joints.is_empty() && inventory.clips.is_empty());
2401 // Equipment can be imported but is never auto-bound to an invented bone.
2402 let index = app.session.add_equipment();
2403 app.session.equipment[index].slug = "sword".into();
2404 app.session.active_body = Some(Sex::Male);
2405 app.take_equipment(index, &fixtures.join("equipment/sword.obj"));
2406 let preset = app.session.equipment[index].preset.clone().expect("preset");
2407 assert!(preset.attachments[0].bones[0].is_none());
2408 assert!(preset.rigs[0].is_none());
2409 app.open_body_configuration(Sex::Male);
2410 let job = app.build_job().expect("render job for static body");
2411 assert_eq!(job.body_model.path, relative);
2412 assert!(job.character.is_static());
2413 let contract = crate::production::contract(job).unwrap();
2414 assert_eq!(contract.plan.images, 60);
2415 assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2416 app.open_equipment_configuration(index, Sex::Male);
2417 let job = app.build_job().expect("equipment job object");
2418 assert!(crate::production::contract(job)
2419 .err()
2420 .unwrap()
2421 .contains("statisch"));
2422 // Headless UI frames for the body and equipment screens must not panic.
2423 let ctx = egui::Context::default();
2424 for screen in 0..2 {
2425 if screen == 0 {
2426 app.open_body_configuration(Sex::Male);
2427 } else {
2428 app.open_equipment_configuration(index, Sex::Male);
2429 }
2430 let _ = ctx.run(egui::RawInput::default(), |ctx| match screen {
2431 0 => app.show_body(ctx),
2432 _ => app.show_equipment(ctx),
2433 });
2434 }
2435 let _ = std::fs::remove_dir_all(root);
2436 }
2437
2438 #[test]
2439 fn open_with_project_path_loads_bodies_and_models() {
2440 let root = root().join("project with spaces");
2441 let opened = crate::session::create_seeded_project(&root).unwrap();
2442 let mut app = EditorApp::default();
2443 app.open_path(opened.manifest_path.clone());
2444 assert_eq!(app.session.screen, Screen::Bodies);
2445 assert!(app.preview_male.is_some());
2446 assert_eq!(
2447 app.session.opened.unwrap().manifest_path,
2448 opened.manifest_path
2449 );
2450 let _ = std::fs::remove_dir_all(root.parent().unwrap());
2451 }
2452
2453 fn pump(app: &mut EditorApp) {
2454 for _ in 0..20000 {
2455 if !app.render_all_running() {
2456 return;
2457 }
2458 app.service_gpu(None);
2459 app.drain_render();
2460 std::thread::sleep(std::time::Duration::from_millis(1));
2461 }
2462 panic!("Render alles liep niet af");
2463 }
2464
2465 #[test]
2466 fn render_all_queues_bodies_and_equipment_with_skip_diagnostics() {
2467 let root = root();
2468 let opened = crate::session::create_seeded_project(&root).unwrap();
2469 let mut app = EditorApp::default();
2470 app.open_path(opened.manifest_path.clone());
2471 let index = app.session.add_equipment();
2472 app.session.equipment[index].slug = "leeg".into();
2473 let (items, skipped) = app.render_all_plan();
2474 let keys: Vec<_> = items.iter().map(|i| (i.sex, i.equipment)).collect();
2475 assert_eq!(
2476 keys,
2477 vec![(0, None), (1, None), (0, Some(index)), (1, Some(index))]
2478 );
2479 assert!(skipped.is_empty());
2480 // Without a GL context every body render fails; empty equipment is
2481 // skipped by the render contract; the queue still continues.
2482 app.start_render_all();
2483 assert!(app.render_all_running());
2484 pump(&mut app);
2485 let summary = app.render_all_summary().unwrap().clone();
2486 assert_eq!(summary.failed.len(), 2, "{summary:?}");
2487 assert!(summary.failed[0].1.contains("Glow"), "{summary:?}");
2488 assert_eq!(summary.skipped.len(), 2, "{summary:?}");
2489 assert!(summary.skipped[0].0.contains("leeg"), "{summary:?}");
2490 assert!(!summary.cancelled);
2491 assert!(app.session.notice.contains("2 overgeslagen, 2 mislukt"));
2492 // Cancel stops the queue; the rest is listed as not started.
2493 app.start_render_all();
2494 app.cancel_render();
2495 pump(&mut app);
2496 let summary = app.render_all_summary().unwrap().clone();
2497 assert!(summary.cancelled, "{summary:?}");
2498 assert_eq!(summary.not_started.len(), 3, "{summary:?}");
2499 let _ = std::fs::remove_dir_all(root);
2500 }
2501
2502 #[test]
2503 fn depth_format_and_range_settings_reach_job_and_persist() {
2504 let root = root();
2505 let opened = crate::session::create_seeded_project(&root).unwrap();
2506 let mut app = EditorApp::default();
2507 app.open_path(opened.manifest_path.clone());
2508 use crate::camera::{DepthEncoding, DepthFormat};
2509 assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2510 // Body screen with the depth controls renders headless.
2511 let ctx = egui::Context::default();
2512 let _ = ctx.run(Default::default(), |ctx| app.show_bodies(ctx));
2513 assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2514 let job = app.job_for(0, None).unwrap();
2515 assert_eq!(job.depth_format, DepthFormat::default());
2516 app.set_depth_format(16, DepthEncoding::RgbPacked);
2517 app.session.opened.as_mut().unwrap().document.render.framing[1].depth_range_milli =
2518 Some(1500);
2519 let job = app.job_for(1, None).unwrap();
2520 assert_eq!(
2521 job.depth_format,
2522 DepthFormat::new(16, DepthEncoding::RgbPacked).unwrap()
2523 );
2524 let doc = crate::production::contract(job).unwrap().render_doc;
2525 assert_eq!(doc["depth_bits"], 16);
2526 assert_eq!(doc["depth_encoding"], "rgb_packed");
2527 assert_eq!(doc["depth_near"].as_f64().unwrap() as f32, 9.25);
2528 app.session.save().unwrap();
2529 let mut again = EditorApp::default();
2530 again.open_path(opened.manifest_path.clone());
2531 assert_eq!(again.depth_format(), Some((16, DepthEncoding::RgbPacked)));
2532 let text = std::fs::read_to_string(&opened.manifest_path).unwrap();
2533 assert!(
2534 text.contains("\"depth_encoding\": \"rgb_packed\""),
2535 "{text}"
2536 );
2537 assert!(text.contains("\"depth_range_milli\": 1500"), "{text}");
2538 let _ = std::fs::remove_dir_all(root);
2539 }
2540
2541 #[test]
2542 fn renderer_disconnect_is_reported() {
2543 let mut app = EditorApp::default();
2544 let (_, gpu) = std::sync::mpsc::channel();
2545 app.render_worker = Some(crate::production::RenderThread {
2546 mailbox: Default::default(),
2547 gpu,
2548 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2549 handle: None,
2550 });
2551 app.render_ui.running = true;
2552 app.drain_render();
2553 assert!(!app.render_ui.running);
2554 assert!(app.render_ui.error.is_some());
2555 }
2556}