BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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