BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 102.4 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 self.framing_ui(ui, usize::from(sex == Sex::Female));
1385 self.render_progress_ui(ui);
1386 self.resume_ui(ui);
1387 if self.render_ui.preview.is_some() {
1388 self.paint_pass(ui);
1389 } else {
1390 let character = match self.controls.sex {
1391 PreviewSex::Male => self.preview_male.as_ref(),
1392 PreviewSex::Female => self.preview_female.as_ref(),
1393 };
1394 if character.is_some() {
1395 let framing = self.fixed_camera();
1396 self.viewport
1397 .show_framed(ui, &mut self.controls, character, &[], framing);
1398 } else {
1399 ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");
1400 }
1401 }
1402 });
1403 }
1404
1405 fn paint_pass(&mut self, ui: &mut egui::Ui) {
1406 let Some(image) = &self.render_ui.preview else {
1407 return;
1408 };
1409 ui.label(format!(
1410 "Laatste voltooide pass: {}",
1411 self.render_ui.preview_label
1412 ));
1413 if self.pass_texture.is_none()
1414 || self.pass_texture_revision != self.render_ui.preview_revision
1415 {
1416 let color = egui::ColorImage::from_rgba_unmultiplied(
1417 [image.width as usize, image.height as usize],
1418 &image.rgba,
1419 );
1420 let options = egui::TextureOptions::NEAREST;
1421 if let Some(texture) = &mut self.pass_texture {
1422 texture.set(color, options);
1423 } else {
1424 self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
1425 }
1426 self.pass_texture_revision = self.render_ui.preview_revision;
1427 }
1428 let texture = self.pass_texture.as_ref().expect("pass texture installed");
1429 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1430 let fit = crate::render_job::fit_preview(
1431 [image.width, image.height],
1432 [bounds.width(), bounds.height()],
1433 );
1434 let rect = egui::Rect::from_min_size(
1435 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1436 egui::vec2(fit.size[0], fit.size[1]),
1437 );
1438 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1439 ui.painter().image(
1440 texture.id(),
1441 rect,
1442 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1443 egui::Color32::WHITE,
1444 );
1445 }
1446
1447 fn show_equipment_list(&mut self, ctx: &egui::Context) {
1448 self.toolbar(ctx, true);
1449 egui::CentralPanel::default().show(ctx, |ui| {
1450 if self.session.confirm.is_some() || self.dialog_pending {
1451 ui.disable();
1452 }
1453 ui.heading(format!(
1454 "Bodies → {} → Equipment",
1455 match self.session.active_body {
1456 Some(Sex::Male) => "Man",
1457 Some(Sex::Female) => "Vrouw",
1458 None => "Body",
1459 }
1460 ));
1461 if ui.button("Terug naar body").clicked() {
1462 self.session.back();
1463 }
1464 let mut open = None;
1465 for (index, item) in self.session.equipment.iter().enumerate() {
1466 let label = match &item.model {
1467 Some(model) => format!("{} {model}", item.slug),
1468 None => item.slug.clone(),
1469 };
1470 if ui.button(label).clicked() {
1471 open = Some(index);
1472 }
1473 }
1474 if ui
1475 .add_enabled(
1476 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1477 egui::Button::new("Nieuw"),
1478 )
1479 .clicked()
1480 {
1481 self.equipment_part = 0;
1482 self.session.add_equipment();
1483 }
1484 if ui
1485 .add_enabled(
1486 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1487 egui::Button::new("Preset importeren"),
1488 )
1489 .clicked()
1490 {
1491 self.dialogs.start(DialogPurpose::ImportEquipment);
1492 }
1493 if let Some(index) = open {
1494 self.session.show_equipment(index);
1495 }
1496 });
1497 }
1498
1499 fn show_equipment(&mut self, ctx: &egui::Context) {
1500 self.toolbar(ctx, true);
1501 let Screen::Equipment(index) = self.session.screen else {
1502 return;
1503 };
1504 let mut sex = if self.session.active_body == Some(Sex::Female) {
1505 1
1506 } else {
1507 0
1508 };
1509 egui::SidePanel::left("equipment-config")
1510 .default_width(330.0)
1511 .resizable(true)
1512 .show(ctx, |ui| {
1513 if self.session.confirm.is_some() || self.dialog_pending {
1514 ui.disable();
1515 }
1516 egui::ScrollArea::vertical().show(ui, |ui| {
1517 ui.heading("Equipment-configuratie");
1518 if ui.button("Terug naar equipment").clicked() {
1519 self.session.show_equipment_list();
1520 }
1521 ui.horizontal(|ui| {
1522 ui.selectable_value(&mut sex, 0, "Man");
1523 ui.selectable_value(&mut sex, 1, "Vrouw");
1524 });
1525 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1526 let Some(item) = self.session.equipment.get_mut(index) else {
1527 return;
1528 };
1529 ui.label("Naam (kleine letters/cijfers/-/_)");
1530 ui.text_edit_singleline(&mut item.slug);
1531 if !crate::equipment::valid_name(&item.slug) {
1532 ui.colored_label(
1533 egui::Color32::LIGHT_RED,
1534 "Ongeldige naam; 'body' is gereserveerd.",
1535 );
1536 }
1537 if let Some(error) = &item.error {
1538 ui.colored_label(egui::Color32::LIGHT_RED, error);
1539 }
1540 if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {
1541 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1542 }
1543 if let Some(preset) = &mut item.preset {
1544 ui.horizontal(|ui| {
1545 ui.label("Geldig voor:");
1546 ui.checkbox(&mut preset.applies[0], "Man");
1547 ui.checkbox(&mut preset.applies[1], "Vrouw");
1548 });
1549 if !preset.applies[0] && !preset.applies[1] {
1550 ui.colored_label(
1551 egui::Color32::LIGHT_RED,
1552 "Kies minstens één geslacht.",
1553 );
1554 }
1555 ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1556 ui.add(
1557 egui::TextEdit::multiline(&mut preset.provenance)
1558 .desired_rows(2)
1559 .char_limit(4096),
1560 );
1561 if preset.provenance.len() > 4096 {
1562 ui.colored_label(
1563 egui::Color32::LIGHT_RED,
1564 "Herkomst/licentie is te lang.",
1565 );
1566 }
1567 if !preset.attachments.is_empty() {
1568 self.equipment_part =
1569 self.equipment_part.min(preset.attachments.len() - 1);
1570 egui::ComboBox::from_id_salt("equipment-part")
1571 .selected_text(&preset.attachments[self.equipment_part].label)
1572 .show_ui(ui, |ui| {
1573 for (part, item) in preset.attachments.iter().enumerate() {
1574 ui.selectable_value(
1575 &mut self.equipment_part,
1576 part,
1577 &item.label,
1578 );
1579 }
1580 });
1581 let body = if sex == 0 {
1582 self.preview_male.as_ref()
1583 } else {
1584 self.preview_female.as_ref()
1585 };
1586 let attachment = &mut preset.attachments[self.equipment_part];
1587 ui.label("Positie relatief aan bot (XYZ)");
1588 ui.horizontal(|ui| {
1589 for value in &mut attachment.translation {
1590 ui.add(
1591 egui::DragValue::new(value)
1592 .speed(0.005)
1593 .range(-1000.0..=1000.0),
1594 );
1595 }
1596 });
1597 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1598 ui.horizontal(|ui| {
1599 for value in &mut attachment.rotation_deg {
1600 ui.add(
1601 egui::DragValue::new(value)
1602 .speed(0.5)
1603 .range(-3600.0..=3600.0),
1604 );
1605 }
1606 });
1607 ui.add(
1608 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1609 .text("Glanssterkte"),
1610 );
1611 let shiny = attachment
1612 .shiny
1613 .and_then(|i| preset.assets.get(i))
1614 .map(|a| a.path.clone());
1615 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1616 if ui.button("Kies shinymap (PNG)").clicked() {
1617 self.dialogs.start(DialogPurpose::EquipmentShiny(
1618 index,
1619 self.equipment_part,
1620 ));
1621 }
1622 let clear_shiny = ui.button("Wis shinymap").clicked();
1623 let selected = attachment.bones[sex].clone();
1624 if clear_shiny {
1625 if let Err(error) =
1626 crate::equipment::clear_shiny(preset, self.equipment_part)
1627 {
1628 item.error = Some(error);
1629 }
1630 }
1631 ui.label(format!(
1632 "Bot: {}",
1633 selected.as_deref().unwrap_or("nog niet gekoppeld")
1634 ));
1635 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1636 ui.add_enabled_ui(false, |ui| {
1637 ui.collapsing("Skeleton / botten", |ui| {
1638 ui.label("Geen botten");
1639 });
1640 });
1641 ui.colored_label(
1642 egui::Color32::LIGHT_RED,
1643 format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),
1644 );
1645 } else if let Some(body) = body {
1646 if preset.rigs[sex].as_ref()
1647 != Some(&crate::equipment::RigSignature::of(body))
1648 {
1649 ui.colored_label(
1650 egui::Color32::LIGHT_RED,
1651 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1652 );
1653 }
1654 ui.collapsing("Skeleton / botten", |ui| {
1655 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1656 if let Err(error) =
1657 preset.bind(sex, body, self.equipment_part, joint)
1658 {
1659 ui.colored_label(egui::Color32::LIGHT_RED, error);
1660 }
1661 }
1662 });
1663 } else {
1664 ui.colored_label(
1665 egui::Color32::LIGHT_RED,
1666 "Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",
1667 );
1668 }
1669 if ui.button("Verwijder onderdeel").clicked() {
1670 if let Err(error) =
1671 crate::equipment::remove_attachment(preset, self.equipment_part)
1672 {
1673 item.error = Some(error);
1674 }
1675 self.equipment_part = self.equipment_part.saturating_sub(1);
1676 }
1677 }
1678 }
1679 if ui.button("Verwijder equipmentdefinitie").clicked() {
1680 self.session.delete_equipment(index);
1681 }
1682 });
1683 });
1684 self.controls.sex = if sex == 0 {
1685 PreviewSex::Male
1686 } else {
1687 PreviewSex::Female
1688 };
1689 egui::CentralPanel::default().show(ctx, |ui| {
1690 if self.session.confirm.is_some() || self.dialog_pending {
1691 ui.disable();
1692 }
1693 ui.horizontal_wrapped(|ui| {
1694 let static_body = self.character().is_some_and(PreviewCharacter::is_static);
1695 if ui
1696 .add_enabled(
1697 !self.render_ui.running && !static_body,
1698 egui::Button::new("Render equipment · alle hoeken"),
1699 )
1700 .on_disabled_hover_text(
1701 "Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",
1702 )
1703 .clicked()
1704 {
1705 self.start_render();
1706 }
1707 if ui.button("Live 3D").clicked() {
1708 self.render_ui.preview = None;
1709 }
1710 if ui.button("2D-preview").clicked() {
1711 self.open_preview2d();
1712 }
1713 });
1714 self.render_progress_ui(ui);
1715 self.resume_ui(ui);
1716 if self.render_ui.preview.is_some() {
1717 self.paint_pass(ui);
1718 return;
1719 }
1720 let framing = self.fixed_camera();
1721 let body = if sex == 0 {
1722 self.preview_male.as_ref()
1723 } else {
1724 self.preview_female.as_ref()
1725 };
1726 let preset = self
1727 .session
1728 .equipment
1729 .get(index)
1730 .and_then(|item| item.preset.as_ref());
1731 // A static body has no bones; equipment layers are not attached
1732 // to an invented skeleton, so the live view shows only the body.
1733 let presets: Vec<_> = preset
1734 .into_iter()
1735 .filter(|_| !body.is_some_and(PreviewCharacter::is_static))
1736 .collect();
1737 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1738 ui.colored_label(
1739 egui::Color32::from_rgb(230, 190, 90),
1740 format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),
1741 );
1742 }
1743 self.viewport
1744 .show_framed(ui, &mut self.controls, body, &presets, framing);
1745 });
1746 }
1747
1748 pub fn open_preview2d(&mut self) {
1749 self.refresh_preview2d_catalog();
1750 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1751 self.preview2d_image = None;
1752 self.preview2d_texture = None;
1753 self.preview2d_texture_key = None;
1754 self.session.show_preview2d();
1755 }
1756
1757 pub fn preview2d_state(&self) -> &Preview2dState {
1758 &self.preview2d
1759 }
1760
1761 /// Mutable 2D-preview state (view/playback), e.g. for GUI smokes.
1762 pub fn preview2d_state_mut(&mut self) -> &mut Preview2dState {
1763 &mut self.preview2d
1764 }
1765
1766 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1767 self.preview2d_image.as_ref()
1768 }
1769
1770 fn refresh_preview2d_catalog(&mut self) {
1771 (self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {
1772 Some(root) => discover_exports(&root),
1773 None => (Vec::new(), Vec::new()),
1774 };
1775 self.preview2d_loaded = None;
1776 }
1777
1778 fn rebuild_preview2d_frame(&mut self) {
1779 let sex = self.preview2d.sex;
1780 let equipment = self.preview2d.selected_equipment.clone();
1781 let clip = self.preview2d.clip_token.clone();
1782 let frame = self.preview2d.frame_index();
1783 let pitch = self.preview2d.pitch_deg();
1784 let yaw = self.preview2d.yaw_deg();
1785 let light = self.preview2d.light();
1786 let view_mode = self.preview2d.view_mode;
1787 if clip.is_empty() {
1788 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1789 self.preview2d_image = None;
1790 return;
1791 }
1792 let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {
1793 Ok(paths) => paths,
1794 Err(err) => {
1795 self.preview2d.diagnostic = Some(err.to_string());
1796 self.preview2d_image = None;
1797 return;
1798 }
1799 };
1800 if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {
1801 self.preview2d_loaded = None;
1802 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1803 Ok((body, gear)) => {
1804 self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })
1805 }
1806 Err(err) => {
1807 self.preview2d.diagnostic = Some(err.to_string());
1808 self.preview2d_image = None;
1809 return;
1810 }
1811 }
1812 }
1813 match self.preview2d_loaded.as_ref().ok_or("geen selectie") {
1814 Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {
1815 body,
1816 equipment: gear,
1817 clip_token: &clip,
1818 frame,
1819 pitch,
1820 yaw,
1821 light,
1822 view_mode,
1823 }) {
1824 Ok(image) => {
1825 self.preview2d.diagnostic = None;
1826 self.preview2d_image = Some(image);
1827 }
1828 Err(err) => {
1829 self.preview2d.diagnostic = Some(err.to_string());
1830 self.preview2d_image = None;
1831 }
1832 },
1833 Err(err) => {
1834 self.preview2d.diagnostic = Some(err.to_string());
1835 self.preview2d_image = None;
1836 }
1837 }
1838 }
1839
1840 fn show_preview2d(&mut self, ctx: &egui::Context) {
1841 self.toolbar(ctx, true);
1842 let dt = ctx.input(|i| i.stable_dt);
1843 let focused = ctx.input(|i| i.focused);
1844 let cancel_holds = ctx.input(|i| {
1845 !i.focused
1846 || i.pointer.any_released()
1847 || i.events.iter().any(|e| {
1848 matches!(
1849 e,
1850 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1851 )
1852 })
1853 });
1854 if cancel_holds || !focused {
1855 self.preview2d.on_interaction_cancel();
1856 }
1857 self.preview2d.tick(dt);
1858
1859 let mut need_rebuild = false;
1860 let mut refresh_catalog = false;
1861 let mut go_back = false;
1862 let mut set_sex = None;
1863 let mut toggle_set = None;
1864 let mut select_clip = None;
1865 let mut step_yaw = 0i32;
1866 let mut step_pitch = 0i32;
1867 let mut toggle_play = false;
1868 let mut apply_speed = false;
1869 let mut hold_az = None;
1870 let mut hold_el = None;
1871 let mut release_az = false;
1872 let mut release_el = false;
1873 let mut set_view_mode = None;
1874
1875 egui::SidePanel::left("preview2d_controls")
1876 .default_width(330.0)
1877 .show(ctx, |ui| {
1878 if self.session.confirm.is_some() || self.dialog_pending {
1879 ui.disable();
1880 }
1881 ui.heading("2D-preview");
1882 if ui.button("Terug").clicked() {
1883 go_back = true;
1884 }
1885 ui.separator();
1886 ui.label("Geslacht");
1887 ui.horizontal(|ui| {
1888 if ui
1889 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1890 .clicked()
1891 {
1892 set_sex = Some(Sex::Male);
1893 }
1894 if ui
1895 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1896 .clicked()
1897 {
1898 set_sex = Some(Sex::Female);
1899 }
1900 });
1901 if ui.button("Vernieuw exportsets").clicked() {
1902 refresh_catalog = true;
1903 }
1904 ui.label(format!(
1905 "{} voltooide exportset(s) (.ppda + atlassen)",
1906 self.preview2d_catalog.len()
1907 ));
1908 if !self.preview2d_skipped.is_empty() {
1909 let reasons = self
1910 .preview2d_skipped
1911 .iter()
1912 .map(|(path, why)| {
1913 let name = path.file_name().unwrap_or_default().to_string_lossy();
1914 format!("{name}: {why}")
1915 })
1916 .collect::<Vec<_>>()
1917 .join("\n");
1918 ui.weak(format!(
1919 "{} run(s) zonder geldige .ppda overgeslagen",
1920 self.preview2d_skipped.len()
1921 ))
1922 .on_hover_text(reasons);
1923 }
1924 ui.separator();
1925 ui.label("Equipment (zelfde project/rig/contract)");
1926 let allowed =
1927 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1928 if allowed.is_empty() {
1929 ui.weak("Geen compatibele equipment-export.");
1930 }
1931 for set in allowed {
1932 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1933 if ui.checkbox(&mut checked, &set).changed() {
1934 toggle_set = Some(set);
1935 }
1936 }
1937 ui.separator();
1938 ui.label("Clip");
1939 let sex_token = self.preview2d.sex_token();
1940 let clips: Vec<_> = self
1941 .preview2d_catalog
1942 .iter()
1943 .filter(|e| e.sex == sex_token && e.is_body())
1944 .max_by_key(|e| e.path.clone())
1945 .map(|e| e.clips.clone())
1946 .unwrap_or_default();
1947 if clips.is_empty() {
1948 ui.colored_label(
1949 egui::Color32::LIGHT_RED,
1950 "Geen body-export met clips voor dit geslacht.",
1951 );
1952 }
1953 for (token, name, frames, duration) in &clips {
1954 let label = if name.is_empty() {
1955 format!("{token} ({frames}f)")
1956 } else {
1957 format!("{name} ({token}, {frames}f)")
1958 };
1959 if ui
1960 .selectable_label(self.preview2d.clip_token == *token, label)
1961 .clicked()
1962 {
1963 select_clip = Some((token.clone(), *frames, *duration));
1964 }
1965 }
1966 ui.separator();
1967 ui.horizontal(|ui| {
1968 let play_label = if self.preview2d.playing {
1969 "Pauze"
1970 } else {
1971 "Speel"
1972 };
1973 if ui.button(play_label).clicked() {
1974 toggle_play = true;
1975 }
1976 ui.label(format!(
1977 "frame {}/{} t={:.2}s",
1978 self.preview2d.frame_index() + 1,
1979 self.preview2d.clip_frames.max(1),
1980 self.preview2d.elapsed_secs
1981 ));
1982 });
1983 ui.horizontal(|ui| {
1984 ui.label("Snelheid");
1985 ui.add(
1986 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1987 .desired_width(48.0),
1988 );
1989 if ui.button("Pas toe").clicked() {
1990 apply_speed = true;
1991 }
1992 ui.label(format!("export-fps {}", self.preview2d.fps));
1993 });
1994 ui.separator();
1995 ui.label("Weergave");
1996 for mode in ViewMode::all() {
1997 if ui
1998 .selectable_label(self.preview2d.view_mode == mode, mode.label_nl())
1999 .clicked()
2000 {
2001 set_view_mode = Some(mode);
2002 }
2003 }
2004 ui.weak("Resultaat = belichte compositie; andere standen tonen de ongebelichte map.");
2005 ui.separator();
2006 ui.label("Hoek (export)");
2007 ui.horizontal(|ui| {
2008 if ui.button("Yaw −").clicked() {
2009 step_yaw = -1;
2010 }
2011 if ui.button("Yaw +").clicked() {
2012 step_yaw = 1;
2013 }
2014 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
2015 });
2016 ui.horizontal(|ui| {
2017 if ui.button("Pitch −").clicked() {
2018 step_pitch = -1;
2019 }
2020 if ui.button("Pitch +").clicked() {
2021 step_pitch = 1;
2022 }
2023 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
2024 });
2025 ui.separator();
2026 ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {
2027 let held_ctx = ui.ctx().clone();
2028 let held = |r: &egui::Response| {
2029 held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i))
2030 };
2031 ui.label("Licht (vasthouden; °/s — alleen Resultaat)");
2032 ui.horizontal(|ui| {
2033 let west = ui.button("Azimut −");
2034 let east = ui.button("Azimut +");
2035 if self.preview2d.view_mode.uses_light() {
2036 if held(&west) {
2037 hold_az = Some(HoldSign::Negative);
2038 } else if held(&east) {
2039 hold_az = Some(HoldSign::Positive);
2040 } else if self.preview2d.hold_azimuth.is_some()
2041 && !held(&west)
2042 && !held(&east)
2043 {
2044 release_az = true;
2045 }
2046 }
2047 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
2048 });
2049 ui.horizontal(|ui| {
2050 let down = ui.button("Elevatie −");
2051 let up = ui.button("Elevatie +");
2052 if self.preview2d.view_mode.uses_light() {
2053 if held(&down) {
2054 hold_el = Some(HoldSign::Negative);
2055 } else if held(&up) {
2056 hold_el = Some(HoldSign::Positive);
2057 } else if self.preview2d.hold_elevation.is_some()
2058 && !held(&down)
2059 && !held(&up)
2060 {
2061 release_el = true;
2062 }
2063 }
2064 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
2065 });
2066 ui.weak(
2067 "Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",
2068 );
2069 });
2070 });
2071
2072 if go_back {
2073 self.session.back();
2074 return;
2075 }
2076 if refresh_catalog {
2077 self.refresh_preview2d_catalog();
2078 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
2079 need_rebuild = true;
2080 }
2081 if let Some(sex) = set_sex {
2082 self.preview2d.set_sex(sex);
2083 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
2084 need_rebuild = true;
2085 }
2086 if let Some(set) = toggle_set {
2087 self.preview2d.toggle_equipment(&set);
2088 need_rebuild = true;
2089 }
2090 if let Some((token, frames, duration)) = select_clip {
2091 self.preview2d.set_clip(token, frames, duration);
2092 need_rebuild = true;
2093 }
2094 if toggle_play {
2095 self.preview2d.playing = !self.preview2d.playing;
2096 }
2097 if apply_speed {
2098 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
2099 if speed.is_finite() && speed >= 0.0 {
2100 self.preview2d.playback_speed = speed;
2101 }
2102 }
2103 }
2104 if let Some(mode) = set_view_mode {
2105 if self.preview2d.view_mode != mode {
2106 self.preview2d.view_mode = mode;
2107 if !mode.uses_light() {
2108 self.preview2d.release_all_holds();
2109 }
2110 need_rebuild = true;
2111 }
2112 }
2113 if step_yaw != 0 {
2114 self.preview2d.step_yaw(step_yaw);
2115 need_rebuild = true;
2116 }
2117 if step_pitch != 0 {
2118 self.preview2d.step_pitch(step_pitch);
2119 need_rebuild = true;
2120 }
2121 if let Some(sign) = hold_az {
2122 self.preview2d.hold_light(LightAxis::Azimuth, sign);
2123 } else if release_az {
2124 self.preview2d.release_light(LightAxis::Azimuth);
2125 }
2126 if let Some(sign) = hold_el {
2127 self.preview2d.hold_light(LightAxis::Elevation, sign);
2128 } else if release_el {
2129 self.preview2d.release_light(LightAxis::Elevation);
2130 }
2131
2132 // Playing always refreshes; light holds only matter in Resultaat.
2133 if self.preview2d.playing
2134 || (self.preview2d.view_mode.uses_light()
2135 && (self.preview2d.hold_azimuth.is_some()
2136 || self.preview2d.hold_elevation.is_some()))
2137 {
2138 need_rebuild = true;
2139 }
2140
2141 let key = (
2142 self.preview2d.clip_token.clone(),
2143 self.preview2d.frame_index(),
2144 self.preview2d.pitch_deg(),
2145 self.preview2d.yaw_deg(),
2146 self.preview2d.selected_equipment.len() as u32,
2147 format!(
2148 "{:.1}/{:.1}/{:?}/{}",
2149 self.preview2d.light_azimuth_deg,
2150 self.preview2d.light_elevation_deg,
2151 self.preview2d.view_mode,
2152 self.preview2d.sex_token()
2153 ),
2154 );
2155 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
2156 self.rebuild_preview2d_frame();
2157 self.preview2d_texture_key = Some(key);
2158 self.preview2d_texture = None;
2159 }
2160
2161 egui::CentralPanel::default().show(ctx, |ui| {
2162 if self.session.confirm.is_some() || self.dialog_pending {
2163 ui.disable();
2164 }
2165 if let Some(diag) = &self.preview2d.diagnostic {
2166 ui.colored_label(egui::Color32::LIGHT_RED, diag);
2167 }
2168 let Some(image) = &self.preview2d_image else {
2169 ui.label(
2170 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
2171 );
2172 return;
2173 };
2174 if self.preview2d_texture.is_none() {
2175 let color = egui::ColorImage::from_rgba_unmultiplied(
2176 [image.width as usize, image.height as usize],
2177 &image.rgba,
2178 );
2179 self.preview2d_texture = Some(ui.ctx().load_texture(
2180 "preview2d-frame",
2181 color,
2182 egui::TextureOptions::NEAREST,
2183 ));
2184 }
2185 let texture = self.preview2d_texture.as_ref().expect("texture");
2186 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
2187 let fit = crate::render_job::fit_preview(
2188 [image.width, image.height],
2189 [bounds.width(), bounds.height()],
2190 );
2191 let rect = egui::Rect::from_min_size(
2192 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
2193 egui::vec2(fit.size[0], fit.size[1]),
2194 );
2195 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
2196 ui.painter().image(
2197 texture.id(),
2198 rect,
2199 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
2200 egui::Color32::WHITE,
2201 );
2202 });
2203 }
2204
2205 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
2206 if !project {
2207 return;
2208 }
2209 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
2210 if self.session.confirm.is_some() || self.dialog_pending {
2211 ui.disable();
2212 }
2213 ui.horizontal(|ui| {
2214 if ui.button("Body").clicked() {
2215 self.session.show_bodies();
2216 }
2217 if ui.button("2D-preview").clicked() {
2218 self.open_preview2d();
2219 }
2220 if ui.button("Opslaan").clicked() {
2221 self.save();
2222 }
2223 if ui
2224 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
2225 .clicked()
2226 {
2227 self.begin(Intent::PickNew);
2228 }
2229 if ui
2230 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
2231 .clicked()
2232 {
2233 self.begin(Intent::PickLoad);
2234 }
2235 });
2236 });
2237 }
2238}
2239
2240fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
2241 fn node(
2242 ui: &mut egui::Ui,
2243 body: &PreviewCharacter,
2244 paths: &[String],
2245 index: usize,
2246 selected: Option<&str>,
2247 choice: &mut Option<usize>,
2248 depth: usize,
2249 ) {
2250 if depth > 64 {
2251 return;
2252 }
2253 let joint = body.joint_nodes.iter().position(|i| *i == index);
2254 let children: Vec<_> = body
2255 .nodes
2256 .iter()
2257 .enumerate()
2258 .filter(|(_, n)| n.parent == Some(index))
2259 .map(|(i, _)| i)
2260 .collect();
2261 let label = if body.nodes[index].name.is_empty() {
2262 format!("node-{index}")
2263 } else {
2264 body.nodes[index].name.clone()
2265 };
2266 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
2267 if let Some(joint) = joint {
2268 if ui
2269 .selectable_label(
2270 selected == Some(paths[joint].as_str()),
2271 format!("Koppel {label}"),
2272 )
2273 .clicked()
2274 {
2275 *choice = Some(joint);
2276 }
2277 }
2278 };
2279 if children.is_empty() {
2280 select(ui, choice);
2281 } else {
2282 egui::CollapsingHeader::new(&label)
2283 .id_salt(index)
2284 .default_open(depth < 2)
2285 .show(ui, |ui| {
2286 select(ui, choice);
2287 for child in children {
2288 node(ui, body, paths, child, selected, choice, depth + 1);
2289 }
2290 });
2291 }
2292 }
2293 let mut choice = None;
2294 let paths = crate::preview::joint_paths(body);
2295 for (index, item) in body.nodes.iter().enumerate() {
2296 if item.parent.is_none() {
2297 node(ui, body, &paths, index, selected, &mut choice, 0);
2298 }
2299 }
2300 choice
2301}
2302
2303#[cfg(test)]
2304mod tests {
2305 use super::*;
2306 use crate::dialogs::ScriptedDialogs;
2307 use crate::session::Choice;
2308
2309 fn root() -> PathBuf {
2310 std::env::temp_dir().join(format!(
2311 "paperdoll-editor-{}",
2312 std::time::SystemTime::now()
2313 .duration_since(std::time::UNIX_EPOCH)
2314 .unwrap()
2315 .as_nanos()
2316 ))
2317 }
2318
2319 #[test]
2320 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2321 let root = root();
2322 let mut dialogs = ScriptedDialogs::default();
2323 dialogs.push(Some(root.clone()));
2324 dialogs.push(None);
2325 let mut app = EditorApp::new(Box::new(dialogs));
2326 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
2327 app.begin(Intent::PickNew);
2328 app.poll_dialogs();
2329 assert_eq!(app.session.screen, Screen::Bodies);
2330 assert!(app.preview_male.is_some());
2331 assert!(app.preview_female.is_some());
2332 app.session.set_name("Keep my edit".into());
2333 app.begin(Intent::PickLoad);
2334 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
2335 app.apply_choice(Choice::Discard, &egui::Context::default());
2336 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
2337 assert_eq!(
2338 app.session.opened.as_ref().unwrap().document.name,
2339 "Keep my edit"
2340 );
2341 assert!(app.session.is_dirty());
2342 let _ = std::fs::remove_dir_all(root);
2343 }
2344
2345 #[test]
2346 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
2347 let root = root();
2348 let mut app = EditorApp::default();
2349 app.session.install_new_project(&root).unwrap();
2350 app.session.set_render(0, 128, 12);
2351 app.begin(Intent::Quit);
2352 app.apply_choice(Choice::Save, &egui::Context::default());
2353 assert!(!app.allow_close);
2354 assert_eq!(app.session.confirm, Some(Intent::Quit));
2355 assert!(app.session.is_dirty());
2356 app.open_path(root.join("missing.ppd"));
2357 assert!(app.session.opened.is_some());
2358 assert_eq!(app.session.screen, Screen::Bodies);
2359 let _ = std::fs::remove_dir_all(root);
2360 }
2361
2362 #[test]
2363 fn fbx_body_and_fbx_equipment_reach_live_view_and_render_job() {
2364 let root = root();
2365 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
2366 let mut app = EditorApp::default();
2367 app.session.install_new_project(&root).unwrap();
2368 let relative = crate::session::adopt_file(
2369 &root,
2370 &fixtures.join("bodies/test-body-male.fbx"),
2371 "assets/body",
2372 )
2373 .unwrap();
2374 app.session.set_body_path(Sex::Male, relative);
2375 app.session.save().unwrap();
2376 app.open_path(root.join("character.ppd"));
2377 let male = app.preview_male.clone().expect("FBX-lichaam in live view");
2378 assert_eq!(male.joint_names.len(), 19);
2379 assert!(male.clips.iter().any(|c| c.name.contains("walk")));
2380 assert!(matches!(&app.inventories[0], Some(Ok(_))));
2381 // Rigid FBX equipment through the same picker path, auto-bound to the FBX rig.
2382 let index = app.session.add_equipment();
2383 app.session.equipment[index].slug = "fbxsword".into();
2384 app.session.active_body = Some(Sex::Male);
2385 app.take_equipment(index, &fixtures.join("equipment/sword-fbx.fbx"));
2386 let preset = app.session.equipment[index].preset.clone().expect("preset");
2387 assert_eq!(preset.attachments[0].label, "sword-fbx.fbx");
2388 assert!(preset.attachments[0].bones[0].is_some());
2389 assert_eq!(
2390 preset.rigs[0].as_ref(),
2391 Some(&crate::equipment::RigSignature::of(&male))
2392 );
2393 app.open_body_configuration(Sex::Male);
2394 let job = app.build_job().expect("render job for FBX body");
2395 assert_eq!(job.body_model.path, "assets/body/test-body-male.fbx");
2396 assert_eq!(job.character.positions, male.positions);
2397 let _ = std::fs::remove_dir_all(root);
2398 }
2399
2400 #[test]
2401 fn static_obj_body_reaches_live_view_and_disables_bone_functions() {
2402 let root = root();
2403 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
2404 let mut app = EditorApp::default();
2405 app.session.install_new_project(&root).unwrap();
2406 let mut relative = String::new();
2407 for name in [
2408 "static-body-male.obj",
2409 "static-body-male.mtl",
2410 "static-body-albedo.png",
2411 ] {
2412 let r = crate::session::adopt_file(
2413 &root,
2414 &fixtures.join("bodies/static").join(name),
2415 "assets/body",
2416 )
2417 .unwrap();
2418 if name.ends_with(".obj") {
2419 relative = r;
2420 }
2421 }
2422 app.session.set_body_path(Sex::Male, relative.clone());
2423 app.session.save().unwrap();
2424 app.open_path(root.join("character.ppd"));
2425 let male = app
2426 .preview_male
2427 .clone()
2428 .expect("statisch OBJ-lichaam in live view");
2429 assert!(male.is_static());
2430 assert!(male.clips.is_empty() && male.joint_names.is_empty());
2431 let inventory = app.inventories[0].as_ref().unwrap().as_ref().unwrap();
2432 assert!(inventory.joints.is_empty() && inventory.clips.is_empty());
2433 // Equipment can be imported but is never auto-bound to an invented bone.
2434 let index = app.session.add_equipment();
2435 app.session.equipment[index].slug = "sword".into();
2436 app.session.active_body = Some(Sex::Male);
2437 app.take_equipment(index, &fixtures.join("equipment/sword.obj"));
2438 let preset = app.session.equipment[index].preset.clone().expect("preset");
2439 assert!(preset.attachments[0].bones[0].is_none());
2440 assert!(preset.rigs[0].is_none());
2441 app.open_body_configuration(Sex::Male);
2442 let job = app.build_job().expect("render job for static body");
2443 assert_eq!(job.body_model.path, relative);
2444 assert!(job.character.is_static());
2445 let contract = crate::production::contract(job).unwrap();
2446 assert_eq!(contract.plan.images, 60);
2447 assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2448 app.open_equipment_configuration(index, Sex::Male);
2449 let job = app.build_job().expect("equipment job object");
2450 assert!(crate::production::contract(job)
2451 .err()
2452 .unwrap()
2453 .contains("statisch"));
2454 // Headless UI frames for the body and equipment screens must not panic.
2455 let ctx = egui::Context::default();
2456 for screen in 0..2 {
2457 if screen == 0 {
2458 app.open_body_configuration(Sex::Male);
2459 } else {
2460 app.open_equipment_configuration(index, Sex::Male);
2461 }
2462 let _ = ctx.run(egui::RawInput::default(), |ctx| match screen {
2463 0 => app.show_body(ctx),
2464 _ => app.show_equipment(ctx),
2465 });
2466 }
2467 let _ = std::fs::remove_dir_all(root);
2468 }
2469
2470 #[test]
2471 fn open_with_project_path_loads_bodies_and_models() {
2472 let root = root().join("project with spaces");
2473 let opened = crate::session::create_seeded_project(&root).unwrap();
2474 let mut app = EditorApp::default();
2475 app.open_path(opened.manifest_path.clone());
2476 assert_eq!(app.session.screen, Screen::Bodies);
2477 assert!(app.preview_male.is_some());
2478 assert_eq!(
2479 app.session.opened.unwrap().manifest_path,
2480 opened.manifest_path
2481 );
2482 let _ = std::fs::remove_dir_all(root.parent().unwrap());
2483 }
2484
2485 fn pump(app: &mut EditorApp) {
2486 for _ in 0..20000 {
2487 if !app.render_all_running() {
2488 return;
2489 }
2490 app.service_gpu(None);
2491 app.drain_render();
2492 std::thread::sleep(std::time::Duration::from_millis(1));
2493 }
2494 panic!("Render alles liep niet af");
2495 }
2496
2497 #[test]
2498 fn render_all_queues_bodies_and_equipment_with_skip_diagnostics() {
2499 let root = root();
2500 let opened = crate::session::create_seeded_project(&root).unwrap();
2501 let mut app = EditorApp::default();
2502 app.open_path(opened.manifest_path.clone());
2503 let index = app.session.add_equipment();
2504 app.session.equipment[index].slug = "leeg".into();
2505 let (items, skipped) = app.render_all_plan();
2506 let keys: Vec<_> = items.iter().map(|i| (i.sex, i.equipment)).collect();
2507 assert_eq!(
2508 keys,
2509 vec![(0, None), (1, None), (0, Some(index)), (1, Some(index))]
2510 );
2511 assert!(skipped.is_empty());
2512 // Without a GL context every body render fails; empty equipment is
2513 // skipped by the render contract; the queue still continues.
2514 app.start_render_all();
2515 assert!(app.render_all_running());
2516 pump(&mut app);
2517 let summary = app.render_all_summary().unwrap().clone();
2518 assert_eq!(summary.failed.len(), 2, "{summary:?}");
2519 assert!(summary.failed[0].1.contains("Glow"), "{summary:?}");
2520 assert_eq!(summary.skipped.len(), 2, "{summary:?}");
2521 assert!(summary.skipped[0].0.contains("leeg"), "{summary:?}");
2522 assert!(!summary.cancelled);
2523 assert!(app.session.notice.contains("2 overgeslagen, 2 mislukt"));
2524 // Cancel stops the queue; the rest is listed as not started.
2525 app.start_render_all();
2526 app.cancel_render();
2527 pump(&mut app);
2528 let summary = app.render_all_summary().unwrap().clone();
2529 assert!(summary.cancelled, "{summary:?}");
2530 assert_eq!(summary.not_started.len(), 3, "{summary:?}");
2531 let _ = std::fs::remove_dir_all(root);
2532 }
2533
2534 #[test]
2535 fn depth_format_and_range_settings_reach_job_and_persist() {
2536 let root = root();
2537 let opened = crate::session::create_seeded_project(&root).unwrap();
2538 let mut app = EditorApp::default();
2539 app.open_path(opened.manifest_path.clone());
2540 use crate::camera::{DepthEncoding, DepthFormat};
2541 assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2542 // Body screen with the depth controls renders headless.
2543 let ctx = egui::Context::default();
2544 let _ = ctx.run(Default::default(), |ctx| app.show_bodies(ctx));
2545 assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2546 let job = app.job_for(0, None).unwrap();
2547 assert_eq!(job.depth_format, DepthFormat::default());
2548 app.set_depth_format(16, DepthEncoding::RgbPacked);
2549 app.session.opened.as_mut().unwrap().document.render.framing[1].depth_range_milli =
2550 Some(1500);
2551 let job = app.job_for(1, None).unwrap();
2552 assert_eq!(
2553 job.depth_format,
2554 DepthFormat::new(16, DepthEncoding::RgbPacked).unwrap()
2555 );
2556 let doc = crate::production::contract(job).unwrap().render_doc;
2557 assert_eq!(doc["depth_bits"], 16);
2558 assert_eq!(doc["depth_encoding"], "rgb_packed");
2559 assert_eq!(doc["depth_near"].as_f64().unwrap() as f32, 9.25);
2560 app.session.save().unwrap();
2561 let mut again = EditorApp::default();
2562 again.open_path(opened.manifest_path.clone());
2563 assert_eq!(again.depth_format(), Some((16, DepthEncoding::RgbPacked)));
2564 let text = std::fs::read_to_string(&opened.manifest_path).unwrap();
2565 assert!(
2566 text.contains("\"depth_encoding\": \"rgb_packed\""),
2567 "{text}"
2568 );
2569 assert!(text.contains("\"depth_range_milli\": 1500"), "{text}");
2570 let _ = std::fs::remove_dir_all(root);
2571 }
2572
2573 #[test]
2574 fn renderer_disconnect_is_reported() {
2575 let mut app = EditorApp::default();
2576 let (_, gpu) = std::sync::mpsc::channel();
2577 app.render_worker = Some(crate::production::RenderThread {
2578 mailbox: Default::default(),
2579 gpu,
2580 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2581 handle: None,
2582 });
2583 app.render_ui.running = true;
2584 app.drain_render();
2585 assert!(!app.render_ui.running);
2586 assert!(app.render_ui.error.is_some());
2587 }
2588}