BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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