BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 29.0 KiB Download Raw
1//! Project window: start screen, body, equipment, and the render queue.
2
3use std::path::PathBuf;
4use std::sync::mpsc::Receiver;
5
6use eframe::egui;
7
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
11use crate::preview_gl::Viewport;
12use crate::project::open_project;
13use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
15
16pub struct EditorApp {
17 session: Session,
18 dialogs: Box<dyn Dialogs>,
19 controls: PreviewControls,
20 preview_male: Option<PreviewCharacter>,
21 preview_female: Option<PreviewCharacter>,
22 viewport: Viewport,
23 inventories: [Option<Result<ImportedModel, String>>; 2],
24 render_rx: Option<Receiver<RenderEvent>>,
25 render_ui: RenderUiState,
26 applied_shell: Option<[f32; 2]>,
27 width_text: String,
28 height_text: String,
29 fps_text: String,
30 allow_close: bool,
31 dialog_pending: bool,
32}
33
34impl EditorApp {
35 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
36 Self {
37 session: Session::new(),
38 dialogs,
39 controls: PreviewControls::default(),
40 preview_male: None,
41 preview_female: None,
42 viewport: Viewport::new(),
43 inventories: [None, None],
44 render_rx: None,
45 render_ui: RenderUiState::idle(),
46 applied_shell: None,
47 width_text: "128".to_string(),
48 height_text: "128".to_string(),
49 fps_text: "12".to_string(),
50 allow_close: false,
51 dialog_pending: false,
52 }
53 }
54
55 pub fn open_path(&mut self, path: PathBuf) {
56 let candidate = std::env::current_dir()
57 .map_err(|e| e.to_string())
58 .and_then(|base| {
59 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
60 });
61 let path = match candidate {
62 Ok(path) => path,
63 Err(error) => {
64 self.session.fail(error);
65 return;
66 }
67 };
68 match open_project(&path) {
69 Ok(opened) => {
70 self.session.install_loaded(opened);
71 self.reset_render();
72 self.sync_fields();
73 self.reload_bodies();
74 }
75 Err(error) => {
76 self.session.fail(error.to_string());
77 }
78 }
79 }
80
81 pub fn open_fixture_project(&mut self) {
82 let root = std::env::temp_dir().join(format!(
83 "paperdoll-renderer-fixture-{}-{}",
84 std::process::id(),
85 std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_nanos()
89 ));
90 if let Err(error) = self.session.install_new_project(&root) {
91 self.session.fail(error.to_string());
92 return;
93 }
94 self.sync_fields();
95 self.reload_bodies();
96 self.session.show_body(Sex::Male);
97 self.controls.sex = PreviewSex::Male;
98 self.controls.select_clip("walk");
99 }
100
101 fn reset_render(&mut self) {
102 self.render_rx = None;
103 self.render_ui = RenderUiState::idle();
104 }
105
106 fn sync_fields(&mut self) {
107 if let Some(opened) = &self.session.opened {
108 self.width_text = opened.document.render.target_width.to_string();
109 self.height_text = opened.document.render.target_height.to_string();
110 self.fps_text = opened.document.render.fps.to_string();
111 }
112 }
113
114 fn reload_bodies(&mut self) {
115 self.preview_male = None;
116 self.preview_female = None;
117 self.inventories = [None, None];
118 let Some(root) = self.session.project_root() else {
119 return;
120 };
121 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
122 if let Some(relative) = self.session.body_path(sex) {
123 let imported =
124 import_project_model(&root, relative).map_err(|error| error.to_string());
125 // Validate the source and its dependencies before the viewport reads it.
126 if imported.is_ok()
127 && matches!(
128 std::path::Path::new(relative)
129 .extension()
130 .and_then(|e| e.to_str()),
131 Some("gltf" | "glb")
132 )
133 {
134 match load_gltf_file(&root.join(relative)) {
135 Ok(character) => match sex {
136 Sex::Male => self.preview_male = Some(character),
137 Sex::Female => self.preview_female = Some(character),
138 },
139 Err(error) => self.session.fail(format!("3D: {error}")),
140 }
141 }
142 self.inventories[index] = Some(imported);
143 }
144 }
145 }
146
147 fn character(&self) -> Option<&PreviewCharacter> {
148 match self.controls.sex {
149 PreviewSex::Male => self.preview_male.as_ref(),
150 PreviewSex::Female => self.preview_female.as_ref(),
151 }
152 }
153
154 fn poll_dialogs(&mut self) {
155 let poll = self.dialogs.poll();
156 self.dialog_pending = matches!(poll, DialogPoll::Pending);
157 if let DialogPoll::Ready { purpose, path } = poll {
158 let Some(path) = path else {
159 return;
160 };
161 match purpose {
162 DialogPurpose::NewProject => {
163 if let Err(error) = self.session.install_new_project(&path) {
164 self.session.fail(error.to_string());
165 } else {
166 self.reset_render();
167 self.sync_fields();
168 self.reload_bodies();
169 }
170 }
171 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
172 Ok(opened) => {
173 self.session.install_loaded(opened);
174 self.reset_render();
175 self.sync_fields();
176 self.reload_bodies();
177 }
178 Err(error) => self.session.fail(error.to_string()),
179 },
180 DialogPurpose::Body(sex) => self.take_model(sex, &path),
181 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
182 }
183 }
184 }
185
186 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
187 let Some(root) = self.session.project_root() else {
188 return;
189 };
190 match adopt_file(&root, path, "assets/body") {
191 Ok(relative) => {
192 self.session.set_body_path(sex, relative.clone());
193 self.reload_bodies();
194 self.reset_render();
195 let index = if sex == Sex::Male { 0 } else { 1 };
196 if let Some(Err(error)) = &self.inventories[index] {
197 self.session.fail(format!("{relative}: {error}"));
198 } else {
199 self.session.notice = format!("{relative} staat in het project.");
200 self.session.notice_ok = true;
201 }
202 }
203 Err(error) => self.session.fail(error),
204 }
205 }
206
207 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208 let Some(root) = self.session.project_root() else {
209 return;
210 };
211 match adopt_file(&root, path, "assets/equipment") {
212 Ok(relative) => {
213 self.session.set_equipment_model(index, relative.clone());
214 self.session.notice = format!("{relative} hoort bij deze equipment.");
215 self.session.notice_ok = true;
216 }
217 Err(error) => self.session.fail(error),
218 }
219 }
220
221 fn begin(&mut self, intent: Intent) {
222 if self.session.gate(intent) {
223 self.run_intent(intent);
224 }
225 }
226
227 fn run_intent(&mut self, intent: Intent) {
228 match intent {
229 Intent::Quit => {}
230 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
231 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
232 }
233 }
234
235 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
236 match self.session.resolve(choice) {
237 AfterChoice::Stay => {}
238 AfterChoice::SaveThen(intent) => match self.session.save() {
239 Ok(()) => self.finish_intent(intent, ctx),
240 Err(error) => self.session.fail(error.to_string()),
241 },
242 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
243 }
244 }
245
246 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
247 if intent == Intent::Quit {
248 self.allow_close = true;
249 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
250 } else {
251 self.run_intent(intent);
252 }
253 }
254
255 fn save(&mut self) {
256 if let Err(error) = self.session.save() {
257 self.session.fail(error.to_string());
258 }
259 }
260
261 fn apply_render_fields(&mut self) {
262 let width = self.width_text.parse::<u32>().unwrap_or(0);
263 let height = self.height_text.parse::<u32>().unwrap_or(0);
264 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
265 self.session.set_render(width, height, fps);
266 }
267
268 fn start_render(&mut self, preview: [u32; 2]) {
269 if let Some(opened) = &self.session.opened {
270 if let Err(error) = opened.document.validate_shape() {
271 self.session.fail(error.to_string());
272 return;
273 }
274 }
275 let Some(character) = self.character().cloned() else {
276 self.session
277 .fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
278 return;
279 };
280 let Some(root) = self.session.project_root() else {
281 return;
282 };
283 let Some(opened) = &self.session.opened else {
284 return;
285 };
286 if let Err(error) =
287 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
288 {
289 self.session.fail(error.to_string());
290 return;
291 }
292 let sex = match self.controls.sex {
293 PreviewSex::Male => "male",
294 PreviewSex::Female => "female",
295 };
296 let output = root
297 .join(&opened.document.output_directory)
298 .join(format!("body-{sex}-still.png"));
299 let job = RenderJob {
300 character,
301 controls: self.controls.clone(),
302 target_width: opened.document.render.target_width.max(1),
303 target_height: opened.document.render.target_height.max(1),
304 preview_width: preview[0].max(1),
305 preview_height: preview[1].max(1),
306 output,
307 };
308 let thread = spawn(job);
309 self.render_rx = Some(thread.rx);
310 self.render_ui = RenderUiState::idle();
311 self.render_ui.running = true;
312 let _ = thread.handle;
313 }
314
315 fn drain_render(&mut self) {
316 let Some(rx) = &self.render_rx else {
317 return;
318 };
319 loop {
320 let event = match rx.try_recv() {
321 Ok(event) => event,
322 Err(std::sync::mpsc::TryRecvError::Empty) => break,
323 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
324 let message = "De renderworker stopte zonder eindresultaat.".to_string();
325 self.session.fail(message.clone());
326 self.render_ui.apply(RenderEvent::Failed(message));
327 self.render_rx = None;
328 break;
329 }
330 };
331 let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
332 if let RenderEvent::Finished { saved: Some(path) } = &event {
333 self.session.notice = format!("Standbeeld opgeslagen: {path}");
334 self.session.notice_ok = true;
335 }
336 if let RenderEvent::Failed(message) = &event {
337 self.session.fail(message.clone());
338 }
339 self.render_ui.apply(event);
340 if finished {
341 self.render_rx = None;
342 break;
343 }
344 }
345 }
346}
347
348impl Default for EditorApp {
349 fn default() -> Self {
350 Self::new(Box::new(SystemDialogs::new()))
351 }
352}
353
354impl eframe::App for EditorApp {
355 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
356 let shell = self.session.shell_size();
357 if self.applied_shell != Some(shell) {
358 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
359 shell[0], shell[1],
360 )));
361 let min = if shell == crate::session::START_WINDOW {
362 egui::vec2(420.0, 220.0)
363 } else {
364 egui::vec2(900.0, 560.0)
365 };
366 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
367 self.applied_shell = Some(shell);
368 }
369 if ctx.input(|input| input.viewport().close_requested())
370 && !self.allow_close
371 && self.session.confirm.is_none()
372 {
373 if !self.session.gate(Intent::Quit) {
374 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
375 }
376 } else if self.session.confirm.is_some()
377 && ctx.input(|input| input.viewport().close_requested())
378 {
379 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
380 }
381 self.poll_dialogs();
382 self.drain_render();
383 if self.render_ui.running {
384 ctx.request_repaint();
385 }
386 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
387 let color = if self.session.notice_ok {
388 egui::Color32::LIGHT_GREEN
389 } else {
390 egui::Color32::LIGHT_RED
391 };
392 ui.colored_label(color, &self.session.notice);
393 });
394 match self.session.screen {
395 Screen::Start => self.show_start(ctx),
396 Screen::Bodies => self.show_bodies(ctx),
397 Screen::Body(_) => self.show_body(ctx),
398 Screen::EquipmentList => self.show_equipment_list(ctx),
399 Screen::Equipment(_) => self.show_equipment(ctx),
400 }
401 self.show_confirm(ctx);
402 if self.dialog_pending {
403 ctx.request_repaint_after(std::time::Duration::from_millis(50));
404 }
405 }
406}
407
408impl EditorApp {
409 fn show_confirm(&mut self, ctx: &egui::Context) {
410 let Some(_intent) = self.session.confirm else {
411 return;
412 };
413 let mut choice = None;
414 egui::Window::new("Wijzigingen")
415 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
416 .collapsible(false)
417 .resizable(false)
418 .show(ctx, |ui| {
419 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
420 ui.horizontal(|ui| {
421 if ui.button("Opslaan").clicked() {
422 choice = Some(crate::session::Choice::Save);
423 }
424 if ui.button("Niet opslaan").clicked() {
425 choice = Some(crate::session::Choice::Discard);
426 }
427 if ui.button("Annuleren").clicked() {
428 choice = Some(crate::session::Choice::Cancel);
429 }
430 });
431 });
432 if let Some(choice) = choice {
433 self.apply_choice(choice, ctx);
434 }
435 }
436
437 fn show_start(&mut self, ctx: &egui::Context) {
438 egui::CentralPanel::default().show(ctx, |ui| {
439 if self.session.confirm.is_some() || self.dialog_pending {
440 ui.disable();
441 }
442 ui.heading("Paperdoll Renderer");
443 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
444 ui.add_space(12.0);
445 if ui.button("Project laden").clicked() {
446 self.begin(Intent::PickLoad);
447 }
448 if ui.button("Nieuw project").clicked() {
449 self.begin(Intent::PickNew);
450 }
451 });
452 }
453
454 fn show_bodies(&mut self, ctx: &egui::Context) {
455 self.toolbar(ctx, true);
456 egui::CentralPanel::default().show(ctx, |ui| {
457 if self.session.confirm.is_some() || self.dialog_pending {
458 ui.disable();
459 }
460 ui.heading("Body");
461 if let Some(opened) = &mut self.session.opened {
462 ui.horizontal(|ui| {
463 ui.label("Naam");
464 ui.text_edit_singleline(&mut opened.document.name);
465 });
466 }
467 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
468 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
469 ui.add_space(8.0);
470 ui.horizontal(|ui| {
471 ui.label("Breedte");
472 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
473 ui.label("Hoogte");
474 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
475 ui.label("fps");
476 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
477 });
478 self.apply_render_fields();
479 });
480 }
481
482 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
483 ui.horizontal(|ui| {
484 ui.label(title);
485 let path = self
486 .session
487 .body_path(sex)
488 .unwrap_or("geen model")
489 .to_string();
490 ui.label(path);
491 if ui.button(pick).clicked() {
492 self.dialogs.start(DialogPurpose::Body(sex));
493 }
494 if ui.button(open).clicked() {
495 self.controls.sex = match sex {
496 Sex::Male => PreviewSex::Male,
497 Sex::Female => PreviewSex::Female,
498 };
499 self.session.show_body(sex);
500 }
501 });
502 let index = if sex == Sex::Male { 0 } else { 1 };
503 if let Some(inventory) = &self.inventories[index] {
504 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
505 Ok(model) => {
506 for mesh in &model.meshes {
507 ui.label(format!(
508 "{}: {} vertices, {} driehoeken",
509 mesh.name, mesh.vertex_count, mesh.triangle_count
510 ));
511 }
512 for material in &model.materials {
513 ui.label(format!("materiaal {}", material.name));
514 }
515 for texture in &model.textures {
516 ui.label(format!(
517 "texture {}: {}×{}",
518 texture.source, texture.width, texture.height
519 ));
520 }
521 ui.label(format!("{} joints", model.joints.len()));
522 for clip in &model.clips {
523 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
524 }
525 for notice in &model.notices {
526 ui.label(notice);
527 }
528 }
529 Err(message) => {
530 ui.colored_label(egui::Color32::LIGHT_RED, message);
531 }
532 });
533 }
534 }
535
536 fn show_body(&mut self, ctx: &egui::Context) {
537 self.toolbar(ctx, true);
538 let sex = match self.session.screen {
539 Screen::Body(sex) => sex,
540 _ => Sex::Male,
541 };
542 self.controls.sex = match sex {
543 Sex::Male => PreviewSex::Male,
544 Sex::Female => PreviewSex::Female,
545 };
546 egui::CentralPanel::default().show(ctx, |ui| {
547 if self.session.confirm.is_some() || self.dialog_pending {
548 ui.disable();
549 }
550 ui.horizontal_wrapped(|ui| {
551 if ui.button("Terug").clicked() {
552 self.session.show_bodies();
553 }
554 if ui.button("Equipment").clicked() {
555 self.session.show_equipment_list();
556 }
557 if ui
558 .add_enabled(!self.render_ui.running, egui::Button::new("Render"))
559 .clicked()
560 {
561 self.start_render([480, 640]);
562 }
563 if ui.button("Live 3D").clicked() {
564 self.render_ui.preview = None;
565 }
566 });
567 if self.render_ui.progress.total > 0 {
568 let fraction = self.render_ui.progress.completed as f32
569 / self.render_ui.progress.total.max(1) as f32;
570 ui.add(egui::ProgressBar::new(fraction).text(format!(
571 "{} / {} {}",
572 self.render_ui.progress.completed,
573 self.render_ui.progress.total,
574 self.render_ui.progress.label
575 )));
576 }
577 if let Some(image) = &self.render_ui.preview {
578 self.paint_pass(ui, image);
579 } else {
580 let character = match self.controls.sex {
581 PreviewSex::Male => self.preview_male.as_ref(),
582 PreviewSex::Female => self.preview_female.as_ref(),
583 };
584 if character.is_some() {
585 self.viewport.show(ui, &mut self.controls, character);
586 } else {
587 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
588 }
589 }
590 });
591 }
592
593 fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
594 let color = egui::ColorImage::from_rgba_unmultiplied(
595 [image.width as usize, image.height as usize],
596 &image.rgba,
597 );
598 let texture = ui.ctx().load_texture(
599 "render-pass",
600 color,
601 egui::TextureOptions {
602 magnification: egui::TextureFilter::Nearest,
603 minification: egui::TextureFilter::Nearest,
604 wrap_mode: egui::TextureWrapMode::ClampToEdge,
605 ..Default::default()
606 },
607 );
608 let available = ui.available_size();
609 ui.image((texture.id(), available));
610 }
611
612 fn show_equipment_list(&mut self, ctx: &egui::Context) {
613 self.toolbar(ctx, true);
614 egui::CentralPanel::default().show(ctx, |ui| {
615 if self.session.confirm.is_some() || self.dialog_pending {
616 ui.disable();
617 }
618 ui.heading(format!(
619 "Bodies → {} → Equipment",
620 match self.session.active_body {
621 Some(Sex::Male) => "Man",
622 Some(Sex::Female) => "Vrouw",
623 None => "Body",
624 }
625 ));
626 if ui.button("Terug naar body").clicked() {
627 self.session.back();
628 }
629 let mut open = None;
630 for (index, item) in self.session.equipment.iter().enumerate() {
631 let label = match &item.model {
632 Some(model) => format!("{} {model}", item.slug),
633 None => item.slug.clone(),
634 };
635 if ui.button(label).clicked() {
636 open = Some(index);
637 }
638 }
639 if ui.button("Nieuw").clicked() {
640 self.session.add_equipment();
641 }
642 if let Some(index) = open {
643 self.session.show_equipment(index);
644 }
645 });
646 }
647
648 fn show_equipment(&mut self, ctx: &egui::Context) {
649 self.toolbar(ctx, true);
650 let Screen::Equipment(index) = self.session.screen else {
651 return;
652 };
653 egui::CentralPanel::default().show(ctx, |ui| {
654 if self.session.confirm.is_some() || self.dialog_pending {
655 ui.disable();
656 }
657 ui.heading("Equipment");
658 if ui.button("Terug").clicked() {
659 self.session.show_equipment_list();
660 }
661 if let Some(item) = self.session.equipment.get_mut(index) {
662 ui.horizontal(|ui| {
663 ui.label("Naam");
664 ui.text_edit_singleline(&mut item.slug);
665 });
666 let model = item
667 .model
668 .clone()
669 .unwrap_or_else(|| "geen model".to_string());
670 ui.label(model);
671 }
672 if ui.button("Kies model").clicked() {
673 self.dialogs.start(DialogPurpose::EquipmentModel(index));
674 }
675 if ui.button("Verwijder").clicked() {
676 self.session.delete_equipment(index);
677 }
678 });
679 }
680
681 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
682 if !project {
683 return;
684 }
685 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
686 if self.session.confirm.is_some() || self.dialog_pending {
687 ui.disable();
688 }
689 ui.horizontal(|ui| {
690 if ui.button("Body").clicked() {
691 self.session.show_bodies();
692 }
693 if ui.button("Opslaan").clicked() {
694 self.save();
695 }
696 if ui
697 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
698 .clicked()
699 {
700 self.begin(Intent::PickNew);
701 }
702 if ui
703 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
704 .clicked()
705 {
706 self.begin(Intent::PickLoad);
707 }
708 });
709 });
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::dialogs::ScriptedDialogs;
717 use crate::session::Choice;
718
719 fn root() -> PathBuf {
720 std::env::temp_dir().join(format!(
721 "paperdoll-editor-{}",
722 std::time::SystemTime::now()
723 .duration_since(std::time::UNIX_EPOCH)
724 .unwrap()
725 .as_nanos()
726 ))
727 }
728
729 #[test]
730 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
731 let root = root();
732 let mut dialogs = ScriptedDialogs::default();
733 dialogs.push(Some(root.clone()));
734 dialogs.push(None);
735 let mut app = EditorApp::new(Box::new(dialogs));
736 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
737 app.begin(Intent::PickNew);
738 app.poll_dialogs();
739 assert_eq!(app.session.screen, Screen::Bodies);
740 assert!(app.preview_male.is_some());
741 assert!(app.preview_female.is_some());
742 app.session.set_name("Keep my edit".into());
743 app.begin(Intent::PickLoad);
744 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
745 app.apply_choice(Choice::Discard, &egui::Context::default());
746 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
747 assert_eq!(
748 app.session.opened.as_ref().unwrap().document.name,
749 "Keep my edit"
750 );
751 assert!(app.session.is_dirty());
752 let _ = std::fs::remove_dir_all(root);
753 }
754
755 #[test]
756 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
757 let root = root();
758 let mut app = EditorApp::default();
759 app.session.install_new_project(&root).unwrap();
760 app.session.set_render(0, 128, 12);
761 app.begin(Intent::Quit);
762 app.apply_choice(Choice::Save, &egui::Context::default());
763 assert!(!app.allow_close);
764 assert_eq!(app.session.confirm, Some(Intent::Quit));
765 assert!(app.session.is_dirty());
766 app.open_path(root.join("missing.ppd"));
767 assert!(app.session.opened.is_some());
768 assert_eq!(app.session.screen, Screen::Bodies);
769 let _ = std::fs::remove_dir_all(root);
770 }
771
772 #[test]
773 fn open_with_project_path_loads_bodies_and_models() {
774 let root = root().join("project with spaces");
775 let opened = crate::session::create_seeded_project(&root).unwrap();
776 let mut app = EditorApp::default();
777 app.open_path(opened.manifest_path.clone());
778 assert_eq!(app.session.screen, Screen::Bodies);
779 assert!(app.preview_male.is_some());
780 assert_eq!(
781 app.session.opened.unwrap().manifest_path,
782 opened.manifest_path
783 );
784 let _ = std::fs::remove_dir_all(root.parent().unwrap());
785 }
786
787 #[test]
788 fn renderer_disconnect_is_reported() {
789 let mut app = EditorApp::default();
790 let (tx, rx) = std::sync::mpsc::channel();
791 app.render_rx = Some(rx);
792 app.render_ui.running = true;
793 drop(tx);
794 app.drain_render();
795 assert!(!app.render_ui.running);
796 assert!(app.render_ui.error.is_some());
797 }
798}