| 1 | //! Project window. It edits a `.ppd` manifest and does not import or render. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | use eframe::egui; |
| 6 | |
| 7 | use crate::project::{ |
| 8 | assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft, |
| 9 | ProjectError, FORMAT_VERSION, MANIFEST_NAME, |
| 10 | }; |
| 11 | |
| 12 | pub struct EditorApp { |
| 13 | root_input: String, |
| 14 | open_input: String, |
| 15 | opened: Option<OpenedProject>, |
| 16 | name: String, |
| 17 | male: String, |
| 18 | female: String, |
| 19 | presets: Vec<String>, |
| 20 | preset_input: String, |
| 21 | output: String, |
| 22 | width: String, |
| 23 | height: String, |
| 24 | fps: String, |
| 25 | browser: String, |
| 26 | notice: String, |
| 27 | notice_ok: bool, |
| 28 | } |
| 29 | |
| 30 | impl EditorApp { |
| 31 | pub fn new() -> Self { |
| 32 | Self { |
| 33 | root_input: String::new(), |
| 34 | open_input: String::new(), |
| 35 | opened: None, |
| 36 | name: "Nieuw project".to_string(), |
| 37 | male: String::new(), |
| 38 | female: String::new(), |
| 39 | presets: Vec::new(), |
| 40 | preset_input: String::new(), |
| 41 | output: "renders".to_string(), |
| 42 | width: "128".to_string(), |
| 43 | height: "128".to_string(), |
| 44 | fps: "12".to_string(), |
| 45 | browser: String::new(), |
| 46 | notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(), |
| 47 | notice_ok: true, |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | fn load(&mut self, opened: OpenedProject, notice: &str) { |
| 52 | self.name = opened.document.name.clone(); |
| 53 | self.male = opened.document.male_model.clone().unwrap_or_default(); |
| 54 | self.female = opened.document.female_model.clone().unwrap_or_default(); |
| 55 | self.presets = opened.document.equipment_presets.clone(); |
| 56 | self.output = opened.document.output_directory.clone(); |
| 57 | self.width = opened.document.render.target_width.to_string(); |
| 58 | self.height = opened.document.render.target_height.to_string(); |
| 59 | self.fps = opened.document.render.fps.to_string(); |
| 60 | self.browser.clear(); |
| 61 | self.opened = Some(opened.clone()); |
| 62 | if opened.disk_issues.is_empty() { |
| 63 | self.ok(notice); |
| 64 | } else { |
| 65 | self.fail_text(opened.disk_issues.join("\n")); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | fn ok(&mut self, message: &str) { |
| 70 | self.notice = message.to_string(); |
| 71 | self.notice_ok = true; |
| 72 | } |
| 73 | |
| 74 | fn fail(&mut self, error: ProjectError) { |
| 75 | self.notice = error.to_string(); |
| 76 | self.notice_ok = false; |
| 77 | } |
| 78 | |
| 79 | fn fail_text(&mut self, message: impl Into<String>) { |
| 80 | self.notice = message.into(); |
| 81 | self.notice_ok = false; |
| 82 | } |
| 83 | |
| 84 | fn draft(&self) -> Result<ProjectDraft, String> { |
| 85 | let opened = self |
| 86 | .opened |
| 87 | .as_ref() |
| 88 | .ok_or_else(|| "open of maak eerst een project".to_string())?; |
| 89 | Ok(ProjectDraft { |
| 90 | id: opened.document.id.clone(), |
| 91 | name: self.name.clone(), |
| 92 | male_model: self.male.clone(), |
| 93 | female_model: self.female.clone(), |
| 94 | equipment_presets: self.presets.clone(), |
| 95 | output_directory: self.output.clone(), |
| 96 | target_width: parse_u32("breedte", &self.width)?, |
| 97 | target_height: parse_u32("hoogte", &self.height)?, |
| 98 | fps: parse_u32("fps", &self.fps)?, |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | fn create(&mut self) { |
| 103 | let root = PathBuf::from(self.root_input.trim()); |
| 104 | if self.root_input.trim().is_empty() { |
| 105 | self.fail_text("vul de projectmap in"); |
| 106 | return; |
| 107 | } |
| 108 | match create_project(&root, "Nieuw project") { |
| 109 | Ok(opened) => self.load( |
| 110 | opened, |
| 111 | "Projectmap aangemaakt. De paden worden bewaard; de modellen worden niet geladen.", |
| 112 | ), |
| 113 | Err(error) => self.fail(error), |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | fn open(&mut self) { |
| 118 | let path = PathBuf::from(self.open_input.trim()); |
| 119 | if self.open_input.trim().is_empty() { |
| 120 | self.fail_text("vul het pad naar een .ppd-bestand in"); |
| 121 | return; |
| 122 | } |
| 123 | match open_project(&path) { |
| 124 | Ok(opened) => self.load(opened, "Project geopend. Er wordt niets gerenderd."), |
| 125 | Err(error) => self.fail(error), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | fn save(&mut self) { |
| 130 | let Some(current) = self.opened.clone() else { |
| 131 | self.fail_text("open of maak eerst een project"); |
| 132 | return; |
| 133 | }; |
| 134 | let draft = match self.draft() { |
| 135 | Ok(draft) => draft, |
| 136 | Err(message) => { |
| 137 | self.fail_text(message); |
| 138 | return; |
| 139 | } |
| 140 | }; |
| 141 | let document = match assemble(draft) { |
| 142 | Ok(document) => document, |
| 143 | Err(error) => { |
| 144 | self.fail(error); |
| 145 | return; |
| 146 | } |
| 147 | }; |
| 148 | let candidate = OpenedProject { |
| 149 | manifest_path: current.manifest_path, |
| 150 | document, |
| 151 | disk_issues: Vec::new(), |
| 152 | }; |
| 153 | match save_project(&candidate) { |
| 154 | Ok(()) => { |
| 155 | let path = candidate.manifest_path.display().to_string(); |
| 156 | self.opened = Some(candidate); |
| 157 | self.ok(&format!( |
| 158 | "Opgeslagen naar {path}. Import en render zijn niet uitgevoerd." |
| 159 | )); |
| 160 | } |
| 161 | Err(error) => self.fail(error), |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | fn add_preset(&mut self, relative: String) { |
| 166 | if relative.trim().is_empty() { |
| 167 | return; |
| 168 | } |
| 169 | self.presets.push(relative); |
| 170 | if let Err(error) = self |
| 171 | .draft() |
| 172 | .and_then(|draft| assemble(draft).map_err(|error| error.to_string())) |
| 173 | { |
| 174 | self.presets.pop(); |
| 175 | self.fail_text(error); |
| 176 | } else { |
| 177 | self.preset_input.clear(); |
| 178 | self.ok("Presetpad toegevoegd. Het bestand wordt pas bij opslaan gecontroleerd."); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | fn assign_file(&mut self, relative: &str) { |
| 183 | if relative == MANIFEST_NAME { |
| 184 | self.fail_text("character.ppd is het projectbestand, geen model of preset"); |
| 185 | return; |
| 186 | } |
| 187 | let lower = relative.to_ascii_lowercase(); |
| 188 | if lower.contains("/preset") || lower.starts_with("presets/") { |
| 189 | self.add_preset(relative.to_string()); |
| 190 | } else if self.male.trim().is_empty() { |
| 191 | self.male = relative.to_string(); |
| 192 | self.ok("Pad ingevuld bij het mannelijke model. Het model wordt niet geladen."); |
| 193 | } else if self.female.trim().is_empty() { |
| 194 | self.female = relative.to_string(); |
| 195 | self.ok("Pad ingevuld bij het vrouwelijke model. Het model wordt niet geladen."); |
| 196 | } else { |
| 197 | self.add_preset(relative.to_string()); |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | impl eframe::App for EditorApp { |
| 203 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { |
| 204 | egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { |
| 205 | ui.horizontal_wrapped(|ui| { |
| 206 | if ui.button("Nieuw").clicked() { |
| 207 | self.create(); |
| 208 | } |
| 209 | if ui.button("Open").clicked() { |
| 210 | self.open(); |
| 211 | } |
| 212 | if ui.button("Opslaan").clicked() { |
| 213 | self.save(); |
| 214 | } |
| 215 | if ui.button("Sluiten").clicked() { |
| 216 | ctx.send_viewport_cmd(egui::ViewportCommand::Close); |
| 217 | } |
| 218 | ui.label(format!("format_version {FORMAT_VERSION}")); |
| 219 | }); |
| 220 | }); |
| 221 | egui::TopBottomPanel::bottom("notice").show(ctx, |ui| { |
| 222 | let color = if self.notice_ok { |
| 223 | egui::Color32::from_rgb(20, 90, 40) |
| 224 | } else { |
| 225 | egui::Color32::from_rgb(150, 30, 30) |
| 226 | }; |
| 227 | ui.colored_label(color, &self.notice); |
| 228 | }); |
| 229 | egui::SidePanel::right("browser") |
| 230 | .min_width(240.0) |
| 231 | .show(ctx, |ui| { |
| 232 | self.show_browser(ui); |
| 233 | }); |
| 234 | egui::CentralPanel::default().show(ctx, |ui| { |
| 235 | egui::ScrollArea::vertical().show(ui, |ui| { |
| 236 | self.show_form(ui); |
| 237 | }); |
| 238 | }); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | impl EditorApp { |
| 243 | fn show_form(&mut self, ui: &mut egui::Ui) { |
| 244 | ui.heading("Paperdoll Renderer"); |
| 245 | ui.label("Projectmap met één .ppd-bestand. Modellen kiezen slaat alleen het pad op."); |
| 246 | ui.add_space(8.0); |
| 247 | ui.horizontal(|ui| { |
| 248 | ui.label("Projectmap"); |
| 249 | ui.add( |
| 250 | egui::TextEdit::singleline(&mut self.root_input) |
| 251 | .desired_width(360.0) |
| 252 | .hint_text("/pad/naar/nieuwe-map"), |
| 253 | ); |
| 254 | }); |
| 255 | ui.horizontal(|ui| { |
| 256 | ui.label(".ppd openen"); |
| 257 | ui.add( |
| 258 | egui::TextEdit::singleline(&mut self.open_input) |
| 259 | .desired_width(360.0) |
| 260 | .hint_text("/pad/naar/character.ppd"), |
| 261 | ); |
| 262 | }); |
| 263 | ui.separator(); |
| 264 | ui.horizontal(|ui| { |
| 265 | ui.label("Naam"); |
| 266 | ui.text_edit_singleline(&mut self.name); |
| 267 | }); |
| 268 | ui.horizontal(|ui| { |
| 269 | ui.label("Man"); |
| 270 | ui.add( |
| 271 | egui::TextEdit::singleline(&mut self.male) |
| 272 | .desired_width(360.0) |
| 273 | .hint_text("assets/body/male.glb"), |
| 274 | ); |
| 275 | if ui.button("Wis man").clicked() { |
| 276 | self.male.clear(); |
| 277 | } |
| 278 | }); |
| 279 | ui.horizontal(|ui| { |
| 280 | ui.label("Vrouw"); |
| 281 | ui.add( |
| 282 | egui::TextEdit::singleline(&mut self.female) |
| 283 | .desired_width(360.0) |
| 284 | .hint_text("assets/body/female.glb"), |
| 285 | ); |
| 286 | if ui.button("Wis vrouw").clicked() { |
| 287 | self.female.clear(); |
| 288 | } |
| 289 | }); |
| 290 | ui.label("Equipment-presets, relatief aan de projectmap"); |
| 291 | let mut remove = None; |
| 292 | for (index, preset) in self.presets.iter().enumerate() { |
| 293 | ui.horizontal(|ui| { |
| 294 | ui.label(preset); |
| 295 | if ui.button("Verwijder").clicked() { |
| 296 | remove = Some(index); |
| 297 | } |
| 298 | }); |
| 299 | } |
| 300 | if let Some(index) = remove { |
| 301 | self.presets.remove(index); |
| 302 | } |
| 303 | ui.horizontal(|ui| { |
| 304 | ui.add( |
| 305 | egui::TextEdit::singleline(&mut self.preset_input) |
| 306 | .desired_width(280.0) |
| 307 | .hint_text("presets/zwaard.bin"), |
| 308 | ); |
| 309 | if ui.button("Voeg preset toe").clicked() { |
| 310 | let value = self.preset_input.trim().to_string(); |
| 311 | self.add_preset(value); |
| 312 | } |
| 313 | }); |
| 314 | ui.horizontal(|ui| { |
| 315 | ui.label("Uitvoer"); |
| 316 | ui.text_edit_singleline(&mut self.output); |
| 317 | }); |
| 318 | ui.horizontal(|ui| { |
| 319 | ui.label("Breedte"); |
| 320 | ui.add(egui::TextEdit::singleline(&mut self.width).desired_width(70.0)); |
| 321 | ui.label("Hoogte"); |
| 322 | ui.add(egui::TextEdit::singleline(&mut self.height).desired_width(70.0)); |
| 323 | ui.label("fps"); |
| 324 | ui.add(egui::TextEdit::singleline(&mut self.fps).desired_width(50.0)); |
| 325 | }); |
| 326 | ui.add_space(8.0); |
| 327 | ui.label( |
| 328 | "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.", |
| 329 | ); |
| 330 | } |
| 331 | |
| 332 | fn show_browser(&mut self, ui: &mut egui::Ui) { |
| 333 | ui.heading("Bestanden"); |
| 334 | let Some(opened) = self.opened.clone() else { |
| 335 | ui.label("Na Nieuw of Open kun je hier een bestand in de projectmap kiezen."); |
| 336 | return; |
| 337 | }; |
| 338 | let root = opened |
| 339 | .manifest_path |
| 340 | .parent() |
| 341 | .map(PathBuf::from) |
| 342 | .unwrap_or_default(); |
| 343 | ui.label(if self.browser.is_empty() { |
| 344 | "projectmap".to_string() |
| 345 | } else { |
| 346 | self.browser.clone() |
| 347 | }); |
| 348 | if !self.browser.is_empty() && ui.button("Omhoog").clicked() { |
| 349 | self.browser = parent_relative(&self.browser); |
| 350 | } |
| 351 | if ui.button("Deze map als uitvoer").clicked() { |
| 352 | if self.browser.is_empty() { |
| 353 | self.fail_text("de projectmap zelf is geen uitvoermap; kies een submap"); |
| 354 | } else { |
| 355 | self.output = self.browser.clone(); |
| 356 | self.ok("Uitvoermap gekozen. Nog niet opgeslagen."); |
| 357 | } |
| 358 | } |
| 359 | let entries = match list_dir(&root, &self.browser) { |
| 360 | Ok(entries) => entries, |
| 361 | Err(error) => { |
| 362 | ui.colored_label(egui::Color32::from_rgb(150, 30, 30), error.to_string()); |
| 363 | return; |
| 364 | } |
| 365 | }; |
| 366 | let mut enter = None; |
| 367 | let mut choose = None; |
| 368 | for entry in &entries { |
| 369 | let label = if entry.is_dir { |
| 370 | format!("{}/", entry.name) |
| 371 | } else { |
| 372 | entry.name.clone() |
| 373 | }; |
| 374 | if ui.button(label).clicked() { |
| 375 | if entry.is_dir { |
| 376 | enter = Some(entry.relative.clone()); |
| 377 | } else { |
| 378 | choose = Some(entry.relative.clone()); |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | if let Some(relative) = enter { |
| 383 | self.browser = relative; |
| 384 | } |
| 385 | if let Some(relative) = choose { |
| 386 | self.assign_file(&relative); |
| 387 | } |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | fn parent_relative(relative: &str) -> String { |
| 392 | match relative.rfind('/') { |
| 393 | Some(index) => relative[..index].to_string(), |
| 394 | None => String::new(), |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | fn parse_u32(label: &str, text: &str) -> Result<u32, String> { |
| 399 | let text = text.trim(); |
| 400 | if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) { |
| 401 | return Err(format!("{label} moet een geheel getal zijn")); |
| 402 | } |
| 403 | text.parse::<u32>() |
| 404 | .map_err(|_| format!("{label} is te groot")) |
| 405 | } |
| 406 | |
| 407 | impl Default for EditorApp { |
| 408 | fn default() -> Self { |
| 409 | Self::new() |
| 410 | } |
| 411 | } |