//! Project window: start screen, body, equipment, and the render queue. 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::{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_worker: Option, gpu_renderer: crate::production::GpuRenderer, show_render_frame: bool, render_ui: RenderUiState, pass_texture: Option, pass_texture_revision: u64, applied_shell: Option<[f32; 2]>, width_text: String, height_text: String, fps_text: String, allow_close: bool, dialog_pending: bool, equipment_part: usize, } impl EditorApp { pub fn new(dialogs: Box) -> Self { Self { session: Session::new(), dialogs, controls: PreviewControls::default(), preview_male: None, preview_female: None, viewport: Viewport::new(), inventories: [None, None], render_worker: None, gpu_renderer: Default::default(), show_render_frame: true, render_ui: RenderUiState::idle(), pass_texture: None, pass_texture_revision: 0, applied_shell: None, width_text: "128".to_string(), height_text: "128".to_string(), fps_text: "12".to_string(), allow_close: false, dialog_pending: false, equipment_part: 0, } } pub fn open_path(&mut self, path: PathBuf) { let candidate = std::env::current_dir() .map_err(|e| e.to_string()) .and_then(|base| { crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy()) }); let path = match candidate { Ok(path) => path, Err(error) => { self.session.fail(error); return; } }; match open_project(&path) { Ok(opened) => { self.session.install_loaded(opened); self.reset_render(); self.sync_fields(); self.reload_bodies(); } Err(error) => { self.session.fail(error.to_string()); } } } pub fn open_fixture_project(&mut self) { let root = std::env::temp_dir().join(format!( "paperdoll-renderer-fixture-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); if let Err(error) = self.session.install_new_project(&root) { self.session.fail(error.to_string()); return; } self.sync_fields(); self.reload_bodies(); self.session.show_body(Sex::Male); self.controls.sex = PreviewSex::Male; self.controls.select_clip("walk"); } fn reset_render(&mut self) { self.render_worker = None; self.render_ui = RenderUiState::idle(); self.pass_texture = None; } 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), DialogPurpose::EquipmentShiny(index, part) => { if let Some(mut preset) = self .session .equipment .get(index) .and_then(|i| i.preset.clone()) { let result = crate::equipment::import_shiny(&mut preset, part, &path) .and_then(|_| self.session.ensure_preset_budget(Some(index), &preset)); match result { Ok(()) => self.session.equipment[index].preset = Some(preset), Err(error) => self.session.fail(error), } } } DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) { Ok(mut preset) => { let result = self .session .available_equipment_name(&preset.name) .and_then(|name| { preset.name = name; self.session.ensure_preset_budget(None, &preset) }); if let Err(error) = result { self.session.fail(error); return; } let index = self.session.equipment.len(); self.session.equipment.push(crate::session::EquipmentItem { slug: preset.name.clone(), model: preset.attachments.first().map(|p| p.label.clone()), preset: Some(preset), legacy_path: None, error: None, }); self.equipment_part = 0; self.session.show_equipment(index); } Err(error) => self.session.fail(error), }, } } } fn take_model(&mut self, sex: Sex, path: &std::path::Path) { let Some(root) = self.session.project_root() else { return; }; match adopt_file(&root, path, "assets/body") { Ok(relative) => { self.session.set_body_path(sex, relative.clone()); self.reload_bodies(); self.reset_render(); let index = if sex == Sex::Male { 0 } else { 1 }; if let Some(Err(error)) = &self.inventories[index] { self.session.fail(format!("{relative}: {error}")); } else { self.session.notice = format!("{relative} staat in het project."); self.session.notice_ok = true; } } Err(error) => self.session.fail(error), } } fn take_equipment(&mut self, index: usize, path: &std::path::Path) { let sex = if self.session.active_body == Some(Sex::Female) { 1 } else { 0 }; let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let Some(item) = self.session.equipment.get(index) else { return; }; let mut preset = item .preset .clone() .unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone())); preset.name = item.slug.clone(); match crate::equipment::import_obj(&mut preset, path) { Ok(part) => { if let Some(body) = body { if !body.joint_nodes.is_empty() { let _ = preset.bind(sex, body, part, 0); } } if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) { self.session.fail(error); return; } let item = &mut self.session.equipment[index]; item.model = Some(preset.attachments[part].label.clone()); item.legacy_path = None; item.error = None; item.preset = Some(preset); self.equipment_part = part; self.session.notice = "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot." .into(); self.session.notice_ok = true; } Err(error) => self.session.fail(error), } } pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) { self.session.active_body = Some(sex); self.session.show_equipment(index); self.equipment_part = 0; } fn begin(&mut self, intent: Intent) { if self.session.gate(intent) { self.run_intent(intent); } } fn run_intent(&mut self, intent: Intent) { match intent { Intent::Quit => {} Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject), Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject), } } fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) { match self.session.resolve(choice) { AfterChoice::Stay => {} AfterChoice::SaveThen(intent) => match self.session.save() { Ok(()) => self.finish_intent(intent, ctx), Err(error) => self.session.fail(error.to_string()), }, AfterChoice::Go(intent) => self.finish_intent(intent, ctx), } } fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) { if intent == Intent::Quit { self.allow_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); } else { self.run_intent(intent); } } fn save(&mut self) { if let Err(error) = self.session.save() { self.session.fail(error.to_string()); } } fn apply_render_fields(&mut self) { let width = self.width_text.parse::().unwrap_or(0); let height = self.height_text.parse::().unwrap_or(0); let fps = self.fps_text.parse::().unwrap_or(0); self.session.set_render(width, height, fps); } /// Open the body editor, also used by the native smoke example. pub fn open_body_configuration(&mut self, sex: Sex) { self.session.show_body(sex); self.controls.sex = match sex { Sex::Male => PreviewSex::Male, Sex::Female => PreviewSex::Female, }; } pub fn render_error(&self) -> Option<&str> { self.render_ui.error.as_deref() } pub fn render_all_angles(&mut self) { self.start_render(); } /// Legacy smoke entry point, now starts the production batch as well. pub fn render_current_pose(&mut self) { self.render_all_angles(); } pub fn render_is_running(&self) -> bool { self.render_ui.running } pub fn render_progress(&self) -> (u32, u32, String) { ( self.render_ui.progress.completed, self.render_ui.progress.total, self.render_ui.progress.label.clone(), ) } fn start_render(&mut self) { if self.render_ui.running { return; } let Some(opened) = &self.session.opened else { return; }; 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 tekenbaar glTF"); return; }; let Some(root) = self.session.project_root() 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 = usize::from(self.controls.sex == PreviewSex::Female); let preset = match self.session.screen { Screen::Equipment(i) => self.session.equipment.get(i).and_then(|i| i.preset.clone()), _ => None, }; if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() { self.session.fail("configureer eerst het equipment"); return; } let job = 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), }; self.render_worker = Some(spawn(job, None)); self.render_ui = RenderUiState::idle(); self.pass_texture = None; self.render_ui.running = true; } fn service_gpu(&mut self, gl: Option<&std::sync::Arc>) { let request = self .render_worker .as_ref() .and_then(|worker| worker.gpu.try_recv().ok()); if let Some(request) = request { if let Some(gl) = gl { unsafe { request.service(&mut self.gpu_renderer, gl); } } else { request.fail("Glow-context ontbreekt".into()); } } } fn drain_render(&mut self) { let Some(worker) = &self.render_worker else { return; }; let ended = worker .handle .as_ref() .map(|h| h.is_finished()) .unwrap_or(true); let events = worker.mailbox.drain(); let mut terminal = false; for event in events { terminal |= matches!( event, RenderEvent::Finished { .. } | RenderEvent::Failed(_) | RenderEvent::Cancelled ); if let RenderEvent::Finished { saved: Some(path) } = &event { self.session.notice = format!("Batch opgeslagen: {path}"); self.session.notice_ok = true; } if let RenderEvent::Failed(error) = &event { self.session.fail(error.clone()); } self.render_ui.apply(event); } if terminal { self.render_worker = None; } else if ended { self.render_ui.apply(RenderEvent::Failed( "De renderworker stopte zonder eindresultaat.".into(), )); self.render_worker = None; } } fn cancel_render(&mut self) { if let Some(worker) = &self.render_worker { worker .cancel .store(true, std::sync::atomic::Ordering::Relaxed); } } fn render_progress_ui(&mut self, ui: &mut egui::Ui) { if self.render_ui.progress.total > 0 { ui.add( egui::ProgressBar::new( self.render_ui.progress.completed as f32 / self.render_ui.progress.total.max(1) as f32, ) .text(format!( "{} / {} · {}", self.render_ui.progress.completed, self.render_ui.progress.total, self.render_ui.progress.label )), ); } if self.render_ui.running && ui.button("Annuleer render").clicked() { self.cancel_render(); } } fn framing_ui(&mut self, ui: &mut egui::Ui, sex: usize) { if let Some(opened) = &mut self.session.opened { let camera = &mut opened.document.render.framing[sex]; let mut height = camera.height_milli as f32 / 1000.; let mut center = camera.center_y_milli as f32 / 1000.; ui.horizontal_wrapped(|ui| { ui.label("Vaste renderhoogte (m)"); if ui .add( egui::DragValue::new(&mut height) .speed(0.01) .range(0.1..=10.), ) .changed() { camera.height_milli = (height * 1000.).round() as u32; } ui.label("Centrum Y (m)"); if ui .add( egui::DragValue::new(&mut center) .speed(0.01) .range(-10.0..=10.0), ) .changed() { camera.center_y_milli = (center * 1000.).round() as i32; } ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen"); }); } } fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> { if !self.show_render_frame { return None; } let r = &self.session.opened.as_ref()?.document.render; let sex = usize::from(self.controls.sex == PreviewSex::Female); Some((r.framing[sex], [r.target_width, r.target_height])) } } impl Default for EditorApp { fn default() -> Self { Self::new(Box::new(SystemDialogs::new())) } } impl eframe::App for EditorApp { fn on_exit(&mut self, gl: Option<&glow::Context>) { self.render_worker = None; if let Some(gl) = gl { unsafe { self.gpu_renderer.destroy(gl); } self.viewport.destroy(gl); } } fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.service_gpu(frame.gl()); let shell = self.session.shell_size(); if self.applied_shell != Some(shell) { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2( shell[0], shell[1], ))); let min = if shell == crate::session::START_WINDOW { egui::vec2(420.0, 220.0) } else { egui::vec2(900.0, 560.0) }; ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min)); self.applied_shell = Some(shell); } if ctx.input(|input| input.viewport().close_requested()) && !self.allow_close && self.session.confirm.is_none() { if !self.session.gate(Intent::Quit) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } } else if self.session.confirm.is_some() && ctx.input(|input| input.viewport().close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } self.poll_dialogs(); self.drain_render(); if self.render_ui.running { 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 alle hoeken"), ) .clicked() { self.start_render(); } if ui.button("Live 3D").clicked() { self.render_ui.preview = None; } }); self.framing_ui(ui, usize::from(sex == Sex::Female)); self.render_progress_ui(ui); if self.render_ui.preview.is_some() { self.paint_pass(ui); } else { let character = match self.controls.sex { PreviewSex::Male => self.preview_male.as_ref(), PreviewSex::Female => self.preview_female.as_ref(), }; if character.is_some() { let framing = self.fixed_camera(); self.viewport .show_framed(ui, &mut self.controls, character, &[], framing); } else { ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave."); } } }); } fn paint_pass(&mut self, ui: &mut egui::Ui) { let Some(image) = &self.render_ui.preview else { return; }; ui.label(format!( "Laatste voltooide pass: {}", self.render_ui.preview_label )); if self.pass_texture.is_none() || self.pass_texture_revision != self.render_ui.preview_revision { let color = egui::ColorImage::from_rgba_unmultiplied( [image.width as usize, image.height as usize], &image.rgba, ); let options = egui::TextureOptions::NEAREST; if let Some(texture) = &mut self.pass_texture { texture.set(color, options); } else { self.pass_texture = Some(ui.ctx().load_texture("render-pass", color, options)); } self.pass_texture_revision = self.render_ui.preview_revision; } let texture = self.pass_texture.as_ref().expect("pass texture installed"); let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover()); let fit = crate::render_job::fit_preview( [image.width, image.height], [bounds.width(), bounds.height()], ); let rect = egui::Rect::from_min_size( bounds.min + egui::vec2(fit.offset[0], fit.offset[1]), egui::vec2(fit.size[0], fit.size[1]), ); ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK); ui.painter().image( texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } fn show_equipment_list(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.heading(format!( "Bodies → {} → Equipment", match self.session.active_body { Some(Sex::Male) => "Man", Some(Sex::Female) => "Vrouw", None => "Body", } )); if ui.button("Terug naar body").clicked() { self.session.back(); } let mut open = None; for (index, item) in self.session.equipment.iter().enumerate() { let label = match &item.model { Some(model) => format!("{} {model}", item.slug), None => item.slug.clone(), }; if ui.button(label).clicked() { open = Some(index); } } if ui .add_enabled( self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS, egui::Button::new("Nieuw"), ) .clicked() { self.equipment_part = 0; self.session.add_equipment(); } if ui .add_enabled( self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS, egui::Button::new("Preset importeren"), ) .clicked() { self.dialogs.start(DialogPurpose::ImportEquipment); } if let Some(index) = open { self.session.show_equipment(index); } }); } fn show_equipment(&mut self, ctx: &egui::Context) { self.toolbar(ctx, true); let Screen::Equipment(index) = self.session.screen else { return; }; let mut sex = if self.session.active_body == Some(Sex::Female) { 1 } else { 0 }; egui::SidePanel::left("equipment-config") .default_width(330.0) .resizable(true) .show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } egui::ScrollArea::vertical().show(ui, |ui| { ui.heading("Equipment-configuratie"); if ui.button("Terug naar equipment").clicked() { self.session.show_equipment_list(); } ui.horizontal(|ui| { ui.selectable_value(&mut sex, 0, "Man"); ui.selectable_value(&mut sex, 1, "Vrouw"); }); self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female }); let Some(item) = self.session.equipment.get_mut(index) else { return; }; ui.label("Naam (kleine letters/cijfers/-/_)"); ui.text_edit_singleline(&mut item.slug); if !crate::equipment::valid_name(&item.slug) { ui.colored_label( egui::Color32::LIGHT_RED, "Ongeldige naam; 'body' is gereserveerd.", ); } if let Some(error) = &item.error { ui.colored_label(egui::Color32::LIGHT_RED, error); } if ui.button("Onderdeel toevoegen (OBJ)").clicked() { self.dialogs.start(DialogPurpose::EquipmentModel(index)); } if let Some(preset) = &mut item.preset { ui.horizontal(|ui| { ui.label("Geldig voor:"); ui.checkbox(&mut preset.applies[0], "Man"); ui.checkbox(&mut preset.applies[1], "Vrouw"); }); if !preset.applies[0] && !preset.applies[1] { ui.colored_label( egui::Color32::LIGHT_RED, "Kies minstens één geslacht.", ); } ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)"); ui.add( egui::TextEdit::multiline(&mut preset.provenance) .desired_rows(2) .char_limit(4096), ); if preset.provenance.len() > 4096 { ui.colored_label( egui::Color32::LIGHT_RED, "Herkomst/licentie is te lang.", ); } if !preset.attachments.is_empty() { self.equipment_part = self.equipment_part.min(preset.attachments.len() - 1); egui::ComboBox::from_id_salt("equipment-part") .selected_text(&preset.attachments[self.equipment_part].label) .show_ui(ui, |ui| { for (part, item) in preset.attachments.iter().enumerate() { ui.selectable_value( &mut self.equipment_part, part, &item.label, ); } }); let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let attachment = &mut preset.attachments[self.equipment_part]; ui.label("Positie relatief aan bot (XYZ)"); ui.horizontal(|ui| { for value in &mut attachment.translation { ui.add( egui::DragValue::new(value) .speed(0.005) .range(-1000.0..=1000.0), ); } }); ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)"); ui.horizontal(|ui| { for value in &mut attachment.rotation_deg { ui.add( egui::DragValue::new(value) .speed(0.5) .range(-3600.0..=3600.0), ); } }); ui.add( egui::Slider::new(&mut attachment.specular, 0.0..=1.0) .text("Glanssterkte"), ); let shiny = attachment .shiny .and_then(|i| preset.assets.get(i)) .map(|a| a.path.clone()); ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into())); if ui.button("Kies shinymap (PNG)").clicked() { self.dialogs.start(DialogPurpose::EquipmentShiny( index, self.equipment_part, )); } let clear_shiny = ui.button("Wis shinymap").clicked(); let selected = attachment.bones[sex].clone(); if clear_shiny { if let Err(error) = crate::equipment::clear_shiny(preset, self.equipment_part) { item.error = Some(error); } } ui.label(format!( "Bot: {}", selected.as_deref().unwrap_or("nog niet gekoppeld") )); if let Some(body) = body { if preset.rigs[sex].as_ref() != Some(&crate::equipment::RigSignature::of(body)) { ui.colored_label( egui::Color32::LIGHT_RED, "Rigbinding ontbreekt of wijkt af; selecteer een bot.", ); } ui.collapsing("Skeleton / botten", |ui| { if let Some(joint) = bone_tree(ui, body, selected.as_deref()) { if let Err(error) = preset.bind(sex, body, self.equipment_part, joint) { ui.colored_label(egui::Color32::LIGHT_RED, error); } } }); } else { ui.colored_label( egui::Color32::LIGHT_RED, "Open een geskind glTF/GLB-lichaam voor deze rigbinding.", ); } if ui.button("Verwijder onderdeel").clicked() { if let Err(error) = crate::equipment::remove_attachment(preset, self.equipment_part) { item.error = Some(error); } self.equipment_part = self.equipment_part.saturating_sub(1); } } } if ui.button("Verwijder equipmentdefinitie").clicked() { self.session.delete_equipment(index); } }); }); self.controls.sex = if sex == 0 { PreviewSex::Male } else { PreviewSex::Female }; egui::CentralPanel::default().show(ctx, |ui| { if self.session.confirm.is_some() || self.dialog_pending { ui.disable(); } ui.horizontal_wrapped(|ui| { if ui .add_enabled( !self.render_ui.running, egui::Button::new("Render equipment · alle hoeken"), ) .clicked() { self.start_render(); } if ui.button("Live 3D").clicked() { self.render_ui.preview = None; } }); self.render_progress_ui(ui); if self.render_ui.preview.is_some() { self.paint_pass(ui); return; } let framing = self.fixed_camera(); let body = if sex == 0 { self.preview_male.as_ref() } else { self.preview_female.as_ref() }; let preset = self .session .equipment .get(index) .and_then(|item| item.preset.as_ref()); let presets: Vec<_> = preset.into_iter().collect(); self.viewport .show_framed(ui, &mut self.controls, body, &presets, framing); }); } 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); } }); }); } } fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option { fn node( ui: &mut egui::Ui, body: &PreviewCharacter, paths: &[String], index: usize, selected: Option<&str>, choice: &mut Option, depth: usize, ) { if depth > 64 { return; } let joint = body.joint_nodes.iter().position(|i| *i == index); let children: Vec<_> = body .nodes .iter() .enumerate() .filter(|(_, n)| n.parent == Some(index)) .map(|(i, _)| i) .collect(); let label = if body.nodes[index].name.is_empty() { format!("node-{index}") } else { body.nodes[index].name.clone() }; let select = |ui: &mut egui::Ui, choice: &mut Option| { if let Some(joint) = joint { if ui .selectable_label( selected == Some(paths[joint].as_str()), format!("Koppel {label}"), ) .clicked() { *choice = Some(joint); } } }; if children.is_empty() { select(ui, choice); } else { egui::CollapsingHeader::new(&label) .id_salt(index) .default_open(depth < 2) .show(ui, |ui| { select(ui, choice); for child in children { node(ui, body, paths, child, selected, choice, depth + 1); } }); } } let mut choice = None; let paths = crate::preview::joint_paths(body); for (index, item) in body.nodes.iter().enumerate() { if item.parent.is_none() { node(ui, body, &paths, index, selected, &mut choice, 0); } } choice } #[cfg(test)] mod tests { use super::*; use crate::dialogs::ScriptedDialogs; use crate::session::Choice; fn root() -> PathBuf { std::env::temp_dir().join(format!( "paperdoll-editor-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )) } #[test] fn picker_creates_project_and_cancelled_switch_preserves_edits() { let root = root(); let mut dialogs = ScriptedDialogs::default(); dialogs.push(Some(root.clone())); dialogs.push(None); let mut app = EditorApp::new(Box::new(dialogs)); assert_eq!(app.session.shell_size(), crate::session::START_WINDOW); app.begin(Intent::PickNew); app.poll_dialogs(); assert_eq!(app.session.screen, Screen::Bodies); assert!(app.preview_male.is_some()); assert!(app.preview_female.is_some()); app.session.set_name("Keep my edit".into()); app.begin(Intent::PickLoad); assert_eq!(app.session.confirm, Some(Intent::PickLoad)); app.apply_choice(Choice::Discard, &egui::Context::default()); app.poll_dialogs(); // OS picker cancel; no actual project switch happened assert_eq!( app.session.opened.as_ref().unwrap().document.name, "Keep my edit" ); assert!(app.session.is_dirty()); let _ = std::fs::remove_dir_all(root); } #[test] fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() { let root = root(); let mut app = EditorApp::default(); app.session.install_new_project(&root).unwrap(); app.session.set_render(0, 128, 12); app.begin(Intent::Quit); app.apply_choice(Choice::Save, &egui::Context::default()); assert!(!app.allow_close); assert_eq!(app.session.confirm, Some(Intent::Quit)); assert!(app.session.is_dirty()); app.open_path(root.join("missing.ppd")); assert!(app.session.opened.is_some()); assert_eq!(app.session.screen, Screen::Bodies); let _ = std::fs::remove_dir_all(root); } #[test] fn open_with_project_path_loads_bodies_and_models() { let root = root().join("project with spaces"); let opened = crate::session::create_seeded_project(&root).unwrap(); let mut app = EditorApp::default(); app.open_path(opened.manifest_path.clone()); assert_eq!(app.session.screen, Screen::Bodies); assert!(app.preview_male.is_some()); assert_eq!( app.session.opened.unwrap().manifest_path, opened.manifest_path ); let _ = std::fs::remove_dir_all(root.parent().unwrap()); } #[test] fn renderer_disconnect_is_reported() { let mut app = EditorApp::default(); let (_, gpu) = std::sync::mpsc::channel(); app.render_worker = Some(crate::production::RenderThread { mailbox: Default::default(), gpu, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), handle: None, }); app.render_ui.running = true; app.drain_render(); assert!(!app.render_ui.running); assert!(app.render_ui.error.is_some()); } }