BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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