BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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