BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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