BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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