BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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