//! Project window: start screen, body, equipment, and the render queue. use std::path::PathBuf; use std::sync::mpsc::Receiver; use eframe::egui; use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs}; use crate::import::{import_project_model, ImportedModel}; use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex}; use crate::preview_gl::Viewport; use crate::project::open_project; use crate::render_job::{spawn, 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_rx: Option>, render_ui: RenderUiState, applied_shell: Option<[f32; 2]>, width_text: String, height_text: String, fps_text: String, allow_close: bool, dialog_pending: bool, } 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_rx: None, render_ui: RenderUiState::idle(), applied_shell: None, width_text: "128".to_string(), height_text: "128".to_string(), fps_text: "12".to_string(), allow_close: false, dialog_pending: false, } } 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_rx = None; self.render_ui = RenderUiState::idle(); } 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() && matches!( std::path::Path::new(relative) .extension() .and_then(|e| e.to_str()), Some("gltf" | "glb") ) { match load_gltf_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), } } } 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 Some(root) = self.session.project_root() else { return; }; match adopt_file(&root, path, "assets/equipment") { Ok(relative) => { self.session.set_equipment_model(index, relative.clone()); self.session.notice = format!("{relative} hoort bij deze equipment."); self.session.notice_ok = true; } Err(error) => self.session.fail(error), } } 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); } fn start_render(&mut self, preview: [u32; 2]) { if let Some(opened) = &self.session.opened { if let Err(error) = opened.document.validate_shape() { self.session.fail(error.to_string()); return; } } let Some(character) = self.character().cloned() else { self.session .fail("dit lichaam heeft geen glTF dat de preview kan tekenen"); return; }; let Some(root) = self.session.project_root() else { return; }; let Some(opened) = &self.session.opened else { return; }; if let Err(error) = crate::project::prepare_output_dir(&root, &opened.document.output_directory) { self.session.fail(error.to_string()); return; } let sex = match self.controls.sex { PreviewSex::Male => "male", PreviewSex::Female => "female", }; let output = root .join(&opened.document.output_directory) .join(format!("body-{sex}-still.png")); let job = RenderJob { character, controls: self.controls.clone(), target_width: opened.document.render.target_width.max(1), target_height: opened.document.render.target_height.max(1), preview_width: preview[0].max(1), preview_height: preview[1].max(1), output, }; let thread = spawn(job); self.render_rx = Some(thread.rx); self.render_ui = RenderUiState::idle(); self.render_ui.running = true; let _ = thread.handle; } fn drain_render(&mut self) { let Some(rx) = &self.render_rx else { return; }; loop { let event = match rx.try_recv() { Ok(event) => event, Err(std::sync::mpsc::TryRecvError::Empty) => break, Err(std::sync::mpsc::TryRecvError::Disconnected) => { let message = "De renderworker stopte zonder eindresultaat.".to_string(); self.session.fail(message.clone()); self.render_ui.apply(RenderEvent::Failed(message)); self.render_rx = None; break; } }; let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_)); if let RenderEvent::Finished { saved: Some(path) } = &event { self.session.notice = format!("Standbeeld opgeslagen: {path}"); self.session.notice_ok = true; } if let RenderEvent::Failed(message) = &event { self.session.fail(message.clone()); } self.render_ui.apply(event); if finished { self.render_rx = None; break; } } } } impl Default for EditorApp { fn default() -> Self { Self::new(Box::new(SystemDialogs::new())) } } impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 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 { 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), } 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(); }); } 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")) .clicked() { self.start_render([480, 640]); } if ui.button("Live 3D").clicked() { self.render_ui.preview = None; } }); if self.render_ui.progress.total > 0 { let fraction = self.render_ui.progress.completed as f32 / self.render_ui.progress.total.max(1) as f32; ui.add(egui::ProgressBar::new(fraction).text(format!( "{} / {} {}", self.render_ui.progress.completed, self.render_ui.progress.total, self.render_ui.progress.label ))); } if let Some(image) = &self.render_ui.preview { self.paint_pass(ui, image); } 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() { self.viewport.show(ui, &mut self.controls, character); } else { ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave."); } } }); } fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) { let color = egui::ColorImage::from_rgba_unmultiplied( [image.width as usize, image.height as usize], &image.rgba, ); let texture = ui.ctx().load_texture( "render-pass", color, egui::TextureOptions { magnification: egui::TextureFilter::Nearest, minification: egui::TextureFilter::Nearest, wrap_mode: egui::TextureWrapMode::ClampToEdge, ..Default::default() }, ); let available = ui.available_size(); ui.image((texture.id(), available)); } 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.button("Nieuw").clicked() { self.session.add_equipment(); } 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; }; egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading("Equipment"); if ui.button("Terug").clicked() { self.session.show_equipment_list(); } if let Some(item) = self.session.equipment.get_mut(index) { ui.horizontal(|ui| { ui.label("Naam"); ui.text_edit_singleline(&mut item.slug); }); let model = item .model .clone() .unwrap_or_else(|| "geen model".to_string()); ui.label(model); } if ui.button("Kies model").clicked() { self.dialogs.start(DialogPurpose::EquipmentModel(index)); } if ui.button("Verwijder").clicked() { self.session.delete_equipment(index); } }); } 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("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); } }); }); } } #[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 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 (tx, rx) = std::sync::mpsc::channel(); app.render_rx = Some(rx); app.render_ui.running = true; drop(tx); app.drain_render(); assert!(!app.render_ui.running); assert!(app.render_ui.error.is_some()); } }