BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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