BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 13.7 KiB Download Raw
1//! Project window. It edits a `.ppd` manifest and does not import or render.
2
3use std::path::PathBuf;
4
5use eframe::egui;
6
7use crate::project::{
8 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
10};
11
12pub 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
30impl 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
202impl 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
242impl 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
391fn parent_relative(relative: &str) -> String {
392 match relative.rfind('/') {
393 Some(index) => relative[..index].to_string(),
394 None => String::new(),
395 }
396}
397
398fn 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
407impl Default for EditorApp {
408 fn default() -> Self {
409 Self::new()
410 }
411}