//! Project window. It edits a `.ppd` manifest and does not import or render. use std::path::PathBuf; use eframe::egui; use crate::import::{import_project_model, ImportedModel, ModelFormat}; use crate::project::{ assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft, ProjectError, FORMAT_VERSION, MANIFEST_NAME, }; pub struct EditorApp { root_input: String, open_input: String, opened: Option, name: String, male: String, female: String, presets: Vec, preset_input: String, output: String, width: String, height: String, fps: String, browser: String, male_import: Option>, female_import: Option>, notice: String, notice_ok: bool, } impl EditorApp { pub fn new() -> Self { Self { root_input: String::new(), open_input: String::new(), opened: None, name: "Nieuw project".to_string(), male: String::new(), female: String::new(), presets: Vec::new(), preset_input: String::new(), output: "renders".to_string(), width: "128".to_string(), height: "128".to_string(), fps: "12".to_string(), browser: String::new(), male_import: None, female_import: None, notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(), notice_ok: true, } } fn load(&mut self, opened: OpenedProject, notice: &str) { self.name = opened.document.name.clone(); self.male = opened.document.male_model.clone().unwrap_or_default(); self.female = opened.document.female_model.clone().unwrap_or_default(); self.presets = opened.document.equipment_presets.clone(); self.output = opened.document.output_directory.clone(); self.width = opened.document.render.target_width.to_string(); self.height = opened.document.render.target_height.to_string(); self.fps = opened.document.render.fps.to_string(); self.browser.clear(); self.male_import = None; self.female_import = None; self.opened = Some(opened.clone()); let male = self.male.clone(); let female = self.female.clone(); if !male.trim().is_empty() { self.inspect(true); } if !female.trim().is_empty() { self.inspect(false); } if male.trim().is_empty() && female.trim().is_empty() { if opened.disk_issues.is_empty() { self.ok(notice); } else { self.fail_text(opened.disk_issues.join("\n")); } } } fn ok(&mut self, message: &str) { self.notice = message.to_string(); self.notice_ok = true; } fn fail(&mut self, error: ProjectError) { self.notice = error.to_string(); self.notice_ok = false; } fn fail_text(&mut self, message: impl Into) { self.notice = message.into(); self.notice_ok = false; } fn draft(&self) -> Result { let opened = self .opened .as_ref() .ok_or_else(|| "open of maak eerst een project".to_string())?; Ok(ProjectDraft { id: opened.document.id.clone(), name: self.name.clone(), male_model: self.male.clone(), female_model: self.female.clone(), equipment_presets: self.presets.clone(), output_directory: self.output.clone(), target_width: parse_u32("breedte", &self.width)?, target_height: parse_u32("hoogte", &self.height)?, fps: parse_u32("fps", &self.fps)?, }) } fn create(&mut self) { let root = PathBuf::from(self.root_input.trim()); if self.root_input.trim().is_empty() { self.fail_text("vul de projectmap in"); return; } match create_project(&root, "Nieuw project") { Ok(opened) => self.load( opened, "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.", ), Err(error) => self.fail(error), } } fn open(&mut self) { let path = PathBuf::from(self.open_input.trim()); if self.open_input.trim().is_empty() { self.fail_text("vul het pad naar een .ppd-bestand in"); return; } match open_project(&path) { Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."), Err(error) => self.fail(error), } } fn save(&mut self) { let Some(current) = self.opened.clone() else { self.fail_text("open of maak eerst een project"); return; }; let draft = match self.draft() { Ok(draft) => draft, Err(message) => { self.fail_text(message); return; } }; let document = match assemble(draft) { Ok(document) => document, Err(error) => { self.fail(error); return; } }; let candidate = OpenedProject { manifest_path: current.manifest_path, document, disk_issues: Vec::new(), }; match save_project(&candidate) { Ok(()) => { let path = candidate.manifest_path.display().to_string(); self.opened = Some(candidate); self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd.")); } Err(error) => self.fail(error), } } fn add_preset(&mut self, relative: String) { if relative.trim().is_empty() { return; } self.presets.push(relative); if let Err(error) = self .draft() .and_then(|draft| assemble(draft).map_err(|error| error.to_string())) { self.presets.pop(); self.fail_text(error); } else { self.preset_input.clear(); self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd."); } } fn assign_file(&mut self, relative: &str) { if relative == MANIFEST_NAME { self.fail_text("character.ppd is het projectbestand, geen model of preset"); return; } let lower = relative.to_ascii_lowercase(); if lower.contains("/preset") || lower.starts_with("presets/") { self.add_preset(relative.to_string()); } else if self.male.trim().is_empty() { self.male = relative.to_string(); self.inspect(true); } else if self.female.trim().is_empty() { self.female = relative.to_string(); self.inspect(false); } else { self.add_preset(relative.to_string()); } } fn inspect(&mut self, male: bool) { let Some(root) = self.opened.as_ref().and_then(|opened| { opened .manifest_path .parent() .map(std::path::Path::to_path_buf) }) else { self.fail_text("open of maak eerst een project"); return; }; let relative = if male { self.male.trim() } else { self.female.trim() }; if relative.is_empty() { if male { self.male_import = None; } else { self.female_import = None; } self.fail_text("dit slot heeft nog geen modelpad"); return; } match import_project_model(&root, relative) { Ok(model) => { let message = format!( "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.", model.meshes.len(), model.joints.len(), model.clips.len() ); if male { self.male_import = Some(Ok(model)); } else { self.female_import = Some(Ok(model)); } self.ok(&message); } Err(error) => { let text = error.to_string(); if male { self.male_import = Some(Err(text.clone())); } else { self.female_import = Some(Err(text.clone())); } self.fail_text(text); } } } } impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { if ui.button("Nieuw").clicked() { self.create(); } if ui.button("Open").clicked() { self.open(); } if ui.button("Opslaan").clicked() { self.save(); } if ui.button("Sluiten").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } ui.label(format!("format_version {FORMAT_VERSION}")); }); }); egui::TopBottomPanel::bottom("notice").show(ctx, |ui| { let color = if self.notice_ok { egui::Color32::from_rgb(20, 90, 40) } else { egui::Color32::from_rgb(150, 30, 30) }; ui.colored_label(color, &self.notice); }); egui::SidePanel::right("browser") .min_width(240.0) .show(ctx, |ui| { self.show_browser(ui); }); egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { self.show_form(ui); }); }); } } impl EditorApp { fn show_form(&mut self, ui: &mut egui::Ui) { ui.heading("Paperdoll Renderer"); ui.label( "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont mesh, skeleton en clips. Er is geen 3D-beeld.", ); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Projectmap"); ui.add( egui::TextEdit::singleline(&mut self.root_input) .desired_width(360.0) .hint_text("/pad/naar/nieuwe-map"), ); }); ui.horizontal(|ui| { ui.label(".ppd openen"); ui.add( egui::TextEdit::singleline(&mut self.open_input) .desired_width(360.0) .hint_text("/pad/naar/character.ppd"), ); }); ui.separator(); ui.horizontal(|ui| { ui.label("Naam"); ui.text_edit_singleline(&mut self.name); }); ui.horizontal(|ui| { ui.label("Man"); let male_edit = ui.add( egui::TextEdit::singleline(&mut self.male) .desired_width(360.0) .hint_text("assets/body/male.glb"), ); if male_edit.changed() { self.male_import = None; } if ui.button("Wis man").clicked() { self.male.clear(); self.male_import = None; } if ui.button("Lees man").clicked() { self.inspect(true); } }); show_import(ui, "Import man", self.male_import.as_ref()); ui.horizontal(|ui| { ui.label("Vrouw"); let female_edit = ui.add( egui::TextEdit::singleline(&mut self.female) .desired_width(360.0) .hint_text("assets/body/female.glb"), ); if female_edit.changed() { self.female_import = None; } if ui.button("Wis vrouw").clicked() { self.female.clear(); self.female_import = None; } if ui.button("Lees vrouw").clicked() { self.inspect(false); } }); show_import(ui, "Import vrouw", self.female_import.as_ref()); ui.label("Equipment-presets, relatief aan de projectmap"); let mut remove = None; for (index, preset) in self.presets.iter().enumerate() { ui.horizontal(|ui| { ui.label(preset); if ui.button("Verwijder").clicked() { remove = Some(index); } }); } if let Some(index) = remove { self.presets.remove(index); } ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut self.preset_input) .desired_width(280.0) .hint_text("presets/zwaard.bin"), ); if ui.button("Voeg preset toe").clicked() { let value = self.preset_input.trim().to_string(); self.add_preset(value); } }); ui.horizontal(|ui| { ui.label("Uitvoer"); ui.text_edit_singleline(&mut self.output); }); ui.horizontal(|ui| { ui.label("Breedte"); ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0)); ui.label("Hoogte"); ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0)); ui.label("fps"); ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0)); }); ui.add_space(8.0); ui.label( "Lege man of vrouw is geldig. Een pad buiten de projectmap, een absolute pad en een preset die 'body' heet worden geweigerd. Opslaan overschrijft het vorige bestand alleen als het nieuwe bestand af is.", ); } fn show_browser(&mut self, ui: &mut egui::Ui) { ui.heading("Bestanden"); let Some(opened) = self.opened.clone() else { ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen."); return; }; let root = opened .manifest_path .parent() .map(PathBuf::from) .unwrap_or_default(); ui.label(if self.browser.is_empty() { "projectmap".to_string() } else { self.browser.clone() }); if !self.browser.is_empty() && ui.button("Omhoog").clicked() { self.browser = parent_relative(&self.browser); } if ui.button("Deze map als uitvoer").clicked() { if self.browser.is_empty() { self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap"); } else { self.output = self.browser.clone(); self.ok("Uitvoermap gekozen. Nog niet opgeslagen."); } } let entries = match list_dir(&root, &self.browser) { Ok(entries) => entries, Err(error) => { ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string()); return; } }; let mut enter = None; let mut choose = None; for entry in &entries { let label = if entry.is_dir { format!("{}/", entry.name) } else { entry.name.clone() }; if ui.button(label).clicked() { if entry.is_dir { enter = Some(entry.relative.clone()); } else { choose = Some(entry.relative.clone()); } } } if let Some(relative) = enter { self.browser = relative; } if let Some(relative) = choose { self.assign_file(&relative); } } } fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result>) { let Some(report) = report else { return; }; ui.group(|ui| { ui.strong(title); match report { Ok(model) => { let format = match model.format { ModelFormat::Gltf => "glTF/GLB", ModelFormat::Obj => "OBJ", ModelFormat::Fbx => "FBX", }; ui.label(format); for mesh in &model.meshes { ui.label(format!( "mesh {}: {} vertices, {} driehoeken{}", mesh.name, mesh.vertex_count, mesh.triangle_count, if mesh.has_skin { ", skin" } else { "" } )); } for material in &model.materials { ui.label(format!( "materiaal {}{}", material.name, material .base_color_texture .as_ref() .map(|name| format!(", texture {name}")) .unwrap_or_default() )); } for texture in &model.textures { ui.label(format!( "texture {} ({}x{})", texture.source, texture.width, texture.height )); } if model.joints.is_empty() { ui.label("geen skeleton"); } else { ui.label(format!("{} joints", model.joints.len())); for joint in model.joints.iter().take(8) { match &joint.parent { Some(parent) => ui.label(format!("{} onder {parent}", joint.name)), None => ui.label(format!("{} (wortel)", joint.name)), }; } } if model.clips.is_empty() { ui.label("geen animation clips"); } else { for clip in &model.clips { ui.label(format!( "clip {} ({:.2} s, {} kanalen)", clip.name, clip.duration_seconds, clip.channel_count )); } } for notice in &model.notices { ui.label(notice); } } Err(message) => { ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message); } } }); } fn parent_relative(relative: &str) -> String { match relative.rfind('/') { Some(index) => relative[..index].to_string(), None => String::new(), } } fn parse_u32(label: &str, text: &str) -> Result { let text = text.trim(); if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) { return Err(format!("{label} moet een geheel getal zijn")); } text.parse::() .map_err(|_| format!("{label} is te groot")) } impl Default for EditorApp { fn default() -> Self { Self::new() } }