BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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