//! Project window: start screen, body, equipment, render queue, and 2D preview. use std::path::PathBuf; use eframe::egui; 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_exports, open_selection, selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState, ViewMode, }; use crate::preview_gl::Viewport; use crate::project::open_project; use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState}; use crate::session::{AfterChoice, Intent, Screen, Session, Sex}; #[derive(Debug, Clone, Default)] struct NewProjectForm { parent: Option, name: String, error: Option, } pub struct EditorApp { session: Session, /// User config dir for the recent-projects list; `None` disables it (tests). recent_dir: Option, recent: Vec, 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, /// Open "Nieuw project" form (parent folder + name), Tim t998u.3. new_form: Option, settings_open: bool, equipment_part: usize, /// Warning after enabling a sex that could not inherit a mapping. equipment_gender_notice: Option, preview2d: Preview2dState, preview2d_catalog: Vec, /// Runs under `renders/` without a valid `.ppda` (reason shown on hover). preview2d_skipped: Vec<(std::path::PathBuf, String)>, /// Decoded `.ppda` atlases of the current selection, keyed by run paths. preview2d_loaded: Option, 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, render_all: Option, render_all_summary: Option, window_focused: bool, last_toast: Option, } /// One queued item of **Render alles**: a body (0 = male, 1 = female) with /// optional equipment item index. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderAllItem { pub sex: usize, pub equipment: Option, pub label: String, } /// What **Render alles** did, per item. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RenderAllSummary { pub rendered: Vec, pub skipped: Vec<(String, String)>, pub failed: Vec<(String, String)>, pub not_started: Vec, pub cancelled: bool, } impl RenderAllSummary { pub fn text(&self) -> String { let mut out = format!( "Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt", if self.cancelled { " (geannuleerd)" } else { "" }, self.rendered.len(), self.skipped.len(), self.failed.len() ); if !self.not_started.is_empty() { out += &format!(", {} niet gestart", self.not_started.len()); } out } } struct RenderAll { queue: std::collections::VecDeque, total: usize, started: usize, current: Option, summary: RenderAllSummary, } /// Decoded atlases for the 2D preview selection (body first). struct PreviewSelection { paths: Vec, body: crate::atlas::LoadedAtlas, gear: Vec, } impl EditorApp { pub fn new(dialogs: Box) -> Self { Self { session: Session::new(), recent_dir: None, recent: Vec::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: crate::project::DEFAULT_FPS.to_string(), allow_close: false, dialog_pending: false, new_form: None, settings_open: false, equipment_part: 0, equipment_gender_notice: None, preview2d: Preview2dState::default(), preview2d_catalog: Vec::new(), preview2d_skipped: Vec::new(), preview2d_loaded: None, 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, render_all: None, render_all_summary: None, window_focused: true, last_toast: None, } } /// Use `dir` for the recent-projects list (`None` = off) and load it. pub fn set_recent_dir(&mut self, dir: Option) { self.recent = dir.as_deref().map(crate::recent::load).unwrap_or_default(); self.recent_dir = dir; } /// Switch the UI language at runtime and persist it in the config dir. pub fn choose_language(&mut self, lang: crate::i18n::Lang) { crate::i18n::set_lang(lang); if let Some(dir) = &self.recent_dir { if let Err(e) = crate::i18n::save_language(dir, lang) { self.session.notice = crate::i18n::t(e); self.session.notice_ok = false; } } } fn show_settings(&mut self, ctx: &egui::Context) { if !self.settings_open { return; } let mut open = true; let mut chosen = None; egui::Window::new(crate::i18n::t("Instellingen")) .open(&mut open) .collapsible(false) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::t("Taal")); let current = crate::i18n::lang(); for lang in [crate::i18n::Lang::En, crate::i18n::Lang::Nl] { if ui .selectable_label(current == lang, lang.native_name()) .clicked() { chosen = Some(lang); } } }); }); if let Some(lang) = chosen { self.choose_language(lang); } self.settings_open = open; } pub fn recent_projects(&self) -> &[PathBuf] { &self.recent } /// Remember the open project's manifest (never fatal). fn remember_project(&mut self) { let (Some(dir), Some(opened)) = (&self.recent_dir, &self.session.opened) else { return; }; if let Ok(list) = crate::recent::record(dir, &opened.manifest_path) { self.recent = list; } } /// "Aanmaken" in the new-project form. fn create_from_form(&mut self) { let Some(form) = self.new_form.as_mut() else { return; }; let Some(parent) = form.parent.clone() else { form.error = Some("kies eerst een map waarin het project komt".into()); return; }; let name = form.name.clone(); match self.session.install_named_project(&parent, &name) { Ok(()) => { self.new_form = None; self.remember_project(); self.reset_render(); self.sync_fields(); self.reload_bodies(); } Err(error) => { if let Some(form) = self.new_form.as_mut() { form.error = Some(error); } } } } fn show_new_project_form(&mut self, ctx: &egui::Context) { let Some(form) = self.new_form.as_mut() else { return; }; let mut pick = false; let mut create = false; let mut cancel = false; egui::Window::new(crate::i18n::t("Nieuw project")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::t("Map")); match &form.parent { Some(p) => ui.monospace(p.display().to_string()), None => ui.weak(crate::i18n::t("nog niet gekozen")), }; pick = ui.button(crate::i18n::t("Map kiezen…")).clicked(); }); ui.horizontal(|ui| { ui.label(crate::i18n::t("Naam")); let edit = ui.text_edit_singleline(&mut form.name); if edit.changed() { form.error = None; } create |= edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); if let Some(parent) = &form.parent { if !form.name.trim().is_empty() { ui.weak(crate::i18n::t(format!( "Wordt aangemaakt: {}", parent.join(form.name.trim()).display() ))); } } if let Some(error) = &form.error { ui.colored_label(egui::Color32::from_rgb(230, 90, 80), crate::i18n::t(error)); } ui.horizontal(|ui| { create |= ui.button(crate::i18n::t("Aanmaken")).clicked(); cancel = ui.button(crate::i18n::t("Annuleren")).clicked(); }); }); if pick { self.dialogs.start(DialogPurpose::NewProject); } if cancel { self.new_form = None; } else if create { self.create_from_form(); } } 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.remember_project(); 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| { // Relink diagnostics: name the sidecars that are missing. match crate::adopt::missing_sidecars(&root.join(relative)) { Ok(missing) if !missing.is_empty() => format!( "{error} (ontbrekende bijbehorende bestanden: {})", missing.join(", ") ), _ => 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 => { let form = self.new_form.get_or_insert_with(NewProjectForm::default); form.parent = Some(path); form.error = None; } DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) { Ok(opened) => { self.session.install_loaded(opened); self.remember_project(); 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::ExportPreview2dFrame => { self.rebuild_preview2d_frame(); match self.preview2d_loaded.as_ref() { None => self.session.fail( self.preview2d .diagnostic .clone() .unwrap_or_else(|| "geen body-exportset voor dit geslacht".into()), ), Some(sel) => { let req = crate::preview2d::ComposeRequest { body: &sel.body, equipment: &sel.gear, clip_token: &self.preview2d.clip_token, frame: self.preview2d.frame_index(), pitch: self.preview2d.pitch_deg(), yaw: self.preview2d.yaw_deg(), light: self.preview2d.light(), view_mode: crate::preview2d::ViewMode::Resultaat, }; match crate::preview2d::export_preview_frame(&path, &req) { Ok(files) => { self.session.ok(&format!( "Frame geëxporteerd ({} bestanden) naar {}", files.len(), path.display() )); } Err(e) => self.session.fail(e.to_string()), } } } } 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 crate::adopt::adopt_model(&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() { } else if preset.attachments[part].skeleton.is_some() { let _ = crate::equipment_rig::auto_link(&mut preset, part, sex, body); } else { 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; let skinned = preset.attachments[part].skeleton.is_some(); item.preset = Some(preset); self.equipment_part = part; self.session.notice = if skinned { "Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling." } else { "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; self.controls.sex = match sex { Sex::Male => PreviewSex::Male, Sex::Female => PreviewSex::Female, }; } 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.new_form = Some(NewProjectForm::default()), 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() } /// Same as the **Live 3D** button: leave the pass preview, show the viewport. pub fn show_live_3d(&mut self) { self.render_ui.preview = None; } 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 } /// Warning steps of the current/last render, in order. pub fn render_warnings(&self) -> &[String] { &self.render_ui.warnings } 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 sex = usize::from(self.controls.sex == PreviewSex::Female); let equipment = match self.session.screen { Screen::Equipment(i) => Some(i), _ => None, }; match self.job_for(sex, equipment) { Ok(job) => Some(job), Err(error) => { self.session.fail(error); None } } } /// Render job for one body (sex 0 = male, 1 = female) and optional equipment item. fn job_for(&self, sex: usize, equipment: Option) -> Result { let opened = self.session.opened.as_ref().ok_or("geen project geopend")?; opened .document .validate_shape() .map_err(|e| e.to_string())?; let character = match sex { 0 => self.preview_male.as_ref(), _ => self.preview_female.as_ref(), } .cloned() .ok_or("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model")?; let root = self.session.project_root().ok_or("geen projectmap")?; crate::project::prepare_output_dir(&root, &opened.document.output_directory) .map_err(|e| e.to_string())?; let preset = match equipment { Some(i) => Some( self.session .equipment .get(i) .and_then(|i| i.preset.clone()) .ok_or("configureer eerst het equipment")?, ), None => None, }; let body_rel = match sex { 0 => opened.document.male_model.clone(), _ => opened.document.female_model.clone(), } .ok_or("dit lichaam heeft geen modelpad")?; crate::project::portable_relative(&body_rel)?; let body_path = root.join(&body_rel); let body_sha = crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) .map_err(|e| format!("body-fingerprint: {e}"))?; let preset_sha256 = match &preset { None => None, Some(p) => Some(crate::digest::sha256_hex( &p.encode() .map_err(|e| format!("equipment-fingerprint: {e}"))?, )), }; let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some()); let semi_transparency = semi_transparency_for(&opened.document.render, preset.as_ref()); Ok(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, depth_format: crate::camera::DepthFormat::new( opened.document.render.depth_bits, opened.document.render.depth_encoding, )?, atlas_trim, semi_transparency, }) } /// Every body (male, female) and then every equipment item per applicable /// sex, plus items skipped before rendering (with the reason). pub fn render_all_plan(&self) -> (Vec, Vec<(String, String)>) { let mut items = Vec::new(); let mut skipped = Vec::new(); let names = ["man", "vrouw"]; for (sex, name) in names.iter().enumerate() { let label = format!("{name} · body"); if self .session .body_path(if sex == 0 { Sex::Male } else { Sex::Female }) .is_none() { skipped.push((label, "geen lichaam ingesteld".into())); } else { items.push(RenderAllItem { sex, equipment: None, label, }); } } for (index, item) in self.session.equipment.iter().enumerate() { let Some(preset) = &item.preset else { skipped.push(( format!("{} · equipment", item.slug), item.error .clone() .unwrap_or_else(|| "equipment is niet geconfigureerd".into()), )); continue; }; for (sex, name) in names.iter().enumerate() { let label = format!("{name} · {}", item.slug); if !preset.applies[sex] { continue; // not applicable to this sex by design } if self .session .body_path(if sex == 0 { Sex::Male } else { Sex::Female }) .is_none() { skipped.push((label, "geen lichaam ingesteld voor dit geslacht".into())); } else { items.push(RenderAllItem { sex, equipment: Some(index), label, }); } } } (items, skipped) } /// **Render alles**: queue every body and equipment export. pub fn start_render_all(&mut self) { if self.render_ui.running || self.render_all.is_some() { return; } let (items, skipped) = self.render_all_plan(); self.render_all_summary = None; self.render_all = Some(RenderAll { total: items.len(), queue: items.into(), started: 0, current: None, summary: RenderAllSummary { skipped, ..Default::default() }, }); self.render_all_next(); } pub fn render_all_running(&self) -> bool { self.render_all.is_some() } pub fn render_all_summary(&self) -> Option<&RenderAllSummary> { self.render_all_summary.as_ref() } /// Start the next renderable queue item; items that cannot be built or fail /// the render contract are skipped with their diagnostic. A matching /// interrupted run of the item is resumed instead of starting over. fn render_all_next(&mut self) { loop { let Some(state) = self.render_all.as_mut() else { return; }; let Some(item) = state.queue.pop_front() else { let mut done = self.render_all.take().unwrap().summary; done.cancelled = false; self.finish_render_all(done); return; }; state.started += 1; let job = self.job_for(item.sex, item.equipment).and_then(|job| { let output = job.output.clone(); let contract = crate::production::contract(job.clone())?; let resume = crate::resume::candidates(&output, &contract.render_doc) .ok() .and_then(|c| c.into_iter().find(|c| c.blocked.is_none())) .map(|c| c.dir); Ok((job, resume)) }); let state = self.render_all.as_mut().unwrap(); match job { Err(reason) => state.summary.skipped.push((item.label, reason)), Ok((job, resume)) => { state.current = Some(item.label); let worker = match resume { Some(dir) => crate::render_job::spawn_resume(job, dir, None), None => spawn(job, None), }; self.render_worker = Some(worker); self.begin_worker(); return; } } } } /// Record the outcome of the current item and continue (or stop on cancel). fn render_all_item_done(&mut self) { let Some(state) = self.render_all.as_mut() else { return; }; let label = state.current.take().unwrap_or_default(); if self.render_ui.saved.is_some() { state.summary.rendered.push(label); } else if let Some(error) = &self.render_ui.error { state.summary.failed.push((label, error.clone())); } else { // Cancelled: stop the queue, the item stays resumable. let state = self.render_all.take().unwrap(); let mut summary = state.summary; summary .failed .push((label, "geannuleerd (hervatbaar)".into())); summary.cancelled = true; summary .not_started .extend(state.queue.into_iter().map(|i| i.label)); self.finish_render_all(summary); return; } self.render_all_next(); } fn finish_render_all(&mut self, summary: RenderAllSummary) { self.session.notice = summary.text(); self.session.notice_ok = summary.failed.is_empty() && !summary.cancelled; self.render_all_summary = Some(summary); self.notify_render_end(); } /// One desktop notification per finished run when the window is unfocused. fn notify_render_end(&mut self) { let outcome = crate::notify::RenderOutcome::Done { ok: self.session.notice_ok, text: self.session.notice.clone(), }; if let Some(toast) = crate::notify::toast_for(&outcome, self.window_focused) { self.last_toast = Some(toast.clone()); if !cfg!(test) { crate::notify::show(toast); } } } fn render_all_ui(&mut self, ui: &mut egui::Ui) { if let Some(state) = &self.render_all { ui.label(crate::i18n::t(format!( "Render alles: item {}/{} · {}", state.started, state.total, state.current.as_deref().unwrap_or("") ))); } if let Some(summary) = &self.render_all_summary { ui.label(crate::i18n::t(summary.text())); for label in &summary.rendered { ui.label(crate::i18n::t(format!("✔ {label}"))); } for (label, reason) in &summary.skipped { ui.weak(crate::i18n::t(format!("overgeslagen: {label} — {reason}"))); } for (label, reason) in &summary.failed { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t(format!("mislukt: {label} — {reason}")), ); } for label in &summary.not_started { ui.weak(crate::i18n::t(format!("niet gestart: {label}"))); } } } 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(crate::i18n::t("Onderbroken renders:")); if ui .add_enabled( !self.render_ui.running, egui::Button::new(crate::i18n::t("Zoek onderbroken runs")), ) .on_hover_text(crate::i18n::t( "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(crate::i18n::t("geen gevonden voor deze set")); } }); if let Some(error) = &self.resume_scan_error { ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error)); } let mut chosen = None; for candidate in &self.resume_candidates { ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t(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(crate::i18n::t("Hervat")), ) .clicked() { chosen = Some(candidate.dir.clone()); } } Some(reason) => { ui.weak(crate::i18n::t(reason)); } } }); } if let Some(dir) = chosen { self.resume_run(dir); } } fn service_gpu(&mut self, gl: Option<&std::sync::Arc>) { // Fill the PBO pipeline: accept several GPU requests per UI frame, then // complete any readbacks whose fences have signaled (Tim t1063u). if let Some(gl) = gl { for _ in 0..crate::production::GPU_PIPELINE_DEPTH + 1 { let request = self .render_worker .as_ref() .and_then(|worker| worker.gpu.try_recv().ok()); let Some(request) = request else { break; }; unsafe { request.service(&mut self.gpu_renderer, gl); } } unsafe { let _ = self.gpu_renderer.poll_readbacks(gl, false); } } else { while let Some(request) = self .render_worker .as_ref() .and_then(|worker| worker.gpu.try_recv().ok()) { request.fail("Glow-context ontbreekt".into()); } } } fn drain_render(&mut self) { let Some(worker) = &self.render_worker else { return; }; // Pull latest-wins preview independently of progress events (Tim t1064u). worker.preview.apply_to(&mut self.render_ui); 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_all.is_some() { self.render_all_item_done(); } else { 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(); } self.notify_render_end(); } } else if ended { self.render_ui.apply(RenderEvent::Failed( "De renderworker stopte zonder eindresultaat.".into(), )); self.render_worker = None; self.render_all_item_done(); } } 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, crate::i18n::t(&self.render_ui.progress.label) )), ); } if self.render_ui.running && ui.button(crate::i18n::t("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 aspect = opened.document.render.target_width.max(1) as f32 / opened.document.render.target_height.max(1) as f32; let fps = opened.document.render.fps; 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(crate::i18n::t("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(crate::i18n::t("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(crate::i18n::t("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(crate::i18n::t("Auto midden")) .on_hover_text(crate::i18n::t( "Wis handmatige pivot; gebruik het midden van het model (rustpose)", )) .clicked() { camera.pivot_y_milli = None; } let body = match sex { 0 => self.preview_male.as_ref(), _ => self.preview_female.as_ref(), }; if let Some(body) = body { if ui .button(crate::i18n::t("Omhullende animaties")) .on_hover_text(crate::i18n::t( "Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \ binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.", )) .clicked() { if let Some((h, c)) = crate::camera::animation_envelope(body, *camera, aspect, fps, 0.05, 0.) { camera.height_milli = h.clamp(100, 10_000); camera.center_y_milli = c.clamp(-10_000, 10_000); } } } ui.checkbox( &mut self.show_render_frame, crate::i18n::t("Vast renderkader tonen"), ); }); let auto_range = diagnostic .as_ref() .and_then(|r| r.framing.depth_range_milli) .unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI); ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Dieptebereik (m)")); let mut auto = camera.depth_range_milli.is_none(); if ui .checkbox(&mut auto, crate::i18n::t("automatisch")) .on_hover_text( crate::i18n::t("T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot"), ) .changed() { camera.depth_range_milli = if auto { None } else { Some(auto_range) }; } let mut meters = camera.depth_range_milli.unwrap_or(auto_range) as f32 / 1000.; if ui .add_enabled( camera.depth_range_milli.is_some(), egui::DragValue::new(&mut meters) .speed(0.01) .range(0.1..=19.0), ) .changed() { camera.depth_range_milli = Some((meters * 1000.).round() as u32); } let (near, far) = crate::camera::depth_range(crate::project::CameraFraming { depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)), ..*camera }); ui.weak(crate::i18n::t(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"))); }); if let Some(resolved) = &diagnostic { ui.label(crate::i18n::t(resolved.description())); } else { ui.label(crate::i18n::t( "Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", )); } } } /// Depth PNG settings (bit depth 8/16, encoding), as set by the GUI. pub fn set_depth_format(&mut self, bits: u8, encoding: crate::camera::DepthEncoding) { if let Some(opened) = &mut self.session.opened { opened.document.render.depth_bits = bits; opened.document.render.depth_encoding = encoding; } } pub fn depth_format(&self) -> Option<(u8, crate::camera::DepthEncoding)> { let r = &self.session.opened.as_ref()?.document.render; Some((r.depth_bits, r.depth_encoding)) } fn depth_format_ui(&mut self, ui: &mut egui::Ui) { let Some((mut bits, mut encoding)) = self.depth_format() else { return; }; ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Depth-map")); egui::ComboBox::from_id_salt("depth_bits") .selected_text(crate::i18n::t(format!("{bits}-bit"))) .show_ui(ui, |ui| { ui.selectable_value(&mut bits, 8, crate::i18n::t("8-bit")); ui.selectable_value(&mut bits, 16, crate::i18n::t("16-bit")); }); egui::ComboBox::from_id_salt("depth_encoding") .selected_text(crate::i18n::t(encoding.label())) .show_ui(ui, |ui| { for e in crate::camera::DepthEncoding::ALL { ui.selectable_value(&mut encoding, e, crate::i18n::t(e.label())); } }); if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) { ui.weak(crate::i18n::t(format!("{} niveaus", fmt.levels() + 1))); } }); if encoding == crate::camera::DepthEncoding::RgbPacked { ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).")); } self.set_depth_format(bits, encoding); if let Some(opened) = &mut self.session.opened { let r = &mut opened.document.render; ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Atlas bijsnijden:")); ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam")); ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment")); }); ui.checkbox( &mut r.semi_transparency_body, crate::i18n::t("Semi-transparantie toegestaan (lichaam)"), ); } } 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 { let mut app = Self::new(Box::new(SystemDialogs::new())); if let Some(dir) = crate::recent::config_dir() { crate::i18n::set_lang(crate::i18n::load_language(&dir)); } app.set_recent_dir(crate::recent::config_dir()); app } } 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.window_focused = ctx.input(|i| i.focused); 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, crate::i18n::t(&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); self.show_new_project_form(ctx); self.show_settings(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(crate::i18n::t("Wijzigingen")) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .collapsible(false) .resizable(false) .show(ctx, |ui| { ui.label(crate::i18n::t( "Er zijn wijzigingen die nog niet zijn opgeslagen.", )); ui.horizontal(|ui| { if ui.button(crate::i18n::t("Opslaan")).clicked() { choice = Some(crate::session::Choice::Save); } if ui.button(crate::i18n::t("Niet opslaan")).clicked() { choice = Some(crate::session::Choice::Discard); } if ui.button(crate::i18n::t("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(crate::i18n::t("Paperdoll Renderer")); ui.label(crate::i18n::t( "Open een bestaand character.ppd of kies een map voor een nieuw project.", )); ui.add_space(12.0); if ui.button(crate::i18n::t("Project laden")).clicked() { self.begin(Intent::PickLoad); } if ui.button(crate::i18n::t("Nieuw project")).clicked() { self.begin(Intent::PickNew); } if ui.button(crate::i18n::t("Instellingen")).clicked() { self.settings_open = true; } if !self.recent.is_empty() { ui.add_space(16.0); ui.label(egui::RichText::new(crate::i18n::t("Recent geopend")).strong()); let mut open = None; for path in &self.recent { let name = crate::recent::label(path); if path.is_file() { if ui .link(name.as_str()) .on_hover_text(path.display().to_string()) .clicked() { open = Some(path.clone()); } } else { ui.add_enabled( false, egui::Label::new(crate::i18n::t(format!("{name} (niet gevonden)"))), ) .on_disabled_hover_text(path.display().to_string()); } } if let Some(path) = open { self.open_path(path); } } }); } 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(crate::i18n::t("Body")); if let Some(opened) = &mut self.session.opened { ui.horizontal(|ui| { ui.label(crate::i18n::t("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(crate::i18n::t("Breedte")); ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0)); ui.label(crate::i18n::t("Hoogte")); ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0)); ui.label(crate::i18n::t("fps")); ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0)); }); self.apply_render_fields(); self.depth_format_ui(ui); ui.add_space(8.0); ui.horizontal_wrapped(|ui| { if ui .add_enabled( !self.render_ui.running && self.render_all.is_none(), egui::Button::new(crate::i18n::t("Render alles")), ) .on_hover_text( crate::i18n::t("Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/--atlas/."), ) .clicked() { self.start_render_all(); } }); self.render_all_ui(ui); self.render_progress_ui(ui); // Same live pass preview as a single body/equipment render (t990u). if self.render_ui.preview.is_some() { self.paint_pass(ui); } ui.add_space(8.0); if ui.button(crate::i18n::t("2D-preview")).clicked() { self.open_preview2d(); } ui.label(crate::i18n::t("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.")); }); } fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) { ui.horizontal(|ui| { ui.label(crate::i18n::t(title)); let path = self .session .body_path(sex) .unwrap_or("geen model") .to_string(); ui.label(crate::i18n::t(path)); if ui.button(crate::i18n::t(pick)).clicked() { self.dialogs.start(DialogPurpose::Body(sex)); } if ui.button(crate::i18n::t(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(crate::i18n::t(format!( "{}: {} vertices, {} driehoeken", mesh.name, mesh.vertex_count, mesh.triangle_count ))); } for material in &model.materials { ui.label(crate::i18n::t(format!("materiaal {}", material.name))); } for texture in &model.textures { ui.label(crate::i18n::t(format!( "texture {}: {}×{}", texture.source, texture.width, texture.height ))); } ui.label(crate::i18n::t(format!("{} joints", model.joints.len()))); for clip in &model.clips { ui.label(crate::i18n::t(format!( "{}: {:.2} s", clip.name, clip.duration_seconds ))); } for notice in &model.notices { ui.label(crate::i18n::t(notice)); } } Err(message) => { ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(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(crate::i18n::t("Terug")).clicked() { self.session.show_bodies(); } if ui.button(crate::i18n::t("Equipment")).clicked() { self.session.show_equipment_list(); } if ui .add_enabled( !self.render_ui.running, egui::Button::new(crate::i18n::t("Render alle hoeken")), ) .clicked() { self.start_render(); } if ui.button(crate::i18n::t("Live 3D")).clicked() { self.render_ui.preview = None; } if ui.button(crate::i18n::t("2D-preview")).clicked() { self.open_preview2d(); } }); if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) { ui.colored_label( egui::Color32::from_rgb(230, 190, 90), crate::i18n::t(format!( "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken." )), ); } 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(crate::i18n::t("Open een glTF/GLB-, FBX- of OBJ-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(crate::i18n::t(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(crate::i18n::t(format!( "Bodies → {} → Equipment", match self.session.active_body { Some(Sex::Male) => "Man", Some(Sex::Female) => "Vrouw", None => "Body", } ))); if ui.button(crate::i18n::t("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(crate::i18n::t(label)).clicked() { open = Some(index); } } if ui .add_enabled( self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS, egui::Button::new(crate::i18n::t("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(crate::i18n::t("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(crate::i18n::t("Equipment-configuratie")); if ui.button(crate::i18n::t("Terug naar equipment")).clicked() { self.session.show_equipment_list(); } ui.horizontal(|ui| { ui.selectable_value(&mut sex, 0, crate::i18n::t("Man")); ui.selectable_value(&mut sex, 1, crate::i18n::t("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(crate::i18n::t("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, crate::i18n::t("Ongeldige naam; 'body' is gereserveerd."), ); } if let Some(error) = &item.error { ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error)); } if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX/glTF)")).clicked() { self.dialogs.start(DialogPurpose::EquipmentModel(index)); } if let Some(preset) = &mut item.preset { ui.horizontal(|ui| { ui.label(crate::i18n::t("Geldig voor:")); let mut applies = preset.applies; ui.checkbox(&mut applies[0], crate::i18n::t("Man")); ui.checkbox(&mut applies[1], crate::i18n::t("Vrouw")); if applies != preset.applies { for (to_sex, enabled) in applies.iter().copied().enumerate() { if enabled && !preset.applies[to_sex] { let body = if to_sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; self.equipment_gender_notice = inherit_gender_mapping(preset, to_sex, body); } } preset.applies = applies; } }); ui.label(crate::i18n::t( "Positie, rotatie en botkoppeling gelden per geslacht.", )); if let Some(notice) = &self.equipment_gender_notice { ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(notice)); } ui.checkbox( &mut preset.semi_transparency, crate::i18n::t("Semi-transparantie toegestaan"), ); if !preset.applies[0] && !preset.applies[1] { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t("Kies minstens één geslacht."), ); } ui.label(crate::i18n::t("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, crate::i18n::t("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(crate::i18n::t(&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, crate::i18n::t(&item.label), ); } }); let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let skinned = preset.attachments[self.equipment_part].skeleton.is_some(); let attachment = &mut preset.attachments[self.equipment_part]; if !skinned { ui.label(crate::i18n::t(if sex == 0 { "Positie relatief aan bot (XYZ) — Man" } else { "Positie relatief aan bot (XYZ) — Vrouw" })); ui.horizontal(|ui| { for value in &mut attachment.translation[sex] { ui.add( egui::DragValue::new(value) .speed(0.005) .range(-1000.0..=1000.0), ); } }); ui.label(crate::i18n::t(if sex == 0 { "Rotatie in graden XYZ (Rz·Ry·Rx) — Man" } else { "Rotatie in graden XYZ (Rz·Ry·Rx) — Vrouw" })); ui.horizontal(|ui| { for value in &mut attachment.rotation_deg[sex] { 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(crate::i18n::t("Glanssterkte")), ); let shiny = attachment .shiny .and_then(|i| preset.assets.get(i)) .map(|a| a.path.clone()); ui.label(crate::i18n::t(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()))); if ui.button(crate::i18n::t("Kies shinymap (PNG)")).clicked() { self.dialogs.start(DialogPurpose::EquipmentShiny( index, self.equipment_part, )); } let clear_shiny = ui.button(crate::i18n::t("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); } } if !skinned { ui.label(crate::i18n::t(format!( "Bot: {}", selected.as_deref().unwrap_or("nog niet gekoppeld") ))); if let Some(reason) = body.and_then(PreviewCharacter::static_reason) { ui.add_enabled_ui(false, |ui| { ui.collapsing(crate::i18n::t("Skeleton / botten"), |ui| { ui.label(crate::i18n::t("Geen botten")); }); }); ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t(format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam.")), ); } else if let Some(body) = body { if preset.rigs[sex].as_ref() != Some(&crate::equipment::RigSignature::of(body)) { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."), ); } ui.collapsing(crate::i18n::t("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, crate::i18n::t(error)); } } }); } else { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."), ); } } else if let Some(error) = skinned_links_ui(ui, preset, self.equipment_part, sex, body) { item.error = Some(error); } if ui.button(crate::i18n::t("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(crate::i18n::t("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| { let static_body = self.character().is_some_and(PreviewCharacter::is_static); if ui .add_enabled( !self.render_ui.running && !static_body, egui::Button::new(crate::i18n::t("Render equipment · alle hoeken")), ) .on_disabled_hover_text( crate::i18n::t("Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)"), ) .clicked() { self.start_render(); } if ui.button(crate::i18n::t("Live 3D")).clicked() { self.render_ui.preview = None; } if ui.button(crate::i18n::t("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()); // A static body has no bones; equipment layers are not attached // to an invented skeleton, so the live view shows only the body. let presets: Vec<_> = preset .into_iter() .filter(|_| !body.is_some_and(PreviewCharacter::is_static)) .collect(); if let Some(reason) = body.and_then(PreviewCharacter::static_reason) { ui.colored_label( egui::Color32::from_rgb(230, 190, 90), crate::i18n::t(format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar.")), ); } 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 } /// Mutable 2D-preview state (view/playback), e.g. for GUI smokes. pub fn preview2d_state_mut(&mut self) -> &mut Preview2dState { &mut self.preview2d } pub fn preview2d_image(&self) -> Option<&ImageBuf> { self.preview2d_image.as_ref() } fn refresh_preview2d_catalog(&mut self) { (self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() { Some(root) => discover_exports(&root), None => (Vec::new(), Vec::new()), }; self.preview2d_loaded = None; } 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 view_mode = self.preview2d.view_mode; if clip.is_empty() { self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into()); self.preview2d_image = None; return; } let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) { Ok(paths) => paths, Err(err) => { self.preview2d.diagnostic = Some(err.to_string()); self.preview2d_image = None; return; } }; if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) { self.preview2d_loaded = None; match open_selection(&self.preview2d_catalog, sex, &equipment) { Ok((body, gear)) => { self.preview2d_loaded = Some(PreviewSelection { paths, body, gear }) } Err(err) => { self.preview2d.diagnostic = Some(err.to_string()); self.preview2d_image = None; return; } } } match self.preview2d_loaded.as_ref().ok_or("geen selectie") { Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest { body, equipment: gear, clip_token: &clip, frame, pitch, yaw, light, view_mode, }) { 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 export_frame = 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; let mut set_view_mode = None; 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(crate::i18n::t("2D-preview")); if ui.button(crate::i18n::t("Terug")).clicked() { go_back = true; } ui.separator(); ui.label(crate::i18n::t("Geslacht")); ui.horizontal(|ui| { if ui .selectable_label(self.preview2d.sex == Sex::Male, crate::i18n::t("Man")) .clicked() { set_sex = Some(Sex::Male); } if ui .selectable_label(self.preview2d.sex == Sex::Female, crate::i18n::t("Vrouw")) .clicked() { set_sex = Some(Sex::Female); } }); if ui.button(crate::i18n::t("Vernieuw exportsets")).clicked() { refresh_catalog = true; } if ui.button(crate::i18n::t("Exporteer frame")).clicked() { export_frame = true; } ui.label(crate::i18n::t(format!( "{} voltooide exportset(s) (.ppda + atlassen)", self.preview2d_catalog.len() ))); if !self.preview2d_skipped.is_empty() { let reasons = self .preview2d_skipped .iter() .map(|(path, why)| { let name = path.file_name().unwrap_or_default().to_string_lossy(); format!("{name}: {why}") }) .collect::>() .join("\n"); ui.weak(crate::i18n::t(format!( "{} run(s) zonder geldige .ppda overgeslagen", self.preview2d_skipped.len() ))) .on_hover_text(crate::i18n::t(reasons)); } ui.separator(); ui.label(crate::i18n::t("Equipment (zelfde project/rig/contract)")); let allowed = compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex); if allowed.is_empty() { ui.weak(crate::i18n::t("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, crate::i18n::t(&set)).changed() { toggle_set = Some(set); } } ui.separator(); ui.label(crate::i18n::t("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, crate::i18n::t("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, crate::i18n::t(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(crate::i18n::t(play_label)).clicked() { toggle_play = true; } ui.label(crate::i18n::t(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(crate::i18n::t("Snelheid")); ui.add( egui::TextEdit::singleline(&mut self.preview2d_speed_text) .desired_width(48.0), ); if ui.button(crate::i18n::t("Pas toe")).clicked() { apply_speed = true; } ui.label(crate::i18n::t(format!("export-fps {}", self.preview2d.fps))); }); ui.separator(); ui.label(crate::i18n::t("Weergave")); for mode in ViewMode::all() { if ui .selectable_label(self.preview2d.view_mode == mode, crate::i18n::t(mode.label_nl())) .clicked() { set_view_mode = Some(mode); } } ui.weak(crate::i18n::t("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd).")); ui.separator(); ui.label(crate::i18n::t("Hoek (export)")); ui.horizontal(|ui| { if ui.button(crate::i18n::t("Yaw −")).clicked() { step_yaw = -1; } if ui.button(crate::i18n::t("Yaw +")).clicked() { step_yaw = 1; } ui.label(crate::i18n::t(format!("yaw {}°", self.preview2d.yaw_deg()))); }); ui.horizontal(|ui| { if ui.button(crate::i18n::t("Pitch −")).clicked() { step_pitch = -1; } if ui.button(crate::i18n::t("Pitch +")).clicked() { step_pitch = 1; } ui.label(crate::i18n::t(format!("pitch {}°", self.preview2d.pitch_deg()))); }); ui.separator(); ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| { let held_ctx = ui.ctx().clone(); let held = |r: &egui::Response| { held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i)) }; ui.label(crate::i18n::t("Licht (vasthouden; °/s — alleen Resultaat)")); ui.horizontal(|ui| { let west = ui.button(crate::i18n::t("Azimut −")); let east = ui.button(crate::i18n::t("Azimut +")); if self.preview2d.view_mode.uses_light() { if held(&west) { hold_az = Some(HoldSign::Negative); } else if held(&east) { hold_az = Some(HoldSign::Positive); } else if self.preview2d.hold_azimuth.is_some() && !held(&west) && !held(&east) { release_az = true; } } ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_azimuth_deg))); }); ui.horizontal(|ui| { let down = ui.button(crate::i18n::t("Elevatie −")); let up = ui.button(crate::i18n::t("Elevatie +")); if self.preview2d.view_mode.uses_light() { if held(&down) { hold_el = Some(HoldSign::Negative); } else if held(&up) { hold_el = Some(HoldSign::Positive); } else if self.preview2d.hold_elevation.is_some() && !held(&down) && !held(&up) { release_el = true; } } ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_elevation_deg))); }); ui.weak( crate::i18n::t("Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht."), ); }); }); 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 export_frame { self.dialogs.start(DialogPurpose::ExportPreview2dFrame); } 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 let Some(mode) = set_view_mode { if self.preview2d.view_mode != mode { self.preview2d.view_mode = mode; if !mode.uses_light() { self.preview2d.release_all_holds(); } need_rebuild = true; } } 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); } // Playing always refreshes; light holds only matter in Resultaat. if self.preview2d.playing || (self.preview2d.view_mode.uses_light() && (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}/{:?}/{}", self.preview2d.light_azimuth_deg, self.preview2d.light_elevation_deg, self.preview2d.view_mode, 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, crate::i18n::t(diag)); } let Some(image) = &self.preview2d_image else { ui.label( crate::i18n::t("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(crate::i18n::t("Body")).clicked() { self.session.show_bodies(); } if ui.button(crate::i18n::t("2D-preview")).clicked() { self.open_preview2d(); } if ui.button(crate::i18n::t("Opslaan")).clicked() { self.save(); } if ui.button(crate::i18n::t("Instellingen")).clicked() { self.settings_open = true; } if ui .add_enabled( !self.render_ui.running, egui::Button::new(crate::i18n::t("Nieuw project")), ) .clicked() { self.begin(Intent::PickNew); } if ui .add_enabled( !self.render_ui.running, egui::Button::new(crate::i18n::t("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()), crate::i18n::t(format!("Koppel {label}")), ) .clicked() { *choice = Some(joint); } } }; if children.is_empty() { select(ui, choice); } else { egui::CollapsingHeader::new(crate::i18n::t(&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 } /// Atlas trim setting for a body or equipment render (t1030u). pub fn atlas_trim_for(render: &crate::project::RenderSettings, equipment: bool) -> bool { if equipment { render.trim_equipment } else { render.trim_body } } /// Semi-transparency setting of the rendered layer (t1031u): the equipment /// preset's own setting, otherwise the body setting in the `.ppd`. pub fn semi_transparency_for( render: &crate::project::RenderSettings, preset: Option<&crate::equipment::Preset>, ) -> bool { preset.map_or(render.semi_transparency_body, |p| p.semi_transparency) } /// When a sex is enabled and has no mapping yet, copy the other sex's mapping /// for bones whose names exist on both bodies (Tim t1092u). fn inherit_gender_mapping( preset: &mut crate::equipment::Preset, to_sex: usize, to_body: Option<&PreviewCharacter>, ) -> Option { let from_sex = 1 - to_sex; let needed = preset.attachments.iter().any(|attachment| { crate::equipment::Preset::attachment_mapped(attachment, from_sex) && !crate::equipment::Preset::attachment_mapped(attachment, to_sex) }); if !needed { return None; } let Some(body) = to_body.filter(|body| !body.is_static()) else { return Some( "Open een geskind glTF/GLB- of FBX-lichaam voor dit geslacht om de botkoppeling over te nemen." .into(), ); }; match preset.copy_gender_mapping(from_sex, to_sex, body) { Ok(outcome) if outcome.mapped == 0 && outcome.unmatched > 0 => { Some("Geen bot met dezelfde naam op het andere lichaam; kies de botten opnieuw.".into()) } Ok(_) => None, Err(error) => Some(error), } } /// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a /// character bone (optional; at least one), with its own position/rotation. fn skinned_links_ui( ui: &mut egui::Ui, preset: &mut crate::equipment::Preset, part: usize, sex: usize, body: Option<&PreviewCharacter>, ) -> Option { let Some(body) = body.filter(|b| !b.is_static()) else { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."), ); return None; }; let mut error = None; let skeleton = preset.attachments[part].skeleton.clone()?; ui.label(crate::i18n::t( "Botkoppeling: kies per equipmentbot een karakterbot (minstens één). Niet-gekoppelde botten volgen hun dichtstbijzijnde gekoppelde ouder.", )); if preset.attachments[part].bone_links[sex].is_empty() { ui.colored_label( egui::Color32::LIGHT_RED, crate::i18n::t("Koppel minstens één equipmentbot."), ); } if ui .button(crate::i18n::t("Automatisch invullen op naam")) .clicked() { match crate::equipment_rig::auto_link(preset, part, sex, body) { Ok(_) => {} Err(e) => error = Some(e), } } egui::ScrollArea::vertical() .id_salt("skinned-links") .max_height(320.0) .show(ui, |ui| { for (bone, info) in skeleton.bones.iter().enumerate() { let link = preset.attachments[part].bone_links[sex] .iter() .position(|l| l.bone == bone); let current = link.and_then(|i| { let target = &preset.attachments[part].bone_links[sex][i].target; crate::preview::joint_paths(body) .iter() .position(|p| p == target) }); let mut choice = current; ui.horizontal(|ui| { ui.label(&info.name); egui::ComboBox::from_id_salt(("skinned-link", part, sex, bone)) .selected_text( current .and_then(|j| body.joint_names.get(j).cloned()) .unwrap_or_else(|| crate::i18n::t("volgt ouder")), ) .show_ui(ui, |ui| { ui.selectable_value(&mut choice, None, crate::i18n::t("volgt ouder")); for (j, name) in body.joint_names.iter().enumerate() { ui.selectable_value(&mut choice, Some(j), name); } }); }); if choice != current { if let Err(e) = crate::equipment_rig::set_link(preset, part, sex, body, bone, choice) { error = Some(e); } } if let Some(link) = preset.attachments[part].bone_links[sex] .iter_mut() .find(|l| l.bone == bone) { ui.horizontal(|ui| { ui.label(crate::i18n::t("Positie")); for value in &mut link.translation { ui.add( egui::DragValue::new(value) .speed(0.005) .range(-1000.0..=1000.0), ); } ui.label(crate::i18n::t("Rotatie°")); for value in &mut link.rotation_deg { ui.add( egui::DragValue::new(value) .speed(0.5) .range(-3600.0..=3600.0), ); } }); } } }); error } #[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 new_project_form_validates_name_and_folder_with_clear_errors() { let parent = root(); std::fs::create_dir_all(&parent).unwrap(); let mut dialogs = ScriptedDialogs::default(); dialogs.push(Some(parent.clone())); let mut app = EditorApp::new(Box::new(dialogs)); app.begin(Intent::PickNew); app.create_from_form(); assert!(app .new_form .as_ref() .unwrap() .error .as_ref() .unwrap() .contains("map")); app.dialogs.start(DialogPurpose::NewProject); app.poll_dialogs(); for (name, part) in [ ("", "naam"), ("a/b", "mag geen"), ("a\\b", "mag geen"), ("..", "punt"), ] { app.new_form.as_mut().unwrap().name = name.into(); app.create_from_form(); let err = app.new_form.as_ref().unwrap().error.clone().unwrap(); assert!(err.contains(part), "{name}: {err}"); } std::fs::create_dir_all(parent.join("bezet")).unwrap(); std::fs::write(parent.join("bezet/x"), b"x").unwrap(); app.new_form.as_mut().unwrap().name = "bezet".into(); app.create_from_form(); assert!(app .new_form .as_ref() .unwrap() .error .as_ref() .unwrap() .contains("niet leeg")); assert_eq!(app.session.screen, Screen::Start); assert!(!parent.join("bezet/character.ppd").exists()); // An existing empty folder is fine. std::fs::create_dir_all(parent.join("leeg")).unwrap(); app.new_form.as_mut().unwrap().name = " leeg ".into(); app.create_from_form(); assert!(app.new_form.is_none()); assert!(parent.join("leeg/character.ppd").is_file()); let _ = std::fs::remove_dir_all(parent); } #[test] fn opened_and_created_projects_are_remembered_for_the_start_screen() { let config = root(); let project = root(); std::fs::create_dir_all(&project).unwrap(); let mut dialogs = ScriptedDialogs::default(); dialogs.push(Some(project.clone())); let mut app = EditorApp::new(Box::new(dialogs)); app.set_recent_dir(Some(config.clone())); assert!(app.recent_projects().is_empty()); app.begin(Intent::PickNew); app.new_form.as_mut().unwrap().name = "p".into(); app.dialogs.start(DialogPurpose::NewProject); app.poll_dialogs(); app.create_from_form(); let manifest = project.join("p/character.ppd").canonicalize().unwrap(); assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest)); // A fresh app (next start) reads the list and can reopen from it. let mut next = EditorApp::new(Box::new(ScriptedDialogs::default())); next.set_recent_dir(Some(config.clone())); assert_eq!(next.recent_projects(), std::slice::from_ref(&manifest)); next.open_path(next.recent_projects()[0].clone()); assert_eq!(next.session.screen, Screen::Bodies); let _ = std::fs::remove_dir_all(config); let _ = std::fs::remove_dir_all(project); } #[test] fn picker_creates_project_and_cancelled_switch_preserves_edits() { let root = root(); std::fs::create_dir_all(&root).unwrap(); 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.new_form.as_mut().unwrap().name = "Mijn held".into(); app.dialogs.start(DialogPurpose::NewProject); app.poll_dialogs(); app.create_from_form(); assert!(app.new_form.is_none()); assert_eq!(app.session.screen, Screen::Bodies); assert!(root.join("Mijn held/character.ppd").is_file()); assert_eq!( app.session.opened.as_ref().unwrap().document.name, "Mijn held" ); 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 static_obj_body_reaches_live_view_and_disables_bone_functions() { 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 mut relative = String::new(); for name in [ "static-body-male.obj", "static-body-male.mtl", "static-body-albedo.png", ] { let r = crate::session::adopt_file( &root, &fixtures.join("bodies/static").join(name), "assets/body", ) .unwrap(); if name.ends_with(".obj") { relative = r; } } app.session.set_body_path(Sex::Male, relative.clone()); app.session.save().unwrap(); app.open_path(root.join("character.ppd")); let male = app .preview_male .clone() .expect("statisch OBJ-lichaam in live view"); assert!(male.is_static()); assert!(male.clips.is_empty() && male.joint_names.is_empty()); let inventory = app.inventories[0].as_ref().unwrap().as_ref().unwrap(); assert!(inventory.joints.is_empty() && inventory.clips.is_empty()); // Equipment can be imported but is never auto-bound to an invented bone. let index = app.session.add_equipment(); app.session.equipment[index].slug = "sword".into(); app.session.active_body = Some(Sex::Male); app.take_equipment(index, &fixtures.join("equipment/sword.obj")); let preset = app.session.equipment[index].preset.clone().expect("preset"); assert!(preset.attachments[0].bones[0].is_none()); assert!(preset.rigs[0].is_none()); app.open_body_configuration(Sex::Male); let job = app.build_job().expect("render job for static body"); assert_eq!(job.body_model.path, relative); assert!(job.character.is_static()); let contract = crate::production::contract(job).unwrap(); assert_eq!(contract.plan.images, 60); assert_eq!(contract.plan.clips[0].token, "clip000-bind"); app.open_equipment_configuration(index, Sex::Male); let job = app.build_job().expect("equipment job object"); assert!(crate::production::contract(job) .err() .unwrap() .contains("statisch")); // Headless UI frames for the body and equipment screens must not panic. let ctx = egui::Context::default(); for screen in 0..2 { if screen == 0 { app.open_body_configuration(Sex::Male); } else { app.open_equipment_configuration(index, Sex::Male); } let _ = ctx.run(egui::RawInput::default(), |ctx| match screen { 0 => app.show_body(ctx), _ => app.show_equipment(ctx), }); } 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()); } fn pump(app: &mut EditorApp) { for _ in 0..20000 { if !app.render_all_running() { return; } app.service_gpu(None); app.drain_render(); std::thread::sleep(std::time::Duration::from_millis(1)); } panic!("Render alles liep niet af"); } #[test] fn render_all_queues_bodies_and_equipment_with_skip_diagnostics() { let root = root(); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); let index = app.session.add_equipment(); app.session.equipment[index].slug = "leeg".into(); let (items, skipped) = app.render_all_plan(); let keys: Vec<_> = items.iter().map(|i| (i.sex, i.equipment)).collect(); assert_eq!( keys, vec![(0, None), (1, None), (0, Some(index)), (1, Some(index))] ); assert!(skipped.is_empty()); // Without a GL context every body render fails; empty equipment is // skipped by the render contract; the queue still continues. app.start_render_all(); assert!(app.render_all_running()); pump(&mut app); let summary = app.render_all_summary().unwrap().clone(); assert_eq!(summary.failed.len(), 2, "{summary:?}"); assert!(summary.failed[0].1.contains("Glow"), "{summary:?}"); assert_eq!(summary.skipped.len(), 2, "{summary:?}"); assert!(summary.skipped[0].0.contains("leeg"), "{summary:?}"); assert!(!summary.cancelled); assert!(app.session.notice.contains("2 overgeslagen, 2 mislukt")); // Cancel stops the queue; the rest is listed as not started. app.start_render_all(); app.cancel_render(); pump(&mut app); let summary = app.render_all_summary().unwrap().clone(); assert!(summary.cancelled, "{summary:?}"); assert_eq!(summary.not_started.len(), 3, "{summary:?}"); let _ = std::fs::remove_dir_all(root); } #[test] fn semi_transparency_defaults_off_per_layer_and_hair_preset_is_on() { let mut r = crate::project::RenderSettings::default(); assert!(!r.semi_transparency_body); let hair = crate::equipment::Preset::decode(crate::default_bodies::FEMALE_HAIR_PPE).unwrap(); assert!( hair.semi_transparency, "bundled female-hair is semi-transparent" ); let plain = crate::equipment::Preset::new("x".into()); assert!(!plain.semi_transparency); assert!(!semi_transparency_for(&r, None)); assert!(semi_transparency_for(&r, Some(&hair))); r.semi_transparency_body = true; assert!(semi_transparency_for(&r, None)); assert!(!semi_transparency_for(&r, Some(&plain))); // Round trip, and v2 files (no field) load with the setting off. let back = crate::equipment::Preset::decode(&hair.encode().unwrap()).unwrap(); assert!(back.semi_transparency); let root = root(); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); assert!(!app.job_for(0, None).unwrap().semi_transparency); app.session .opened .as_mut() .unwrap() .document .render .semi_transparency_body = true; assert!(app.job_for(0, None).unwrap().semi_transparency); let _ = std::fs::remove_dir_all(root); } #[test] fn atlas_trim_toggles_default_on_and_select_body_or_equipment() { let mut r = crate::project::RenderSettings::default(); assert!(r.trim_body && r.trim_equipment); r.trim_body = false; assert!(!atlas_trim_for(&r, false)); assert!(atlas_trim_for(&r, true)); r.trim_body = true; r.trim_equipment = false; assert!(atlas_trim_for(&r, false)); assert!(!atlas_trim_for(&r, true)); // The body render job follows the body toggle. let root = root(); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); app.session .opened .as_mut() .unwrap() .document .render .trim_body = false; assert!(!app.job_for(0, None).unwrap().atlas_trim); app.session .opened .as_mut() .unwrap() .document .render .trim_body = true; assert!(app.job_for(0, None).unwrap().atlas_trim); let _ = std::fs::remove_dir_all(root); } #[test] fn render_all_end_toasts_once_only_when_unfocused() { let root = root(); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); app.window_focused = true; app.start_render_all(); pump(&mut app); assert!(app.last_toast.is_none(), "focused: no toast"); app.window_focused = false; app.start_render_all(); pump(&mut app); let toast = app.last_toast.clone().expect("unfocused: toast"); assert!(toast.body.contains(" 2 "), "{toast:?}"); let _ = std::fs::remove_dir_all(root); } #[test] fn depth_format_and_range_settings_reach_job_and_persist() { let root = root(); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); use crate::camera::{DepthEncoding, DepthFormat}; assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray))); // Body screen with the depth controls renders headless. let ctx = egui::Context::default(); let _ = ctx.run(Default::default(), |ctx| app.show_bodies(ctx)); assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray))); let job = app.job_for(0, None).unwrap(); assert_eq!(job.depth_format, DepthFormat::default()); app.set_depth_format(16, DepthEncoding::RgbPacked); app.session.opened.as_mut().unwrap().document.render.framing[1].depth_range_milli = Some(1500); let job = app.job_for(1, None).unwrap(); assert_eq!( job.depth_format, DepthFormat::new(16, DepthEncoding::RgbPacked).unwrap() ); let doc = crate::production::contract(job).unwrap().render_doc; assert_eq!(doc["depth_bits"], 16); assert_eq!(doc["depth_encoding"], "rgb_packed"); assert_eq!(doc["depth_near"].as_f64().unwrap() as f32, 9.25); app.session.save().unwrap(); let mut again = EditorApp::default(); again.open_path(opened.manifest_path.clone()); assert_eq!(again.depth_format(), Some((16, DepthEncoding::RgbPacked))); let text = std::fs::read_to_string(&opened.manifest_path).unwrap(); assert!( text.contains("\"depth_encoding\": \"rgb_packed\""), "{text}" ); assert!(text.contains("\"depth_range_milli\": 1500"), "{text}"); let _ = std::fs::remove_dir_all(root); } #[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(), preview: 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()); } #[test] fn language_choice_switches_at_runtime_and_is_stored() { let config = root().join("lang-config"); std::fs::create_dir_all(&config).unwrap(); let mut app = EditorApp::new(Box::new(ScriptedDialogs::default())); app.set_recent_dir(Some(config.clone())); app.choose_language(crate::i18n::Lang::Nl); assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::Nl); app.choose_language(crate::i18n::Lang::En); assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::En); assert_eq!(crate::i18n::lang(), crate::i18n::Lang::En); } }