1use crate::document::{Document, Documents, EMPTY_DOCUMENT};
30use crate::panels::parts_library::document_signature;
31use crate::panels::file_explorer::{FileExplorer, FileExplorerOptions};
32use crate::store::{model_display_name, ModelStore};
33use brep_render::engine_state::{
34 ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
35 StepAssemblyReport,
36};
37use eframe::egui;
38#[cfg(target_arch = "wasm32")]
39use serde_json::Value;
40#[cfg(target_arch = "wasm32")]
41use std::collections::HashMap;
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum FileAction {
47 New,
48 Open,
49 Save,
50 SaveAs,
51 Import,
54 Export,
56 ExportFlatPattern,
59 InsertComponent,
65}
66
67#[derive(Clone, Copy, PartialEq, Eq)]
69enum Mode {
70 Open,
72 SaveAs,
74 ConfirmClose,
78 Export,
80 Import,
82 FlatPattern,
84 InsertComponent,
87 StepAssembly,
91}
92
93struct PendingStepImport {
103 name: String,
105 text: String,
108 probe: StepAssemblyProbe,
110 flatten: bool,
115}
116
117#[derive(Clone, Copy, PartialEq, Eq)]
119enum StepChoice {
120 Assembly,
122 Bodies,
124 Cancel,
126}
127
128fn plural(count: usize) -> &'static str {
131 if count == 1 {
132 ""
133 } else {
134 "s"
135 }
136}
137
138fn step_document_name(file_name: &str) -> String {
142 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
143 let cut = base
144 .rfind('.')
145 .filter(|dot| is_step_name(&base[*dot..]))
146 .unwrap_or(base.len());
147 let stem = &base[..cut];
148 if stem.is_empty() {
149 base.to_string()
150 } else {
151 stem.to_string()
152 }
153}
154
155struct StorePartSink<'a> {
203 store: &'a dyn ModelStore,
204 prefix: String,
206 taken: std::collections::HashSet<String>,
210 written: usize,
211 failures: Vec<String>,
212}
213
214impl<'a> StorePartSink<'a> {
215 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
216 Self {
217 store,
218 prefix: sanitize_file_stem(prefix),
219 taken: std::collections::HashSet::new(),
220 written: 0,
221 failures: Vec::new(),
222 }
223 }
224}
225
226impl PartSink for StorePartSink<'_> {
227 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
228 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
229 let mut name = base.clone();
230 let mut suffix = 2;
231 while !self.taken.insert(name.clone()) {
232 name = format!("{base}-{suffix}");
233 suffix += 1;
234 }
235 match self.store.browser_write(&name, document_json) {
236 Ok(identity) => {
237 self.written += 1;
238 Some(identity)
239 }
240 Err(error) => {
241 self.failures.push(format!("{name}: {error}"));
242 None
243 }
244 }
245 }
246}
247
248fn sanitize_file_stem(name: &str) -> String {
254 let mut out = String::with_capacity(name.len());
255 for ch in name.chars() {
256 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
257 out.push(ch);
258 } else if !out.ends_with('_') {
259 out.push('_');
260 }
261 }
262 let trimmed = out.trim_matches('_');
263 if trimmed.is_empty() {
264 "part".to_string()
265 } else {
266 trimmed.to_string()
267 }
268}
269
270fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
271 let mut message = format!(
272 "imported {name} \u{2014} {} part{}, {} component{}",
273 report.parts,
274 plural(report.parts),
275 report.instances,
276 plural(report.instances)
277 );
278 if report.baked_nonrigid > 0 {
279 message.push_str(&format!(
280 " ({} mirrored instance{} baked)",
281 report.baked_nonrigid,
282 plural(report.baked_nonrigid)
283 ));
284 }
285 if report.failed_products > 0 {
286 message.push_str(&format!(
287 "; {} product{} could not be built",
288 report.failed_products,
289 plural(report.failed_products)
290 ));
291 }
292 if report.flat_fallback {
293 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
294 }
295 if let Some(error) = &report.first_error {
296 message.push_str(&format!(" [{error}]"));
297 }
298 message
299}
300
301fn is_step_name(name: &str) -> bool {
306 let lower = name.to_ascii_lowercase();
307 lower.ends_with(".step") || lower.ends_with(".stp")
308}
309
310fn is_iges_name(name: &str) -> bool {
313 let lower = name.to_ascii_lowercase();
314 lower.ends_with(".iges") || lower.ends_with(".igs")
315}
316
317fn is_stl_name(name: &str) -> bool {
318 name.to_ascii_lowercase().ends_with(".stl")
319}
320
321fn is_obj_name(name: &str) -> bool {
322 name.to_ascii_lowercase().ends_with(".obj")
323}
324
325pub struct FileDialog {
328 explorer: FileExplorer,
330 name_buf: String,
332 status: String,
334 open: bool,
336 mode: Mode,
338 want_focus: bool,
340 pending_component_import: bool,
344 pending_close: Option<usize>,
346 pending_step_import: Option<PendingStepImport>,
351 save_generation: u64,
355 #[cfg(target_arch = "wasm32")]
357 hits: HashMap<String, egui::Rect>,
358}
359
360impl FileDialog {
361 pub fn new() -> Self {
364 Self {
365 explorer: FileExplorer::new(),
366 name_buf: String::new(),
367 status: String::new(),
368 open: false,
369 mode: Mode::Open,
370 want_focus: false,
371 pending_component_import: false,
372 pending_close: None,
373 pending_step_import: None,
374 save_generation: 0,
375 #[cfg(target_arch = "wasm32")]
376 hits: HashMap::new(),
377 }
378 }
379
380 pub fn save_generation(&self) -> u64 {
383 self.save_generation
384 }
385
386 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
392 match action {
393 FileAction::New => self.new_document(docs),
394 FileAction::Open => self.open_modal(Mode::Open),
395 FileAction::Save => match docs.active().name().map(str::to_string) {
396 Some(name) => {
398 let _ = self.save_to(docs, store, name);
399 }
400 None => self.dispatch(FileAction::SaveAs, docs, store),
402 },
403 FileAction::SaveAs => {
404 self.name_buf = self.effective_name(docs);
405 self.open_modal(Mode::SaveAs);
406 }
407 FileAction::Import => {
408 self.open_modal(Mode::Import);
409 }
410 FileAction::Export => {
411 self.name_buf = self.effective_name(docs);
412 self.open_modal(Mode::Export);
413 }
414 FileAction::ExportFlatPattern => {
415 self.name_buf = self.effective_name(docs);
416 self.open_modal(Mode::FlatPattern);
417 }
418 FileAction::InsertComponent => self.open_modal(Mode::InsertComponent),
423 }
424 }
425
426 fn open_modal(&mut self, mode: Mode) {
428 self.mode = mode;
429 self.open = true;
430 self.want_focus = true;
431 }
432
433 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
439 #[cfg(target_arch = "wasm32")]
440 self.hits.clear();
441
442 if let Some(imported) = store.take_import() {
450 self.cancel_pending_step_import(docs.engine_mut());
456 let name = imported.name;
457 let bytes = imported.bytes;
458 if is_step_name(&name) {
461 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
462 } else if is_iges_name(&name) {
463 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
464 } else if is_stl_name(&name) {
465 self.import_stl(docs.engine_mut(), &name, &bytes);
466 } else if is_obj_name(&name) {
467 self.import_obj(docs.engine_mut(), &name, &bytes);
468 } else if std::mem::take(&mut self.pending_component_import) {
469 match String::from_utf8(bytes) {
473 Ok(contents) => {
474 self.insert_component_document(docs.engine_mut(), &name, &contents)
475 }
476 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
477 }
478 } else {
479 match String::from_utf8(bytes) {
480 Ok(contents) => self.load_document(docs, &name, &contents),
481 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
482 }
483 }
484 self.close_after_import();
485 }
486
487 if !self.open {
488 return;
489 }
490
491 match self.mode {
492 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
493 Mode::SaveAs => self.show_save_as(ctx, docs, store),
494 Mode::Open => self.show_open(ctx, docs, store),
495 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
496 Mode::Export => self.show_export(ctx, docs, store),
497 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
498 Mode::InsertComponent => self.show_insert_component(ctx, docs.engine_mut(), store),
499 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
500 }
501 }
502
503 fn close_after_import(&mut self) {
509 if self.pending_step_import.is_some() {
510 self.open_modal(Mode::StepAssembly);
511 } else {
512 self.open = false;
513 }
514 }
515
516 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
521 if self.pending_step_import.take().is_some() {
522 state.discard_probed_step_assembly();
523 }
524 }
525
526 fn show_import(
529 &mut self,
530 ctx: &egui::Context,
531 state: &mut EngineState,
532 store: &dyn ModelStore,
533 ) {
534 const EXTENSIONS: &[&str] = &["step", "stp", "iges", "igs", "stl", "obj"];
535 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
536 ui.set_width(600.0);
537 ui.heading("Import CAD file");
538 ui.add_space(4.0);
539 let options = FileExplorerOptions {
540 hit_prefix: "import",
541 empty_label: "(no STEP, IGES, STL, or OBJ files)",
542 row_icon: "\u{1F5CE}",
543 current: None,
544 allow_delete: false,
545 allow_import: store.supports_file_interchange(),
546 import_label: "Upload\u{2026}",
547 import_hit: "import:upload",
548 show_cancel: true,
549 confirm_label: Some("Import"),
550 extensions: EXTENSIONS,
551 };
552 let output = self.explorer.show_store(ui, store, options);
553 self.record_explorer_hits(&output.hits);
554 if !self.status.is_empty() {
555 ui.add_space(4.0);
556 ui.weak(&self.status);
557 }
558 output
559 });
560 let should_close = modal.should_close();
561 let output = modal.inner;
562 if let Some(name) = output.activated {
563 match store.read_external_file(&name) {
564 Some(bytes) if is_step_name(&name) => {
565 self.import_text(state, &name, &bytes, Self::import_step)
566 }
567 Some(bytes) if is_iges_name(&name) => {
568 self.import_text(state, &name, &bytes, Self::import_iges)
569 }
570 Some(bytes) if is_stl_name(&name) => self.import_stl(state, &name, &bytes),
571 Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
572 Some(_) => self.status = format!("unsupported import file: {name}"),
573 None => self.status = format!("import failed: '{name}' not found"),
574 }
575 self.close_after_import();
576 } else if output.import {
577 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
578 Ok(()) => {
579 self.status = "choose a STEP, IGES, STL, or OBJ file\u{2026}".into();
580 self.open = false;
581 }
582 Err(e) => self.status = format!("import failed: {e}"),
583 }
584 } else if output.cancel || should_close {
585 self.open = false;
586 }
587 }
588
589 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
595 let dirty = docs.get(index).is_some_and(Document::is_dirty);
596 if dirty {
597 self.pending_close = Some(index);
598 self.open_modal(Mode::ConfirmClose);
599 } else {
600 docs.close(index);
601 }
602 }
603
604 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
608 let Some(title) = self
611 .pending_close
612 .and_then(|index| docs.get(index))
613 .map(Document::title)
614 else {
615 self.pending_close = None;
616 self.open = false;
617 return;
618 };
619 let mut discard = false;
620 let mut cancel = false;
621 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
622 ui.set_width(340.0);
623 ui.heading("Discard unsaved changes?");
624 ui.add_space(4.0);
625 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
626 ui.add_space(6.0);
627 ui.horizontal(|ui| {
628 let d = ui.button("Discard and close");
629 self.hit("confirm:discard", &d);
630 if d.clicked() {
631 discard = true;
632 }
633 let c = ui.button("Cancel");
634 self.hit("confirm:cancel", &c);
635 if c.clicked() {
636 cancel = true;
637 }
638 });
639 });
640 if discard {
641 if let Some(index) = self.pending_close.take() {
642 docs.close(index);
643 }
644 self.open = false;
645 } else if cancel || modal.should_close() {
646 self.pending_close = None;
647 self.open = false;
648 }
649 }
650
651 fn show_step_assembly(
670 &mut self,
671 ctx: &egui::Context,
672 state: &mut EngineState,
673 store: &dyn ModelStore,
674 ) {
675 let Some(pending) = self.pending_step_import.as_ref() else {
678 self.open = false;
679 return;
680 };
681 let name = pending.name.clone();
682 let probe = pending.probe;
683 let mut flatten = pending.flatten;
684 let mut choice: Option<StepChoice> = None;
685 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
686 ui.set_width(360.0);
687 ui.heading(format!("\"{name}\" contains an assembly"));
688 ui.add_space(4.0);
689 ui.label(format!(
690 "{} part{}, {} instance{}{}.",
691 probe.parts,
692 plural(probe.parts),
693 probe.instances,
694 plural(probe.instances),
695 match probe.nested_depth {
696 0 | 1 => String::new(),
697 depth => format!(", {depth} levels deep"),
698 }
699 ));
700 if probe.nested_depth > 1 {
701 ui.add_space(4.0);
702 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
703 self.hit("stepassembly:flatten", &check);
704 check.on_hover_text(
705 "Off: each sub-assembly becomes one rigid component you can \
706 expand in the structure tree.\nOn: every part is placed \
707 directly in this document at its world position.",
708 );
709 }
710 ui.add_space(6.0);
711 ui.horizontal(|ui| {
712 let a = ui.button("Import as assembly");
713 self.hit("stepassembly:assembly", &a);
714 if a.clicked() {
715 choice = Some(StepChoice::Assembly);
716 }
717 let b = ui.button("Import as bodies");
718 self.hit("stepassembly:bodies", &b);
719 if b.clicked() {
720 choice = Some(StepChoice::Bodies);
721 }
722 let c = ui.button("Cancel");
723 self.hit("stepassembly:cancel", &c);
724 if c.clicked() {
725 choice = Some(StepChoice::Cancel);
726 }
727 });
728 });
729 if let Some(pending) = self.pending_step_import.as_mut() {
731 pending.flatten = flatten;
732 }
733 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
736 let Some(choice) = choice else { return };
737 match choice {
738 StepChoice::Assembly => self.step_assembly_import(state, store),
739 StepChoice::Bodies => self.step_assembly_bodies(state),
740 StepChoice::Cancel => self.step_assembly_cancel(state),
741 }
742 self.open = false;
743 }
744
745 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
747 let current = docs.active().name().map(str::to_string);
748 let mut do_save = false;
749 let mut cancel = false;
750 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
751 ui.set_width(600.0);
752 ui.heading("Save model as");
753 ui.add_space(4.0);
754 let options = FileExplorerOptions {
755 hit_prefix: "saveas:file",
756 empty_label: "(no saved models)",
757 row_icon: "\u{1F5CE}",
758 current: current.as_deref(),
759 allow_delete: false,
760 allow_import: false,
761 import_label: "",
762 import_hit: "saveas:upload",
763 show_cancel: false,
764 confirm_label: None,
765 extensions: &["BREP.json", "json"],
766 };
767 let output = self.explorer.show_store(ui, store, options);
768 self.record_explorer_hits(&output.hits);
769 if let Some(name) = output.picked.or(output.activated) {
773 self.name_buf = model_display_name(&name);
774 }
775 ui.label("File name");
776 let field = ui.add(
777 egui::TextEdit::singleline(&mut self.name_buf)
778 .hint_text("model name")
779 .desired_width(f32::INFINITY),
780 );
781 self.hit("field:name", &field);
782 if self.want_focus {
783 field.request_focus();
784 self.want_focus = false;
785 }
786 let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
787 ui.add_space(6.0);
788 ui.horizontal(|ui| {
789 let save = ui.button("Save");
790 self.hit("save", &save);
791 if save.clicked() || enter {
792 do_save = true;
793 }
794 let c = ui.button("Cancel");
795 self.hit("cancel", &c);
796 if c.clicked() {
797 cancel = true;
798 }
799 });
800 if !self.status.is_empty() {
801 ui.add_space(4.0);
802 ui.weak(&self.status);
803 }
804 });
805 if do_save {
806 let name = self.name_buf.clone();
807 if self.save_to_browser(docs, store, name) {
808 self.open = false;
809 }
810 } else if cancel || modal.should_close() {
811 self.open = false;
812 }
813 }
814
815 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
822 let has_components = !docs.engine().component_ids().is_empty();
827 let mut chosen: Option<&'static str> = None;
828 let mut bom_chosen: Option<&'static str> = None;
829 let mut cancel = false;
830 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
831 ui.set_width(320.0);
832 ui.heading("Export model");
833 ui.add_space(4.0);
834 let field = ui.add(
835 egui::TextEdit::singleline(&mut self.name_buf)
836 .hint_text("file name")
837 .desired_width(f32::INFINITY),
838 );
839 self.hit("field:name", &field);
840 if self.want_focus {
841 field.request_focus();
842 self.want_focus = false;
843 }
844 ui.add_space(6.0);
845 ui.horizontal(|ui| {
846 let step = ui.button("STEP (.step)");
847 self.hit("export:step", &step);
848 if step.clicked() {
849 chosen = Some("step");
850 }
851 let iges = ui.button("IGES (.igs)");
852 self.hit("export:iges", &iges);
853 if iges.clicked() {
854 chosen = Some("iges");
855 }
856 let stl = ui.button("STL (.stl)");
857 self.hit("export:stl", &stl);
858 if stl.clicked() {
859 chosen = Some("stl");
860 }
861 let json = ui.button("JSON (.BREP.json)");
865 self.hit("export:json", &json);
866 if json.clicked() {
867 chosen = Some("json");
868 }
869 let c = ui.button("Cancel");
870 self.hit("cancel", &c);
871 if c.clicked() {
872 cancel = true;
873 }
874 });
875 ui.add_space(4.0);
876 ui.horizontal(|ui| {
877 let csv = ui.add_enabled(has_components, egui::Button::new("BOM (CSV)"));
881 self.hit("export:bomcsv", &csv);
882 if csv.clicked() {
883 bom_chosen = Some("csv");
884 }
885 let json = ui.add_enabled(has_components, egui::Button::new("BOM (JSON)"));
886 self.hit("export:bomjson", &json);
887 if json.clicked() {
888 bom_chosen = Some("json");
889 }
890 });
891 if !self.status.is_empty() {
892 ui.add_space(4.0);
893 ui.weak(&self.status);
894 }
895 });
896 if let Some(format) = chosen {
897 if self.export_as(docs, store, format) {
898 self.open = false;
899 }
900 } else if let Some(format) = bom_chosen {
901 if self.export_bom_as(docs, store, format) {
902 self.open = false;
903 }
904 } else if cancel || modal.should_close() {
905 self.open = false;
906 }
907 }
908
909 fn show_flat_pattern(
915 &mut self,
916 ctx: &egui::Context,
917 docs: &mut Documents,
918 store: &dyn ModelStore,
919 ) {
920 let mut chosen: Option<&'static str> = None;
921 let mut cancel = false;
922 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
923 ui.set_width(340.0);
924 ui.heading("Export flat pattern");
925 ui.add_space(2.0);
926 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
927 ui.add_space(4.0);
928 let field = ui.add(
929 egui::TextEdit::singleline(&mut self.name_buf)
930 .hint_text("file name")
931 .desired_width(f32::INFINITY),
932 );
933 self.hit("field:name", &field);
934 if self.want_focus {
935 field.request_focus();
936 self.want_focus = false;
937 }
938 ui.add_space(6.0);
939 ui.horizontal(|ui| {
940 let dxf = ui.button("DXF (.dxf)");
941 self.hit("flat:dxf", &dxf);
942 if dxf.clicked() {
943 chosen = Some("dxf");
944 }
945 let svg = ui.button("SVG (.svg)");
946 self.hit("flat:svg", &svg);
947 if svg.clicked() {
948 chosen = Some("svg");
949 }
950 let c = ui.button("Cancel");
951 self.hit("cancel", &c);
952 if c.clicked() {
953 cancel = true;
954 }
955 });
956 if !self.status.is_empty() {
957 ui.add_space(4.0);
958 ui.weak(&self.status);
959 }
960 });
961 if let Some(format) = chosen {
962 if self.export_flat_pattern_as(docs, store, format) {
963 self.open = false;
964 }
965 } else if cancel || modal.should_close() {
966 self.open = false;
967 }
968 }
969
970 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
973 let current = docs.active().name().map(str::to_string);
974 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
975 ui.set_width(600.0);
976 ui.heading("Open model");
977 ui.add_space(4.0);
978 let mut options = FileExplorerOptions::open(current.as_deref());
979 options.allow_import = store.supports_file_interchange();
980 let output = self.explorer.show_store(ui, store, options);
981 self.record_explorer_hits(&output.hits);
982 if !self.status.is_empty() {
983 ui.add_space(4.0);
984 ui.weak(&self.status);
985 }
986 output
987 });
988 let should_close = modal.should_close();
989 let output = modal.inner;
990 if let Some(name) = output.activated {
991 self.open_document(docs, store, &name);
992 self.open = false;
993 } else if let Some(name) = output.remove {
994 let _ = store.remove(&name);
995 self.status = format!("removed {name}");
996 if let Some(index) = docs.index_of(&name) {
1000 if let Some(doc) = docs.get_mut(index) {
1001 doc.set_name(None);
1002 }
1003 }
1004 } else if output.import {
1005 match store.begin_import() {
1008 Ok(()) => {
1009 self.status = "choose a file…".into();
1010 self.open = false;
1011 }
1012 Err(e) => self.status = format!("import failed: {e}"),
1013 }
1014 } else if output.cancel || should_close {
1015 self.open = false;
1016 }
1017 }
1018
1019 fn show_insert_component(
1025 &mut self,
1026 ctx: &egui::Context,
1027 state: &mut EngineState,
1028 store: &dyn ModelStore,
1029 ) {
1030 let library = state.parts_library_names();
1031 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1032 ui.set_width(600.0);
1033 ui.heading("Insert component");
1034 ui.add_space(4.0);
1035 let mut chosen_library = None;
1036 if !library.is_empty() {
1037 ui.weak("In this document (parts library)");
1038 for name in &library {
1039 let row = ui.add_sized(
1040 egui::vec2(ui.available_width(), 18.0),
1041 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}")).frame(false),
1042 );
1043 self.hit(&format!("insert:lib:{name}"), &row);
1044 if row.clicked() {
1045 chosen_library = Some(name.clone());
1046 }
1047 }
1048 ui.add_space(4.0);
1049 }
1050 let options = FileExplorerOptions {
1051 hit_prefix: "insert:model",
1052 empty_label: "(no saved models)",
1053 row_icon: "\u{1F5CE}",
1054 current: None,
1055 allow_delete: false,
1056 allow_import: store.supports_file_interchange(),
1057 import_label: "Upload\u{2026}",
1058 import_hit: "insert:upload",
1059 show_cancel: true,
1060 confirm_label: Some("Insert"),
1061 extensions: &["BREP.json", "json"],
1062 };
1063 let output = self.explorer.show_store(ui, store, options);
1064 self.record_explorer_hits(&output.hits);
1065 if !self.status.is_empty() {
1066 ui.add_space(4.0);
1067 ui.weak(&self.status);
1068 }
1069 (chosen_library, output)
1070 });
1071 let should_close = modal.should_close();
1072 let (chosen_library, output) = modal.inner;
1073 if let Some(part_name) = chosen_library {
1074 match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1076 Ok(id) => {
1077 self.status = format!("inserted {part_name} ({id})");
1078 self.open = false;
1079 }
1080 Err(e) => self.status = format!("insert failed: {e}"),
1081 }
1082 } else if let Some(name) = output.activated {
1083 match store.read(&name) {
1084 Some(contents) => {
1085 self.insert_component_document(state, &name, &contents);
1086 self.open = false;
1087 }
1088 None => self.status = format!("insert failed: '{name}' not found"),
1089 }
1090 } else if output.import {
1091 match store.begin_import() {
1092 Ok(()) => {
1093 self.pending_component_import = true;
1095 self.status = "choose a part file…".into();
1096 self.open = false;
1097 }
1098 Err(e) => self.status = format!("insert failed: {e}"),
1099 }
1100 } else if output.cancel || should_close {
1101 self.open = false;
1102 }
1103 }
1104
1105 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1113 let display = model_display_name(name);
1114 match state.insert_component(ComponentInsert::New {
1115 name: &display,
1116 source_key: name,
1117 source_signature: &document_signature(contents),
1118 document_json: contents,
1119 }) {
1120 Ok(id) => self.status = format!("inserted {display} ({id})"),
1121 Err(e) => self.status = format!("insert failed: {e}"),
1122 }
1123 }
1124
1125 fn effective_name(&self, docs: &Documents) -> String {
1130 let field = self.name_buf.trim();
1131 if !field.is_empty() {
1132 field.to_string()
1133 } else {
1134 docs.active()
1135 .name()
1136 .map(str::to_string)
1137 .unwrap_or_else(|| "untitled".into())
1138 }
1139 }
1140
1141 fn new_document(&mut self, docs: &mut Documents) {
1144 let mut engine = docs.spawn_engine();
1145 let _ = engine.set_history_json(EMPTY_DOCUMENT);
1146 docs.open_document(Document::new(engine));
1147 self.name_buf.clear();
1148 self.status = "new (empty) model".into();
1149 }
1150
1151 fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
1162 let display = model_display_name(name);
1163 let taken = docs.iter().enumerate().any(|(index, doc)| {
1164 index != docs.active_index()
1165 && doc
1166 .name()
1167 .is_some_and(|open| model_display_name(open) == display)
1168 });
1169 if taken {
1170 self.status = format!("'{display}' is already open in another tab");
1171 }
1172 !taken
1173 }
1174
1175 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
1178 let name = name.trim().to_string();
1179 if name.is_empty() {
1180 self.status = "enter a name to save".into();
1181 return false;
1182 }
1183 if !self.name_is_free(docs, &name) {
1184 return false;
1185 }
1186 match store.write(&name, &docs.engine().history_request_json()) {
1187 Ok(()) => {
1188 self.name_buf = model_display_name(&name);
1191 let doc = docs.active_mut();
1192 doc.set_name(Some(name.clone()));
1193 doc.mark_clean();
1194 self.save_generation += 1;
1195 self.status = format!("saved {name}");
1196 true
1197 }
1198 Err(e) => {
1199 self.status = format!("save failed: {e}");
1200 false
1201 }
1202 }
1203 }
1204
1205 fn save_to_browser(
1209 &mut self,
1210 docs: &mut Documents,
1211 store: &dyn ModelStore,
1212 name: String,
1213 ) -> bool {
1214 let name = name.trim().to_string();
1215 if name.is_empty() {
1216 self.status = "enter a name to save".into();
1217 return false;
1218 }
1219 if !self.name_is_free(docs, &name) {
1220 return false;
1221 }
1222 match store.browser_write(&name, &docs.engine().history_request_json()) {
1223 Ok(identity) => {
1224 self.name_buf = model_display_name(&identity);
1225 let doc = docs.active_mut();
1226 doc.set_name(Some(identity.clone()));
1227 doc.mark_clean();
1228 self.save_generation += 1;
1229 self.status = format!("saved {identity}");
1230 true
1231 }
1232 Err(e) => {
1233 self.status = format!("save failed: {e}");
1234 false
1235 }
1236 }
1237 }
1238
1239 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1243 if docs.focus_named(name) {
1244 self.status = format!("{name} is already open");
1245 return;
1246 }
1247 match store.read(name) {
1248 Some(contents) => self.load_document(docs, name, &contents),
1249 None => self.status = format!("open failed: '{name}' not found"),
1250 }
1251 }
1252
1253 fn export_as(&mut self, docs: &Documents, store: &dyn ModelStore, format: &str) -> bool {
1260 let name = self.effective_name(docs);
1261 let state = docs.engine();
1262 let (text, ext) = match format {
1263 "stl" => (state.export_stl_text(), "stl"),
1264 "iges" => (state.export_iges_text(), "igs"),
1265 "json" => (Ok(state.history_request_json()), "BREP.json"),
1266 _ => (state.export_step_text(), "step"),
1267 };
1268 match text {
1269 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1270 Ok(()) => {
1271 self.status = format!("exported {name}.{ext}");
1272 true
1273 }
1274 Err(e) => {
1275 self.status = format!("export failed: {e}");
1276 false
1277 }
1278 },
1279 Err(e) => {
1280 self.status = format!("export failed: {e}");
1281 false
1282 }
1283 }
1284 }
1285
1286 fn export_flat_pattern_as(
1292 &mut self,
1293 docs: &mut Documents,
1294 store: &dyn ModelStore,
1295 format: &str,
1296 ) -> bool {
1297 let name = self.effective_name(docs);
1298 let state = docs.engine_mut();
1299 let (text, ext) = match format {
1300 "svg" => (state.export_flat_pattern_svg(), "svg"),
1301 _ => (state.export_flat_pattern_dxf(), "dxf"),
1302 };
1303 match text {
1304 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1305 Ok(()) => {
1306 self.status = format!("exported {name}.{ext}");
1307 true
1308 }
1309 Err(e) => {
1310 self.status = format!("flat-pattern export failed: {e}");
1311 false
1312 }
1313 },
1314 Err(e) => {
1315 self.status = format!("flat-pattern export failed: {e}");
1316 state.push_notice(format!("Flat pattern: {e}"));
1317 false
1318 }
1319 }
1320 }
1321
1322 fn export_bom_as(
1328 &mut self,
1329 docs: &mut Documents,
1330 store: &dyn ModelStore,
1331 format: &str,
1332 ) -> bool {
1333 let name = self.effective_name(docs);
1334 let state = docs.engine_mut();
1335 let (text, ext) = match format {
1336 "json" => (state.export_bom_json(), "bom.json"),
1337 _ => (state.export_bom_csv(), "bom.csv"),
1338 };
1339 match text {
1340 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1341 Ok(()) => {
1342 self.status = format!("exported {name}.{ext}");
1343 true
1344 }
1345 Err(e) => {
1346 self.status = format!("BOM export failed: {e}");
1347 false
1348 }
1349 },
1350 Err(e) => {
1351 self.status = format!("BOM export failed: {e}");
1352 state.push_notice(format!("BOM export: {e}"));
1353 false
1354 }
1355 }
1356 }
1357
1358 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1371 self.pending_step_import = None;
1376 match state.probe_step_assembly(contents) {
1377 Ok(Some(probe)) => {
1378 self.status = format!(
1379 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
1380 probe.parts,
1381 plural(probe.parts),
1382 probe.instances,
1383 plural(probe.instances)
1384 );
1385 self.pending_step_import = Some(PendingStepImport {
1386 name: name.to_string(),
1387 text: contents.to_string(),
1388 probe,
1389 flatten: false,
1392 });
1393 }
1394 _ => self.import_step_flat(state, name, contents),
1395 }
1396 }
1397
1398 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1404 match state.import_step_feature(contents) {
1405 Ok(_) => self.status = format!("imported {name}"),
1409 Err(e) => self.status = format!("import failed: {e}"),
1410 }
1411 }
1412
1413 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
1423 let Some(pending) = self.pending_step_import.take() else {
1424 return;
1425 };
1426 let was_assembly = state.history_has_assembly();
1429 let doc_name = step_document_name(&pending.name);
1430 let opts = StepAssemblyImport {
1433 nested: !pending.flatten,
1434 };
1435 let mut sink = StorePartSink::new(store, &doc_name);
1442 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
1443 Ok(report) => {
1444 if !was_assembly {
1445 self.enter_assembly_workbench(state);
1446 }
1447 for failure in &sink.failures {
1450 state.push_notice(format!("part not saved — {failure}"));
1451 }
1452 let saved = sink.written;
1453 let base = assembly_import_message(&pending.name, &report);
1454 match (saved, sink.failures.len()) {
1455 (0, _) => base,
1456 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
1457 (saved, failed) => {
1458 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
1459 }
1460 }
1461 }
1462 Err(error) => match state.import_step_feature(&pending.text) {
1463 Ok(_) => format!(
1464 "imported {} as bodies — the assembly structure could not be built ({error})",
1465 pending.name
1466 ),
1467 Err(flat) => format!("import failed: {flat}"),
1468 },
1469 };
1470 self.status = message.clone();
1473 state.push_notice(message);
1474 }
1475
1476 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
1480 let Some(pending) = self.pending_step_import.take() else {
1481 return;
1482 };
1483 state.discard_probed_step_assembly();
1484 self.import_step_flat(state, &pending.name, &pending.text);
1485 }
1486
1487 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
1489 let Some(pending) = self.pending_step_import.take() else {
1490 return;
1491 };
1492 state.discard_probed_step_assembly();
1493 self.status = format!("import cancelled: {}", pending.name);
1494 }
1495
1496 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
1508 if crate::workbench::panel_visible(
1509 &state.settings.workbench,
1510 crate::workbench::assembly::BOM_PANEL_ID,
1511 ) {
1512 return;
1513 }
1514 let _ = state.apply_settings_json(
1515 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
1516 .to_string(),
1517 );
1518 }
1519
1520 fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1523 match state.import_iges_feature(contents) {
1524 Ok(_) => self.status = format!("imported {name}"),
1526 Err(e) => self.status = format!("import failed: {e}"),
1527 }
1528 }
1529
1530 fn import_stl(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
1531 match state.import_stl_feature(contents) {
1532 Ok(_) => self.status = format!("reconstructing {name} in background…"),
1533 Err(e) => self.status = format!("import failed: {e}"),
1534 }
1535 }
1536
1537 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
1538 match state.import_obj_bytes_feature(contents) {
1539 Ok(_) => self.status = format!("reconstructing {name} in background…"),
1540 Err(e) => self.status = format!("import failed: {e}"),
1541 }
1542 }
1543
1544 fn import_text(
1545 &mut self,
1546 state: &mut EngineState,
1547 name: &str,
1548 bytes: &[u8],
1549 importer: fn(&mut Self, &mut EngineState, &str, &str),
1550 ) {
1551 match std::str::from_utf8(bytes) {
1552 Ok(contents) => importer(self, state, name, contents),
1553 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
1554 }
1555 }
1556
1557 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str) {
1562 let mut engine = docs.spawn_engine();
1563 match engine.load_model_and_fit(contents) {
1564 Ok(_) => {
1565 let mut doc = Document::new(engine);
1569 doc.set_name(Some(name.to_string()));
1570 docs.open_document(doc);
1571 self.name_buf = model_display_name(name);
1572 self.status = format!("opened {name}");
1573 }
1574 Err(e) => self.status = format!("open failed: {e}"),
1575 }
1576 }
1577
1578 #[cfg(target_arch = "wasm32")]
1583 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
1584 serde_json::json!({
1585 "name": docs.active().name(),
1586 "nameBuf": self.name_buf,
1587 "selected": self.explorer.selected(),
1588 "location": store.browser_location(),
1589 "dirty": docs.active().is_dirty(),
1590 "backend": store.backend_label(),
1591 "interchange": store.supports_file_interchange(),
1592 "list": store.list(),
1593 "status": self.status,
1594 "open": self.open,
1595 "mode": match self.mode {
1596 Mode::Open => "open",
1597 Mode::SaveAs => "saveas",
1598 Mode::ConfirmClose => "confirmclose",
1599 Mode::Export => "export",
1600 Mode::Import => "import",
1601 Mode::FlatPattern => "flatpattern",
1602 Mode::InsertComponent => "insertcomponent",
1603 Mode::StepAssembly => "stepassembly",
1604 },
1605 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
1609 serde_json::json!({
1610 "name": pending.name,
1611 "parts": pending.probe.parts,
1612 "instances": pending.probe.instances,
1613 "nestedDepth": pending.probe.nested_depth,
1614 "flatten": pending.flatten,
1617 })
1618 }),
1619 })
1620 .to_string()
1621 }
1622
1623 #[cfg(target_arch = "wasm32")]
1625 pub fn hits_json(&self) -> String {
1626 let map: serde_json::Map<String, Value> = self
1627 .hits
1628 .iter()
1629 .map(|(k, r)| {
1630 (
1631 k.clone(),
1632 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
1633 )
1634 })
1635 .collect();
1636 Value::Object(map).to_string()
1637 }
1638
1639 #[cfg(target_arch = "wasm32")]
1642 fn hit(&mut self, key: &str, resp: &egui::Response) {
1643 self.hits.insert(key.to_string(), resp.rect);
1644 }
1645 #[cfg(not(target_arch = "wasm32"))]
1646 #[inline]
1647 fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
1648
1649 #[cfg(target_arch = "wasm32")]
1650 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
1651 self.hits.extend(hits.iter().cloned());
1652 }
1653 #[cfg(not(target_arch = "wasm32"))]
1654 #[inline]
1655 fn record_explorer_hits(&mut self, _hits: &[(String, egui::Rect)]) {}
1656}
1657
1658#[cfg(all(test, not(target_arch = "wasm32")))]
1659mod tests {
1660 use super::*;
1661 use crate::store::native_test_store;
1662
1663 fn documents(history: &str) -> Documents {
1669 let mut docs = Documents::new(Box::new(EngineState::new));
1670 docs.engine_mut().set_history_json(history).unwrap();
1671 docs.active_mut().mark_clean();
1672 docs
1673 }
1674
1675 #[test]
1679 fn model_document_round_trips_through_store_and_engine() {
1680 let dir = std::env::temp_dir().join(format!("brep-app-file-{}", std::process::id()));
1682 let _ = std::fs::remove_dir_all(&dir);
1683 let store = native_test_store(dir.clone());
1684
1685 let mut engine = EngineState::new();
1687 let seed = r#"{"expressions":"","configurator":{},"features":[
1688 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1689 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1690 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1691 ]}"#;
1692 engine.set_history_json(seed).unwrap();
1693 let before = engine.history_request_json();
1694 store.write("roundtrip", &before).unwrap();
1695
1696 let mut reopened = EngineState::new();
1698 let contents = store.read("roundtrip").unwrap();
1699 reopened.load_model_and_fit(&contents).unwrap();
1700 let after = reopened.history_request_json();
1701
1702 assert_eq!(before, after, "request JSON round-trips through the store");
1703 assert_eq!(reopened.history_len(), 1);
1704 assert_eq!(reopened.scene.solids().len(), 1);
1705
1706 let _ = std::fs::remove_dir_all(&dir);
1707 }
1708
1709 #[test]
1718 fn saved_documents_remember_and_restore_the_workbench() {
1719 use serde_json::Value;
1720 let dir = std::env::temp_dir().join(format!("brep-app-wb-{}", std::process::id()));
1721 let _ = std::fs::remove_dir_all(&dir);
1722 let store = native_test_store(dir.clone());
1723
1724 let mut docs = documents(BOX_SEED);
1726 docs.engine_mut()
1727 .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
1728 .unwrap();
1729 let mut dialog = FileDialog::new();
1730 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1731 let stored: Value = serde_json::from_str(&store.read("part").unwrap()).unwrap();
1732 assert_eq!(
1733 stored.get("workbench").and_then(Value::as_str),
1734 Some("sheetMetal"),
1735 "Save embeds the active workbench id in the document"
1736 );
1737
1738 let mut reopened = documents(EMPTY_DOCUMENT);
1741 assert_eq!(
1742 reopened.engine().settings.workbench, "modeling",
1743 "fresh session default"
1744 );
1745 let mut viewer = FileDialog::new();
1746 viewer.open_document(&mut reopened, &*store, "part");
1747 assert_eq!(reopened.len(), 2, "Open adds a tab");
1748 assert_eq!(
1749 reopened.engine().settings.workbench, "sheetMetal",
1750 "Open restores the saved workbench"
1751 );
1752 assert!(
1753 !reopened.active().is_dirty(),
1754 "a just-opened document is clean"
1755 );
1756
1757 store.write("legacy", BOX_SEED).unwrap();
1759 viewer.open_document(&mut reopened, &*store, "legacy");
1760 assert!(viewer.status.starts_with("opened"), "legacy open succeeds");
1761 assert_eq!(
1762 reopened.engine().settings.workbench, "sheetMetal",
1763 "a legacy document inherits the session's workbench, unchanged"
1764 );
1765
1766 let mut bogus: Value = serde_json::from_str(BOX_SEED).unwrap();
1769 bogus["workbench"] = Value::String("conveyorBelts".into());
1770 store.write("bogus", &bogus.to_string()).unwrap();
1771 viewer.open_document(&mut reopened, &*store, "bogus");
1772 assert!(
1773 viewer.status.starts_with("opened"),
1774 "a bogus workbench id must not fail the open: {}",
1775 viewer.status
1776 );
1777 assert_eq!(
1778 reopened.engine().settings.workbench, "conveyorBelts",
1779 "raw id stored"
1780 );
1781 assert_eq!(
1782 crate::workbench::resolve(&reopened.engine().settings.workbench).id,
1783 "modeling",
1784 "…and it resolves to the default workbench wherever it is consumed"
1785 );
1786
1787 let _ = std::fs::remove_dir_all(&dir);
1788 }
1789
1790 #[test]
1793 fn dirty_flag_flips_on_edit_and_clears_on_save() {
1794 let dir = std::env::temp_dir().join(format!("brep-app-dirty-{}", std::process::id()));
1795 let _ = std::fs::remove_dir_all(&dir);
1796 let store = native_test_store(dir.clone());
1797
1798 let mut docs = documents(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"}}}]}"#);
1799 let mut dialog = FileDialog::new();
1800 assert!(!docs.active().is_dirty(), "seeded model is clean");
1801 assert_eq!(dialog.save_generation(), 0, "no saves yet");
1802
1803 docs.engine_mut()
1805 .update_feature_params(
1806 "Box",
1807 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"}}"#,
1808 )
1809 .unwrap();
1810 assert!(docs.active().is_dirty(), "edit marks dirty");
1811
1812 assert!(
1814 dialog.save_to(&mut docs, &*store, "m".into()),
1815 "save succeeds"
1816 );
1817 assert!(!docs.active().is_dirty(), "save clears dirty");
1818 assert_eq!(docs.active().name(), Some("m"));
1819 assert_eq!(
1820 dialog.save_generation(),
1821 1,
1822 "a successful save bumps the update-components staleness key"
1823 );
1824
1825 let _ = std::fs::remove_dir_all(&dir);
1826 }
1827
1828 #[test]
1833 fn new_adds_a_tab_and_only_closing_confirms() {
1834 let dir = std::env::temp_dir().join(format!("brep-app-newcfm-{}", std::process::id()));
1835 let _ = std::fs::remove_dir_all(&dir);
1836 let store = native_test_store(dir.clone());
1837
1838 let mut docs = documents(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"}}}]}"#);
1839 let mut dialog = FileDialog::new();
1840
1841 docs.engine_mut()
1844 .update_feature_params(
1845 "Box",
1846 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"}}"#,
1847 )
1848 .unwrap();
1849 assert!(docs.active().is_dirty());
1850 dialog.dispatch(FileAction::New, &mut docs, &*store);
1851 assert!(!dialog.open, "New never opens a modal");
1852 assert_eq!(docs.len(), 2, "New adds a tab");
1853 assert_eq!(docs.active_index(), 1, "…and focuses it");
1854 assert_eq!(docs.engine().history_len(), 0, "the new tab is empty");
1855 assert_eq!(
1856 docs.get(0).unwrap().engine.history_len(),
1857 1,
1858 "the dirty document is untouched in its own tab"
1859 );
1860
1861 dialog.request_close(&mut docs, 1);
1863 assert!(!dialog.open, "a clean close needs no confirmation");
1864 assert_eq!(docs.len(), 1);
1865
1866 dialog.request_close(&mut docs, 0);
1868 assert!(dialog.open && dialog.mode == Mode::ConfirmClose);
1869 assert_eq!(dialog.pending_close, Some(0));
1870 assert_eq!(docs.len(), 1, "nothing closed until the user discards");
1871
1872 let _ = std::fs::remove_dir_all(&dir);
1873 }
1874
1875 #[test]
1879 fn save_on_unnamed_falls_through_to_save_as_modal() {
1880 let dir = std::env::temp_dir().join(format!("brep-app-unnamed-{}", std::process::id()));
1881 let _ = std::fs::remove_dir_all(&dir);
1882 let store = native_test_store(dir.clone());
1883
1884 let mut docs = documents(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"}}}]}"#);
1885 let mut dialog = FileDialog::new();
1886
1887 dialog.dispatch(FileAction::Save, &mut docs, &*store);
1889 assert!(dialog.open, "unnamed Save opens the Save As modal");
1890 assert!(store.list().is_empty(), "nothing written yet");
1891
1892 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1894 assert_eq!(docs.active().name(), Some("part"));
1895 assert_eq!(store.list(), vec!["part".to_string()]);
1896
1897 let _ = std::fs::remove_dir_all(&dir);
1898 }
1899
1900 const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
1902 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1903 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1904 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1905 ]}"#;
1906
1907 #[test]
1910 fn step_names_route_to_the_import_lane() {
1911 assert!(is_step_name("part.step"));
1912 assert!(is_step_name("PART.STP"));
1913 assert!(is_stl_name("scan.STL"));
1914 assert!(is_obj_name("scan.obj"));
1915 assert!(!is_step_name("model"), "model docs arrive extension-less");
1916 assert!(!is_step_name("model.json"));
1917 assert!(!is_stl_name("model.json"));
1918 assert!(!is_obj_name("model.json"));
1919 }
1920
1921 #[test]
1925 fn export_dispatch_and_chooser_serialize_the_model() {
1926 let dir = std::env::temp_dir().join(format!("brep-app-export-{}", std::process::id()));
1927 let _ = std::fs::remove_dir_all(&dir);
1928 let store = native_test_store(dir.clone());
1929
1930 let mut docs = documents(BOX_SEED);
1931 let mut dialog = FileDialog::new();
1932
1933 dialog.dispatch(FileAction::Export, &mut docs, &*store);
1934 assert!(dialog.open && dialog.mode == Mode::Export, "Export opens the chooser");
1935 assert!(dialog.export_as(&docs, &*store, "step"), "STEP export ok");
1936 assert!(dialog.status.contains("exported"), "status: {}", dialog.status);
1937 assert!(dialog.export_as(&docs, &*store, "stl"), "STL export ok");
1938 assert!(dialog.export_as(&docs, &*store, "json"), "JSON recipe export ok");
1939 assert!(dialog.status.contains(".BREP.json"), "json status: {}", dialog.status);
1940 assert!(dir.join("untitled.step").is_file());
1941 assert!(dir.join("untitled.stl").is_file());
1942 assert!(dir.join("untitled.BREP.json").is_file());
1943
1944 let empty = documents(EMPTY_DOCUMENT);
1945 assert!(!dialog.export_as(&empty, &*store, "step"), "empty model does not export");
1946 assert!(dialog.status.contains("nothing to export"), "status: {}", dialog.status);
1947
1948 let _ = std::fs::remove_dir_all(&dir);
1949 }
1950
1951 #[test]
1952 fn import_dispatch_opens_the_common_explorer() {
1953 let dir = std::env::temp_dir().join(format!("brep-app-import-{}", std::process::id()));
1954 let _ = std::fs::remove_dir_all(&dir);
1955 let store = native_test_store(dir.clone());
1956 let mut docs = documents(EMPTY_DOCUMENT);
1957 let mut dialog = FileDialog::new();
1958
1959 dialog.dispatch(FileAction::Import, &mut docs, &*store);
1960 assert!(dialog.open && dialog.mode == Mode::Import);
1961
1962 let _ = std::fs::remove_dir_all(&dir);
1963 }
1964
1965 #[test]
1969 fn flat_pattern_dispatch_errors_and_toasts_without_sheet_metal() {
1970 let dir = std::env::temp_dir().join(format!("brep-app-flat-{}", std::process::id()));
1971 let _ = std::fs::remove_dir_all(&dir);
1972 let store = native_test_store(dir.clone());
1973
1974 let mut docs = documents(BOX_SEED);
1976 let mut dialog = FileDialog::new();
1977
1978 dialog.dispatch(FileAction::ExportFlatPattern, &mut docs, &*store);
1979 assert!(
1980 dialog.open && dialog.mode == Mode::FlatPattern,
1981 "flat-pattern action opens the DXF/SVG chooser"
1982 );
1983
1984 assert!(!dialog.export_flat_pattern_as(&mut docs, &*store, "dxf"));
1987 assert!(
1988 dialog.status.contains("no sheet-metal body"),
1989 "status names the missing body: {}",
1990 dialog.status
1991 );
1992 let notices = docs.engine_mut().take_notices();
1993 assert!(
1994 notices.iter().any(|n| n.contains("no sheet-metal body")),
1995 "the error is queued as a toast: {notices:?}"
1996 );
1997
1998 let _ = std::fs::remove_dir_all(&dir);
1999 }
2000
2001 #[test]
2007 fn bom_export_gates_on_components_and_exports_both_formats() {
2008 brep_render::brep_kernel::clear_history_cache();
2009 let dir = std::env::temp_dir().join(format!("brep-app-bom-{}", std::process::id()));
2010 let _ = std::fs::remove_dir_all(&dir);
2011 let store = native_test_store(dir.clone());
2012
2013 let mut docs = documents(BOX_SEED);
2015 let mut dialog = FileDialog::new();
2016 assert!(
2017 docs.engine().component_ids().is_empty(),
2018 "componentless doc → the chooser draws the BOM buttons disabled"
2019 );
2020 assert!(!dialog.export_bom_as(&mut docs, &*store, "csv"));
2021 assert!(
2022 dialog.status.contains("no components"),
2023 "status names the guard: {}",
2024 dialog.status
2025 );
2026 let notices = docs.engine_mut().take_notices();
2027 assert!(
2028 notices.iter().any(|n| n.contains("no components")),
2029 "the error is queued as a toast: {notices:?}"
2030 );
2031
2032 dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2035 dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2036 assert!(
2037 !docs.engine().component_ids().is_empty(),
2038 "components resident → the BOM buttons enable"
2039 );
2040 assert!(dialog.export_bom_as(&mut docs, &*store, "csv"), "CSV export ok");
2041 assert!(dialog.status.contains(".bom.csv"), "status: {}", dialog.status);
2042 assert!(dialog.export_bom_as(&mut docs, &*store, "json"), "JSON export ok");
2043 assert!(dialog.status.contains(".bom.json"), "status: {}", dialog.status);
2044
2045 let _ = std::fs::remove_dir_all(&dir);
2046 }
2047
2048 #[test]
2054 fn insert_component_document_adds_a_library_backed_instance() {
2055 brep_render::brep_kernel::clear_history_cache();
2056 let mut engine = EngineState::new();
2057 let mut dialog = FileDialog::new();
2058
2059 dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2061 assert!(
2062 dialog.status.contains("inserted box-part (ACOMP1)"),
2063 "status: {}",
2064 dialog.status
2065 );
2066 let params = engine
2067 .history
2068 .feature_params(engine.history.index_of("ACOMP1").unwrap())
2069 .unwrap();
2070 assert_eq!(params["partName"], "box-part", "effective library name");
2071 assert_eq!(params["isFixed"], true, "first instance explicitly fixed");
2072 assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2073 assert!(engine.scene.solid("ACOMP1:Box").is_some());
2075 let doc: serde_json::Value =
2078 serde_json::from_str(&engine.history_request_json()).unwrap();
2079 assert_eq!(
2080 doc["partsLibrary"]["box-part"]["sourceSignature"],
2081 document_signature(BOX_SEED),
2082 "insert writes the ONE signature fn's value"
2083 );
2084
2085 dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2088 let params2 = engine
2089 .history
2090 .feature_params(engine.history.index_of("ACOMP2").unwrap())
2091 .unwrap();
2092 assert_eq!(params2["partName"], "box-part");
2093 assert_eq!(params2["isFixed"], false);
2094 assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2095 }
2096
2097 #[test]
2102 fn open_document_adds_a_tab_or_focuses_the_open_one() {
2103 let dir = std::env::temp_dir().join(format!("brep-app-openpart-{}", std::process::id()));
2104 let _ = std::fs::remove_dir_all(&dir);
2105 let store = native_test_store(dir.clone());
2106 store.write("part", BOX_SEED).unwrap();
2107 store.write("other", BOX_SEED).unwrap();
2108
2109 let mut docs = documents(BOX_SEED);
2111 docs.engine_mut()
2112 .update_feature_params(
2113 "Box",
2114 r#"{"id":"Box","sizeX":9.0,"sizeY":8.0,"sizeZ":8.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
2115 )
2116 .unwrap();
2117 assert!(docs.active().is_dirty());
2118 let mut dialog = FileDialog::new();
2119
2120 dialog.open_document(&mut docs, &*store, "part");
2121 assert!(!dialog.open, "opening never raises a modal");
2122 assert_eq!(docs.len(), 2, "a tab was added");
2123 assert_eq!(docs.active().name(), Some("part"));
2124 assert!(!docs.active().is_dirty(), "freshly opened is clean");
2125 assert!(
2126 docs.get(0).unwrap().is_dirty(),
2127 "the dirty document is still open and still dirty"
2128 );
2129
2130 dialog.open_document(&mut docs, &*store, "other");
2132 assert_eq!(docs.len(), 3);
2133 assert_eq!(docs.active_index(), 2);
2134
2135 dialog.open_document(&mut docs, &*store, "part");
2137 assert_eq!(docs.len(), 3, "no second tab for one file");
2138 assert_eq!(docs.active_index(), 1);
2139 assert!(dialog.status.contains("already open"), "status: {}", dialog.status);
2140
2141 dialog.open_document(&mut docs, &*store, "missing");
2143 assert_eq!(docs.len(), 3);
2144 assert!(dialog.status.contains("not found"), "status: {}", dialog.status);
2145
2146 let _ = std::fs::remove_dir_all(&dir);
2147 }
2148
2149 #[test]
2153 fn saving_onto_a_name_open_in_another_tab_is_refused() {
2154 let dir = std::env::temp_dir().join(format!("brep-app-dupname-{}", std::process::id()));
2155 let _ = std::fs::remove_dir_all(&dir);
2156 let store = native_test_store(dir.clone());
2157 store.write("part", BOX_SEED).unwrap();
2158
2159 let mut docs = documents(BOX_SEED);
2160 let mut dialog = FileDialog::new();
2161 dialog.open_document(&mut docs, &*store, "part");
2162 assert_eq!(docs.active_index(), 1);
2163
2164 docs.activate(0);
2168 assert!(!dialog.save_to(&mut docs, &*store, "part".into()));
2169 assert!(
2170 !dialog.save_to_browser(&mut docs, &*store, "part".into()),
2171 "the Save As lane refuses BEFORE it writes, never after"
2172 );
2173 assert!(
2174 dialog.status.contains("already open in another tab"),
2175 "status: {}",
2176 dialog.status
2177 );
2178 assert_eq!(docs.active().name(), None, "nothing claimed the name");
2179
2180 docs.activate(1);
2182 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
2183
2184 let _ = std::fs::remove_dir_all(&dir);
2185 }
2186
2187 #[test]
2191 fn import_step_appends_and_marks_dirty() {
2192 let dir = std::env::temp_dir().join(format!("brep-app-imp-{}", std::process::id()));
2193 let _ = std::fs::remove_dir_all(&dir);
2194 let store = native_test_store(dir.clone());
2195
2196 let mut source = EngineState::new();
2198 source.set_history_json(BOX_SEED).unwrap();
2199 let step = source.export_step_text().unwrap();
2200
2201 let mut docs = documents(EMPTY_DOCUMENT);
2203 let mut dialog = FileDialog::new();
2204 assert!(!docs.active().is_dirty(), "empty seed is clean");
2205 dialog.import_step(docs.engine_mut(), "part.step", &step);
2206 assert_eq!(docs.engine().history_len(), 1, "IMPORT3D feature appended");
2207 assert_eq!(
2208 docs.engine().scene.solids().len(),
2209 1,
2210 "the imported body is in the scene"
2211 );
2212 assert!(docs.active().is_dirty(), "an import is an edit");
2213 assert!(dialog.status.contains("imported"), "status: {}", dialog.status);
2214 let _ = store; let _ = std::fs::remove_dir_all(&dir);
2217 }
2218
2219 fn step_fixture(name: &str) -> String {
2229 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2230 .join("../BREP_kernel/tests/fixtures/step-import")
2231 .join(name);
2232 std::fs::read_to_string(&path)
2233 .unwrap_or_else(|error| panic!("read fixture {}: {error}", path.display()))
2234 }
2235
2236 const AS1: &str = "as1-ug-214.stp";
2238
2239 fn armed(engine: &mut EngineState) -> FileDialog {
2243 let mut dialog = FileDialog::new();
2244 dialog.import_step(engine, AS1, &step_fixture(AS1));
2245 let pending = dialog
2246 .pending_step_import
2247 .as_ref()
2248 .expect("as1-ug-214 carries a product structure, so the choice arms");
2249 assert_eq!(
2250 (pending.probe.parts, pending.probe.instances),
2251 (5, 18),
2252 "the prompt shows the counts the import will deliver"
2253 );
2254 assert_eq!(engine.history_len(), 0, "nothing is imported before the click");
2255 dialog
2256 }
2257
2258 fn stash_is_empty(engine: &mut EngineState) -> bool {
2262 engine
2263 .import_probed_step_assembly(
2264 "probe",
2265 StepAssemblyImport::default(),
2266 &mut brep_render::engine_state::EmbeddedOnly,
2267 )
2268 .is_err()
2269 }
2270
2271 #[test]
2276 fn imported_part_file_names_are_sanitized_and_never_collide() {
2277 assert_eq!(sanitize_file_stem("L Bracket"), "L_Bracket");
2278 assert_eq!(sanitize_file_stem("rod/assem"), "rod_assem", "no path escape");
2279 assert_eq!(
2280 sanitize_file_stem("bolt (mirrored)"),
2281 "bolt_mirrored",
2282 "the crate's own factor suffix survives readably"
2283 );
2284 assert_eq!(sanitize_file_stem("a///b"), "a_b", "runs collapse");
2285 assert_eq!(sanitize_file_stem(" "), "part", "never an empty name");
2286 assert_eq!(sanitize_file_stem("plate-1.2"), "plate-1.2", "kept as-is");
2287
2288 let store = crate::store::MemModelStore::new();
2289 let mut sink = StorePartSink::new(&store, "as1 ug");
2290 let first = sink.store_part("L Bracket", "{\"a\":1}").expect("written");
2292 let second = sink.store_part("L/Bracket", "{\"a\":2}").expect("written");
2293 assert_eq!(first, "as1_ug-L_Bracket");
2294 assert_eq!(second, "as1_ug-L_Bracket-2", "disambiguated, not overwritten");
2295 assert_eq!(store.read(&first).as_deref(), Some("{\"a\":1}"));
2296 assert_eq!(store.read(&second).as_deref(), Some("{\"a\":2}"));
2297 assert_eq!(sink.written, 2);
2298 assert!(sink.failures.is_empty());
2299 }
2300
2301 #[test]
2306 fn a_structureless_step_never_sees_the_dialog() {
2307 let mut source = EngineState::new();
2308 source.set_history_json(BOX_SEED).unwrap();
2309 let step = source.export_step_text().unwrap();
2310
2311 let mut engine = EngineState::new();
2312 let mut dialog = FileDialog::new();
2313 dialog.import_step(&mut engine, "part.step", &step);
2314
2315 assert!(
2316 dialog.pending_step_import.is_none(),
2317 "a part file must not arm the assembly choice"
2318 );
2319 dialog.close_after_import();
2320 assert!(!dialog.open, "nothing armed, so the modal closes");
2321 assert_eq!(engine.history_len(), 1, "one IMPORT3D feature, as before");
2322 assert!(
2323 engine.history_request_json().contains("stepText"),
2324 "the flat lane stores the STEP text, byte-for-byte as before"
2325 );
2326 assert!(!engine.history_has_assembly(), "no components");
2327 assert!(engine.parts_library_names().is_empty(), "no library entries");
2328 assert_eq!(dialog.status, "imported part.step");
2329 assert!(stash_is_empty(&mut engine), "a structureless probe stashes nothing");
2330 }
2331
2332 #[test]
2341 fn importing_as_an_assembly_reports_the_counts_and_enters_the_workbench() {
2342 let mut engine = EngineState::new();
2343 assert_eq!(engine.settings.workbench, "modeling", "fresh session default");
2344 let mut dialog = armed(&mut engine);
2345
2346 dialog.close_after_import();
2347 assert!(dialog.open && dialog.mode == Mode::StepAssembly, "the modal opens");
2348 assert!(
2349 !dialog.pending_step_import.as_ref().unwrap().flatten,
2350 "the §3.9 box defaults OFF — an import keeps the tree"
2351 );
2352
2353 let store = crate::store::MemModelStore::new();
2354 dialog.step_assembly_import(&mut engine, &store);
2355
2356 assert_eq!(
2361 dialog.status,
2362 "imported as1-ug-214.stp \u{2014} 3 parts, 4 components; saved 8 part file(s)",
2363 "the §3.9 outcome line, with the report's real numbers + the writes"
2364 );
2365 let saved = store.list();
2368 assert_eq!(saved.len(), 8, "one file per distinct part: {saved:?}");
2369 assert!(
2370 saved.iter().all(|name| name.starts_with("as1-ug-")),
2371 "each file carries the assembly name, so one import groups in a \
2372 listing without a folder convention `browser_write` cannot express: \
2373 {saved:?}"
2374 );
2375 for name in engine.parts_library_names() {
2378 let (key, signature) = engine.part_source(&name).expect("a library entry");
2379 assert!(!key.is_empty(), "'{name}' carries a real sourceKey");
2380 let stored = store.read(&key).unwrap_or_else(|| panic!("'{key}' written"));
2381 assert_eq!(
2382 signature,
2383 document_signature(&stored),
2384 "'{name}': entry signature and stored file describe one thing"
2385 );
2386 }
2387 let notices = engine.take_notices();
2388 assert!(
2389 notices.iter().any(|notice| notice == &dialog.status),
2390 "the outcome is toasted too — the status line is invisible once the \
2391 modal closes: {notices:?}"
2392 );
2393 assert_eq!(
2394 engine.parts_library_names().len(),
2395 3,
2396 "one library entry per unique ROOT-level product: plate, lb_assem, \
2397 rod_assem — the deeper parts live in THEIR documents' libraries"
2398 );
2399 assert_eq!(
2400 component_part_names(&engine).len(),
2401 4,
2402 "one ACOMP per root occurrence — a sub-assembly is ONE component"
2403 );
2404 assert!(engine.history_has_assembly(), "the document is an assembly now");
2405 assert_eq!(
2406 engine.settings.workbench, "assembly",
2407 "an imported assembly switches to the workbench its panels are gated on"
2408 );
2409 assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2410 assert!(
2411 stash_is_empty(&mut engine),
2412 "the consume TAKES the parse — a second one is never a double insert"
2413 );
2414 }
2415
2416 #[test]
2422 fn flattening_sub_assemblies_places_every_leaf_in_this_document() {
2423 let mut engine = EngineState::new();
2424 let mut dialog = armed(&mut engine);
2425 dialog.pending_step_import.as_mut().unwrap().flatten = true;
2426
2427 let store = crate::store::MemModelStore::new();
2428 dialog.step_assembly_import(&mut engine, &store);
2429
2430 assert_eq!(
2431 dialog.status,
2432 "imported as1-ug-214.stp \u{2014} 5 parts, 18 components; saved 5 part file(s)",
2433 "flattened: the leaf counts the prompt showed, plus the writes"
2434 );
2435 assert_eq!(store.list().len(), 5, "one file per distinct part");
2438 assert_eq!(engine.parts_library_names().len(), 5);
2439 assert_eq!(component_part_names(&engine).len(), 18);
2440 let document: serde_json::Value =
2441 serde_json::from_str(&engine.history_request_json()).expect("document JSON");
2442 for (name, entry) in document["partsLibrary"].as_object().expect("library") {
2443 assert!(
2444 entry["document"]["partsLibrary"].is_null(),
2445 "'{name}' must be a leaf part — flattening leaves no nested library"
2446 );
2447 }
2448 }
2449
2450 #[test]
2455 fn importing_as_bodies_uses_the_flat_lane_and_drops_the_parse() {
2456 let mut engine = EngineState::new();
2457 let mut dialog = armed(&mut engine);
2458
2459 dialog.step_assembly_bodies(&mut engine);
2460
2461 assert_eq!(engine.history_len(), 1, "one IMPORT3D feature");
2462 assert!(
2463 engine.history_request_json().contains("stepText"),
2464 "the flat lane stores the STEP text"
2465 );
2466 assert!(engine.parts_library_names().is_empty(), "no library entries");
2467 assert!(!engine.history_has_assembly(), "no components");
2468 assert_eq!(engine.settings.workbench, "modeling", "no assembly, no switch");
2469 assert_eq!(dialog.status, "imported as1-ug-214.stp");
2470 assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2471 assert!(stash_is_empty(&mut engine), "the parse is dropped, not left resident");
2472 }
2473
2474 #[test]
2479 fn cancel_imports_nothing_and_discards_the_parse() {
2480 let mut engine = EngineState::new();
2481 let mut dialog = armed(&mut engine);
2482
2483 dialog.step_assembly_cancel(&mut engine);
2484
2485 assert_eq!(engine.history_len(), 0, "cancel imports nothing");
2486 assert!(engine.parts_library_names().is_empty(), "no library entries");
2487 assert_eq!(engine.settings.workbench, "modeling", "no switch");
2488 assert!(dialog.status.contains("cancelled"), "status: {}", dialog.status);
2489 assert!(dialog.pending_step_import.is_none());
2490 assert!(stash_is_empty(&mut engine), "Cancel drops the parse");
2491 }
2492
2493 #[test]
2498 fn a_new_upload_supersedes_an_armed_choice() {
2499 let mut source = EngineState::new();
2500 source.set_history_json(BOX_SEED).unwrap();
2501 let part = source.export_step_text().unwrap();
2502
2503 let mut engine = EngineState::new();
2505 let mut dialog = armed(&mut engine);
2506 dialog.import_step(&mut engine, "part.step", &part);
2507 assert!(
2508 dialog.pending_step_import.is_none(),
2509 "the prompt cannot outlive the parse the new probe dropped"
2510 );
2511 assert_eq!(engine.history_len(), 1, "the part file imported flat");
2512
2513 let mut other = EngineState::new();
2515 let mut dialog = armed(&mut other);
2516 dialog.cancel_pending_step_import(&mut other);
2517 assert!(dialog.pending_step_import.is_none());
2518 assert!(stash_is_empty(&mut other), "the superseded parse is discarded");
2519 dialog.close_after_import();
2520 assert!(!dialog.open, "nothing armed, so the import routing closes the modal");
2521 }
2522
2523 #[test]
2527 fn importing_an_assembly_leaves_the_all_workbench_alone() {
2528 let mut engine = EngineState::new();
2529 engine.apply_settings_json(r#"{"workbench":"all"}"#).unwrap();
2530 let mut dialog = armed(&mut engine);
2531
2532 dialog.step_assembly_import(&mut engine, &crate::store::MemModelStore::new());
2533
2534 assert_eq!(
2535 engine.settings.workbench, "all",
2536 "'All' already shows the assembly panels — never switch away from it"
2537 );
2538 assert_eq!(component_part_names(&engine).len(), 4, "the import still ran");
2539 }
2540
2541 fn component_part_names(state: &EngineState) -> Vec<String> {
2544 serde_json::from_str::<serde_json::Value>(&state.history_request_json())
2545 .expect("history JSON")["features"]
2546 .as_array()
2547 .expect("features array")
2548 .iter()
2549 .filter(|feature| feature["type"] == "ACOMP")
2550 .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2551 .collect()
2552 }
2553
2554 #[test]
2558 fn the_outcome_line_reports_exactly_what_happened() {
2559 let clean = StepAssemblyReport {
2560 parts: 5,
2561 instances: 18,
2562 ..StepAssemblyReport::default()
2563 };
2564 assert_eq!(
2565 assembly_import_message("as1.stp", &clean),
2566 "imported as1.stp \u{2014} 5 parts, 18 components"
2567 );
2568 assert_eq!(
2569 assembly_import_message(
2570 "one.stp",
2571 &StepAssemblyReport { parts: 1, instances: 1, ..clean.clone() }
2572 ),
2573 "imported one.stp \u{2014} 1 part, 1 component",
2574 "singulars, so the sentence is one a user believes"
2575 );
2576
2577 let messy = StepAssemblyReport {
2578 baked_nonrigid: 2,
2579 failed_products: 1,
2580 first_error: Some("body 7: unsupported surface".into()),
2581 ..clean.clone()
2582 };
2583 let line = assembly_import_message("mixed.stp", &messy);
2584 assert!(line.contains("(2 mirrored instances baked)"), "{line}");
2585 assert!(line.contains("1 product could not be built"), "{line}");
2586 assert!(line.contains("unsupported surface"), "{line}");
2587
2588 let fell_back = StepAssemblyReport { flat_fallback: true, ..clean };
2589 assert!(
2590 assembly_import_message("flat.stp", &fell_back).contains("the bodies came in flat"),
2591 "a user who asked for an assembly and got bodies must be told"
2592 );
2593 }
2594
2595 #[test]
2598 fn the_document_name_drops_the_step_extension() {
2599 assert_eq!(step_document_name("bracket-assy.step"), "bracket-assy");
2600 assert_eq!(step_document_name("BRACKET.STP"), "BRACKET");
2601 assert_eq!(step_document_name("/tmp/a/b/as1-ug-214.stp"), "as1-ug-214");
2602 assert_eq!(step_document_name("no-extension"), "no-extension");
2603 assert_eq!(step_document_name(".step"), ".step", "a bare extension is the name");
2604 }
2605}