BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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