BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

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