BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 74.1 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::compositor::MAX_RIM_INTENSITY;
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
11use crate::preview2d::{
12 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
13 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
14};
15use crate::preview_gl::Viewport;
16use crate::project::open_project;
17use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
18use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
19
20pub struct EditorApp {
21 session: Session,
22 dialogs: Box<dyn Dialogs>,
23 controls: PreviewControls,
24 preview_male: Option<PreviewCharacter>,
25 preview_female: Option<PreviewCharacter>,
26 viewport: Viewport,
27 inventories: [Option<Result<ImportedModel, String>>; 2],
28 render_worker: Option<crate::production::RenderThread>,
29 gpu_renderer: crate::production::GpuRenderer,
30 show_render_frame: bool,
31 render_ui: RenderUiState,
32 pass_texture: Option<egui::TextureHandle>,
33 pass_texture_revision: u64,
34 applied_shell: Option<[f32; 2]>,
35 width_text: String,
36 height_text: String,
37 fps_text: String,
38 allow_close: bool,
39 dialog_pending: bool,
40 equipment_part: usize,
41 preview2d: Preview2dState,
42 preview2d_catalog: Vec<CatalogEntry>,
43 preview2d_texture: Option<egui::TextureHandle>,
44 preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
45 preview2d_image: Option<ImageBuf>,
46 preview2d_speed_text: String,
47 resume_candidates: Vec<crate::resume::ResumeCandidate>,
48 resume_scanned: bool,
49 resume_scan_error: Option<String>,
50}
51
52impl EditorApp {
53 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
54 Self {
55 session: Session::new(),
56 dialogs,
57 controls: PreviewControls::default(),
58 preview_male: None,
59 preview_female: None,
60 viewport: Viewport::new(),
61 inventories: [None, None],
62 render_worker: None,
63 gpu_renderer: Default::default(),
64 show_render_frame: true,
65 render_ui: RenderUiState::idle(),
66 pass_texture: None,
67 pass_texture_revision: 0,
68 applied_shell: None,
69 width_text: "128".to_string(),
70 height_text: "128".to_string(),
71 fps_text: "12".to_string(),
72 allow_close: false,
73 dialog_pending: false,
74 equipment_part: 0,
75 preview2d: Preview2dState::default(),
76 preview2d_catalog: Vec::new(),
77 preview2d_texture: None,
78 preview2d_texture_key: None,
79 preview2d_image: None,
80 preview2d_speed_text: "1.0".to_string(),
81 resume_candidates: Vec::new(),
82 resume_scanned: false,
83 resume_scan_error: None,
84 }
85 }
86
87 pub fn open_path(&mut self, path: PathBuf) {
88 let candidate = std::env::current_dir()
89 .map_err(|e| e.to_string())
90 .and_then(|base| {
91 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
92 });
93 let path = match candidate {
94 Ok(path) => path,
95 Err(error) => {
96 self.session.fail(error);
97 return;
98 }
99 };
100 match open_project(&path) {
101 Ok(opened) => {
102 self.session.install_loaded(opened);
103 self.reset_render();
104 self.sync_fields();
105 self.reload_bodies();
106 }
107 Err(error) => {
108 self.session.fail(error.to_string());
109 }
110 }
111 }
112
113 pub fn open_fixture_project(&mut self) {
114 let root = std::env::temp_dir().join(format!(
115 "paperdoll-renderer-fixture-{}-{}",
116 std::process::id(),
117 std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .unwrap_or_default()
120 .as_nanos()
121 ));
122 if let Err(error) = self.session.install_new_project(&root) {
123 self.session.fail(error.to_string());
124 return;
125 }
126 self.sync_fields();
127 self.reload_bodies();
128 self.session.show_body(Sex::Male);
129 self.controls.sex = PreviewSex::Male;
130 self.controls.select_clip("walk");
131 }
132
133 fn reset_render(&mut self) {
134 self.render_worker = None;
135 self.render_ui = RenderUiState::idle();
136 self.pass_texture = None;
137 self.resume_candidates.clear();
138 self.resume_scanned = false;
139 self.resume_scan_error = None;
140 }
141
142 fn sync_fields(&mut self) {
143 if let Some(opened) = &self.session.opened {
144 self.width_text = opened.document.render.target_width.to_string();
145 self.height_text = opened.document.render.target_height.to_string();
146 self.fps_text = opened.document.render.fps.to_string();
147 }
148 }
149
150 fn reload_bodies(&mut self) {
151 self.preview_male = None;
152 self.preview_female = None;
153 self.inventories = [None, None];
154 let Some(root) = self.session.project_root() else {
155 return;
156 };
157 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
158 if let Some(relative) = self.session.body_path(sex) {
159 let imported =
160 import_project_model(&root, relative).map_err(|error| error.to_string());
161 // Validate the source and its dependencies before the viewport reads it.
162 if imported.is_ok()
163 && matches!(
164 std::path::Path::new(relative)
165 .extension()
166 .and_then(|e| e.to_str()),
167 Some("gltf" | "glb")
168 )
169 {
170 match load_gltf_file(&root.join(relative)) {
171 Ok(character) => match sex {
172 Sex::Male => self.preview_male = Some(character),
173 Sex::Female => self.preview_female = Some(character),
174 },
175 Err(error) => self.session.fail(format!("3D: {error}")),
176 }
177 }
178 self.inventories[index] = Some(imported);
179 }
180 }
181 }
182
183 fn character(&self) -> Option<&PreviewCharacter> {
184 match self.controls.sex {
185 PreviewSex::Male => self.preview_male.as_ref(),
186 PreviewSex::Female => self.preview_female.as_ref(),
187 }
188 }
189
190 fn poll_dialogs(&mut self) {
191 let poll = self.dialogs.poll();
192 self.dialog_pending = matches!(poll, DialogPoll::Pending);
193 if let DialogPoll::Ready { purpose, path } = poll {
194 let Some(path) = path else {
195 return;
196 };
197 match purpose {
198 DialogPurpose::NewProject => {
199 if let Err(error) = self.session.install_new_project(&path) {
200 self.session.fail(error.to_string());
201 } else {
202 self.reset_render();
203 self.sync_fields();
204 self.reload_bodies();
205 }
206 }
207 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
208 Ok(opened) => {
209 self.session.install_loaded(opened);
210 self.reset_render();
211 self.sync_fields();
212 self.reload_bodies();
213 }
214 Err(error) => self.session.fail(error.to_string()),
215 },
216 DialogPurpose::Body(sex) => self.take_model(sex, &path),
217 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
218 DialogPurpose::EquipmentShiny(index, part) => {
219 if let Some(mut preset) = self
220 .session
221 .equipment
222 .get(index)
223 .and_then(|i| i.preset.clone())
224 {
225 let result = crate::equipment::import_shiny(&mut preset, part, &path)
226 .and_then(|_| self.session.ensure_preset_budget(Some(index), &preset));
227 match result {
228 Ok(()) => self.session.equipment[index].preset = Some(preset),
229 Err(error) => self.session.fail(error),
230 }
231 }
232 }
233 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
234 Ok(mut preset) => {
235 let result = self
236 .session
237 .available_equipment_name(&preset.name)
238 .and_then(|name| {
239 preset.name = name;
240 self.session.ensure_preset_budget(None, &preset)
241 });
242 if let Err(error) = result {
243 self.session.fail(error);
244 return;
245 }
246 let index = self.session.equipment.len();
247 self.session.equipment.push(crate::session::EquipmentItem {
248 slug: preset.name.clone(),
249 model: preset.attachments.first().map(|p| p.label.clone()),
250 preset: Some(preset),
251 legacy_path: None,
252 error: None,
253 });
254 self.equipment_part = 0;
255 self.session.show_equipment(index);
256 }
257 Err(error) => self.session.fail(error),
258 },
259 }
260 }
261 }
262
263 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
264 let Some(root) = self.session.project_root() else {
265 return;
266 };
267 match adopt_file(&root, path, "assets/body") {
268 Ok(relative) => {
269 self.session.set_body_path(sex, relative.clone());
270 self.reload_bodies();
271 self.reset_render();
272 let index = if sex == Sex::Male { 0 } else { 1 };
273 if let Some(Err(error)) = &self.inventories[index] {
274 self.session.fail(format!("{relative}: {error}"));
275 } else {
276 self.session.notice = format!("{relative} staat in het project.");
277 self.session.notice_ok = true;
278 }
279 }
280 Err(error) => self.session.fail(error),
281 }
282 }
283
284 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
285 let sex = if self.session.active_body == Some(Sex::Female) {
286 1
287 } else {
288 0
289 };
290 let body = if sex == 0 {
291 self.preview_male.as_ref()
292 } else {
293 self.preview_female.as_ref()
294 };
295 let Some(item) = self.session.equipment.get(index) else {
296 return;
297 };
298 let mut preset = item
299 .preset
300 .clone()
301 .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
302 preset.name = item.slug.clone();
303 match crate::equipment::import_obj(&mut preset, path) {
304 Ok(part) => {
305 if let Some(body) = body {
306 if !body.joint_nodes.is_empty() {
307 let _ = preset.bind(sex, body, part, 0);
308 }
309 }
310 if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) {
311 self.session.fail(error);
312 return;
313 }
314 let item = &mut self.session.equipment[index];
315 item.model = Some(preset.attachments[part].label.clone());
316 item.legacy_path = None;
317 item.error = None;
318 item.preset = Some(preset);
319 self.equipment_part = part;
320 self.session.notice =
321 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
322 .into();
323 self.session.notice_ok = true;
324 }
325 Err(error) => self.session.fail(error),
326 }
327 }
328
329 pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
330 self.session.active_body = Some(sex);
331 self.session.show_equipment(index);
332 self.equipment_part = 0;
333 }
334
335 fn begin(&mut self, intent: Intent) {
336 if self.session.gate(intent) {
337 self.run_intent(intent);
338 }
339 }
340
341 fn run_intent(&mut self, intent: Intent) {
342 match intent {
343 Intent::Quit => {}
344 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
345 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
346 }
347 }
348
349 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
350 match self.session.resolve(choice) {
351 AfterChoice::Stay => {}
352 AfterChoice::SaveThen(intent) => match self.session.save() {
353 Ok(()) => self.finish_intent(intent, ctx),
354 Err(error) => self.session.fail(error.to_string()),
355 },
356 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
357 }
358 }
359
360 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
361 if intent == Intent::Quit {
362 self.allow_close = true;
363 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
364 } else {
365 self.run_intent(intent);
366 }
367 }
368
369 fn save(&mut self) {
370 if let Err(error) = self.session.save() {
371 self.session.fail(error.to_string());
372 }
373 }
374
375 fn apply_render_fields(&mut self) {
376 let width = self.width_text.parse::<u32>().unwrap_or(0);
377 let height = self.height_text.parse::<u32>().unwrap_or(0);
378 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
379 self.session.set_render(width, height, fps);
380 }
381
382 /// Open the body editor, also used by the native smoke example.
383 pub fn open_body_configuration(&mut self, sex: Sex) {
384 self.session.show_body(sex);
385 self.controls.sex = match sex {
386 Sex::Male => PreviewSex::Male,
387 Sex::Female => PreviewSex::Female,
388 };
389 }
390 pub fn render_error(&self) -> Option<&str> {
391 self.render_ui.error.as_deref()
392 }
393 pub fn render_all_angles(&mut self) {
394 self.start_render();
395 }
396 /// Legacy smoke entry point, now starts the production batch as well.
397 pub fn render_current_pose(&mut self) {
398 self.render_all_angles();
399 }
400 pub fn render_is_running(&self) -> bool {
401 self.render_ui.running
402 }
403 pub fn render_progress(&self) -> (u32, u32, String) {
404 (
405 self.render_ui.progress.completed,
406 self.render_ui.progress.total,
407 self.render_ui.progress.label.clone(),
408 )
409 }
410 /// The job the Render action would start now (current project, body, equipment).
411 fn build_job(&mut self) -> Option<RenderJob> {
412 let opened = self.session.opened.as_ref()?;
413 if let Err(error) = opened.document.validate_shape() {
414 self.session.fail(error.to_string());
415 return None;
416 }
417 let Some(character) = self.character().cloned() else {
418 self.session.fail("dit lichaam heeft geen tekenbaar glTF");
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-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 self.framing_ui(ui, usize::from(sex == Sex::Female));
1005 self.render_progress_ui(ui);
1006 self.resume_ui(ui);
1007 if self.render_ui.preview.is_some() {
1008 self.paint_pass(ui);
1009 } else {
1010 let character = match self.controls.sex {
1011 PreviewSex::Male => self.preview_male.as_ref(),
1012 PreviewSex::Female => self.preview_female.as_ref(),
1013 };
1014 if character.is_some() {
1015 let framing = self.fixed_camera();
1016 self.viewport
1017 .show_framed(ui, &mut self.controls, character, &[], framing);
1018 } else {
1019 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
1020 }
1021 }
1022 });
1023 }
1024
1025 fn paint_pass(&mut self, ui: &mut egui::Ui) {
1026 let Some(image) = &self.render_ui.preview else {
1027 return;
1028 };
1029 ui.label(format!(
1030 "Laatste voltooide pass: {}",
1031 self.render_ui.preview_label
1032 ));
1033 if self.pass_texture.is_none()
1034 || self.pass_texture_revision != self.render_ui.preview_revision
1035 {
1036 let color = egui::ColorImage::from_rgba_unmultiplied(
1037 [image.width as usize, image.height as usize],
1038 &image.rgba,
1039 );
1040 let options = egui::TextureOptions::NEAREST;
1041 if let Some(texture) = &mut self.pass_texture {
1042 texture.set(color, options);
1043 } else {
1044 self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options));
1045 }
1046 self.pass_texture_revision = self.render_ui.preview_revision;
1047 }
1048 let texture = self.pass_texture.as_ref().expect("pass texture installed");
1049 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1050 let fit = crate::render_job::fit_preview(
1051 [image.width, image.height],
1052 [bounds.width(), bounds.height()],
1053 );
1054 let rect = egui::Rect::from_min_size(
1055 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1056 egui::vec2(fit.size[0], fit.size[1]),
1057 );
1058 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1059 ui.painter().image(
1060 texture.id(),
1061 rect,
1062 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1063 egui::Color32::WHITE,
1064 );
1065 }
1066
1067 fn show_equipment_list(&mut self, ctx: &egui::Context) {
1068 self.toolbar(ctx, true);
1069 egui::CentralPanel::default().show(ctx, |ui| {
1070 if self.session.confirm.is_some() || self.dialog_pending {
1071 ui.disable();
1072 }
1073 ui.heading(format!(
1074 "Bodies → {} → Equipment",
1075 match self.session.active_body {
1076 Some(Sex::Male) => "Man",
1077 Some(Sex::Female) => "Vrouw",
1078 None => "Body",
1079 }
1080 ));
1081 if ui.button("Terug naar body").clicked() {
1082 self.session.back();
1083 }
1084 let mut open = None;
1085 for (index, item) in self.session.equipment.iter().enumerate() {
1086 let label = match &item.model {
1087 Some(model) => format!("{} {model}", item.slug),
1088 None => item.slug.clone(),
1089 };
1090 if ui.button(label).clicked() {
1091 open = Some(index);
1092 }
1093 }
1094 if ui
1095 .add_enabled(
1096 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1097 egui::Button::new("Nieuw"),
1098 )
1099 .clicked()
1100 {
1101 self.equipment_part = 0;
1102 self.session.add_equipment();
1103 }
1104 if ui
1105 .add_enabled(
1106 self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1107 egui::Button::new("Preset importeren"),
1108 )
1109 .clicked()
1110 {
1111 self.dialogs.start(DialogPurpose::ImportEquipment);
1112 }
1113 if let Some(index) = open {
1114 self.session.show_equipment(index);
1115 }
1116 });
1117 }
1118
1119 fn show_equipment(&mut self, ctx: &egui::Context) {
1120 self.toolbar(ctx, true);
1121 let Screen::Equipment(index) = self.session.screen else {
1122 return;
1123 };
1124 let mut sex = if self.session.active_body == Some(Sex::Female) {
1125 1
1126 } else {
1127 0
1128 };
1129 egui::SidePanel::left("equipment-config")
1130 .default_width(330.0)
1131 .resizable(true)
1132 .show(ctx, |ui| {
1133 if self.session.confirm.is_some() || self.dialog_pending {
1134 ui.disable();
1135 }
1136 egui::ScrollArea::vertical().show(ui, |ui| {
1137 ui.heading("Equipment-configuratie");
1138 if ui.button("Terug naar equipment").clicked() {
1139 self.session.show_equipment_list();
1140 }
1141 ui.horizontal(|ui| {
1142 ui.selectable_value(&mut sex, 0, "Man");
1143 ui.selectable_value(&mut sex, 1, "Vrouw");
1144 });
1145 self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1146 let Some(item) = self.session.equipment.get_mut(index) else {
1147 return;
1148 };
1149 ui.label("Naam (kleine letters/cijfers/-/_)");
1150 ui.text_edit_singleline(&mut item.slug);
1151 if !crate::equipment::valid_name(&item.slug) {
1152 ui.colored_label(
1153 egui::Color32::LIGHT_RED,
1154 "Ongeldige naam; 'body' is gereserveerd.",
1155 );
1156 }
1157 if let Some(error) = &item.error {
1158 ui.colored_label(egui::Color32::LIGHT_RED, error);
1159 }
1160 if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
1161 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1162 }
1163 if let Some(preset) = &mut item.preset {
1164 ui.horizontal(|ui| {
1165 ui.label("Geldig voor:");
1166 ui.checkbox(&mut preset.applies[0], "Man");
1167 ui.checkbox(&mut preset.applies[1], "Vrouw");
1168 });
1169 if !preset.applies[0] && !preset.applies[1] {
1170 ui.colored_label(
1171 egui::Color32::LIGHT_RED,
1172 "Kies minstens één geslacht.",
1173 );
1174 }
1175 ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1176 ui.add(
1177 egui::TextEdit::multiline(&mut preset.provenance)
1178 .desired_rows(2)
1179 .char_limit(4096),
1180 );
1181 if preset.provenance.len() > 4096 {
1182 ui.colored_label(
1183 egui::Color32::LIGHT_RED,
1184 "Herkomst/licentie is te lang.",
1185 );
1186 }
1187 if !preset.attachments.is_empty() {
1188 self.equipment_part =
1189 self.equipment_part.min(preset.attachments.len() - 1);
1190 egui::ComboBox::from_id_salt("equipment-part")
1191 .selected_text(&preset.attachments[self.equipment_part].label)
1192 .show_ui(ui, |ui| {
1193 for (part, item) in preset.attachments.iter().enumerate() {
1194 ui.selectable_value(
1195 &mut self.equipment_part,
1196 part,
1197 &item.label,
1198 );
1199 }
1200 });
1201 let body = if sex == 0 {
1202 self.preview_male.as_ref()
1203 } else {
1204 self.preview_female.as_ref()
1205 };
1206 let attachment = &mut preset.attachments[self.equipment_part];
1207 ui.label("Positie relatief aan bot (XYZ)");
1208 ui.horizontal(|ui| {
1209 for value in &mut attachment.translation {
1210 ui.add(
1211 egui::DragValue::new(value)
1212 .speed(0.005)
1213 .range(-1000.0..=1000.0),
1214 );
1215 }
1216 });
1217 ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1218 ui.horizontal(|ui| {
1219 for value in &mut attachment.rotation_deg {
1220 ui.add(
1221 egui::DragValue::new(value)
1222 .speed(0.5)
1223 .range(-3600.0..=3600.0),
1224 );
1225 }
1226 });
1227 ui.add(
1228 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1229 .text("Glanssterkte"),
1230 );
1231 let shiny = attachment
1232 .shiny
1233 .and_then(|i| preset.assets.get(i))
1234 .map(|a| a.path.clone());
1235 ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1236 if ui.button("Kies shinymap (PNG)").clicked() {
1237 self.dialogs.start(DialogPurpose::EquipmentShiny(
1238 index,
1239 self.equipment_part,
1240 ));
1241 }
1242 let clear_shiny = ui.button("Wis shinymap").clicked();
1243 let selected = attachment.bones[sex].clone();
1244 if clear_shiny {
1245 if let Err(error) =
1246 crate::equipment::clear_shiny(preset, self.equipment_part)
1247 {
1248 item.error = Some(error);
1249 }
1250 }
1251 ui.label(format!(
1252 "Bot: {}",
1253 selected.as_deref().unwrap_or("nog niet gekoppeld")
1254 ));
1255 if let Some(body) = body {
1256 if preset.rigs[sex].as_ref()
1257 != Some(&crate::equipment::RigSignature::of(body))
1258 {
1259 ui.colored_label(
1260 egui::Color32::LIGHT_RED,
1261 "Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1262 );
1263 }
1264 ui.collapsing("Skeleton / botten", |ui| {
1265 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1266 if let Err(error) =
1267 preset.bind(sex, body, self.equipment_part, joint)
1268 {
1269 ui.colored_label(egui::Color32::LIGHT_RED, error);
1270 }
1271 }
1272 });
1273 } else {
1274 ui.colored_label(
1275 egui::Color32::LIGHT_RED,
1276 "Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
1277 );
1278 }
1279 if ui.button("Verwijder onderdeel").clicked() {
1280 if let Err(error) =
1281 crate::equipment::remove_attachment(preset, self.equipment_part)
1282 {
1283 item.error = Some(error);
1284 }
1285 self.equipment_part = self.equipment_part.saturating_sub(1);
1286 }
1287 }
1288 }
1289 if ui.button("Verwijder equipmentdefinitie").clicked() {
1290 self.session.delete_equipment(index);
1291 }
1292 });
1293 });
1294 self.controls.sex = if sex == 0 {
1295 PreviewSex::Male
1296 } else {
1297 PreviewSex::Female
1298 };
1299 egui::CentralPanel::default().show(ctx, |ui| {
1300 if self.session.confirm.is_some() || self.dialog_pending {
1301 ui.disable();
1302 }
1303 ui.horizontal_wrapped(|ui| {
1304 if ui
1305 .add_enabled(
1306 !self.render_ui.running,
1307 egui::Button::new("Render equipment · alle hoeken"),
1308 )
1309 .clicked()
1310 {
1311 self.start_render();
1312 }
1313 if ui.button("Live 3D").clicked() {
1314 self.render_ui.preview = None;
1315 }
1316 if ui.button("2D-preview").clicked() {
1317 self.open_preview2d();
1318 }
1319 });
1320 self.render_progress_ui(ui);
1321 self.resume_ui(ui);
1322 if self.render_ui.preview.is_some() {
1323 self.paint_pass(ui);
1324 return;
1325 }
1326 let framing = self.fixed_camera();
1327 let body = if sex == 0 {
1328 self.preview_male.as_ref()
1329 } else {
1330 self.preview_female.as_ref()
1331 };
1332 let preset = self
1333 .session
1334 .equipment
1335 .get(index)
1336 .and_then(|item| item.preset.as_ref());
1337 let presets: Vec<_> = preset.into_iter().collect();
1338 self.viewport
1339 .show_framed(ui, &mut self.controls, body, &presets, framing);
1340 });
1341 }
1342
1343 pub fn open_preview2d(&mut self) {
1344 self.refresh_preview2d_catalog();
1345 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1346 self.preview2d_image = None;
1347 self.preview2d_texture = None;
1348 self.preview2d_texture_key = None;
1349 self.session.show_preview2d();
1350 }
1351
1352 pub fn preview2d_state(&self) -> &Preview2dState {
1353 &self.preview2d
1354 }
1355
1356 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1357 self.preview2d_image.as_ref()
1358 }
1359
1360 fn refresh_preview2d_catalog(&mut self) {
1361 self.preview2d_catalog = match self.session.project_root() {
1362 Some(root) => discover_completed_runs(&root),
1363 None => Vec::new(),
1364 };
1365 }
1366
1367 fn rebuild_preview2d_frame(&mut self) {
1368 let sex = self.preview2d.sex;
1369 let equipment = self.preview2d.selected_equipment.clone();
1370 let clip = self.preview2d.clip_token.clone();
1371 let frame = self.preview2d.frame_index();
1372 let pitch = self.preview2d.pitch_deg();
1373 let yaw = self.preview2d.yaw_deg();
1374 let light = self.preview2d.light();
1375 let rim = self.preview2d.rim();
1376 if clip.is_empty() {
1377 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1378 self.preview2d_image = None;
1379 return;
1380 }
1381 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1382 Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1383 body: &body,
1384 equipment: &gear,
1385 clip_token: &clip,
1386 frame,
1387 pitch,
1388 yaw,
1389 light,
1390 rim,
1391 }) {
1392 Ok(image) => {
1393 self.preview2d.diagnostic = None;
1394 self.preview2d_image = Some(image);
1395 }
1396 Err(err) => {
1397 self.preview2d.diagnostic = Some(err.to_string());
1398 self.preview2d_image = None;
1399 }
1400 },
1401 Err(err) => {
1402 self.preview2d.diagnostic = Some(err.to_string());
1403 self.preview2d_image = None;
1404 }
1405 }
1406 }
1407
1408 fn show_preview2d(&mut self, ctx: &egui::Context) {
1409 self.toolbar(ctx, true);
1410 let dt = ctx.input(|i| i.stable_dt);
1411 let focused = ctx.input(|i| i.focused);
1412 let cancel_holds = ctx.input(|i| {
1413 !i.focused
1414 || i.pointer.any_released()
1415 || i.events.iter().any(|e| {
1416 matches!(
1417 e,
1418 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1419 )
1420 })
1421 });
1422 if cancel_holds || !focused {
1423 self.preview2d.on_interaction_cancel();
1424 }
1425 self.preview2d.tick(dt);
1426
1427 let mut need_rebuild = false;
1428 let mut refresh_catalog = false;
1429 let mut go_back = false;
1430 let mut set_sex = None;
1431 let mut toggle_set = None;
1432 let mut select_clip = None;
1433 let mut step_yaw = 0i32;
1434 let mut step_pitch = 0i32;
1435 let mut toggle_play = false;
1436 let mut apply_speed = false;
1437 let mut hold_az = None;
1438 let mut hold_el = None;
1439 let mut release_az = false;
1440 let mut release_el = false;
1441
1442 egui::SidePanel::left("preview2d_controls")
1443 .default_width(330.0)
1444 .show(ctx, |ui| {
1445 if self.session.confirm.is_some() || self.dialog_pending {
1446 ui.disable();
1447 }
1448 ui.heading("2D-preview");
1449 if ui.button("Terug").clicked() {
1450 go_back = true;
1451 }
1452 ui.separator();
1453 ui.label("Geslacht");
1454 ui.horizontal(|ui| {
1455 if ui
1456 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1457 .clicked()
1458 {
1459 set_sex = Some(Sex::Male);
1460 }
1461 if ui
1462 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1463 .clicked()
1464 {
1465 set_sex = Some(Sex::Female);
1466 }
1467 });
1468 if ui.button("Vernieuw exportsets").clicked() {
1469 refresh_catalog = true;
1470 }
1471 ui.label(format!(
1472 "{} voltooide exportset(s)",
1473 self.preview2d_catalog.len()
1474 ));
1475 ui.separator();
1476 ui.label("Equipment (zelfde project/rig/contract)");
1477 let allowed =
1478 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1479 if allowed.is_empty() {
1480 ui.weak("Geen compatibele equipment-export.");
1481 }
1482 for set in allowed {
1483 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1484 if ui.checkbox(&mut checked, &set).changed() {
1485 toggle_set = Some(set);
1486 }
1487 }
1488 ui.separator();
1489 ui.label("Clip");
1490 let sex_token = self.preview2d.sex_token();
1491 let clips: Vec<_> = self
1492 .preview2d_catalog
1493 .iter()
1494 .filter(|e| e.sex == sex_token && e.is_body())
1495 .max_by_key(|e| e.path.clone())
1496 .map(|e| e.clips.clone())
1497 .unwrap_or_default();
1498 if clips.is_empty() {
1499 ui.colored_label(
1500 egui::Color32::LIGHT_RED,
1501 "Geen body-export met clips voor dit geslacht.",
1502 );
1503 }
1504 for (token, name, frames, duration) in &clips {
1505 let label = if name.is_empty() {
1506 format!("{token} ({frames}f)")
1507 } else {
1508 format!("{name} ({token}, {frames}f)")
1509 };
1510 if ui
1511 .selectable_label(self.preview2d.clip_token == *token, label)
1512 .clicked()
1513 {
1514 select_clip = Some((token.clone(), *frames, *duration));
1515 }
1516 }
1517 ui.separator();
1518 ui.horizontal(|ui| {
1519 let play_label = if self.preview2d.playing {
1520 "Pauze"
1521 } else {
1522 "Speel"
1523 };
1524 if ui.button(play_label).clicked() {
1525 toggle_play = true;
1526 }
1527 ui.label(format!(
1528 "frame {}/{} t={:.2}s",
1529 self.preview2d.frame_index() + 1,
1530 self.preview2d.clip_frames.max(1),
1531 self.preview2d.elapsed_secs
1532 ));
1533 });
1534 ui.horizontal(|ui| {
1535 ui.label("Snelheid");
1536 ui.add(
1537 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1538 .desired_width(48.0),
1539 );
1540 if ui.button("Pas toe").clicked() {
1541 apply_speed = true;
1542 }
1543 ui.label(format!("export-fps {}", self.preview2d.fps));
1544 });
1545 ui.separator();
1546 ui.label("Hoek (export)");
1547 ui.horizontal(|ui| {
1548 if ui.button("Yaw −").clicked() {
1549 step_yaw = -1;
1550 }
1551 if ui.button("Yaw +").clicked() {
1552 step_yaw = 1;
1553 }
1554 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
1555 });
1556 ui.horizontal(|ui| {
1557 if ui.button("Pitch −").clicked() {
1558 step_pitch = -1;
1559 }
1560 if ui.button("Pitch +").clicked() {
1561 step_pitch = 1;
1562 }
1563 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1564 });
1565 ui.separator();
1566 ui.label("Licht (vasthouden; °/s)");
1567 ui.horizontal(|ui| {
1568 let west = ui.button("Azimut −");
1569 let east = ui.button("Azimut +");
1570 if west.is_pointer_button_down_on() {
1571 hold_az = Some(HoldSign::Negative);
1572 } else if east.is_pointer_button_down_on() {
1573 hold_az = Some(HoldSign::Positive);
1574 } else if self.preview2d.hold_azimuth.is_some()
1575 && !west.is_pointer_button_down_on()
1576 && !east.is_pointer_button_down_on()
1577 {
1578 release_az = true;
1579 }
1580 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1581 });
1582 ui.horizontal(|ui| {
1583 let down = ui.button("Elevatie −");
1584 let up = ui.button("Elevatie +");
1585 if down.is_pointer_button_down_on() {
1586 hold_el = Some(HoldSign::Negative);
1587 } else if up.is_pointer_button_down_on() {
1588 hold_el = Some(HoldSign::Positive);
1589 } else if self.preview2d.hold_elevation.is_some()
1590 && !down.is_pointer_button_down_on()
1591 && !up.is_pointer_button_down_on()
1592 {
1593 release_el = true;
1594 }
1595 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1596 });
1597 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1598 ui.separator();
1599 ui.label("Randlicht (preview)");
1600 let mut rim = self.preview2d.rim_intensity;
1601 let rim_slider =
1602 ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte"));
1603 if rim_slider.changed() {
1604 self.preview2d.set_rim_intensity(rim);
1605 need_rebuild = true;
1606 }
1607 ui.weak(
1608 "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.",
1609 );
1610 });
1611
1612 if go_back {
1613 self.session.back();
1614 return;
1615 }
1616 if refresh_catalog {
1617 self.refresh_preview2d_catalog();
1618 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1619 need_rebuild = true;
1620 }
1621 if let Some(sex) = set_sex {
1622 self.preview2d.set_sex(sex);
1623 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1624 need_rebuild = true;
1625 }
1626 if let Some(set) = toggle_set {
1627 self.preview2d.toggle_equipment(&set);
1628 need_rebuild = true;
1629 }
1630 if let Some((token, frames, duration)) = select_clip {
1631 self.preview2d.set_clip(token, frames, duration);
1632 need_rebuild = true;
1633 }
1634 if toggle_play {
1635 self.preview2d.playing = !self.preview2d.playing;
1636 }
1637 if apply_speed {
1638 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
1639 if speed.is_finite() && speed >= 0.0 {
1640 self.preview2d.playback_speed = speed;
1641 }
1642 }
1643 }
1644 if step_yaw != 0 {
1645 self.preview2d.step_yaw(step_yaw);
1646 need_rebuild = true;
1647 }
1648 if step_pitch != 0 {
1649 self.preview2d.step_pitch(step_pitch);
1650 need_rebuild = true;
1651 }
1652 if let Some(sign) = hold_az {
1653 self.preview2d.hold_light(LightAxis::Azimuth, sign);
1654 } else if release_az {
1655 self.preview2d.release_light(LightAxis::Azimuth);
1656 }
1657 if let Some(sign) = hold_el {
1658 self.preview2d.hold_light(LightAxis::Elevation, sign);
1659 } else if release_el {
1660 self.preview2d.release_light(LightAxis::Elevation);
1661 }
1662
1663 // Continuous light or playing changes the image every tick.
1664 if self.preview2d.playing
1665 || self.preview2d.hold_azimuth.is_some()
1666 || self.preview2d.hold_elevation.is_some()
1667 {
1668 need_rebuild = true;
1669 }
1670
1671 let key = (
1672 self.preview2d.clip_token.clone(),
1673 self.preview2d.frame_index(),
1674 self.preview2d.pitch_deg(),
1675 self.preview2d.yaw_deg(),
1676 self.preview2d.selected_equipment.len() as u32,
1677 format!(
1678 "{:.1}/{:.1}/{:.3}/{}",
1679 self.preview2d.light_azimuth_deg,
1680 self.preview2d.light_elevation_deg,
1681 self.preview2d.rim_intensity,
1682 self.preview2d.sex_token()
1683 ),
1684 );
1685 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
1686 self.rebuild_preview2d_frame();
1687 self.preview2d_texture_key = Some(key);
1688 self.preview2d_texture = None;
1689 }
1690
1691 egui::CentralPanel::default().show(ctx, |ui| {
1692 if self.session.confirm.is_some() || self.dialog_pending {
1693 ui.disable();
1694 }
1695 if let Some(diag) = &self.preview2d.diagnostic {
1696 ui.colored_label(egui::Color32::LIGHT_RED, diag);
1697 }
1698 let Some(image) = &self.preview2d_image else {
1699 ui.label(
1700 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
1701 );
1702 return;
1703 };
1704 if self.preview2d_texture.is_none() {
1705 let color = egui::ColorImage::from_rgba_unmultiplied(
1706 [image.width as usize, image.height as usize],
1707 &image.rgba,
1708 );
1709 self.preview2d_texture = Some(ui.ctx().load_texture(
1710 "preview2d-frame",
1711 color,
1712 egui::TextureOptions::NEAREST,
1713 ));
1714 }
1715 let texture = self.preview2d_texture.as_ref().expect("texture");
1716 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1717 let fit = crate::render_job::fit_preview(
1718 [image.width, image.height],
1719 [bounds.width(), bounds.height()],
1720 );
1721 let rect = egui::Rect::from_min_size(
1722 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1723 egui::vec2(fit.size[0], fit.size[1]),
1724 );
1725 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1726 ui.painter().image(
1727 texture.id(),
1728 rect,
1729 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1730 egui::Color32::WHITE,
1731 );
1732 });
1733 }
1734
1735 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1736 if !project {
1737 return;
1738 }
1739 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
1740 if self.session.confirm.is_some() || self.dialog_pending {
1741 ui.disable();
1742 }
1743 ui.horizontal(|ui| {
1744 if ui.button("Body").clicked() {
1745 self.session.show_bodies();
1746 }
1747 if ui.button("2D-preview").clicked() {
1748 self.open_preview2d();
1749 }
1750 if ui.button("Opslaan").clicked() {
1751 self.save();
1752 }
1753 if ui
1754 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
1755 .clicked()
1756 {
1757 self.begin(Intent::PickNew);
1758 }
1759 if ui
1760 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
1761 .clicked()
1762 {
1763 self.begin(Intent::PickLoad);
1764 }
1765 });
1766 });
1767 }
1768}
1769
1770fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
1771 fn node(
1772 ui: &mut egui::Ui,
1773 body: &PreviewCharacter,
1774 paths: &[String],
1775 index: usize,
1776 selected: Option<&str>,
1777 choice: &mut Option<usize>,
1778 depth: usize,
1779 ) {
1780 if depth > 64 {
1781 return;
1782 }
1783 let joint = body.joint_nodes.iter().position(|i| *i == index);
1784 let children: Vec<_> = body
1785 .nodes
1786 .iter()
1787 .enumerate()
1788 .filter(|(_, n)| n.parent == Some(index))
1789 .map(|(i, _)| i)
1790 .collect();
1791 let label = if body.nodes[index].name.is_empty() {
1792 format!("node-{index}")
1793 } else {
1794 body.nodes[index].name.clone()
1795 };
1796 let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
1797 if let Some(joint) = joint {
1798 if ui
1799 .selectable_label(
1800 selected == Some(paths[joint].as_str()),
1801 format!("Koppel {label}"),
1802 )
1803 .clicked()
1804 {
1805 *choice = Some(joint);
1806 }
1807 }
1808 };
1809 if children.is_empty() {
1810 select(ui, choice);
1811 } else {
1812 egui::CollapsingHeader::new(&label)
1813 .id_salt(index)
1814 .default_open(depth < 2)
1815 .show(ui, |ui| {
1816 select(ui, choice);
1817 for child in children {
1818 node(ui, body, paths, child, selected, choice, depth + 1);
1819 }
1820 });
1821 }
1822 }
1823 let mut choice = None;
1824 let paths = crate::preview::joint_paths(body);
1825 for (index, item) in body.nodes.iter().enumerate() {
1826 if item.parent.is_none() {
1827 node(ui, body, &paths, index, selected, &mut choice, 0);
1828 }
1829 }
1830 choice
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835 use super::*;
1836 use crate::dialogs::ScriptedDialogs;
1837 use crate::session::Choice;
1838
1839 fn root() -> PathBuf {
1840 std::env::temp_dir().join(format!(
1841 "paperdoll-editor-{}",
1842 std::time::SystemTime::now()
1843 .duration_since(std::time::UNIX_EPOCH)
1844 .unwrap()
1845 .as_nanos()
1846 ))
1847 }
1848
1849 #[test]
1850 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
1851 let root = root();
1852 let mut dialogs = ScriptedDialogs::default();
1853 dialogs.push(Some(root.clone()));
1854 dialogs.push(None);
1855 let mut app = EditorApp::new(Box::new(dialogs));
1856 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
1857 app.begin(Intent::PickNew);
1858 app.poll_dialogs();
1859 assert_eq!(app.session.screen, Screen::Bodies);
1860 assert!(app.preview_male.is_some());
1861 assert!(app.preview_female.is_some());
1862 app.session.set_name("Keep my edit".into());
1863 app.begin(Intent::PickLoad);
1864 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
1865 app.apply_choice(Choice::Discard, &egui::Context::default());
1866 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
1867 assert_eq!(
1868 app.session.opened.as_ref().unwrap().document.name,
1869 "Keep my edit"
1870 );
1871 assert!(app.session.is_dirty());
1872 let _ = std::fs::remove_dir_all(root);
1873 }
1874
1875 #[test]
1876 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
1877 let root = root();
1878 let mut app = EditorApp::default();
1879 app.session.install_new_project(&root).unwrap();
1880 app.session.set_render(0, 128, 12);
1881 app.begin(Intent::Quit);
1882 app.apply_choice(Choice::Save, &egui::Context::default());
1883 assert!(!app.allow_close);
1884 assert_eq!(app.session.confirm, Some(Intent::Quit));
1885 assert!(app.session.is_dirty());
1886 app.open_path(root.join("missing.ppd"));
1887 assert!(app.session.opened.is_some());
1888 assert_eq!(app.session.screen, Screen::Bodies);
1889 let _ = std::fs::remove_dir_all(root);
1890 }
1891
1892 #[test]
1893 fn open_with_project_path_loads_bodies_and_models() {
1894 let root = root().join("project with spaces");
1895 let opened = crate::session::create_seeded_project(&root).unwrap();
1896 let mut app = EditorApp::default();
1897 app.open_path(opened.manifest_path.clone());
1898 assert_eq!(app.session.screen, Screen::Bodies);
1899 assert!(app.preview_male.is_some());
1900 assert_eq!(
1901 app.session.opened.unwrap().manifest_path,
1902 opened.manifest_path
1903 );
1904 let _ = std::fs::remove_dir_all(root.parent().unwrap());
1905 }
1906
1907 #[test]
1908 fn renderer_disconnect_is_reported() {
1909 let mut app = EditorApp::default();
1910 let (_, gpu) = std::sync::mpsc::channel();
1911 app.render_worker = Some(crate::production::RenderThread {
1912 mailbox: Default::default(),
1913 gpu,
1914 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1915 handle: None,
1916 });
1917 app.render_ui.running = true;
1918 app.drain_render();
1919 assert!(!app.render_ui.running);
1920 assert!(app.render_ui.error.is_some());
1921 }
1922}