BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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