//! Project window: start screen, body, equipment, render queue, and 2D preview. use std::path::PathBuf; use eframe::egui; use crate::compositor::MAX_RIM_INTENSITY; use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs}; use crate::import::{import_project_model, ImportedModel}; use crate::preview::{ is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex, }; use crate::preview2d::{ compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState, }; use crate::preview_gl::Viewport; use crate::project::open_project; use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState}; use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex}; pub struct EditorApp { session: Session, dialogs: Box, controls: PreviewControls, preview_male: Option, preview_female: Option, viewport: Viewport, inventories: [Option>; 2], render_worker: Option, gpu_renderer: crate::production::GpuRenderer, show_render_frame: bool, render_ui: RenderUiState, pass_texture: Option, pass_texture_revision: u64, applied_shell: Option<[f32; 2]>, width_text: String, height_text: String, fps_text: String, allow_close: bool, dialog_pending: bool, equipment_part: usize, preview2d: Preview2dState, preview2d_catalog: Vec, preview2d_texture: Option, preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>, preview2d_image: Option, preview2d_speed_text: String, resume_candidates: Vec, resume_scanned: bool, resume_scan_error: Option, } impl EditorApp { pub fn new(dialogs: Box) -> Self { Self { session: Session::new(), dialogs, controls: PreviewControls::default(), preview_male: None, preview_female: None, viewport: Viewport::new(), inventories: [None, None], render_worker: None, gpu_renderer: Default::default(), show_render_frame: true, render_ui: RenderUiState::idle(), pass_texture: None, pass_texture_revision: 0, applied_shell: None, width_text: "128".to_string(), height_text: "128".to_string(), fps_text: "12".to_string(), allow_close: false, dialog_pending: false, equipment_part: 0, preview2d: Preview2dState::default(), preview2d_catalog: Vec::new(), preview2d_texture: None, preview2d_texture_key: None, preview2d_image: None, preview2d_speed_text: "1.0".to_string(), resume_candidates: Vec::new(), resume_scanned: false, resume_scan_error: None, } } pub fn open_path(&mut self, path: PathBuf) { let candidate = std::env::current_dir() .map_err(|e| e.to_string()) .and_then(|base| { crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy()) }); let path = match candidate { Ok(path) => path, Err(error) => { self.session.fail(error); return; } }; match open_project(&path) { Ok(opened) => { self.session.install_loaded(opened); self.reset_render(); self.sync_fields(); self.reload_bodies(); } Err(error) => { self.session.fail(error.to_string()); } } } pub fn open_fixture_project(&mut self) { let root = std::env::temp_dir().join(format!( "paperdoll-renderer-fixture-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); if let Err(error) = self.session.install_new_project(&root) { self.session.fail(error.to_string()); return; } self.sync_fields(); self.reload_bodies(); self.session.show_body(Sex::Male); self.controls.sex = PreviewSex::Male; self.controls.select_clip("walk"); } fn reset_render(&mut self) { self.render_worker = None; self.render_ui = RenderUiState::idle(); self.pass_texture = None; self.resume_candidates.clear(); self.resume_scanned = false; self.resume_scan_error = None; } fn sync_fields(&mut self) { if let Some(opened) = &self.session.opened { self.width_text = opened.document.render.target_width.to_string(); self.height_text = opened.document.render.target_height.to_string(); self.fps_text = opened.document.render.fps.to_string(); } } fn reload_bodies(&mut self) { self.preview_male = None; self.preview_female = None; self.inventories = [None, None]; let Some(root) = self.session.project_root() else { return; }; for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() { if let Some(relative) = self.session.body_path(sex) { let imported = import_project_model(&root, relative).map_err(|error| error.to_string()); // Validate the source and its dependencies before the viewport reads it. if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) { match load_body_file(&root.join(relative)) { Ok(character) => match sex { Sex::Male => self.preview_male = Some(character), Sex::Female => self.preview_female = Some(character), }, Err(error) => self.session.fail(format!("3D: {error}")), } } self.inventories[index] = Some(imported); } } } fn character(&self) -> Option<&PreviewCharacter> { match self.controls.sex { PreviewSex::Male => self.preview_male.as_ref(), PreviewSex::Female => self.preview_female.as_ref(), } } fn poll_dialogs(&mut self) { let poll = self.dialogs.poll(); self.dialog_pending = matches!(poll, DialogPoll::Pending); if let DialogPoll::Ready { purpose, path } = poll { let Some(path) = path else { return; }; match purpose { DialogPurpose::NewProject => { if let Err(error) = self.session.install_new_project(&path) { self.session.fail(error.to_string()); } else { self.reset_render(); self.sync_fields(); self.reload_bodies(); } } DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) { Ok(opened) => { self.session.install_loaded(opened); self.reset_render(); self.sync_fields(); self.reload_bodies(); } Err(error) => self.session.fail(error.to_string()), }, DialogPurpose::Body(sex) => self.take_model(sex, &path), DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path), DialogPurpose::EquipmentShiny(index, part) => { if let Some(mut preset) = self .session .equipment .get(index) .and_then(|i| i.preset.clone()) { let result = crate::equipment::import_shiny(&mut preset, part, &path) .and_then(|_| self.session.ensure_preset_budget(Some(index), &preset)); match result { Ok(()) => self.session.equipment[index].preset = Some(preset), Err(error) => self.session.fail(error), } } } DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) { Ok(mut preset) => { let result = self .session .available_equipment_name(&preset.name) .and_then(|name| { preset.name = name; self.session.ensure_preset_budget(None, &preset) }); if let Err(error) = result { self.session.fail(error); return; } let index = self.session.equipment.len(); self.session.equipment.push(crate::session::EquipmentItem { slug: preset.name.clone(), model: preset.attachments.first().map(|p| p.label.clone()), preset: Some(preset), legacy_path: None, error: None, }); self.equipment_part = 0; self.session.show_equipment(index); } Err(error) => self.session.fail(error), }, } } } fn take_model(&mut self, sex: Sex, path: &std::path::Path) { let Some(root) = self.session.project_root() else { return; }; match adopt_file(&root, path, "assets/body") { Ok(relative) => { self.session.set_body_path(sex, relative.clone()); self.reload_bodies(); self.reset_render(); let index = if sex == Sex::Male { 0 } else { 1 }; if let Some(Err(error)) = &self.inventories[index] { self.session.fail(format!("{relative}: {error}")); } else { self.session.notice = format!("{relative} staat in het project."); self.session.notice_ok = true; } } Err(error) => self.session.fail(error), } } fn take_equipment(&mut self, index: usize, path: &std::path::Path) { let sex = if self.session.active_body == Some(Sex::Female) { 1 } else { 0 }; let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let Some(item) = self.session.equipment.get(index) else { return; }; let mut preset = item .preset .clone() .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone())); preset.name = item.slug.clone(); match crate::equipment::import_model(&mut preset, path) { Ok(part) => { if let Some(body) = body { if !body.joint_nodes.is_empty() { let _ = preset.bind(sex, body, part, 0); } } if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) { self.session.fail(error); return; } let item = &mut self.session.equipment[index]; item.model = Some(preset.attachments[part].label.clone()); item.legacy_path = None; item.error = None; item.preset = Some(preset); self.equipment_part = part; self.session.notice = "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot." .into(); self.session.notice_ok = true; } Err(error) => self.session.fail(error), } } pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) { self.session.active_body = Some(sex); self.session.show_equipment(index); self.equipment_part = 0; } fn begin(&mut self, intent: Intent) { if self.session.gate(intent) { self.run_intent(intent); } } fn run_intent(&mut self, intent: Intent) { match intent { Intent::Quit => {} Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject), Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject), } } fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) { match self.session.resolve(choice) { AfterChoice::Stay => {} AfterChoice::SaveThen(intent) => match self.session.save() { Ok(()) => self.finish_intent(intent, ctx), Err(error) => self.session.fail(error.to_string()), }, AfterChoice::Go(intent) => self.finish_intent(intent, ctx), } } fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) { if intent == Intent::Quit { self.allow_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); } else { self.run_intent(intent); } } fn save(&mut self) { if let Err(error) = self.session.save() { self.session.fail(error.to_string()); } } fn apply_render_fields(&mut self) { let width = self.width_text.parse::().unwrap_or(0); let height = self.height_text.parse::().unwrap_or(0); let fps = self.fps_text.parse::().unwrap_or(0); self.session.set_render(width, height, fps); } /// Open the body editor, also used by the native smoke example. pub fn open_body_configuration(&mut self, sex: Sex) { self.session.show_body(sex); self.controls.sex = match sex { Sex::Male => PreviewSex::Male, Sex::Female => PreviewSex::Female, }; } pub fn render_error(&self) -> Option<&str> { self.render_ui.error.as_deref() } pub fn render_all_angles(&mut self) { self.start_render(); } /// Legacy smoke entry point, now starts the production batch as well. pub fn render_current_pose(&mut self) { self.render_all_angles(); } pub fn render_is_running(&self) -> bool { self.render_ui.running } pub fn render_progress(&self) -> (u32, u32, String) { ( self.render_ui.progress.completed, self.render_ui.progress.total, self.render_ui.progress.label.clone(), ) } /// The job the Render action would start now (current project, body, equipment). fn build_job(&mut self) -> Option { let opened = self.session.opened.as_ref()?; if let Err(error) = opened.document.validate_shape() { self.session.fail(error.to_string()); return None; } let Some(character) = self.character().cloned() else { self.session .fail("dit lichaam heeft geen tekenbaar glTF/GLB- of FBX-model"); return None; }; let root = self.session.project_root()?; if let Err(error) = crate::project::prepare_output_dir(&root, &opened.document.output_directory) { self.session.fail(error.to_string()); return None; } let sex = usize::from(self.controls.sex == PreviewSex::Female); let preset = match self.session.screen { Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()), _ => None, }; if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() { self.session.fail("configureer eerst het equipment"); return None; } let body_rel = match self.controls.sex { PreviewSex::Male => opened.document.male_model.clone(), PreviewSex::Female => opened.document.female_model.clone(), }; let Some(body_rel) = body_rel else { self.session.fail("dit lichaam heeft geen modelpad"); return None; }; if let Err(error) = crate::project::portable_relative(&body_rel) { self.session.fail(error); return None; } let body_path = root.join(&body_rel); let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) { Ok(hash) => hash, Err(error) => { self.session.fail(format!("body-fingerprint: {error}")); return None; } }; let preset_sha256 = match &preset { None => None, Some(p) => match p.encode() { Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)), Err(error) => { self.session.fail(format!("equipment-fingerprint: {error}")); return None; } }, }; Some(RenderJob { character, sex: if sex == 0 { "male" } else { "female" }.into(), preset, target_width: opened.document.render.target_width, target_height: opened.document.render.target_height, fps: opened.document.render.fps, framing: opened.document.render.framing[sex], output: root.join(&opened.document.output_directory), project_id: opened.document.id.clone(), project_name: opened.document.name.clone(), body_model: crate::production::SourceFingerprint { path: body_rel, sha256: body_sha, }, preset_sha256, }) } fn start_render(&mut self) { if self.render_ui.running { return; } let Some(job) = self.build_job() else { return; }; self.render_worker = Some(spawn(job, None)); self.begin_worker(); } fn begin_worker(&mut self) { self.render_ui = RenderUiState::idle(); self.pass_texture = None; self.render_ui.running = true; self.resume_candidates.clear(); self.resume_scanned = false; } /// Find incomplete runs of the current body/equipment set and check their contract. pub fn scan_resumable_runs(&mut self) { self.resume_scanned = true; self.resume_candidates.clear(); self.resume_scan_error = None; let Some(job) = self.build_job() else { self.resume_scan_error = Some("geen renderbare job voor deze weergave".into()); return; }; let output = job.output.clone(); let result = crate::production::contract(job) .and_then(|c| crate::resume::candidates(&output, &c.render_doc)); match result { Ok(found) => self.resume_candidates = found, Err(error) => self.resume_scan_error = Some(error), } } pub fn resumable_runs(&self) -> &[crate::resume::ResumeCandidate] { &self.resume_candidates } /// Resume one incomplete run; the worker re-checks contract, lock and checksums. pub fn resume_run(&mut self, dir: PathBuf) { if self.render_ui.running { return; } let Some(job) = self.build_job() else { return; }; self.render_worker = Some(crate::render_job::spawn_resume(job, dir, None)); self.begin_worker(); } /// Request cancellation of the running batch (same as the GUI button). pub fn request_render_cancel(&mut self) { self.cancel_render(); } fn resume_ui(&mut self, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { ui.label("Onderbroken renders:"); if ui .add_enabled( !self.render_ui.running, egui::Button::new("Zoek onderbroken runs"), ) .on_hover_text( "Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.", ) .clicked() { self.scan_resumable_runs(); } if self.resume_scanned && self.resume_candidates.is_empty() { ui.weak("geen gevonden voor deze set"); } }); if let Some(error) = &self.resume_scan_error { ui.colored_label(egui::Color32::LIGHT_RED, error); } let mut chosen = None; for candidate in &self.resume_candidates { ui.horizontal_wrapped(|ui| { ui.label(format!( "{} · {} · {} van {} PNG's vastgelegd", candidate.name, candidate.state, candidate.journaled, candidate.planned )); match &candidate.blocked { None => { if ui .add_enabled(!self.render_ui.running, egui::Button::new("Hervat")) .clicked() { chosen = Some(candidate.dir.clone()); } } Some(reason) => { ui.weak(reason); } } }); } if let Some(dir) = chosen { self.resume_run(dir); } } fn service_gpu(&mut self, gl: Option<&std::sync::Arc>) { let request = self .render_worker .as_ref() .and_then(|worker| worker.gpu.try_recv().ok()); if let Some(request) = request { if let Some(gl) = gl { unsafe { request.service(&mut self.gpu_renderer, gl); } } else { request.fail("Glow-context ontbreekt".into()); } } } fn drain_render(&mut self) { let Some(worker) = &self.render_worker else { return; }; let ended = worker .handle .as_ref() .map(|h| h.is_finished()) .unwrap_or(true); let events = worker.mailbox.drain(); let mut terminal = false; for event in events { terminal |= matches!( event, RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled ); if let RenderEvent::Finished { saved: Some(path) } = &event { self.session.notice = format!("Batch opgeslagen: {path}"); self.session.notice_ok = true; } if let RenderEvent::Failed(error) = &event { self.session.fail(error.clone()); } self.render_ui.apply(event); } if terminal { self.render_worker = None; if self.render_ui.saved.is_none() && self.render_ui.error.is_none() { // Cancelled: offer the run for resumption right away. self.scan_resumable_runs(); } } else if ended { self.render_ui.apply(RenderEvent::Failed( "De renderworker stopte zonder eindresultaat.".into(), )); self.render_worker = None; } } fn cancel_render(&mut self) { if let Some(worker) = &self.render_worker { worker .cancel .store(true, std::sync::atomic::Ordering::Relaxed); } } fn render_progress_ui(&mut self, ui: &mut egui::Ui) { if self.render_ui.progress.total > 0 { ui.add( egui::ProgressBar::new( self.render_ui.progress.completed as f32 / self.render_ui.progress.total.max(1) as f32, ) .text(format!( "{} / {} · {}", self.render_ui.progress.completed, self.render_ui.progress.total, self.render_ui.progress.label )), ); } if self.render_ui.running && ui.button("Annuleer render").clicked() { self.cancel_render(); } } fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) { let diagnostic = { let framing = self .session .opened .as_ref() .map(|opened| opened.document.render.framing[sex]); let body = match sex { 0 => self.preview_male.as_ref(), _ => self.preview_female.as_ref(), }; match (body, framing) { (Some(body), Some(framing)) => Some(crate::camera::resolve(body, framing)), _ => None, } }; if let Some(opened) = &mut self.session.opened { let camera = &mut opened.document.render.framing[sex]; let mut height = camera.height_milli as f32 / 1000.; let mut center = camera.center_y_milli as f32 / 1000.; let mut pivot = diagnostic .as_ref() .map(|resolved| { resolved .framing .pivot_y_milli .unwrap_or(resolved.framing.center_y_milli) as f32 / 1000. }) .unwrap_or_else(|| { camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000. }); ui.horizontal_wrapped(|ui| { ui.label("Vaste renderhoogte (m)"); if ui .add( egui::DragValue::new(&mut height) .speed(0.01) .range(0.1..=10.), ) .changed() { camera.height_milli = (height * 1000.).round() as u32; } ui.label("Centrum Y (m)"); if ui .add( egui::DragValue::new(&mut center) .speed(0.01) .range(-10.0..=10.0), ) .changed() { camera.center_y_milli = (center * 1000.).round() as i32; } ui.label("Pivot Y (m)"); if ui .add( egui::DragValue::new(&mut pivot) .speed(0.01) .range(-10.0..=10.0), ) .changed() { camera.pivot_y_milli = Some((pivot * 1000.).round() as i32); } if camera.pivot_y_milli.is_some() && ui .button("Auto nek") .on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte") .clicked() { camera.pivot_y_milli = None; } ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen"); }); if let Some(resolved) = &diagnostic { ui.label(resolved.description()); } else { ui.label("Laad een glTF/GLB- of FBX-lichaam om de nekpivot te bepalen."); } } } fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> { if !self.show_render_frame { return None; } let r = &self.session.opened.as_ref()?.document.render; let sex = usize::from(self.controls.sex == PreviewSex::Female); Some((r.framing[sex], [r.target_width, r.target_height])) } } impl Default for EditorApp { fn default() -> Self { Self::new(Box::new(SystemDialogs::new())) } } impl eframe::App for EditorApp { fn on_exit(&mut self, gl: Option<&glow::Context>) { self.render_worker = None; if let Some(gl) = gl { unsafe { self.gpu_renderer.destroy(gl); } self.viewport.destroy(gl); } } fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.service_gpu(frame.gl()); let shell = self.session.shell_size(); if self.applied_shell != Some(shell) { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2( shell[0], shell[1], ))); let min = if shell == crate::session::START_WINDOW { egui::vec2(420.0, 220.0) } else { egui::vec2(900.0, 560.0) }; ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min)); self.applied_shell = Some(shell); } if ctx.input(|input| input.viewport().close_requested()) && !self.allow_close && self.session.confirm.is_none() { if !self.session.gate(Intent::Quit) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } } else if self.session.confirm.is_some() && ctx.input(|input| input.viewport().close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } self.poll_dialogs(); self.drain_render(); if self.render_ui.running || matches!(self.session.screen, Screen::Preview2d) && (self.preview2d.playing || self.preview2d.hold_azimuth.is_some() || self.preview2d.hold_elevation.is_some()) { ctx.request_repaint(); } egui::TopBottomPanel::bottom("notice").show(ctx, |ui| { let color = if self.session.notice_ok { egui::Color32::LIGHT_GREEN } else { egui::Color32::LIGHT_RED }; ui.colored_label(color, &self.session.notice); }); match self.session.screen { Screen::Start => self.show_start(ctx), Screen::Bodies => self.show_bodies(ctx), Screen::Body(_) => self.show_body(ctx), Screen::EquipmentList => self.show_equipment_list(ctx), Screen::Equipment(_) => self.show_equipment(ctx), Screen::Preview2d => self.show_preview2d(ctx), } self.show_confirm(ctx); if self.dialog_pending { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } } } impl EditorApp { fn show_confirm(&mut self, ctx: &egui::Context) { let Some(_intent) = self.session.confirm else { return; }; let mut choice = None; egui::Window::new("Wijzigingen") .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .collapsible(false) .resizable(false) .show(ctx, |ui| { ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen."); ui.horizontal(|ui| { if ui.button("Opslaan").clicked() { choice = Some(crate::session::Choice::Save); } if ui.button("Niet opslaan").clicked() { choice = Some(crate::session::Choice::Discard); } if ui.button("Annuleren").clicked() { choice = Some(crate::session::Choice::Cancel); } }); }); if let Some(choice) = choice { self.apply_choice(choice, ctx); } } fn show_start(&mut self, ctx: &egui::Context) { egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading("Paperdoll Renderer"); ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project."); ui.add_space(12.0); if ui.button("Project laden").clicked() { self.begin(Intent::PickLoad); } if ui.button("Nieuw project").clicked() { self.begin(Intent::PickNew); } }); } fn show_bodies(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading("Body"); if let Some(opened) = &mut self.session.opened { ui.horizontal(|ui| { ui.label("Naam"); ui.text_edit_singleline(&mut opened.document.name); }); } self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man"); self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw"); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Breedte"); ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0)); ui.label("Hoogte"); ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0)); ui.label("fps"); ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0)); }); self.apply_render_fields(); ui.add_space(8.0); if ui.button("2D-preview").clicked() { self.open_preview2d(); } ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/."); }); } fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) { ui.horizontal(|ui| { ui.label(title); let path = self .session .body_path(sex) .unwrap_or("geen model") .to_string(); ui.label(path); if ui.button(pick).clicked() { self.dialogs.start(DialogPurpose::Body(sex)); } if ui.button(open).clicked() { self.controls.sex = match sex { Sex::Male => PreviewSex::Male, Sex::Female => PreviewSex::Female, }; self.session.show_body(sex); } }); let index = if sex == Sex::Male { 0 } else { 1 }; if let Some(inventory) = &self.inventories[index] { ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory { Ok(model) => { for mesh in &model.meshes { ui.label(format!( "{}: {} vertices, {} driehoeken", mesh.name, mesh.vertex_count, mesh.triangle_count )); } for material in &model.materials { ui.label(format!("materiaal {}", material.name)); } for texture in &model.textures { ui.label(format!( "texture {}: {}×{}", texture.source, texture.width, texture.height )); } ui.label(format!("{} joints", model.joints.len())); for clip in &model.clips { ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds)); } for notice in &model.notices { ui.label(notice); } } Err(message) => { ui.colored_label(egui::Color32::LIGHT_RED, message); } }); } } fn show_body(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); let sex = match self.session.screen { Screen::Body(sex) => sex, _ => Sex::Male, }; self.controls.sex = match sex { Sex::Male => PreviewSex::Male, Sex::Female => PreviewSex::Female, }; egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.horizontal_wrapped(|ui| { if ui.button("Terug").clicked() { self.session.show_bodies(); } if ui.button("Equipment").clicked() { self.session.show_equipment_list(); } if ui .add_enabled( !self.render_ui.running, egui::Button::new("Render alle hoeken"), ) .clicked() { self.start_render(); } if ui.button("Live 3D").clicked() { self.render_ui.preview = None; } if ui.button("2D-preview").clicked() { self.open_preview2d(); } }); self.framing_ui(ui, usize::from(sex == Sex::Female)); self.render_progress_ui(ui); self.resume_ui(ui); if self.render_ui.preview.is_some() { self.paint_pass(ui); } else { let character = match self.controls.sex { PreviewSex::Male => self.preview_male.as_ref(), PreviewSex::Female => self.preview_female.as_ref(), }; if character.is_some() { let framing = self.fixed_camera(); self.viewport .show_framed(ui, &mut self.controls, character, &[], framing); } else { ui.label("Open een glTF/GLB- of FBX-lichaam voor de live 3D-weergave."); } } }); } fn paint_pass(&mut self, ui: &mut egui::Ui) { let Some(image) = &self.render_ui.preview else { return; }; ui.label(format!( "Laatste voltooide pass: {}", self.render_ui.preview_label )); if self.pass_texture.is_none() || self.pass_texture_revision != self.render_ui.preview_revision { let color = egui::ColorImage::from_rgba_unmultiplied( [image.width as usize, image.height as usize], &image.rgba, ); let options = egui::TextureOptions::NEAREST; if let Some(texture) = &mut self.pass_texture { texture.set(color, options); } else { self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options)); } self.pass_texture_revision = self.render_ui.preview_revision; } let texture = self.pass_texture.as_ref().expect("pass texture installed"); let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover()); let fit = crate::render_job::fit_preview( [image.width, image.height], [bounds.width(), bounds.height()], ); let rect = egui::Rect::from_min_size( bounds.min + egui::vec2(fit.offset[0], fit.offset[1]), egui::vec2(fit.size[0], fit.size[1]), ); ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK); ui.painter().image( texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } fn show_equipment_list(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading(format!( "Bodies → {} → Equipment", match self.session.active_body { Some(Sex::Male) => "Man", Some(Sex::Female) => "Vrouw", None => "Body", } )); if ui.button("Terug naar body").clicked() { self.session.back(); } let mut open = None; for (index, item) in self.session.equipment.iter().enumerate() { let label = match &item.model { Some(model) => format!("{} {model}", item.slug), None => item.slug.clone(), }; if ui.button(label).clicked() { open = Some(index); } } if ui .add_enabled( self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS, egui::Button::new("Nieuw"), ) .clicked() { self.equipment_part = 0; self.session.add_equipment(); } if ui .add_enabled( self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS, egui::Button::new("Preset importeren"), ) .clicked() { self.dialogs.start(DialogPurpose::ImportEquipment); } if let Some(index) = open { self.session.show_equipment(index); } }); } fn show_equipment(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); let Screen::Equipment(index) = self.session.screen else { return; }; let mut sex = if self.session.active_body == Some(Sex::Female) { 1 } else { 0 }; egui::SidePanel::left("equipment-config") .default_width(330.0) .resizable(true) .show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } egui::ScrollArea::vertical().show(ui, |ui| { ui.heading("Equipment-configuratie"); if ui.button("Terug naar equipment").clicked() { self.session.show_equipment_list(); } ui.horizontal(|ui| { ui.selectable_value(&mut sex, 0, "Man"); ui.selectable_value(&mut sex, 1, "Vrouw"); }); self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female }); let Some(item) = self.session.equipment.get_mut(index) else { return; }; ui.label("Naam (kleine letters/cijfers/-/_)"); ui.text_edit_singleline(&mut item.slug); if !crate::equipment::valid_name(&item.slug) { ui.colored_label( egui::Color32::LIGHT_RED, "Ongeldige naam; 'body' is gereserveerd.", ); } if let Some(error) = &item.error { ui.colored_label(egui::Color32::LIGHT_RED, error); } if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() { self.dialogs.start(DialogPurpose::EquipmentModel(index)); } if let Some(preset) = &mut item.preset { ui.horizontal(|ui| { ui.label("Geldig voor:"); ui.checkbox(&mut preset.applies[0], "Man"); ui.checkbox(&mut preset.applies[1], "Vrouw"); }); if !preset.applies[0] && !preset.applies[1] { ui.colored_label( egui::Color32::LIGHT_RED, "Kies minstens één geslacht.", ); } ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)"); ui.add( egui::TextEdit::multiline(&mut preset.provenance) .desired_rows(2) .char_limit(4096), ); if preset.provenance.len() > 4096 { ui.colored_label( egui::Color32::LIGHT_RED, "Herkomst/licentie is te lang.", ); } if !preset.attachments.is_empty() { self.equipment_part = self.equipment_part.min(preset.attachments.len() - 1); egui::ComboBox::from_id_salt("equipment-part") .selected_text(&preset.attachments[self.equipment_part].label) .show_ui(ui, |ui| { for (part, item) in preset.attachments.iter().enumerate() { ui.selectable_value( &mut self.equipment_part, part, &item.label, ); } }); let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let attachment = &mut preset.attachments[self.equipment_part]; ui.label("Positie relatief aan bot (XYZ)"); ui.horizontal(|ui| { for value in &mut attachment.translation { ui.add( egui::DragValue::new(value) .speed(0.005) .range(-1000.0..=1000.0), ); } }); ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)"); ui.horizontal(|ui| { for value in &mut attachment.rotation_deg { ui.add( egui::DragValue::new(value) .speed(0.5) .range(-3600.0..=3600.0), ); } }); ui.add( egui::Slider::new(&mut attachment.specular, 0.0..=1.0) .text("Glanssterkte"), ); let shiny = attachment .shiny .and_then(|i| preset.assets.get(i)) .map(|a| a.path.clone()); ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into())); if ui.button("Kies shinymap (PNG)").clicked() { self.dialogs.start(DialogPurpose::EquipmentShiny( index, self.equipment_part, )); } let clear_shiny = ui.button("Wis shinymap").clicked(); let selected = attachment.bones[sex].clone(); if clear_shiny { if let Err(error) = crate::equipment::clear_shiny(preset, self.equipment_part) { item.error = Some(error); } } ui.label(format!( "Bot: {}", selected.as_deref().unwrap_or("nog niet gekoppeld") )); if let Some(body) = body { if preset.rigs[sex].as_ref() != Some(&crate::equipment::RigSignature::of(body)) { ui.colored_label( egui::Color32::LIGHT_RED, "Rigbinding ontbreekt of wijkt af; selecteer een bot.", ); } ui.collapsing("Skeleton / botten", |ui| { if let Some(joint) = bone_tree(ui, body, selected.as_deref()) { if let Err(error) = preset.bind(sex, body, self.equipment_part, joint) { ui.colored_label(egui::Color32::LIGHT_RED, error); } } }); } else { ui.colored_label( egui::Color32::LIGHT_RED, "Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.", ); } if ui.button("Verwijder onderdeel").clicked() { if let Err(error) = crate::equipment::remove_attachment(preset, self.equipment_part) { item.error = Some(error); } self.equipment_part = self.equipment_part.saturating_sub(1); } } } if ui.button("Verwijder equipmentdefinitie").clicked() { self.session.delete_equipment(index); } }); }); self.controls.sex = if sex == 0 { PreviewSex::Male } else { PreviewSex::Female }; egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.horizontal_wrapped(|ui| { if ui .add_enabled( !self.render_ui.running, egui::Button::new("Render equipment · alle hoeken"), ) .clicked() { self.start_render(); } if ui.button("Live 3D").clicked() { self.render_ui.preview = None; } if ui.button("2D-preview").clicked() { self.open_preview2d(); } }); self.render_progress_ui(ui); self.resume_ui(ui); if self.render_ui.preview.is_some() { self.paint_pass(ui); return; } let framing = self.fixed_camera(); let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let preset = self .session .equipment .get(index) .and_then(|item| item.preset.as_ref()); let presets: Vec<_> = preset.into_iter().collect(); self.viewport .show_framed(ui, &mut self.controls, body, &presets, framing); }); } pub fn open_preview2d(&mut self) { self.refresh_preview2d_catalog(); sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog); self.preview2d_image = None; self.preview2d_texture = None; self.preview2d_texture_key = None; self.session.show_preview2d(); } pub fn preview2d_state(&self) -> &Preview2dState { &self.preview2d } pub fn preview2d_image(&self) -> Option<&ImageBuf> { self.preview2d_image.as_ref() } fn refresh_preview2d_catalog(&mut self) { self.preview2d_catalog = match self.session.project_root() { Some(root) => discover_completed_runs(&root), None => Vec::new(), }; } fn rebuild_preview2d_frame(&mut self) { let sex = self.preview2d.sex; let equipment = self.preview2d.selected_equipment.clone(); let clip = self.preview2d.clip_token.clone(); let frame = self.preview2d.frame_index(); let pitch = self.preview2d.pitch_deg(); let yaw = self.preview2d.yaw_deg(); let light = self.preview2d.light(); let rim = self.preview2d.rim(); if clip.is_empty() { self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into()); self.preview2d_image = None; return; } match open_selection(&self.preview2d_catalog, sex, &equipment) { Ok((body, gear)) => match compose_preview_frame(ComposeRequest { body: &body, equipment: &gear, clip_token: &clip, frame, pitch, yaw, light, rim, }) { Ok(image) => { self.preview2d.diagnostic = None; self.preview2d_image = Some(image); } Err(err) => { self.preview2d.diagnostic = Some(err.to_string()); self.preview2d_image = None; } }, Err(err) => { self.preview2d.diagnostic = Some(err.to_string()); self.preview2d_image = None; } } } fn show_preview2d(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); let dt = ctx.input(|i| i.stable_dt); let focused = ctx.input(|i| i.focused); let cancel_holds = ctx.input(|i| { !i.focused || i.pointer.any_released() || i.events.iter().any(|e| { matches!( e, egui::Event::PointerGone | egui::Event::WindowFocused(false) ) }) }); if cancel_holds || !focused { self.preview2d.on_interaction_cancel(); } self.preview2d.tick(dt); let mut need_rebuild = false; let mut refresh_catalog = false; let mut go_back = false; let mut set_sex = None; let mut toggle_set = None; let mut select_clip = None; let mut step_yaw = 0i32; let mut step_pitch = 0i32; let mut toggle_play = false; let mut apply_speed = false; let mut hold_az = None; let mut hold_el = None; let mut release_az = false; let mut release_el = false; egui::SidePanel::left("preview2d_controls") .default_width(330.0) .show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading("2D-preview"); if ui.button("Terug").clicked() { go_back = true; } ui.separator(); ui.label("Geslacht"); ui.horizontal(|ui| { if ui .selectable_label(self.preview2d.sex == Sex::Male, "Man") .clicked() { set_sex = Some(Sex::Male); } if ui .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw") .clicked() { set_sex = Some(Sex::Female); } }); if ui.button("Vernieuw exportsets").clicked() { refresh_catalog = true; } ui.label(format!( "{} voltooide exportset(s)", self.preview2d_catalog.len() )); ui.separator(); ui.label("Equipment (zelfde project/rig/contract)"); let allowed = compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex); if allowed.is_empty() { ui.weak("Geen compatibele equipment-export."); } for set in allowed { let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set); if ui.checkbox(&mut checked, &set).changed() { toggle_set = Some(set); } } ui.separator(); ui.label("Clip"); let sex_token = self.preview2d.sex_token(); let clips: Vec<_> = self .preview2d_catalog .iter() .filter(|e| e.sex == sex_token && e.is_body()) .max_by_key(|e| e.path.clone()) .map(|e| e.clips.clone()) .unwrap_or_default(); if clips.is_empty() { ui.colored_label( egui::Color32::LIGHT_RED, "Geen body-export met clips voor dit geslacht.", ); } for (token, name, frames, duration) in &clips { let label = if name.is_empty() { format!("{token} ({frames}f)") } else { format!("{name} ({token}, {frames}f)") }; if ui .selectable_label(self.preview2d.clip_token == *token, label) .clicked() { select_clip = Some((token.clone(), *frames, *duration)); } } ui.separator(); ui.horizontal(|ui| { let play_label = if self.preview2d.playing { "Pauze" } else { "Speel" }; if ui.button(play_label).clicked() { toggle_play = true; } ui.label(format!( "frame {}/{} t={:.2}s", self.preview2d.frame_index() + 1, self.preview2d.clip_frames.max(1), self.preview2d.elapsed_secs )); }); ui.horizontal(|ui| { ui.label("Snelheid"); ui.add( egui::TextEdit::singleline(&mut self.preview2d_speed_text) .desired_width(48.0), ); if ui.button("Pas toe").clicked() { apply_speed = true; } ui.label(format!("export-fps {}", self.preview2d.fps)); }); ui.separator(); ui.label("Hoek (export)"); ui.horizontal(|ui| { if ui.button("Yaw −").clicked() { step_yaw = -1; } if ui.button("Yaw +").clicked() { step_yaw = 1; } ui.label(format!("yaw {}°", self.preview2d.yaw_deg())); }); ui.horizontal(|ui| { if ui.button("Pitch −").clicked() { step_pitch = -1; } if ui.button("Pitch +").clicked() { step_pitch = 1; } ui.label(format!("pitch {}°", self.preview2d.pitch_deg())); }); ui.separator(); ui.label("Licht (vasthouden; °/s)"); ui.horizontal(|ui| { let west = ui.button("Azimut −"); let east = ui.button("Azimut +"); if west.is_pointer_button_down_on() { hold_az = Some(HoldSign::Negative); } else if east.is_pointer_button_down_on() { hold_az = Some(HoldSign::Positive); } else if self.preview2d.hold_azimuth.is_some() && !west.is_pointer_button_down_on() && !east.is_pointer_button_down_on() { release_az = true; } ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg)); }); ui.horizontal(|ui| { let down = ui.button("Elevatie −"); let up = ui.button("Elevatie +"); if down.is_pointer_button_down_on() { hold_el = Some(HoldSign::Negative); } else if up.is_pointer_button_down_on() { hold_el = Some(HoldSign::Positive); } else if self.preview2d.hold_elevation.is_some() && !down.is_pointer_button_down_on() && !up.is_pointer_button_down_on() { release_el = true; } ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg)); }); ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80."); ui.separator(); ui.label("Randlicht (preview)"); let mut rim = self.preview2d.rim_intensity; let rim_slider = ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte")); if rim_slider.changed() { self.preview2d.set_rim_intensity(rim); need_rebuild = true; } ui.weak( "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.", ); }); if go_back { self.session.back(); return; } if refresh_catalog { self.refresh_preview2d_catalog(); sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog); need_rebuild = true; } if let Some(sex) = set_sex { self.preview2d.set_sex(sex); sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog); need_rebuild = true; } if let Some(set) = toggle_set { self.preview2d.toggle_equipment(&set); need_rebuild = true; } if let Some((token, frames, duration)) = select_clip { self.preview2d.set_clip(token, frames, duration); need_rebuild = true; } if toggle_play { self.preview2d.playing = !self.preview2d.playing; } if apply_speed { if let Ok(speed) = self.preview2d_speed_text.parse::() { if speed.is_finite() && speed >= 0.0 { self.preview2d.playback_speed = speed; } } } if step_yaw != 0 { self.preview2d.step_yaw(step_yaw); need_rebuild = true; } if step_pitch != 0 { self.preview2d.step_pitch(step_pitch); need_rebuild = true; } if let Some(sign) = hold_az { self.preview2d.hold_light(LightAxis::Azimuth, sign); } else if release_az { self.preview2d.release_light(LightAxis::Azimuth); } if let Some(sign) = hold_el { self.preview2d.hold_light(LightAxis::Elevation, sign); } else if release_el { self.preview2d.release_light(LightAxis::Elevation); } // Continuous light or playing changes the image every tick. if self.preview2d.playing || self.preview2d.hold_azimuth.is_some() || self.preview2d.hold_elevation.is_some() { need_rebuild = true; } let key = ( self.preview2d.clip_token.clone(), self.preview2d.frame_index(), self.preview2d.pitch_deg(), self.preview2d.yaw_deg(), self.preview2d.selected_equipment.len() as u32, format!( "{:.1}/{:.1}/{:.3}/{}", self.preview2d.light_azimuth_deg, self.preview2d.light_elevation_deg, self.preview2d.rim_intensity, self.preview2d.sex_token() ), ); if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) { self.rebuild_preview2d_frame(); self.preview2d_texture_key = Some(key); self.preview2d_texture = None; } egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } if let Some(diag) = &self.preview2d.diagnostic { ui.colored_label(egui::Color32::LIGHT_RED, diag); } let Some(image) = &self.preview2d_image else { ui.label( "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.", ); return; }; if self.preview2d_texture.is_none() { let color = egui::ColorImage::from_rgba_unmultiplied( [image.width as usize, image.height as usize], &image.rgba, ); self.preview2d_texture = Some(ui.ctx().load_texture( "preview2d-frame", color, egui::TextureOptions::NEAREST, )); } let texture = self.preview2d_texture.as_ref().expect("texture"); let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover()); let fit = crate::render_job::fit_preview( [image.width, image.height], [bounds.width(), bounds.height()], ); let rect = egui::Rect::from_min_size( bounds.min + egui::vec2(fit.offset[0], fit.offset[1]), egui::vec2(fit.size[0], fit.size[1]), ); ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK); ui.painter().image( texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); }); } fn toolbar(&mut self, ctx: &egui::Context, project: bool) { if !project { return; } egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.horizontal(|ui| { if ui.button("Body").clicked() { self.session.show_bodies(); } if ui.button("2D-preview").clicked() { self.open_preview2d(); } if ui.button("Opslaan").clicked() { self.save(); } if ui .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project")) .clicked() { self.begin(Intent::PickNew); } if ui .add_enabled(!self.render_ui.running, egui::Button::new("Ander project")) .clicked() { self.begin(Intent::PickLoad); } }); }); } } fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option { fn node( ui: &mut egui::Ui, body: &PreviewCharacter, paths: &[String], index: usize, selected: Option<&str>, choice: &mut Option, depth: usize, ) { if depth > 64 { return; } let joint = body.joint_nodes.iter().position(|i| *i == index); let children: Vec<_> = body .nodes .iter() .enumerate() .filter(|(_, n)| n.parent == Some(index)) .map(|(i, _)| i) .collect(); let label = if body.nodes[index].name.is_empty() { format!("node-{index}") } else { body.nodes[index].name.clone() }; let select = |ui: &mut egui::Ui, choice: &mut Option| { if let Some(joint) = joint { if ui .selectable_label( selected == Some(paths[joint].as_str()), format!("Koppel {label}"), ) .clicked() { *choice = Some(joint); } } }; if children.is_empty() { select(ui, choice); } else { egui::CollapsingHeader::new(&label) .id_salt(index) .default_open(depth < 2) .show(ui, |ui| { select(ui, choice); for child in children { node(ui, body, paths, child, selected, choice, depth + 1); } }); } } let mut choice = None; let paths = crate::preview::joint_paths(body); for (index, item) in body.nodes.iter().enumerate() { if item.parent.is_none() { node(ui, body, &paths, index, selected, &mut choice, 0); } } choice } #[cfg(test)] mod tests { use super::*; use crate::dialogs::ScriptedDialogs; use crate::session::Choice; fn root() -> PathBuf { std::env::temp_dir().join(format!( "paperdoll-editor-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )) } #[test] fn picker_creates_project_and_cancelled_switch_preserves_edits() { let root = root(); let mut dialogs = ScriptedDialogs::default(); dialogs.push(Some(root.clone())); dialogs.push(None); let mut app = EditorApp::new(Box::new(dialogs)); assert_eq!(app.session.shell_size(), crate::session::START_WINDOW); app.begin(Intent::PickNew); app.poll_dialogs(); assert_eq!(app.session.screen, Screen::Bodies); assert!(app.preview_male.is_some()); assert!(app.preview_female.is_some()); app.session.set_name("Keep my edit".into()); app.begin(Intent::PickLoad); assert_eq!(app.session.confirm, Some(Intent::PickLoad)); app.apply_choice(Choice::Discard, &egui::Context::default()); app.poll_dialogs(); // OS picker cancel; no actual project switch happened assert_eq!( app.session.opened.as_ref().unwrap().document.name, "Keep my edit" ); assert!(app.session.is_dirty()); let _ = std::fs::remove_dir_all(root); } #[test] fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() { let root = root(); let mut app = EditorApp::default(); app.session.install_new_project(&root).unwrap(); app.session.set_render(0, 128, 12); app.begin(Intent::Quit); app.apply_choice(Choice::Save, &egui::Context::default()); assert!(!app.allow_close); assert_eq!(app.session.confirm, Some(Intent::Quit)); assert!(app.session.is_dirty()); app.open_path(root.join("missing.ppd")); assert!(app.session.opened.is_some()); assert_eq!(app.session.screen, Screen::Bodies); let _ = std::fs::remove_dir_all(root); } #[test] fn fbx_body_and_fbx_equipment_reach_live_view_and_render_job() { let root = root(); let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); let mut app = EditorApp::default(); app.session.install_new_project(&root).unwrap(); let relative = crate::session::adopt_file( &root, &fixtures.join("bodies/test-body-male.fbx"), "assets/body", ) .unwrap(); app.session.set_body_path(Sex::Male, relative); app.session.save().unwrap(); app.open_path(root.join("character.ppd")); let male = app.preview_male.clone().expect("FBX-lichaam in live view"); assert_eq!(male.joint_names.len(), 19); assert!(male.clips.iter().any(|c| c.name.contains("walk"))); assert!(matches!(&app.inventories[0], Some(Ok(_)))); // Rigid FBX equipment through the same picker path, auto-bound to the FBX rig. let index = app.session.add_equipment(); app.session.equipment[index].slug = "fbxsword".into(); app.session.active_body = Some(Sex::Male); app.take_equipment(index, &fixtures.join("equipment/sword-fbx.fbx")); let preset = app.session.equipment[index].preset.clone().expect("preset"); assert_eq!(preset.attachments[0].label, "sword-fbx.fbx"); assert!(preset.attachments[0].bones[0].is_some()); assert_eq!( preset.rigs[0].as_ref(), Some(&crate::equipment::RigSignature::of(&male)) ); app.open_body_configuration(Sex::Male); let job = app.build_job().expect("render job for FBX body"); assert_eq!(job.body_model.path, "assets/body/test-body-male.fbx"); assert_eq!(job.character.positions, male.positions); let _ = std::fs::remove_dir_all(root); } #[test] fn open_with_project_path_loads_bodies_and_models() { let root = root().join("project with spaces"); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); assert_eq!(app.session.screen, Screen::Bodies); assert!(app.preview_male.is_some()); assert_eq!( app.session.opened.unwrap().manifest_path, opened.manifest_path ); let _ = std::fs::remove_dir_all(root.parent().unwrap()); } #[test] fn renderer_disconnect_is_reported() { let mut app = EditorApp::default(); let (_, gpu) = std::sync::mpsc::channel(); app.render_worker = Some(crate::production::RenderThread { mailbox: Default::default(), gpu, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), handle: None, }); app.render_ui.running = true; app.drain_render(); assert!(!app.render_ui.running); assert!(app.render_ui.error.is_some()); } }