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, StepProbeOutcome,
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
117struct PendingStepProbe {
123 id: u64,
125 name: String,
126 text: String,
128}
129
130#[derive(Clone, Copy, PartialEq, Eq)]
132enum StepChoice {
133 Assembly,
135 Bodies,
137 Cancel,
139}
140
141fn plural(count: usize) -> &'static str {
144 if count == 1 {
145 ""
146 } else {
147 "s"
148 }
149}
150
151fn step_document_name(file_name: &str) -> String {
155 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
156 let cut = base
157 .rfind('.')
158 .filter(|dot| is_step_name(&base[*dot..]))
159 .unwrap_or(base.len());
160 let stem = &base[..cut];
161 if stem.is_empty() {
162 base.to_string()
163 } else {
164 stem.to_string()
165 }
166}
167
168struct StorePartSink<'a> {
216 store: &'a dyn ModelStore,
217 prefix: String,
219 taken: std::collections::HashSet<String>,
223 written: usize,
224 failures: Vec<String>,
225}
226
227impl<'a> StorePartSink<'a> {
228 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
229 Self {
230 store,
231 prefix: sanitize_file_stem(prefix),
232 taken: std::collections::HashSet::new(),
233 written: 0,
234 failures: Vec::new(),
235 }
236 }
237}
238
239impl PartSink for StorePartSink<'_> {
240 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
241 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
242 let mut name = base.clone();
243 let mut suffix = 2;
244 while !self.taken.insert(name.clone()) {
245 name = format!("{base}-{suffix}");
246 suffix += 1;
247 }
248 match self.store.browser_write(&name, document_json) {
249 Ok(identity) => {
250 self.written += 1;
251 Some(identity)
252 }
253 Err(error) => {
254 self.failures.push(format!("{name}: {error}"));
255 None
256 }
257 }
258 }
259}
260
261fn sanitize_file_stem(name: &str) -> String {
267 let mut out = String::with_capacity(name.len());
268 for ch in name.chars() {
269 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
270 out.push(ch);
271 } else if !out.ends_with('_') {
272 out.push('_');
273 }
274 }
275 let trimmed = out.trim_matches('_');
276 if trimmed.is_empty() {
277 "part".to_string()
278 } else {
279 trimmed.to_string()
280 }
281}
282
283fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
284 let mut message = format!(
285 "imported {name} \u{2014} {} part{}, {} component{}",
286 report.parts,
287 plural(report.parts),
288 report.instances,
289 plural(report.instances)
290 );
291 if report.baked_nonrigid > 0 {
292 message.push_str(&format!(
293 " ({} mirrored instance{} baked)",
294 report.baked_nonrigid,
295 plural(report.baked_nonrigid)
296 ));
297 }
298 if report.failed_products > 0 {
299 message.push_str(&format!(
300 "; {} product{} could not be built",
301 report.failed_products,
302 plural(report.failed_products)
303 ));
304 }
305 if report.flat_fallback {
306 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
307 }
308 if let Some(error) = &report.first_error {
309 message.push_str(&format!(" [{error}]"));
310 }
311 message
312}
313
314fn is_step_name(name: &str) -> bool {
319 let lower = name.to_ascii_lowercase();
320 lower.ends_with(".step") || lower.ends_with(".stp")
321}
322
323fn is_iges_name(name: &str) -> bool {
326 let lower = name.to_ascii_lowercase();
327 lower.ends_with(".iges") || lower.ends_with(".igs")
328}
329
330fn is_stl_name(name: &str) -> bool {
331 name.to_ascii_lowercase().ends_with(".stl")
332}
333
334fn is_obj_name(name: &str) -> bool {
335 name.to_ascii_lowercase().ends_with(".obj")
336}
337
338pub struct FileDialog {
341 explorer: FileExplorer,
343 name_buf: String,
345 status: String,
347 open: bool,
349 mode: Mode,
351 want_focus: bool,
353 pending_component_import: bool,
357 pending_close: Option<usize>,
359 pending_step_import: Option<PendingStepImport>,
364 pending_step_probe: Option<PendingStepProbe>,
367 pending_stl_import: Option<(String, Vec<u8>)>,
368 save_generation: u64,
372 #[cfg(target_arch = "wasm32")]
374 hits: HashMap<String, egui::Rect>,
375}
376
377impl FileDialog {
378 pub fn new() -> Self {
381 Self {
382 explorer: FileExplorer::new(),
383 name_buf: String::new(),
384 status: String::new(),
385 open: false,
386 mode: Mode::Open,
387 want_focus: false,
388 pending_component_import: false,
389 pending_close: None,
390 pending_step_import: None,
391 pending_step_probe: None,
392 pending_stl_import: None,
393 save_generation: 0,
394 #[cfg(target_arch = "wasm32")]
395 hits: HashMap::new(),
396 }
397 }
398
399 pub fn save_generation(&self) -> u64 {
402 self.save_generation
403 }
404
405 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
411 match action {
412 FileAction::New => self.new_document(docs),
413 FileAction::Open => self.open_modal(Mode::Open),
414 FileAction::Save => match docs.active().name().map(str::to_string) {
415 Some(name) => {
417 let _ = self.save_to(docs, store, name);
418 }
419 None => self.dispatch(FileAction::SaveAs, docs, store),
421 },
422 FileAction::SaveAs => {
423 self.name_buf = self.effective_name(docs);
424 self.open_modal(Mode::SaveAs);
425 }
426 FileAction::Import => {
427 self.open_modal(Mode::Import);
428 }
429 FileAction::Export => {
430 self.name_buf = self.effective_name(docs);
431 self.open_modal(Mode::Export);
432 }
433 FileAction::ExportFlatPattern => {
434 self.name_buf = self.effective_name(docs);
435 self.open_modal(Mode::FlatPattern);
436 }
437 FileAction::InsertComponent => self.open_modal(Mode::InsertComponent),
442 }
443 }
444
445 fn open_modal(&mut self, mode: Mode) {
447 self.mode = mode;
448 self.open = true;
449 self.want_focus = true;
450 }
451
452 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
458 #[cfg(target_arch = "wasm32")]
459 self.hits.clear();
460
461 self.poll_step_probe(docs.engine_mut());
464
465 if let Some(imported) = store.take_import() {
473 self.cancel_pending_step_import(docs.engine_mut());
479 let name = imported.name;
480 let bytes = imported.bytes;
481 if is_step_name(&name) {
484 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
485 } else if is_iges_name(&name) {
486 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
487 } else if is_stl_name(&name) {
488 self.stage_stl_preview(name, bytes);
489 } else if is_obj_name(&name) {
490 self.import_obj(docs.engine_mut(), &name, &bytes);
491 } else if std::mem::take(&mut self.pending_component_import) {
492 match String::from_utf8(bytes) {
496 Ok(contents) => {
497 self.insert_component_document(docs.engine_mut(), &name, &contents)
498 }
499 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
500 }
501 } else {
502 match String::from_utf8(bytes) {
503 Ok(contents) => self.load_document(docs, &name, &contents),
504 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
505 }
506 }
507 self.close_after_import();
508 }
509
510 if !self.open {
511 return;
512 }
513
514 match self.mode {
515 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
516 Mode::SaveAs => self.show_save_as(ctx, docs, store),
517 Mode::Open => self.show_open(ctx, docs, store),
518 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
519 Mode::Export => self.show_export(ctx, docs, store),
520 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
521 Mode::InsertComponent => self.show_insert_component(ctx, docs.engine_mut(), store),
522 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
523 }
524 }
525
526 fn close_after_import(&mut self) {
532 if self.pending_step_import.is_some() {
533 self.open_modal(Mode::StepAssembly);
534 } else {
535 self.open = false;
536 }
537 }
538
539 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
544 if self.pending_step_import.take().is_some() {
545 state.discard_probed_step_assembly();
546 }
547 }
548
549 fn show_import(
552 &mut self,
553 ctx: &egui::Context,
554 state: &mut EngineState,
555 store: &dyn ModelStore,
556 ) {
557 const EXTENSIONS: &[&str] = &["step", "stp", "iges", "igs", "stl", "obj"];
558 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
559 ui.set_width(600.0);
560 ui.heading("Import CAD file");
561 ui.add_space(4.0);
562 let options = FileExplorerOptions {
563 hit_prefix: "import",
564 empty_label: "(no STEP, IGES, STL, or OBJ files)",
565 row_icon: "\u{1F5CE}",
566 current: None,
567 allow_delete: false,
568 allow_import: store.supports_file_interchange(),
569 import_label: "Upload\u{2026}",
570 import_hit: "import:upload",
571 show_cancel: true,
572 confirm_label: Some("Import"),
573 extensions: EXTENSIONS,
574 };
575 let output = self.explorer.show_store(ui, store, options);
576 self.record_explorer_hits(&output.hits);
577 if !self.status.is_empty() {
578 ui.add_space(4.0);
579 ui.weak(&self.status);
580 }
581 output
582 });
583 let should_close = modal.should_close();
584 let output = modal.inner;
585 if let Some(name) = output.activated {
586 match store.read_external_file(&name) {
587 Some(bytes) if is_step_name(&name) => {
588 self.import_text(state, &name, &bytes, Self::import_step)
589 }
590 Some(bytes) if is_iges_name(&name) => {
591 self.import_text(state, &name, &bytes, Self::import_iges)
592 }
593 Some(bytes) if is_stl_name(&name) => self.stage_stl_preview(name, bytes),
594 Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
595 Some(_) => self.status = format!("unsupported import file: {name}"),
596 None => self.status = format!("import failed: '{name}' not found"),
597 }
598 self.close_after_import();
599 } else if output.import {
600 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
601 Ok(()) => {
602 self.status = "choose a STEP, IGES, STL, or OBJ file\u{2026}".into();
603 self.open = false;
604 }
605 Err(e) => self.status = format!("import failed: {e}"),
606 }
607 } else if output.cancel || should_close {
608 self.open = false;
609 }
610 }
611
612 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
618 let dirty = docs.get(index).is_some_and(Document::is_dirty);
619 if dirty {
620 self.pending_close = Some(index);
621 self.open_modal(Mode::ConfirmClose);
622 } else {
623 docs.close(index);
624 }
625 }
626
627 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
631 let Some(title) = self
634 .pending_close
635 .and_then(|index| docs.get(index))
636 .map(Document::title)
637 else {
638 self.pending_close = None;
639 self.open = false;
640 return;
641 };
642 let mut discard = false;
643 let mut cancel = false;
644 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
645 ui.set_width(340.0);
646 ui.heading("Discard unsaved changes?");
647 ui.add_space(4.0);
648 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
649 ui.add_space(6.0);
650 ui.horizontal(|ui| {
651 let d = ui.button("Discard and close");
652 self.hit("confirm:discard", &d);
653 if d.clicked() {
654 discard = true;
655 }
656 let c = ui.button("Cancel");
657 self.hit("confirm:cancel", &c);
658 if c.clicked() {
659 cancel = true;
660 }
661 });
662 });
663 if discard {
664 if let Some(index) = self.pending_close.take() {
665 docs.close(index);
666 }
667 self.open = false;
668 } else if cancel || modal.should_close() {
669 self.pending_close = None;
670 self.open = false;
671 }
672 }
673
674 fn show_step_assembly(
693 &mut self,
694 ctx: &egui::Context,
695 state: &mut EngineState,
696 store: &dyn ModelStore,
697 ) {
698 let Some(pending) = self.pending_step_import.as_ref() else {
701 self.open = false;
702 return;
703 };
704 let name = pending.name.clone();
705 let probe = pending.probe;
706 let mut flatten = pending.flatten;
707 let mut choice: Option<StepChoice> = None;
708 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
709 ui.set_width(360.0);
710 ui.heading(format!("\"{name}\" contains an assembly"));
711 ui.add_space(4.0);
712 ui.label(format!(
713 "{} part{}, {} instance{}{}.",
714 probe.parts,
715 plural(probe.parts),
716 probe.instances,
717 plural(probe.instances),
718 match probe.nested_depth {
719 0 | 1 => String::new(),
720 depth => format!(", {depth} levels deep"),
721 }
722 ));
723 if probe.nested_depth > 1 {
724 ui.add_space(4.0);
725 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
726 self.hit("stepassembly:flatten", &check);
727 check.on_hover_text(
728 "Off: each sub-assembly becomes one rigid component you can \
729 expand in the structure tree.\nOn: every part is placed \
730 directly in this document at its world position.",
731 );
732 }
733 ui.add_space(6.0);
734 ui.horizontal(|ui| {
735 let a = ui.button("Import as assembly");
736 self.hit("stepassembly:assembly", &a);
737 if a.clicked() {
738 choice = Some(StepChoice::Assembly);
739 }
740 let b = ui.button("Import as bodies");
741 self.hit("stepassembly:bodies", &b);
742 if b.clicked() {
743 choice = Some(StepChoice::Bodies);
744 }
745 let c = ui.button("Cancel");
746 self.hit("stepassembly:cancel", &c);
747 if c.clicked() {
748 choice = Some(StepChoice::Cancel);
749 }
750 });
751 });
752 if let Some(pending) = self.pending_step_import.as_mut() {
754 pending.flatten = flatten;
755 }
756 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
759 let Some(choice) = choice else { return };
760 match choice {
761 StepChoice::Assembly => self.step_assembly_import(state, store),
762 StepChoice::Bodies => self.step_assembly_bodies(state),
763 StepChoice::Cancel => self.step_assembly_cancel(state),
764 }
765 self.open = false;
766 }
767
768 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
770 let current = docs.active().name().map(str::to_string);
771 let mut do_save = false;
772 let mut cancel = false;
773 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
774 ui.set_width(600.0);
775 ui.heading("Save model as");
776 ui.add_space(4.0);
777 let options = FileExplorerOptions {
778 hit_prefix: "saveas:file",
779 empty_label: "(no saved models)",
780 row_icon: "\u{1F5CE}",
781 current: current.as_deref(),
782 allow_delete: false,
783 allow_import: false,
784 import_label: "",
785 import_hit: "saveas:upload",
786 show_cancel: false,
787 confirm_label: None,
788 extensions: &["BREP.json", "json"],
789 };
790 let output = self.explorer.show_store(ui, store, options);
791 self.record_explorer_hits(&output.hits);
792 if let Some(name) = output.picked.or(output.activated) {
796 self.name_buf = model_display_name(&name);
797 }
798 ui.label("File name");
799 let field = ui.add(
800 egui::TextEdit::singleline(&mut self.name_buf)
801 .hint_text("model name")
802 .desired_width(f32::INFINITY),
803 );
804 self.hit("field:name", &field);
805 if self.want_focus {
806 field.request_focus();
807 self.want_focus = false;
808 }
809 let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
810 ui.add_space(6.0);
811 ui.horizontal(|ui| {
812 let save = ui.button("Save");
813 self.hit("save", &save);
814 if save.clicked() || enter {
815 do_save = true;
816 }
817 let c = ui.button("Cancel");
818 self.hit("cancel", &c);
819 if c.clicked() {
820 cancel = true;
821 }
822 });
823 if !self.status.is_empty() {
824 ui.add_space(4.0);
825 ui.weak(&self.status);
826 }
827 });
828 if do_save {
829 let name = self.name_buf.clone();
830 if self.save_to_browser(docs, store, name) {
831 self.open = false;
832 }
833 } else if cancel || modal.should_close() {
834 self.open = false;
835 }
836 }
837
838 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
845 let has_components = !docs.engine().component_ids().is_empty();
850 let mut chosen: Option<&'static str> = None;
851 let mut bom_chosen: Option<&'static str> = None;
852 let mut cancel = false;
853 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
854 ui.set_width(320.0);
855 ui.heading("Export model");
856 ui.add_space(4.0);
857 let field = ui.add(
858 egui::TextEdit::singleline(&mut self.name_buf)
859 .hint_text("file name")
860 .desired_width(f32::INFINITY),
861 );
862 self.hit("field:name", &field);
863 if self.want_focus {
864 field.request_focus();
865 self.want_focus = false;
866 }
867 ui.add_space(6.0);
868 ui.horizontal(|ui| {
869 let step = ui.button("STEP (.step)");
870 self.hit("export:step", &step);
871 if step.clicked() {
872 chosen = Some("step");
873 }
874 let iges = ui.button("IGES (.igs)");
875 self.hit("export:iges", &iges);
876 if iges.clicked() {
877 chosen = Some("iges");
878 }
879 let stl = ui.button("STL (.stl)");
880 self.hit("export:stl", &stl);
881 if stl.clicked() {
882 chosen = Some("stl");
883 }
884 let json = ui.button("JSON (.BREP.json)");
888 self.hit("export:json", &json);
889 if json.clicked() {
890 chosen = Some("json");
891 }
892 let c = ui.button("Cancel");
893 self.hit("cancel", &c);
894 if c.clicked() {
895 cancel = true;
896 }
897 });
898 ui.add_space(4.0);
899 ui.horizontal(|ui| {
900 let csv = ui.add_enabled(has_components, egui::Button::new("BOM (CSV)"));
904 self.hit("export:bomcsv", &csv);
905 if csv.clicked() {
906 bom_chosen = Some("csv");
907 }
908 let json = ui.add_enabled(has_components, egui::Button::new("BOM (JSON)"));
909 self.hit("export:bomjson", &json);
910 if json.clicked() {
911 bom_chosen = Some("json");
912 }
913 });
914 if !self.status.is_empty() {
915 ui.add_space(4.0);
916 ui.weak(&self.status);
917 }
918 });
919 if let Some(format) = chosen {
920 if self.export_as(docs, store, format) {
921 self.open = false;
922 }
923 } else if let Some(format) = bom_chosen {
924 if self.export_bom_as(docs, store, format) {
925 self.open = false;
926 }
927 } else if cancel || modal.should_close() {
928 self.open = false;
929 }
930 }
931
932 fn show_flat_pattern(
938 &mut self,
939 ctx: &egui::Context,
940 docs: &mut Documents,
941 store: &dyn ModelStore,
942 ) {
943 let mut chosen: Option<&'static str> = None;
944 let mut cancel = false;
945 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
946 ui.set_width(340.0);
947 ui.heading("Export flat pattern");
948 ui.add_space(2.0);
949 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
950 ui.add_space(4.0);
951 let field = ui.add(
952 egui::TextEdit::singleline(&mut self.name_buf)
953 .hint_text("file name")
954 .desired_width(f32::INFINITY),
955 );
956 self.hit("field:name", &field);
957 if self.want_focus {
958 field.request_focus();
959 self.want_focus = false;
960 }
961 ui.add_space(6.0);
962 ui.horizontal(|ui| {
963 let dxf = ui.button("DXF (.dxf)");
964 self.hit("flat:dxf", &dxf);
965 if dxf.clicked() {
966 chosen = Some("dxf");
967 }
968 let svg = ui.button("SVG (.svg)");
969 self.hit("flat:svg", &svg);
970 if svg.clicked() {
971 chosen = Some("svg");
972 }
973 let c = ui.button("Cancel");
974 self.hit("cancel", &c);
975 if c.clicked() {
976 cancel = true;
977 }
978 });
979 if !self.status.is_empty() {
980 ui.add_space(4.0);
981 ui.weak(&self.status);
982 }
983 });
984 if let Some(format) = chosen {
985 if self.export_flat_pattern_as(docs, store, format) {
986 self.open = false;
987 }
988 } else if cancel || modal.should_close() {
989 self.open = false;
990 }
991 }
992
993 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
996 let current = docs.active().name().map(str::to_string);
997 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
998 ui.set_width(600.0);
999 ui.heading("Open model");
1000 ui.add_space(4.0);
1001 let mut options = FileExplorerOptions::open(current.as_deref());
1002 options.allow_import = store.supports_file_interchange();
1003 let output = self.explorer.show_store(ui, store, options);
1004 self.record_explorer_hits(&output.hits);
1005 if !self.status.is_empty() {
1006 ui.add_space(4.0);
1007 ui.weak(&self.status);
1008 }
1009 output
1010 });
1011 let should_close = modal.should_close();
1012 let output = modal.inner;
1013 if let Some(name) = output.activated {
1014 self.open_document(docs, store, &name);
1015 self.open = false;
1016 } else if let Some(name) = output.remove {
1017 let _ = store.remove(&name);
1018 self.status = format!("removed {name}");
1019 if let Some(index) = docs.index_of(&name) {
1023 if let Some(doc) = docs.get_mut(index) {
1024 doc.set_name(None);
1025 }
1026 }
1027 } else if output.import {
1028 match store.begin_import() {
1031 Ok(()) => {
1032 self.status = "choose a file…".into();
1033 self.open = false;
1034 }
1035 Err(e) => self.status = format!("import failed: {e}"),
1036 }
1037 } else if output.cancel || should_close {
1038 self.open = false;
1039 }
1040 }
1041
1042 fn show_insert_component(
1048 &mut self,
1049 ctx: &egui::Context,
1050 state: &mut EngineState,
1051 store: &dyn ModelStore,
1052 ) {
1053 let library = state.parts_library_names();
1054 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1055 ui.set_width(600.0);
1056 ui.heading("Insert component");
1057 ui.add_space(4.0);
1058 let mut chosen_library = None;
1059 if !library.is_empty() {
1060 ui.weak("In this document (parts library)");
1061 for name in &library {
1062 let row = ui.add_sized(
1063 egui::vec2(ui.available_width(), 18.0),
1064 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}")).frame(false),
1065 );
1066 self.hit(&format!("insert:lib:{name}"), &row);
1067 if row.clicked() {
1068 chosen_library = Some(name.clone());
1069 }
1070 }
1071 ui.add_space(4.0);
1072 }
1073 let options = FileExplorerOptions {
1074 hit_prefix: "insert:model",
1075 empty_label: "(no saved models)",
1076 row_icon: "\u{1F5CE}",
1077 current: None,
1078 allow_delete: false,
1079 allow_import: store.supports_file_interchange(),
1080 import_label: "Upload\u{2026}",
1081 import_hit: "insert:upload",
1082 show_cancel: true,
1083 confirm_label: Some("Insert"),
1084 extensions: &["BREP.json", "json"],
1085 };
1086 let output = self.explorer.show_store(ui, store, options);
1087 self.record_explorer_hits(&output.hits);
1088 if !self.status.is_empty() {
1089 ui.add_space(4.0);
1090 ui.weak(&self.status);
1091 }
1092 (chosen_library, output)
1093 });
1094 let should_close = modal.should_close();
1095 let (chosen_library, output) = modal.inner;
1096 if let Some(part_name) = chosen_library {
1097 match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1099 Ok(id) => {
1100 self.status = format!("inserted {part_name} ({id})");
1101 self.open = false;
1102 }
1103 Err(e) => self.status = format!("insert failed: {e}"),
1104 }
1105 } else if let Some(name) = output.activated {
1106 match store.read(&name) {
1107 Some(contents) => {
1108 self.insert_component_document(state, &name, &contents);
1109 self.open = false;
1110 }
1111 None => self.status = format!("insert failed: '{name}' not found"),
1112 }
1113 } else if output.import {
1114 match store.begin_import() {
1115 Ok(()) => {
1116 self.pending_component_import = true;
1118 self.status = "choose a part file…".into();
1119 self.open = false;
1120 }
1121 Err(e) => self.status = format!("insert failed: {e}"),
1122 }
1123 } else if output.cancel || should_close {
1124 self.open = false;
1125 }
1126 }
1127
1128 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1136 let display = model_display_name(name);
1137 match state.insert_component(ComponentInsert::New {
1138 name: &display,
1139 source_key: name,
1140 source_signature: &document_signature(contents),
1141 document_json: contents,
1142 }) {
1143 Ok(id) => self.status = format!("inserted {display} ({id})"),
1144 Err(e) => self.status = format!("insert failed: {e}"),
1145 }
1146 }
1147
1148 fn effective_name(&self, docs: &Documents) -> String {
1153 let field = self.name_buf.trim();
1154 if !field.is_empty() {
1155 field.to_string()
1156 } else {
1157 docs.active()
1158 .name()
1159 .map(str::to_string)
1160 .unwrap_or_else(|| "untitled".into())
1161 }
1162 }
1163
1164 fn new_document(&mut self, docs: &mut Documents) {
1167 let mut engine = docs.spawn_engine();
1168 let _ = engine.set_history_json(EMPTY_DOCUMENT);
1169 docs.open_document(Document::new(engine));
1170 self.name_buf.clear();
1171 self.status = "new (empty) model".into();
1172 }
1173
1174 fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
1185 let display = model_display_name(name);
1186 let taken = docs.iter().enumerate().any(|(index, doc)| {
1187 index != docs.active_index()
1188 && doc
1189 .name()
1190 .is_some_and(|open| model_display_name(open) == display)
1191 });
1192 if taken {
1193 self.status = format!("'{display}' is already open in another tab");
1194 }
1195 !taken
1196 }
1197
1198 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
1201 let name = name.trim().to_string();
1202 if name.is_empty() {
1203 self.status = "enter a name to save".into();
1204 return false;
1205 }
1206 if !self.name_is_free(docs, &name) {
1207 return false;
1208 }
1209 match store.write(&name, &docs.engine().history_request_json()) {
1210 Ok(()) => {
1211 self.name_buf = model_display_name(&name);
1214 let doc = docs.active_mut();
1215 doc.set_name(Some(name.clone()));
1216 doc.mark_clean();
1217 self.save_generation += 1;
1218 self.status = format!("saved {name}");
1219 true
1220 }
1221 Err(e) => {
1222 self.status = format!("save failed: {e}");
1223 false
1224 }
1225 }
1226 }
1227
1228 fn save_to_browser(
1232 &mut self,
1233 docs: &mut Documents,
1234 store: &dyn ModelStore,
1235 name: String,
1236 ) -> bool {
1237 let name = name.trim().to_string();
1238 if name.is_empty() {
1239 self.status = "enter a name to save".into();
1240 return false;
1241 }
1242 if !self.name_is_free(docs, &name) {
1243 return false;
1244 }
1245 match store.browser_write(&name, &docs.engine().history_request_json()) {
1246 Ok(identity) => {
1247 self.name_buf = model_display_name(&identity);
1248 let doc = docs.active_mut();
1249 doc.set_name(Some(identity.clone()));
1250 doc.mark_clean();
1251 self.save_generation += 1;
1252 self.status = format!("saved {identity}");
1253 true
1254 }
1255 Err(e) => {
1256 self.status = format!("save failed: {e}");
1257 false
1258 }
1259 }
1260 }
1261
1262 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1266 if docs.focus_named(name) {
1267 self.status = format!("{name} is already open");
1268 return;
1269 }
1270 match store.read(name) {
1271 Some(contents) => self.load_document(docs, name, &contents),
1272 None => self.status = format!("open failed: '{name}' not found"),
1273 }
1274 }
1275
1276 fn export_as(&mut self, docs: &Documents, store: &dyn ModelStore, format: &str) -> bool {
1283 let name = self.effective_name(docs);
1284 let state = docs.engine();
1285 let (text, ext) = match format {
1286 "stl" => (state.export_stl_text(), "stl"),
1287 "iges" => (state.export_iges_text(), "igs"),
1288 "json" => (Ok(state.history_request_json()), "BREP.json"),
1289 _ => (state.export_step_text(), "step"),
1290 };
1291 match text {
1292 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1293 Ok(()) => {
1294 self.status = format!("exported {name}.{ext}");
1295 true
1296 }
1297 Err(e) => {
1298 self.status = format!("export failed: {e}");
1299 false
1300 }
1301 },
1302 Err(e) => {
1303 self.status = format!("export failed: {e}");
1304 false
1305 }
1306 }
1307 }
1308
1309 fn export_flat_pattern_as(
1315 &mut self,
1316 docs: &mut Documents,
1317 store: &dyn ModelStore,
1318 format: &str,
1319 ) -> bool {
1320 let name = self.effective_name(docs);
1321 let state = docs.engine_mut();
1322 let (text, ext) = match format {
1323 "svg" => (state.export_flat_pattern_svg(), "svg"),
1324 _ => (state.export_flat_pattern_dxf(), "dxf"),
1325 };
1326 match text {
1327 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1328 Ok(()) => {
1329 self.status = format!("exported {name}.{ext}");
1330 true
1331 }
1332 Err(e) => {
1333 self.status = format!("flat-pattern export failed: {e}");
1334 false
1335 }
1336 },
1337 Err(e) => {
1338 self.status = format!("flat-pattern export failed: {e}");
1339 state.push_notice(format!("Flat pattern: {e}"));
1340 false
1341 }
1342 }
1343 }
1344
1345 fn export_bom_as(
1351 &mut self,
1352 docs: &mut Documents,
1353 store: &dyn ModelStore,
1354 format: &str,
1355 ) -> bool {
1356 let name = self.effective_name(docs);
1357 let state = docs.engine_mut();
1358 let (text, ext) = match format {
1359 "json" => (state.export_bom_json(), "bom.json"),
1360 _ => (state.export_bom_csv(), "bom.csv"),
1361 };
1362 match text {
1363 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1364 Ok(()) => {
1365 self.status = format!("exported {name}.{ext}");
1366 true
1367 }
1368 Err(e) => {
1369 self.status = format!("BOM export failed: {e}");
1370 false
1371 }
1372 },
1373 Err(e) => {
1374 self.status = format!("BOM export failed: {e}");
1375 state.push_notice(format!("BOM export: {e}"));
1376 false
1377 }
1378 }
1379 }
1380
1381 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1394 self.pending_step_import = None;
1400 let id = state.submit_step_probe(contents);
1401 self.pending_step_probe = Some(PendingStepProbe {
1402 id,
1403 name: name.to_string(),
1404 text: contents.to_string(),
1405 });
1406 self.status = format!("reading \"{name}\"\u{2026}");
1407 self.poll_step_probe(state);
1410 }
1411
1412 fn poll_step_probe(&mut self, state: &mut EngineState) {
1417 while let Some((id, outcome)) = state.take_step_probe() {
1418 let Some(pending) = self.pending_step_probe.as_ref() else {
1419 continue;
1420 };
1421 if pending.id != id {
1422 continue; }
1424 let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
1425 match outcome {
1426 StepProbeOutcome::Structure(probe) => {
1427 self.status = format!(
1428 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
1429 probe.parts,
1430 plural(probe.parts),
1431 probe.instances,
1432 plural(probe.instances)
1433 );
1434 self.pending_step_import = Some(PendingStepImport {
1435 name,
1436 text,
1437 probe,
1438 flatten: false,
1441 });
1442 self.open_modal(Mode::StepAssembly);
1448 }
1449 StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
1450 self.import_step_flat(state, &name, &text);
1451 }
1452 }
1453 }
1454 if self.pending_step_probe.is_some() && !state.step_probes_pending() {
1455 let pending = self.pending_step_probe.take().unwrap();
1459 self.status = format!("import of \"{}\" was cancelled", pending.name);
1460 }
1461 }
1462
1463 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1469 match state.import_step_feature(contents) {
1470 Ok(_) => self.status = format!("imported {name}"),
1474 Err(e) => self.status = format!("import failed: {e}"),
1475 }
1476 }
1477
1478 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
1488 let Some(pending) = self.pending_step_import.take() else {
1489 return;
1490 };
1491 let was_assembly = state.history_has_assembly();
1494 let doc_name = step_document_name(&pending.name);
1495 let opts = StepAssemblyImport {
1498 nested: !pending.flatten,
1499 };
1500 let mut sink = StorePartSink::new(store, &doc_name);
1507 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
1508 Ok(report) => {
1509 if !was_assembly {
1510 self.enter_assembly_workbench(state);
1511 }
1512 for failure in &sink.failures {
1515 state.push_notice(format!("part not saved — {failure}"));
1516 }
1517 let saved = sink.written;
1518 let base = assembly_import_message(&pending.name, &report);
1519 match (saved, sink.failures.len()) {
1520 (0, _) => base,
1521 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
1522 (saved, failed) => {
1523 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
1524 }
1525 }
1526 }
1527 Err(error) => match state.import_step_feature(&pending.text) {
1528 Ok(_) => format!(
1529 "imported {} as bodies — the assembly structure could not be built ({error})",
1530 pending.name
1531 ),
1532 Err(flat) => format!("import failed: {flat}"),
1533 },
1534 };
1535 self.status = message.clone();
1538 state.push_notice(message);
1539 }
1540
1541 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
1545 let Some(pending) = self.pending_step_import.take() else {
1546 return;
1547 };
1548 state.discard_probed_step_assembly();
1549 self.import_step_flat(state, &pending.name, &pending.text);
1550 }
1551
1552 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
1554 let Some(pending) = self.pending_step_import.take() else {
1555 return;
1556 };
1557 state.discard_probed_step_assembly();
1558 self.status = format!("import cancelled: {}", pending.name);
1559 }
1560
1561 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
1573 if crate::workbench::panel_visible(
1574 &state.settings.workbench,
1575 crate::workbench::assembly::BOM_PANEL_ID,
1576 ) {
1577 return;
1578 }
1579 let _ = state.apply_settings_json(
1580 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
1581 .to_string(),
1582 );
1583 }
1584
1585 fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1588 match state.import_iges_feature(contents) {
1589 Ok(_) => self.status = format!("imported {name}"),
1591 Err(e) => self.status = format!("import failed: {e}"),
1592 }
1593 }
1594
1595 fn stage_stl_preview(&mut self, name: String, contents: Vec<u8>) {
1596 self.pending_stl_import = Some((name, contents));
1597 self.status.clear();
1598 }
1599
1600 pub fn take_stl_import(&mut self) -> Option<(String, Vec<u8>)> {
1603 self.pending_stl_import.take()
1604 }
1605
1606 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
1607 match state.import_obj_bytes_feature(contents) {
1608 Ok(_) => self.status = format!("reconstructing {name} in background…"),
1609 Err(e) => self.status = format!("import failed: {e}"),
1610 }
1611 }
1612
1613 fn import_text(
1614 &mut self,
1615 state: &mut EngineState,
1616 name: &str,
1617 bytes: &[u8],
1618 importer: fn(&mut Self, &mut EngineState, &str, &str),
1619 ) {
1620 match std::str::from_utf8(bytes) {
1621 Ok(contents) => importer(self, state, name, contents),
1622 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
1623 }
1624 }
1625
1626 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str) {
1631 let mut engine = docs.spawn_engine();
1632 match engine.load_model_and_fit(contents) {
1633 Ok(_) => {
1634 let mut doc = Document::new(engine);
1638 doc.set_name(Some(name.to_string()));
1639 docs.open_document(doc);
1640 self.name_buf = model_display_name(name);
1641 self.status = format!("opened {name}");
1642 }
1643 Err(e) => self.status = format!("open failed: {e}"),
1644 }
1645 }
1646
1647 #[cfg(target_arch = "wasm32")]
1652 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
1653 serde_json::json!({
1654 "name": docs.active().name(),
1655 "nameBuf": self.name_buf,
1656 "selected": self.explorer.selected(),
1657 "location": store.browser_location(),
1658 "dirty": docs.active().is_dirty(),
1659 "backend": store.backend_label(),
1660 "interchange": store.supports_file_interchange(),
1661 "list": store.list(),
1662 "status": self.status,
1663 "open": self.open,
1664 "mode": match self.mode {
1665 Mode::Open => "open",
1666 Mode::SaveAs => "saveas",
1667 Mode::ConfirmClose => "confirmclose",
1668 Mode::Export => "export",
1669 Mode::Import => "import",
1670 Mode::FlatPattern => "flatpattern",
1671 Mode::InsertComponent => "insertcomponent",
1672 Mode::StepAssembly => "stepassembly",
1673 },
1674 "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
1680 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
1681 serde_json::json!({
1682 "name": pending.name,
1683 "parts": pending.probe.parts,
1684 "instances": pending.probe.instances,
1685 "nestedDepth": pending.probe.nested_depth,
1686 "flatten": pending.flatten,
1689 })
1690 }),
1691 })
1692 .to_string()
1693 }
1694
1695 #[cfg(target_arch = "wasm32")]
1697 pub fn hits_json(&self) -> String {
1698 let map: serde_json::Map<String, Value> = self
1699 .hits
1700 .iter()
1701 .map(|(k, r)| {
1702 (
1703 k.clone(),
1704 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
1705 )
1706 })
1707 .collect();
1708 Value::Object(map).to_string()
1709 }
1710
1711 #[cfg(target_arch = "wasm32")]
1714 fn hit(&mut self, key: &str, resp: &egui::Response) {
1715 self.hits.insert(key.to_string(), resp.rect);
1716 }
1717 #[cfg(not(target_arch = "wasm32"))]
1718 #[inline]
1719 fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
1720
1721 #[cfg(target_arch = "wasm32")]
1722 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
1723 self.hits.extend(hits.iter().cloned());
1724 }
1725 #[cfg(not(target_arch = "wasm32"))]
1726 #[inline]
1727 fn record_explorer_hits(&mut self, _hits: &[(String, egui::Rect)]) {}
1728}
1729
1730#[cfg(all(test, not(target_arch = "wasm32")))]
1731mod tests {
1732 use super::*;
1733 use crate::store::native_test_store;
1734
1735 fn documents(history: &str) -> Documents {
1741 let mut docs = Documents::new(Box::new(EngineState::new));
1742 docs.engine_mut().set_history_json(history).unwrap();
1743 docs.active_mut().mark_clean();
1744 docs
1745 }
1746
1747 #[test]
1751 fn model_document_round_trips_through_store_and_engine() {
1752 let dir = std::env::temp_dir().join(format!("brep-app-file-{}", std::process::id()));
1754 let _ = std::fs::remove_dir_all(&dir);
1755 let store = native_test_store(dir.clone());
1756
1757 let mut engine = EngineState::new();
1759 let seed = r#"{"expressions":"","configurator":{},"features":[
1760 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1761 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1762 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1763 ]}"#;
1764 engine.set_history_json(seed).unwrap();
1765 let before = engine.history_request_json();
1766 store.write("roundtrip", &before).unwrap();
1767
1768 let mut reopened = EngineState::new();
1770 let contents = store.read("roundtrip").unwrap();
1771 reopened.load_model_and_fit(&contents).unwrap();
1772 let after = reopened.history_request_json();
1773
1774 assert_eq!(before, after, "request JSON round-trips through the store");
1775 assert_eq!(reopened.history_len(), 1);
1776 assert_eq!(reopened.scene.solids().len(), 1);
1777
1778 let _ = std::fs::remove_dir_all(&dir);
1779 }
1780
1781 #[test]
1790 fn saved_documents_remember_and_restore_the_workbench() {
1791 use serde_json::Value;
1792 let dir = std::env::temp_dir().join(format!("brep-app-wb-{}", std::process::id()));
1793 let _ = std::fs::remove_dir_all(&dir);
1794 let store = native_test_store(dir.clone());
1795
1796 let mut docs = documents(BOX_SEED);
1798 docs.engine_mut()
1799 .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
1800 .unwrap();
1801 let mut dialog = FileDialog::new();
1802 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1803 let stored: Value = serde_json::from_str(&store.read("part").unwrap()).unwrap();
1804 assert_eq!(
1805 stored.get("workbench").and_then(Value::as_str),
1806 Some("sheetMetal"),
1807 "Save embeds the active workbench id in the document"
1808 );
1809
1810 let mut reopened = documents(EMPTY_DOCUMENT);
1813 assert_eq!(
1814 reopened.engine().settings.workbench, "modeling",
1815 "fresh session default"
1816 );
1817 let mut viewer = FileDialog::new();
1818 viewer.open_document(&mut reopened, &*store, "part");
1819 assert_eq!(reopened.len(), 2, "Open adds a tab");
1820 assert_eq!(
1821 reopened.engine().settings.workbench, "sheetMetal",
1822 "Open restores the saved workbench"
1823 );
1824 assert!(
1825 !reopened.active().is_dirty(),
1826 "a just-opened document is clean"
1827 );
1828
1829 store.write("legacy", BOX_SEED).unwrap();
1831 viewer.open_document(&mut reopened, &*store, "legacy");
1832 assert!(viewer.status.starts_with("opened"), "legacy open succeeds");
1833 assert_eq!(
1834 reopened.engine().settings.workbench, "sheetMetal",
1835 "a legacy document inherits the session's workbench, unchanged"
1836 );
1837
1838 let mut bogus: Value = serde_json::from_str(BOX_SEED).unwrap();
1841 bogus["workbench"] = Value::String("conveyorBelts".into());
1842 store.write("bogus", &bogus.to_string()).unwrap();
1843 viewer.open_document(&mut reopened, &*store, "bogus");
1844 assert!(
1845 viewer.status.starts_with("opened"),
1846 "a bogus workbench id must not fail the open: {}",
1847 viewer.status
1848 );
1849 assert_eq!(
1850 reopened.engine().settings.workbench, "conveyorBelts",
1851 "raw id stored"
1852 );
1853 assert_eq!(
1854 crate::workbench::resolve(&reopened.engine().settings.workbench).id,
1855 "modeling",
1856 "…and it resolves to the default workbench wherever it is consumed"
1857 );
1858
1859 let _ = std::fs::remove_dir_all(&dir);
1860 }
1861
1862 #[test]
1865 fn dirty_flag_flips_on_edit_and_clears_on_save() {
1866 let dir = std::env::temp_dir().join(format!("brep-app-dirty-{}", std::process::id()));
1867 let _ = std::fs::remove_dir_all(&dir);
1868 let store = native_test_store(dir.clone());
1869
1870 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"}}}]}"#);
1871 let mut dialog = FileDialog::new();
1872 assert!(!docs.active().is_dirty(), "seeded model is clean");
1873 assert_eq!(dialog.save_generation(), 0, "no saves yet");
1874
1875 docs.engine_mut()
1877 .update_feature_params(
1878 "Box",
1879 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"}}"#,
1880 )
1881 .unwrap();
1882 assert!(docs.active().is_dirty(), "edit marks dirty");
1883
1884 assert!(
1886 dialog.save_to(&mut docs, &*store, "m".into()),
1887 "save succeeds"
1888 );
1889 assert!(!docs.active().is_dirty(), "save clears dirty");
1890 assert_eq!(docs.active().name(), Some("m"));
1891 assert_eq!(
1892 dialog.save_generation(),
1893 1,
1894 "a successful save bumps the update-components staleness key"
1895 );
1896
1897 let _ = std::fs::remove_dir_all(&dir);
1898 }
1899
1900 #[test]
1905 fn new_adds_a_tab_and_only_closing_confirms() {
1906 let dir = std::env::temp_dir().join(format!("brep-app-newcfm-{}", std::process::id()));
1907 let _ = std::fs::remove_dir_all(&dir);
1908 let store = native_test_store(dir.clone());
1909
1910 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"}}}]}"#);
1911 let mut dialog = FileDialog::new();
1912
1913 docs.engine_mut()
1916 .update_feature_params(
1917 "Box",
1918 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"}}"#,
1919 )
1920 .unwrap();
1921 assert!(docs.active().is_dirty());
1922 dialog.dispatch(FileAction::New, &mut docs, &*store);
1923 assert!(!dialog.open, "New never opens a modal");
1924 assert_eq!(docs.len(), 2, "New adds a tab");
1925 assert_eq!(docs.active_index(), 1, "…and focuses it");
1926 assert_eq!(docs.engine().history_len(), 0, "the new tab is empty");
1927 assert_eq!(
1928 docs.get(0).unwrap().engine.history_len(),
1929 1,
1930 "the dirty document is untouched in its own tab"
1931 );
1932
1933 dialog.request_close(&mut docs, 1);
1935 assert!(!dialog.open, "a clean close needs no confirmation");
1936 assert_eq!(docs.len(), 1);
1937
1938 dialog.request_close(&mut docs, 0);
1940 assert!(dialog.open && dialog.mode == Mode::ConfirmClose);
1941 assert_eq!(dialog.pending_close, Some(0));
1942 assert_eq!(docs.len(), 1, "nothing closed until the user discards");
1943
1944 let _ = std::fs::remove_dir_all(&dir);
1945 }
1946
1947 #[test]
1951 fn save_on_unnamed_falls_through_to_save_as_modal() {
1952 let dir = std::env::temp_dir().join(format!("brep-app-unnamed-{}", std::process::id()));
1953 let _ = std::fs::remove_dir_all(&dir);
1954 let store = native_test_store(dir.clone());
1955
1956 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"}}}]}"#);
1957 let mut dialog = FileDialog::new();
1958
1959 dialog.dispatch(FileAction::Save, &mut docs, &*store);
1961 assert!(dialog.open, "unnamed Save opens the Save As modal");
1962 assert!(store.list().is_empty(), "nothing written yet");
1963
1964 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1966 assert_eq!(docs.active().name(), Some("part"));
1967 assert_eq!(store.list(), vec!["part".to_string()]);
1968
1969 let _ = std::fs::remove_dir_all(&dir);
1970 }
1971
1972 const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
1974 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1975 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1976 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1977 ]}"#;
1978
1979 #[test]
1982 fn step_names_route_to_the_import_lane() {
1983 assert!(is_step_name("part.step"));
1984 assert!(is_step_name("PART.STP"));
1985 assert!(is_stl_name("scan.STL"));
1986 assert!(is_obj_name("scan.obj"));
1987 assert!(!is_step_name("model"), "model docs arrive extension-less");
1988 assert!(!is_step_name("model.json"));
1989 assert!(!is_stl_name("model.json"));
1990 assert!(!is_obj_name("model.json"));
1991 }
1992
1993 #[test]
1997 fn export_dispatch_and_chooser_serialize_the_model() {
1998 let dir = std::env::temp_dir().join(format!("brep-app-export-{}", std::process::id()));
1999 let _ = std::fs::remove_dir_all(&dir);
2000 let store = native_test_store(dir.clone());
2001
2002 let mut docs = documents(BOX_SEED);
2003 let mut dialog = FileDialog::new();
2004
2005 dialog.dispatch(FileAction::Export, &mut docs, &*store);
2006 assert!(dialog.open && dialog.mode == Mode::Export, "Export opens the chooser");
2007 assert!(dialog.export_as(&docs, &*store, "step"), "STEP export ok");
2008 assert!(dialog.status.contains("exported"), "status: {}", dialog.status);
2009 assert!(dialog.export_as(&docs, &*store, "stl"), "STL export ok");
2010 assert!(dialog.export_as(&docs, &*store, "json"), "JSON recipe export ok");
2011 assert!(dialog.status.contains(".BREP.json"), "json status: {}", dialog.status);
2012 assert!(dir.join("untitled.step").is_file());
2013 assert!(dir.join("untitled.stl").is_file());
2014 assert!(dir.join("untitled.BREP.json").is_file());
2015
2016 let empty = documents(EMPTY_DOCUMENT);
2017 assert!(!dialog.export_as(&empty, &*store, "step"), "empty model does not export");
2018 assert!(dialog.status.contains("nothing to export"), "status: {}", dialog.status);
2019
2020 let _ = std::fs::remove_dir_all(&dir);
2021 }
2022
2023 #[test]
2024 fn import_dispatch_opens_the_common_explorer() {
2025 let dir = std::env::temp_dir().join(format!("brep-app-import-{}", std::process::id()));
2026 let _ = std::fs::remove_dir_all(&dir);
2027 let store = native_test_store(dir.clone());
2028 let mut docs = documents(EMPTY_DOCUMENT);
2029 let mut dialog = FileDialog::new();
2030
2031 dialog.dispatch(FileAction::Import, &mut docs, &*store);
2032 assert!(dialog.open && dialog.mode == Mode::Import);
2033
2034 let _ = std::fs::remove_dir_all(&dir);
2035 }
2036
2037 #[test]
2041 fn flat_pattern_dispatch_errors_and_toasts_without_sheet_metal() {
2042 let dir = std::env::temp_dir().join(format!("brep-app-flat-{}", std::process::id()));
2043 let _ = std::fs::remove_dir_all(&dir);
2044 let store = native_test_store(dir.clone());
2045
2046 let mut docs = documents(BOX_SEED);
2048 let mut dialog = FileDialog::new();
2049
2050 dialog.dispatch(FileAction::ExportFlatPattern, &mut docs, &*store);
2051 assert!(
2052 dialog.open && dialog.mode == Mode::FlatPattern,
2053 "flat-pattern action opens the DXF/SVG chooser"
2054 );
2055
2056 assert!(!dialog.export_flat_pattern_as(&mut docs, &*store, "dxf"));
2059 assert!(
2060 dialog.status.contains("no sheet-metal body"),
2061 "status names the missing body: {}",
2062 dialog.status
2063 );
2064 let notices = docs.engine_mut().take_notices();
2065 assert!(
2066 notices.iter().any(|n| n.contains("no sheet-metal body")),
2067 "the error is queued as a toast: {notices:?}"
2068 );
2069
2070 let _ = std::fs::remove_dir_all(&dir);
2071 }
2072
2073 #[test]
2079 fn bom_export_gates_on_components_and_exports_both_formats() {
2080 brep_render::brep_kernel::clear_history_cache();
2081 let dir = std::env::temp_dir().join(format!("brep-app-bom-{}", std::process::id()));
2082 let _ = std::fs::remove_dir_all(&dir);
2083 let store = native_test_store(dir.clone());
2084
2085 let mut docs = documents(BOX_SEED);
2087 let mut dialog = FileDialog::new();
2088 assert!(
2089 docs.engine().component_ids().is_empty(),
2090 "componentless doc → the chooser draws the BOM buttons disabled"
2091 );
2092 assert!(!dialog.export_bom_as(&mut docs, &*store, "csv"));
2093 assert!(
2094 dialog.status.contains("no components"),
2095 "status names the guard: {}",
2096 dialog.status
2097 );
2098 let notices = docs.engine_mut().take_notices();
2099 assert!(
2100 notices.iter().any(|n| n.contains("no components")),
2101 "the error is queued as a toast: {notices:?}"
2102 );
2103
2104 dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2107 dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2108 assert!(
2109 !docs.engine().component_ids().is_empty(),
2110 "components resident → the BOM buttons enable"
2111 );
2112 assert!(dialog.export_bom_as(&mut docs, &*store, "csv"), "CSV export ok");
2113 assert!(dialog.status.contains(".bom.csv"), "status: {}", dialog.status);
2114 assert!(dialog.export_bom_as(&mut docs, &*store, "json"), "JSON export ok");
2115 assert!(dialog.status.contains(".bom.json"), "status: {}", dialog.status);
2116
2117 let _ = std::fs::remove_dir_all(&dir);
2118 }
2119
2120 #[test]
2126 fn insert_component_document_adds_a_library_backed_instance() {
2127 brep_render::brep_kernel::clear_history_cache();
2128 let mut engine = EngineState::new();
2129 let mut dialog = FileDialog::new();
2130
2131 dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2133 assert!(
2134 dialog.status.contains("inserted box-part (ACOMP1)"),
2135 "status: {}",
2136 dialog.status
2137 );
2138 let params = engine
2139 .history
2140 .feature_params(engine.history.index_of("ACOMP1").unwrap())
2141 .unwrap();
2142 assert_eq!(params["partName"], "box-part", "effective library name");
2143 assert_eq!(params["isFixed"], true, "first instance explicitly fixed");
2144 assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2145 assert!(engine.scene.solid("ACOMP1:Box").is_some());
2147 let doc: serde_json::Value =
2150 serde_json::from_str(&engine.history_request_json()).unwrap();
2151 assert_eq!(
2152 doc["partsLibrary"]["box-part"]["sourceSignature"],
2153 document_signature(BOX_SEED),
2154 "insert writes the ONE signature fn's value"
2155 );
2156
2157 dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2160 let params2 = engine
2161 .history
2162 .feature_params(engine.history.index_of("ACOMP2").unwrap())
2163 .unwrap();
2164 assert_eq!(params2["partName"], "box-part");
2165 assert_eq!(params2["isFixed"], false);
2166 assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2167 }
2168
2169 #[test]
2174 fn open_document_adds_a_tab_or_focuses_the_open_one() {
2175 let dir = std::env::temp_dir().join(format!("brep-app-openpart-{}", std::process::id()));
2176 let _ = std::fs::remove_dir_all(&dir);
2177 let store = native_test_store(dir.clone());
2178 store.write("part", BOX_SEED).unwrap();
2179 store.write("other", BOX_SEED).unwrap();
2180
2181 let mut docs = documents(BOX_SEED);
2183 docs.engine_mut()
2184 .update_feature_params(
2185 "Box",
2186 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"}}"#,
2187 )
2188 .unwrap();
2189 assert!(docs.active().is_dirty());
2190 let mut dialog = FileDialog::new();
2191
2192 dialog.open_document(&mut docs, &*store, "part");
2193 assert!(!dialog.open, "opening never raises a modal");
2194 assert_eq!(docs.len(), 2, "a tab was added");
2195 assert_eq!(docs.active().name(), Some("part"));
2196 assert!(!docs.active().is_dirty(), "freshly opened is clean");
2197 assert!(
2198 docs.get(0).unwrap().is_dirty(),
2199 "the dirty document is still open and still dirty"
2200 );
2201
2202 dialog.open_document(&mut docs, &*store, "other");
2204 assert_eq!(docs.len(), 3);
2205 assert_eq!(docs.active_index(), 2);
2206
2207 dialog.open_document(&mut docs, &*store, "part");
2209 assert_eq!(docs.len(), 3, "no second tab for one file");
2210 assert_eq!(docs.active_index(), 1);
2211 assert!(dialog.status.contains("already open"), "status: {}", dialog.status);
2212
2213 dialog.open_document(&mut docs, &*store, "missing");
2215 assert_eq!(docs.len(), 3);
2216 assert!(dialog.status.contains("not found"), "status: {}", dialog.status);
2217
2218 let _ = std::fs::remove_dir_all(&dir);
2219 }
2220
2221 #[test]
2225 fn saving_onto_a_name_open_in_another_tab_is_refused() {
2226 let dir = std::env::temp_dir().join(format!("brep-app-dupname-{}", std::process::id()));
2227 let _ = std::fs::remove_dir_all(&dir);
2228 let store = native_test_store(dir.clone());
2229 store.write("part", BOX_SEED).unwrap();
2230
2231 let mut docs = documents(BOX_SEED);
2232 let mut dialog = FileDialog::new();
2233 dialog.open_document(&mut docs, &*store, "part");
2234 assert_eq!(docs.active_index(), 1);
2235
2236 docs.activate(0);
2240 assert!(!dialog.save_to(&mut docs, &*store, "part".into()));
2241 assert!(
2242 !dialog.save_to_browser(&mut docs, &*store, "part".into()),
2243 "the Save As lane refuses BEFORE it writes, never after"
2244 );
2245 assert!(
2246 dialog.status.contains("already open in another tab"),
2247 "status: {}",
2248 dialog.status
2249 );
2250 assert_eq!(docs.active().name(), None, "nothing claimed the name");
2251
2252 docs.activate(1);
2254 assert!(dialog.save_to(&mut docs, &*store, "part".into()));
2255
2256 let _ = std::fs::remove_dir_all(&dir);
2257 }
2258
2259 #[test]
2263 fn import_step_appends_and_marks_dirty() {
2264 let dir = std::env::temp_dir().join(format!("brep-app-imp-{}", std::process::id()));
2265 let _ = std::fs::remove_dir_all(&dir);
2266 let store = native_test_store(dir.clone());
2267
2268 let mut source = EngineState::new();
2270 source.set_history_json(BOX_SEED).unwrap();
2271 let step = source.export_step_text().unwrap();
2272
2273 let mut docs = documents(EMPTY_DOCUMENT);
2275 let mut dialog = FileDialog::new();
2276 assert!(!docs.active().is_dirty(), "empty seed is clean");
2277 dialog.import_step(docs.engine_mut(), "part.step", &step);
2278 assert_eq!(docs.engine().history_len(), 1, "IMPORT3D feature appended");
2279 assert_eq!(
2280 docs.engine().scene.solids().len(),
2281 1,
2282 "the imported body is in the scene"
2283 );
2284 assert!(docs.active().is_dirty(), "an import is an edit");
2285 assert!(dialog.status.contains("imported"), "status: {}", dialog.status);
2286 let _ = store; let _ = std::fs::remove_dir_all(&dir);
2289 }
2290
2291 fn step_fixture(name: &str) -> String {
2301 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2302 .join("../BREP_kernel/tests/fixtures/step-import")
2303 .join(name);
2304 std::fs::read_to_string(&path)
2305 .unwrap_or_else(|error| panic!("read fixture {}: {error}", path.display()))
2306 }
2307
2308 const AS1: &str = "as1-ug-214.stp";
2310
2311 fn armed(engine: &mut EngineState) -> FileDialog {
2315 let mut dialog = FileDialog::new();
2316 dialog.import_step(engine, AS1, &step_fixture(AS1));
2317 let pending = dialog
2318 .pending_step_import
2319 .as_ref()
2320 .expect("as1-ug-214 carries a product structure, so the choice arms");
2321 assert_eq!(
2322 (pending.probe.parts, pending.probe.instances),
2323 (5, 18),
2324 "the prompt shows the counts the import will deliver"
2325 );
2326 assert_eq!(engine.history_len(), 0, "nothing is imported before the click");
2327 dialog
2328 }
2329
2330 fn stash_is_empty(engine: &mut EngineState) -> bool {
2334 engine
2335 .import_probed_step_assembly(
2336 "probe",
2337 StepAssemblyImport::default(),
2338 &mut brep_render::engine_state::EmbeddedOnly,
2339 )
2340 .is_err()
2341 }
2342
2343 #[test]
2348 fn imported_part_file_names_are_sanitized_and_never_collide() {
2349 assert_eq!(sanitize_file_stem("L Bracket"), "L_Bracket");
2350 assert_eq!(sanitize_file_stem("rod/assem"), "rod_assem", "no path escape");
2351 assert_eq!(
2352 sanitize_file_stem("bolt (mirrored)"),
2353 "bolt_mirrored",
2354 "the crate's own factor suffix survives readably"
2355 );
2356 assert_eq!(sanitize_file_stem("a///b"), "a_b", "runs collapse");
2357 assert_eq!(sanitize_file_stem(" "), "part", "never an empty name");
2358 assert_eq!(sanitize_file_stem("plate-1.2"), "plate-1.2", "kept as-is");
2359
2360 let store = crate::store::MemModelStore::new();
2361 let mut sink = StorePartSink::new(&store, "as1 ug");
2362 let first = sink.store_part("L Bracket", "{\"a\":1}").expect("written");
2364 let second = sink.store_part("L/Bracket", "{\"a\":2}").expect("written");
2365 assert_eq!(first, "as1_ug-L_Bracket");
2366 assert_eq!(second, "as1_ug-L_Bracket-2", "disambiguated, not overwritten");
2367 assert_eq!(store.read(&first).as_deref(), Some("{\"a\":1}"));
2368 assert_eq!(store.read(&second).as_deref(), Some("{\"a\":2}"));
2369 assert_eq!(sink.written, 2);
2370 assert!(sink.failures.is_empty());
2371 }
2372
2373 #[test]
2378 fn a_structureless_step_never_sees_the_dialog() {
2379 let mut source = EngineState::new();
2380 source.set_history_json(BOX_SEED).unwrap();
2381 let step = source.export_step_text().unwrap();
2382
2383 let mut engine = EngineState::new();
2384 let mut dialog = FileDialog::new();
2385 dialog.import_step(&mut engine, "part.step", &step);
2386
2387 assert!(
2388 dialog.pending_step_import.is_none(),
2389 "a part file must not arm the assembly choice"
2390 );
2391 dialog.close_after_import();
2392 assert!(!dialog.open, "nothing armed, so the modal closes");
2393 assert_eq!(engine.history_len(), 1, "one IMPORT3D feature, as before");
2394 assert!(
2395 engine.history_request_json().contains("stepText"),
2396 "the flat lane stores the STEP text, byte-for-byte as before"
2397 );
2398 assert!(!engine.history_has_assembly(), "no components");
2399 assert!(engine.parts_library_names().is_empty(), "no library entries");
2400 assert_eq!(dialog.status, "imported part.step");
2401 assert!(stash_is_empty(&mut engine), "a structureless probe stashes nothing");
2402 }
2403
2404 #[test]
2413 fn importing_as_an_assembly_reports_the_counts_and_enters_the_workbench() {
2414 let mut engine = EngineState::new();
2415 assert_eq!(engine.settings.workbench, "modeling", "fresh session default");
2416 let mut dialog = armed(&mut engine);
2417
2418 dialog.close_after_import();
2419 assert!(dialog.open && dialog.mode == Mode::StepAssembly, "the modal opens");
2420 assert!(
2421 !dialog.pending_step_import.as_ref().unwrap().flatten,
2422 "the §3.9 box defaults OFF — an import keeps the tree"
2423 );
2424
2425 let store = crate::store::MemModelStore::new();
2426 dialog.step_assembly_import(&mut engine, &store);
2427
2428 assert_eq!(
2433 dialog.status,
2434 "imported as1-ug-214.stp \u{2014} 3 parts, 4 components; saved 8 part file(s)",
2435 "the §3.9 outcome line, with the report's real numbers + the writes"
2436 );
2437 let saved = store.list();
2440 assert_eq!(saved.len(), 8, "one file per distinct part: {saved:?}");
2441 assert!(
2442 saved.iter().all(|name| name.starts_with("as1-ug-")),
2443 "each file carries the assembly name, so one import groups in a \
2444 listing without a folder convention `browser_write` cannot express: \
2445 {saved:?}"
2446 );
2447 for name in engine.parts_library_names() {
2450 let (key, signature) = engine.part_source(&name).expect("a library entry");
2451 assert!(!key.is_empty(), "'{name}' carries a real sourceKey");
2452 let stored = store.read(&key).unwrap_or_else(|| panic!("'{key}' written"));
2453 assert_eq!(
2454 signature,
2455 document_signature(&stored),
2456 "'{name}': entry signature and stored file describe one thing"
2457 );
2458 }
2459 let notices = engine.take_notices();
2460 assert!(
2461 notices.iter().any(|notice| notice == &dialog.status),
2462 "the outcome is toasted too — the status line is invisible once the \
2463 modal closes: {notices:?}"
2464 );
2465 assert_eq!(
2466 engine.parts_library_names().len(),
2467 3,
2468 "one library entry per unique ROOT-level product: plate, lb_assem, \
2469 rod_assem — the deeper parts live in THEIR documents' libraries"
2470 );
2471 assert_eq!(
2472 component_part_names(&engine).len(),
2473 4,
2474 "one ACOMP per root occurrence — a sub-assembly is ONE component"
2475 );
2476 assert!(engine.history_has_assembly(), "the document is an assembly now");
2477 assert_eq!(
2478 engine.settings.workbench, "assembly",
2479 "an imported assembly switches to the workbench its panels are gated on"
2480 );
2481 assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2482 assert!(
2483 stash_is_empty(&mut engine),
2484 "the consume TAKES the parse — a second one is never a double insert"
2485 );
2486 }
2487
2488 #[test]
2494 fn flattening_sub_assemblies_places_every_leaf_in_this_document() {
2495 let mut engine = EngineState::new();
2496 let mut dialog = armed(&mut engine);
2497 dialog.pending_step_import.as_mut().unwrap().flatten = true;
2498
2499 let store = crate::store::MemModelStore::new();
2500 dialog.step_assembly_import(&mut engine, &store);
2501
2502 assert_eq!(
2503 dialog.status,
2504 "imported as1-ug-214.stp \u{2014} 5 parts, 18 components; saved 5 part file(s)",
2505 "flattened: the leaf counts the prompt showed, plus the writes"
2506 );
2507 assert_eq!(store.list().len(), 5, "one file per distinct part");
2510 assert_eq!(engine.parts_library_names().len(), 5);
2511 assert_eq!(component_part_names(&engine).len(), 18);
2512 let document: serde_json::Value =
2513 serde_json::from_str(&engine.history_request_json()).expect("document JSON");
2514 for (name, entry) in document["partsLibrary"].as_object().expect("library") {
2515 assert!(
2516 entry["document"]["partsLibrary"].is_null(),
2517 "'{name}' must be a leaf part — flattening leaves no nested library"
2518 );
2519 }
2520 }
2521
2522 #[test]
2527 fn importing_as_bodies_uses_the_flat_lane_and_drops_the_parse() {
2528 let mut engine = EngineState::new();
2529 let mut dialog = armed(&mut engine);
2530
2531 dialog.step_assembly_bodies(&mut engine);
2532
2533 assert_eq!(engine.history_len(), 1, "one IMPORT3D feature");
2534 assert!(
2535 engine.history_request_json().contains("stepText"),
2536 "the flat lane stores the STEP text"
2537 );
2538 assert!(engine.parts_library_names().is_empty(), "no library entries");
2539 assert!(!engine.history_has_assembly(), "no components");
2540 assert_eq!(engine.settings.workbench, "modeling", "no assembly, no switch");
2541 assert_eq!(dialog.status, "imported as1-ug-214.stp");
2542 assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2543 assert!(stash_is_empty(&mut engine), "the parse is dropped, not left resident");
2544 }
2545
2546 #[test]
2551 fn cancel_imports_nothing_and_discards_the_parse() {
2552 let mut engine = EngineState::new();
2553 let mut dialog = armed(&mut engine);
2554
2555 dialog.step_assembly_cancel(&mut engine);
2556
2557 assert_eq!(engine.history_len(), 0, "cancel imports nothing");
2558 assert!(engine.parts_library_names().is_empty(), "no library entries");
2559 assert_eq!(engine.settings.workbench, "modeling", "no switch");
2560 assert!(dialog.status.contains("cancelled"), "status: {}", dialog.status);
2561 assert!(dialog.pending_step_import.is_none());
2562 assert!(stash_is_empty(&mut engine), "Cancel drops the parse");
2563 }
2564
2565 #[test]
2570 fn a_new_upload_supersedes_an_armed_choice() {
2571 let mut source = EngineState::new();
2572 source.set_history_json(BOX_SEED).unwrap();
2573 let part = source.export_step_text().unwrap();
2574
2575 let mut engine = EngineState::new();
2577 let mut dialog = armed(&mut engine);
2578 dialog.import_step(&mut engine, "part.step", &part);
2579 assert!(
2580 dialog.pending_step_import.is_none(),
2581 "the prompt cannot outlive the parse the new probe dropped"
2582 );
2583 assert_eq!(engine.history_len(), 1, "the part file imported flat");
2584
2585 let mut other = EngineState::new();
2587 let mut dialog = armed(&mut other);
2588 dialog.cancel_pending_step_import(&mut other);
2589 assert!(dialog.pending_step_import.is_none());
2590 assert!(stash_is_empty(&mut other), "the superseded parse is discarded");
2591 dialog.close_after_import();
2592 assert!(!dialog.open, "nothing armed, so the import routing closes the modal");
2593 }
2594
2595 #[test]
2599 fn importing_an_assembly_leaves_the_all_workbench_alone() {
2600 let mut engine = EngineState::new();
2601 engine.apply_settings_json(r#"{"workbench":"all"}"#).unwrap();
2602 let mut dialog = armed(&mut engine);
2603
2604 dialog.step_assembly_import(&mut engine, &crate::store::MemModelStore::new());
2605
2606 assert_eq!(
2607 engine.settings.workbench, "all",
2608 "'All' already shows the assembly panels — never switch away from it"
2609 );
2610 assert_eq!(component_part_names(&engine).len(), 4, "the import still ran");
2611 }
2612
2613 fn component_part_names(state: &EngineState) -> Vec<String> {
2616 serde_json::from_str::<serde_json::Value>(&state.history_request_json())
2617 .expect("history JSON")["features"]
2618 .as_array()
2619 .expect("features array")
2620 .iter()
2621 .filter(|feature| feature["type"] == "ACOMP")
2622 .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2623 .collect()
2624 }
2625
2626 #[test]
2630 fn the_outcome_line_reports_exactly_what_happened() {
2631 let clean = StepAssemblyReport {
2632 parts: 5,
2633 instances: 18,
2634 ..StepAssemblyReport::default()
2635 };
2636 assert_eq!(
2637 assembly_import_message("as1.stp", &clean),
2638 "imported as1.stp \u{2014} 5 parts, 18 components"
2639 );
2640 assert_eq!(
2641 assembly_import_message(
2642 "one.stp",
2643 &StepAssemblyReport { parts: 1, instances: 1, ..clean.clone() }
2644 ),
2645 "imported one.stp \u{2014} 1 part, 1 component",
2646 "singulars, so the sentence is one a user believes"
2647 );
2648
2649 let messy = StepAssemblyReport {
2650 baked_nonrigid: 2,
2651 failed_products: 1,
2652 first_error: Some("body 7: unsupported surface".into()),
2653 ..clean.clone()
2654 };
2655 let line = assembly_import_message("mixed.stp", &messy);
2656 assert!(line.contains("(2 mirrored instances baked)"), "{line}");
2657 assert!(line.contains("1 product could not be built"), "{line}");
2658 assert!(line.contains("unsupported surface"), "{line}");
2659
2660 let fell_back = StepAssemblyReport { flat_fallback: true, ..clean };
2661 assert!(
2662 assembly_import_message("flat.stp", &fell_back).contains("the bodies came in flat"),
2663 "a user who asked for an assembly and got bodies must be told"
2664 );
2665 }
2666
2667 #[test]
2670 fn the_document_name_drops_the_step_extension() {
2671 assert_eq!(step_document_name("bracket-assy.step"), "bracket-assy");
2672 assert_eq!(step_document_name("BRACKET.STP"), "BRACKET");
2673 assert_eq!(step_document_name("/tmp/a/b/as1-ug-214.stp"), "as1-ug-214");
2674 assert_eq!(step_document_name("no-extension"), "no-extension");
2675 assert_eq!(step_document_name(".step"), ".step", "a bare extension is the name");
2676 }
2677}