BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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