BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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