BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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