BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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