BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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