1use crate::store::{model_display_name, ModelStore};
25use brep_render::engine_state::EngineState;
26use eframe::egui;
27#[cfg(target_arch = "wasm32")]
28use serde_json::Value;
29#[cfg(target_arch = "wasm32")]
30use std::collections::HashMap;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum FileAction {
36 New,
37 Open,
38 Save,
39 SaveAs,
40 Import,
43 Export,
45}
46
47#[derive(Clone, Copy, PartialEq, Eq)]
49enum Mode {
50 Open,
52 SaveAs,
54 ConfirmNew,
56 Export,
58}
59
60fn use_native_dialog(store: &dyn ModelStore) -> bool {
67 cfg!(all(not(target_arch = "wasm32"), feature = "native-dialog"))
68 && store.supports_file_interchange()
69}
70
71fn is_step_name(name: &str) -> bool {
76 let lower = name.to_ascii_lowercase();
77 lower.ends_with(".step") || lower.ends_with(".stp")
78}
79
80pub struct FileDialog {
83 current_name: Option<String>,
85 name_buf: String,
87 saved_signature: Option<String>,
90 status: String,
92 open: bool,
94 mode: Mode,
96 want_focus: bool,
98 #[cfg(target_arch = "wasm32")]
100 hits: HashMap<String, egui::Rect>,
101}
102
103impl FileDialog {
104 pub fn new(state: &EngineState) -> Self {
107 Self {
108 current_name: None,
109 name_buf: String::new(),
110 saved_signature: Some(state.history_request_json()),
111 status: String::new(),
112 open: false,
113 mode: Mode::Open,
114 want_focus: false,
115 #[cfg(target_arch = "wasm32")]
116 hits: HashMap::new(),
117 }
118 }
119
120 fn mark_clean(&mut self, state: &EngineState) {
124 self.saved_signature = Some(state.history_request_json());
125 }
126
127 pub fn is_dirty(&self, state: &EngineState) -> bool {
131 match &self.saved_signature {
132 Some(saved) => *saved != state.history_request_json(),
133 None => true,
134 }
135 }
136
137 pub fn dispatch(
143 &mut self,
144 action: FileAction,
145 state: &mut EngineState,
146 store: &dyn ModelStore,
147 ) {
148 match action {
149 FileAction::New => {
150 if self.is_dirty(state) {
151 self.open_modal(Mode::ConfirmNew);
152 } else {
153 self.new_document(state);
154 }
155 }
156 FileAction::Open => {
157 if use_native_dialog(store) {
158 match store.begin_import() {
160 Ok(()) => self.status = "choose a file…".into(),
161 Err(e) => self.status = format!("open failed: {e}"),
162 }
163 } else {
164 self.open_modal(Mode::Open);
165 }
166 }
167 FileAction::Save => match self.current_name.clone() {
168 Some(name) => {
170 let _ = self.save_to(state, store, name);
171 }
172 None => self.dispatch(FileAction::SaveAs, state, store),
174 },
175 FileAction::SaveAs => {
176 if use_native_dialog(store) {
177 let name = self.effective_name();
181 match store.export_file(&name, &state.history_request_json()) {
182 Ok(Some(saved)) => {
183 self.name_buf = model_display_name(&saved);
184 self.current_name = Some(saved.clone());
185 self.mark_clean(state);
186 self.status = format!("saved {saved}");
187 }
188 Ok(None) => self.status = "save cancelled".into(),
189 Err(e) => self.status = format!("save failed: {e}"),
190 }
191 } else {
192 self.name_buf = self.effective_name();
194 self.open_modal(Mode::SaveAs);
195 }
196 }
197 FileAction::Import => {
198 match store.begin_import_filtered(("STEP", &["step", "stp", "STEP", "STP"])) {
202 Ok(()) => self.status = "choose a STEP file\u{2026}".into(),
203 Err(e) => self.status = format!("import failed: {e}"),
204 }
205 }
206 FileAction::Export => {
207 self.name_buf = self.effective_name();
208 self.open_modal(Mode::Export);
209 }
210 }
211 }
212
213 fn open_modal(&mut self, mode: Mode) {
215 self.mode = mode;
216 self.open = true;
217 self.want_focus = true;
218 }
219
220 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
226 #[cfg(target_arch = "wasm32")]
227 self.hits.clear();
228
229 if let Some((name, contents)) = store.take_import() {
237 if is_step_name(&name) {
238 self.import_step(state, &name, &contents);
239 } else {
240 self.load_document(state, &name, &contents);
241 }
242 self.open = false;
243 }
244
245 if !self.open {
246 return;
247 }
248
249 match self.mode {
250 Mode::ConfirmNew => self.show_confirm_new(ctx, state),
251 Mode::SaveAs => self.show_save_as(ctx, state, store),
252 Mode::Open => self.show_open(ctx, state, store),
253 Mode::Export => self.show_export(ctx, state, store),
254 }
255 }
256
257 fn show_confirm_new(&mut self, ctx: &egui::Context, state: &mut EngineState) {
260 let title = self.current_name.clone().unwrap_or_else(|| "untitled".into());
261 let mut discard = false;
262 let mut cancel = false;
263 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-new")).show(ctx, |ui| {
264 ui.set_width(320.0);
265 ui.heading("Discard unsaved changes?");
266 ui.add_space(4.0);
267 ui.label(format!("\"{title}\" has unsaved changes."));
268 ui.add_space(6.0);
269 ui.horizontal(|ui| {
270 let d = ui.button("Discard");
271 self.hit("confirm:discard", &d);
272 if d.clicked() {
273 discard = true;
274 }
275 let c = ui.button("Cancel");
276 self.hit("confirm:cancel", &c);
277 if c.clicked() {
278 cancel = true;
279 }
280 });
281 });
282 if discard {
283 self.new_document(state);
284 self.open = false;
285 } else if cancel || modal.should_close() {
286 self.open = false;
287 }
288 }
289
290 fn show_save_as(
292 &mut self,
293 ctx: &egui::Context,
294 state: &mut EngineState,
295 store: &dyn ModelStore,
296 ) {
297 let mut do_save = false;
298 let mut cancel = false;
299 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
300 ui.set_width(320.0);
301 ui.heading("Save model as");
302 ui.add_space(4.0);
303 let field = ui.add(
304 egui::TextEdit::singleline(&mut self.name_buf)
305 .hint_text("model name")
306 .desired_width(f32::INFINITY),
307 );
308 self.hit("field:name", &field);
309 if self.want_focus {
310 field.request_focus();
311 self.want_focus = false;
312 }
313 let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
314 ui.add_space(6.0);
315 ui.horizontal(|ui| {
316 let save = ui.button("Save");
317 self.hit("save", &save);
318 if save.clicked() || enter {
319 do_save = true;
320 }
321 let c = ui.button("Cancel");
322 self.hit("cancel", &c);
323 if c.clicked() {
324 cancel = true;
325 }
326 });
327 if !self.status.is_empty() {
328 ui.add_space(4.0);
329 ui.weak(&self.status);
330 }
331 });
332 if do_save {
333 let name = self.name_buf.clone();
334 if self.save_to(state, store, name) {
335 self.open = false;
336 }
337 } else if cancel || modal.should_close() {
338 self.open = false;
339 }
340 }
341
342 fn show_export(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
347 let mut chosen: Option<&'static str> = None;
348 let mut cancel = false;
349 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
350 ui.set_width(320.0);
351 ui.heading("Export model");
352 ui.add_space(4.0);
353 let field = ui.add(
354 egui::TextEdit::singleline(&mut self.name_buf)
355 .hint_text("file name")
356 .desired_width(f32::INFINITY),
357 );
358 self.hit("field:name", &field);
359 if self.want_focus {
360 field.request_focus();
361 self.want_focus = false;
362 }
363 ui.add_space(6.0);
364 ui.horizontal(|ui| {
365 let step = ui.button("STEP (.step)");
366 self.hit("export:step", &step);
367 if step.clicked() {
368 chosen = Some("step");
369 }
370 let stl = ui.button("STL (.stl)");
371 self.hit("export:stl", &stl);
372 if stl.clicked() {
373 chosen = Some("stl");
374 }
375 let json = ui.button("JSON (.BREP.json)");
379 self.hit("export:json", &json);
380 if json.clicked() {
381 chosen = Some("json");
382 }
383 let c = ui.button("Cancel");
384 self.hit("cancel", &c);
385 if c.clicked() {
386 cancel = true;
387 }
388 });
389 if !self.status.is_empty() {
390 ui.add_space(4.0);
391 ui.weak(&self.status);
392 }
393 });
394 if let Some(format) = chosen {
395 if self.export_as(state, store, format) {
396 self.open = false;
397 }
398 } else if cancel || modal.should_close() {
399 self.open = false;
400 }
401 }
402
403 fn show_open(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
406 let names = store.list();
407 let backend = store.backend_label();
408 let interchange = store.supports_file_interchange();
409 let mut chosen: Option<String> = None;
410 let mut to_delete: Option<String> = None;
411 let mut do_upload = false;
412 let mut cancel = false;
413 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
414 ui.set_width(360.0);
415 ui.heading("Open model");
416 ui.weak(&backend);
417 ui.add_space(4.0);
418 ui.separator();
419 egui::ScrollArea::vertical()
420 .max_height(320.0)
421 .auto_shrink([false, false])
422 .show(ui, |ui| {
423 if names.is_empty() {
424 ui.weak("(no saved models)");
425 }
426 for name in &names {
427 ui.horizontal(|ui| {
428 let is_current = self.current_name.as_deref() == Some(name.as_str());
429 let row = ui.selectable_label(is_current, format!("\u{1F5CE} {name}"));
430 self.hit(&format!("open:{name}"), &row);
431 if row.clicked() {
432 chosen = Some(name.clone());
433 }
434 let del = ui.small_button("\u{2715}");
435 self.hit(&format!("del:{name}"), &del);
436 if del.clicked() {
437 to_delete = Some(name.clone());
438 }
439 });
440 }
441 });
442 ui.separator();
443 ui.horizontal(|ui| {
444 if interchange {
445 let up = ui.button("\u{2B06} Upload\u{2026}");
446 self.hit("upload", &up);
447 if up.clicked() {
448 do_upload = true;
449 }
450 }
451 let c = ui.button("Cancel");
452 self.hit("cancel", &c);
453 if c.clicked() {
454 cancel = true;
455 }
456 });
457 if !self.status.is_empty() {
458 ui.add_space(4.0);
459 ui.weak(&self.status);
460 }
461 });
462 if let Some(name) = chosen {
463 self.open_document(state, store, &name);
464 self.open = false;
465 } else if let Some(name) = to_delete {
466 let _ = store.remove(&name);
467 self.status = format!("removed {name}");
468 if self.current_name.as_deref() == Some(name.as_str()) {
469 self.current_name = None;
470 }
471 } else if do_upload {
472 match store.begin_import() {
475 Ok(()) => {
476 self.status = "choose a file…".into();
477 self.open = false;
478 }
479 Err(e) => self.status = format!("import failed: {e}"),
480 }
481 } else if cancel || modal.should_close() {
482 self.open = false;
483 }
484 }
485
486 fn effective_name(&self) -> String {
491 let field = self.name_buf.trim();
492 if !field.is_empty() {
493 field.to_string()
494 } else {
495 self.current_name.clone().unwrap_or_else(|| "untitled".into())
496 }
497 }
498
499 fn new_document(&mut self, state: &mut EngineState) {
501 let _ = state.set_history_json(r#"{"expressions":"","configurator":{},"features":[]}"#);
502 self.current_name = None;
503 self.name_buf.clear();
504 self.mark_clean(state);
505 self.status = "new (empty) model".into();
506 }
507
508 fn save_to(&mut self, state: &mut EngineState, store: &dyn ModelStore, name: String) -> bool {
511 let name = name.trim().to_string();
512 if name.is_empty() {
513 self.status = "enter a name to save".into();
514 return false;
515 }
516 match store.write(&name, &state.history_request_json()) {
517 Ok(()) => {
518 self.name_buf = model_display_name(&name);
521 self.current_name = Some(name.clone());
522 self.mark_clean(state);
523 self.status = format!("saved {name}");
524 true
525 }
526 Err(e) => {
527 self.status = format!("save failed: {e}");
528 false
529 }
530 }
531 }
532
533 fn open_document(&mut self, state: &mut EngineState, store: &dyn ModelStore, name: &str) {
535 match store.read(name) {
536 Some(contents) => self.load_document(state, name, &contents),
537 None => self.status = format!("open failed: '{name}' not found"),
538 }
539 }
540
541 fn export_as(&mut self, state: &EngineState, store: &dyn ModelStore, format: &str) -> bool {
548 let name = self.effective_name();
549 let (text, ext) = match format {
550 "stl" => (state.export_stl_text(), "stl"),
551 "json" => (Ok(state.history_request_json()), "BREP.json"),
552 _ => (state.export_step_text(), "step"),
553 };
554 match text {
555 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
556 Ok(()) => {
557 self.status = format!("exported {name}.{ext}");
558 true
559 }
560 Err(e) => {
561 self.status = format!("export failed: {e}");
562 false
563 }
564 },
565 Err(e) => {
566 self.status = format!("export failed: {e}");
567 false
568 }
569 }
570 }
571
572 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
576 match state.import_step_feature(contents) {
577 Ok(_) => {
578 state.zoom_to_fit(); self.status = format!("imported {name}");
580 }
581 Err(e) => self.status = format!("import failed: {e}"),
582 }
583 }
584
585 fn load_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
588 match state.load_model_and_fit(contents) {
589 Ok(_) => {
590 self.current_name = Some(name.to_string());
594 self.name_buf = model_display_name(name);
595 self.mark_clean(state);
596 self.status = format!("opened {name}");
597 }
598 Err(e) => self.status = format!("open failed: {e}"),
599 }
600 }
601
602 #[cfg(target_arch = "wasm32")]
607 pub fn file_state_json(&self, state: &EngineState, store: &dyn ModelStore) -> String {
608 serde_json::json!({
609 "name": self.current_name,
610 "nameBuf": self.name_buf,
611 "dirty": self.is_dirty(state),
612 "backend": store.backend_label(),
613 "interchange": store.supports_file_interchange(),
614 "list": store.list(),
615 "status": self.status,
616 "open": self.open,
617 "mode": match self.mode {
618 Mode::Open => "open",
619 Mode::SaveAs => "saveas",
620 Mode::ConfirmNew => "confirmnew",
621 Mode::Export => "export",
622 },
623 })
624 .to_string()
625 }
626
627 #[cfg(target_arch = "wasm32")]
629 pub fn hits_json(&self) -> String {
630 let map: serde_json::Map<String, Value> = self
631 .hits
632 .iter()
633 .map(|(k, r)| {
634 (
635 k.clone(),
636 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
637 )
638 })
639 .collect();
640 Value::Object(map).to_string()
641 }
642
643 #[cfg(target_arch = "wasm32")]
646 fn hit(&mut self, key: &str, resp: &egui::Response) {
647 self.hits.insert(key.to_string(), resp.rect);
648 }
649 #[cfg(not(target_arch = "wasm32"))]
650 #[inline]
651 fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
652}
653
654#[cfg(all(test, not(target_arch = "wasm32")))]
655mod tests {
656 use super::*;
657 use crate::store::native_test_store;
658
659 #[test]
663 fn model_document_round_trips_through_store_and_engine() {
664 let dir = std::env::temp_dir().join(format!("brep-app-file-{}", std::process::id()));
666 let _ = std::fs::remove_dir_all(&dir);
667 let store = native_test_store(dir.clone());
668
669 let mut engine = EngineState::new();
671 let seed = r#"{"expressions":"","configurator":{},"features":[
672 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
673 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
674 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
675 ]}"#;
676 engine.set_history_json(seed).unwrap();
677 let before = engine.history_request_json();
678 store.write("roundtrip", &before).unwrap();
679
680 let mut reopened = EngineState::new();
682 let contents = store.read("roundtrip").unwrap();
683 reopened.load_model_and_fit(&contents).unwrap();
684 let after = reopened.history_request_json();
685
686 assert_eq!(before, after, "request JSON round-trips through the store");
687 assert_eq!(reopened.history_len(), 1);
688 assert_eq!(reopened.scene.solids().len(), 1);
689
690 let _ = std::fs::remove_dir_all(&dir);
691 }
692
693 #[test]
696 fn dirty_flag_flips_on_edit_and_clears_on_save() {
697 let dir = std::env::temp_dir().join(format!("brep-app-dirty-{}", std::process::id()));
698 let _ = std::fs::remove_dir_all(&dir);
699 let store = native_test_store(dir.clone());
700
701 let mut engine = EngineState::new();
702 engine
703 .set_history_json(
704 r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#,
705 )
706 .unwrap();
707 let mut dialog = FileDialog::new(&engine);
708 assert!(!dialog.is_dirty(&engine), "seeded model is clean");
709
710 engine
712 .update_feature_params(
713 "Box",
714 r#"{"id":"Box","sizeX":9.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
715 )
716 .unwrap();
717 assert!(dialog.is_dirty(&engine), "edit marks dirty");
718
719 assert!(
721 dialog.save_to(&mut engine, &*store, "m".into()),
722 "save succeeds"
723 );
724 assert!(!dialog.is_dirty(&engine), "save clears dirty");
725 assert_eq!(dialog.current_name.as_deref(), Some("m"));
726
727 let _ = std::fs::remove_dir_all(&dir);
728 }
729
730 #[test]
733 fn new_confirms_only_when_dirty() {
734 let dir = std::env::temp_dir().join(format!("brep-app-newcfm-{}", std::process::id()));
735 let _ = std::fs::remove_dir_all(&dir);
736 let store = native_test_store(dir.clone());
737
738 let mut engine = EngineState::new();
739 engine
740 .set_history_json(
741 r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#,
742 )
743 .unwrap();
744 let mut dialog = FileDialog::new(&engine);
745
746 dialog.dispatch(FileAction::New, &mut engine, &*store);
748 assert!(!dialog.open, "New on clean doc opens no modal");
749 assert_eq!(engine.history_len(), 0, "clean New cleared the history");
750
751 engine
753 .set_history_json(
754 r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#,
755 )
756 .unwrap();
757 assert!(dialog.is_dirty(&engine), "reseeded model is dirty vs empty baseline");
758 dialog.dispatch(FileAction::New, &mut engine, &*store);
759 assert!(dialog.open, "New on dirty doc opens the confirm modal");
760 assert_eq!(engine.history_len(), 1, "dirty New did NOT clear yet");
761
762 let _ = std::fs::remove_dir_all(&dir);
763 }
764
765 #[test]
769 fn save_on_unnamed_falls_through_to_save_as_modal() {
770 let dir = std::env::temp_dir().join(format!("brep-app-unnamed-{}", std::process::id()));
771 let _ = std::fs::remove_dir_all(&dir);
772 let store = native_test_store(dir.clone());
773
774 let mut engine = EngineState::new();
775 engine
776 .set_history_json(
777 r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#,
778 )
779 .unwrap();
780 let mut dialog = FileDialog::new(&engine);
781
782 dialog.dispatch(FileAction::Save, &mut engine, &*store);
784 assert!(dialog.open, "unnamed Save opens the Save As modal");
785 assert!(store.list().is_empty(), "nothing written yet");
786
787 assert!(dialog.save_to(&mut engine, &*store, "part".into()));
789 assert_eq!(dialog.current_name.as_deref(), Some("part"));
790 assert_eq!(store.list(), vec!["part".to_string()]);
791
792 let _ = std::fs::remove_dir_all(&dir);
793 }
794
795 const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
797 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
798 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
799 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
800 ]}"#;
801
802 #[test]
805 fn step_names_route_to_the_import_lane() {
806 assert!(is_step_name("part.step"));
807 assert!(is_step_name("PART.STP"));
808 assert!(!is_step_name("model"), "model docs arrive extension-less");
809 assert!(!is_step_name("model.json"));
810 }
811
812 #[test]
817 fn export_dispatch_and_chooser_serialize_the_model() {
818 let dir = std::env::temp_dir().join(format!("brep-app-export-{}", std::process::id()));
819 let _ = std::fs::remove_dir_all(&dir);
820 let store = native_test_store(dir.clone());
821
822 let mut engine = EngineState::new();
823 engine.set_history_json(BOX_SEED).unwrap();
824 let mut dialog = FileDialog::new(&engine);
825
826 dialog.dispatch(FileAction::Export, &mut engine, &*store);
827 assert!(dialog.open && dialog.mode == Mode::Export, "Export opens the chooser");
828 assert!(dialog.export_as(&engine, &*store, "step"), "STEP export ok");
829 assert!(dialog.status.contains("exported"), "status: {}", dialog.status);
830 assert!(dialog.export_as(&engine, &*store, "stl"), "STL export ok");
831 assert!(dialog.export_as(&engine, &*store, "json"), "JSON recipe export ok");
832 assert!(dialog.status.contains(".BREP.json"), "json status: {}", dialog.status);
833
834 let empty = EngineState::new();
835 assert!(!dialog.export_as(&empty, &*store, "step"), "empty model does not export");
836 assert!(dialog.status.contains("nothing to export"), "status: {}", dialog.status);
837
838 let _ = std::fs::remove_dir_all(&dir);
839 }
840
841 #[test]
845 fn import_step_appends_and_marks_dirty() {
846 let dir = std::env::temp_dir().join(format!("brep-app-imp-{}", std::process::id()));
847 let _ = std::fs::remove_dir_all(&dir);
848 let store = native_test_store(dir.clone());
849
850 let mut source = EngineState::new();
852 source.set_history_json(BOX_SEED).unwrap();
853 let step = source.export_step_text().unwrap();
854
855 let mut engine = EngineState::new();
857 let mut dialog = FileDialog::new(&engine);
858 assert!(!dialog.is_dirty(&engine), "empty seed is clean");
859 dialog.import_step(&mut engine, "part.step", &step);
860 assert_eq!(engine.history_len(), 1, "IMPORT3D feature appended");
861 assert_eq!(engine.scene.solids().len(), 1, "the imported body is in the scene");
862 assert!(dialog.is_dirty(&engine), "an import is an edit");
863 assert!(dialog.status.contains("imported"), "status: {}", dialog.status);
864 let _ = store; let _ = std::fs::remove_dir_all(&dir);
867 }
868}