1mod plm_save_as;
30
31use crate::automation::hit_keys::HitKeyDoc;
32use crate::document::{Document, Documents};
33use crate::document_class::{self, DocumentClass};
34use crate::family_table::{self, FamilySource};
35use crate::template::{self, TemplateInput};
36use crate::panels::parts_library::document_signature;
37use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
38use crate::panels::kicad_import::{self, DiskFiles, KicadImport, KicadKind};
39use crate::store::{model_display_name, sibling_identity, ModelStore, PlaceKind, MODEL_EXTENSIONS};
40use brep_render::engine_state::{
41 ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
42 StepAssemblyReport, StepProbeOutcome,
43};
44use brep_render::runner::MeshImportFormat;
45use eframe::egui;
46use std::collections::{BTreeMap, HashMap, HashSet};
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FileAction {
52 New,
53 NewOfClass(DocumentClass),
56 Open,
57 Save,
58 SaveAs,
59 Import,
62 Export,
65 ExportFlatPattern,
68 InsertComponent,
74 ImportKicad(KicadKind),
79}
80
81#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84 CreateComponentPart,
86 Open,
88 SaveAs,
90 ConfirmClose,
94 Export,
96 Import,
98 FlatPattern,
100 InsertComponent,
103 StepAssembly,
107 Kicad,
110 PickMember,
113 SpinOut,
116 MemberEdit,
119 PickFile,
123}
124
125const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
127
128#[derive(Clone, Debug, PartialEq)]
130pub struct PickedFile {
131 pub name: String,
133 pub path: Option<String>,
135 pub bytes: Vec<u8>,
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
144enum Purpose {
145 Models,
147 Imports,
149 Kicad,
151}
152
153impl Purpose {
154 const ALL: [Purpose; 3] = [Purpose::Models, Purpose::Imports, Purpose::Kicad];
155
156 fn slug(self) -> &'static str {
157 match self {
158 Purpose::Models => "models",
159 Purpose::Imports => "imports",
160 Purpose::Kicad => "kicad",
161 }
162 }
163}
164
165impl Mode {
166 fn purpose(self) -> Option<Purpose> {
168 match self {
169 Mode::Open | Mode::SaveAs | Mode::InsertComponent | Mode::CreateComponentPart => {
170 Some(Purpose::Models)
171 }
172 Mode::Import => Some(Purpose::Imports),
173 Mode::Kicad => Some(Purpose::Kicad),
174 Mode::ConfirmClose
175 | Mode::Export
176 | Mode::FlatPattern
177 | Mode::StepAssembly
178 | Mode::PickMember
179 | Mode::SpinOut
180 | Mode::MemberEdit
181 | Mode::PickFile => None,
182 }
183 }
184}
185
186fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
191 match purpose {
192 Purpose::Imports | Purpose::Kicad => store.local_files().unwrap_or(store),
193 Purpose::Models => store,
194 }
195}
196
197fn models_folder(store: &dyn ModelStore) -> Option<String> {
201 store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
202}
203
204struct PendingStepImport {
214 name: String,
216 text: String,
219 probe: StepAssemblyProbe,
221 flatten: bool,
226}
227
228struct PendingStepProbe {
234 id: u64,
236 name: String,
237 text: String,
239}
240
241#[derive(Clone, Copy, PartialEq, Eq)]
243enum StepChoice {
244 Assembly,
246 Bodies,
248 Cancel,
250}
251
252const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
255
256struct MemberChoice {
258 part_number: String,
259 description: String,
260 identity: Option<String>,
263 exists: bool,
265 why_not: Option<String>,
268}
269
270struct PickMember {
272 family_file: String,
274 rows: Vec<MemberChoice>,
275 plm: bool,
279 loading: bool,
280}
281
282struct SpinOut {
285 template_file: String,
286 template: serde_json::Value,
287 inputs: Vec<TemplateInput>,
288 name: String,
290 values: BTreeMap<String, String>,
292 assembly: Option<String>,
295 plm_template: Option<String>,
298 number: String,
299}
300
301enum PlmWork {
304 Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
306 Place(crate::panels::plm_family::Pending<(String, String, String)>),
309 OpenFamily(crate::panels::plm_family::Pending<String>),
311}
312
313struct MemberPrompt {
315 doc_id: u64,
316 source: FamilySource,
317 family_identity: String,
319 from_save: bool,
321}
322
323fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
328 document_class::set_document_class(engine, class);
329 let drop: &[&str] = match class {
330 DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
331 DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
332 DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
333 };
334 for key in drop {
335 if engine.history.document_block(key).is_some() {
336 engine.history.set_document_block_no_undo(key, None);
337 }
338 }
339}
340
341fn spin_out_target(spin: &SpinOut) -> String {
344 let file = DocumentClass::Normal.file_name(spin.name.trim());
345 match &spin.assembly {
346 Some(assembly) => sibling_identity(assembly, &file),
347 None => file,
348 }
349}
350
351fn plural(count: usize) -> &'static str {
354 if count == 1 {
355 ""
356 } else {
357 "s"
358 }
359}
360
361fn step_document_name(file_name: &str) -> String {
365 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
366 let cut = base
367 .rfind('.')
368 .filter(|dot| is_step_name(&base[*dot..]))
369 .unwrap_or(base.len());
370 let stem = &base[..cut];
371 if stem.is_empty() {
372 base.to_string()
373 } else {
374 stem.to_string()
375 }
376}
377
378struct StorePartSink<'a> {
426 store: &'a dyn ModelStore,
427 prefix: String,
429 taken: std::collections::HashSet<String>,
433 written: usize,
434 failures: Vec<String>,
435}
436
437impl<'a> StorePartSink<'a> {
438 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
439 Self {
440 store,
441 prefix: sanitize_file_stem(prefix),
442 taken: std::collections::HashSet::new(),
443 written: 0,
444 failures: Vec::new(),
445 }
446 }
447}
448
449impl PartSink for StorePartSink<'_> {
450 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
451 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
452 let mut name = base.clone();
453 let mut suffix = 2;
454 while !self.taken.insert(name.clone()) {
455 name = format!("{base}-{suffix}");
456 suffix += 1;
457 }
458 match self.store.browser_write(&name, document_json) {
459 Ok(identity) => {
460 self.written += 1;
461 Some(identity)
462 }
463 Err(error) => {
464 self.failures.push(format!("{name}: {error}"));
465 None
466 }
467 }
468 }
469}
470
471fn sanitize_file_stem(name: &str) -> String {
477 let mut out = String::with_capacity(name.len());
478 for ch in name.chars() {
479 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
480 out.push(ch);
481 } else if !out.ends_with('_') {
482 out.push('_');
483 }
484 }
485 let trimmed = out.trim_matches('_');
486 if trimmed.is_empty() {
487 "part".to_string()
488 } else {
489 trimmed.to_string()
490 }
491}
492
493fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
494 let mut message = format!(
495 "imported {name} \u{2014} {} part{}, {} component{}",
496 report.parts,
497 plural(report.parts),
498 report.instances,
499 plural(report.instances)
500 );
501 if report.baked_nonrigid > 0 {
502 message.push_str(&format!(
503 " ({} mirrored instance{} baked)",
504 report.baked_nonrigid,
505 plural(report.baked_nonrigid)
506 ));
507 }
508 if report.failed_products > 0 {
509 message.push_str(&format!(
510 "; {} product{} could not be built",
511 report.failed_products,
512 plural(report.failed_products)
513 ));
514 }
515 if report.flat_fallback {
516 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
517 }
518 if let Some(error) = &report.first_error {
519 message.push_str(&format!(" [{error}]"));
520 }
521 message
522}
523
524fn is_step_name(name: &str) -> bool {
529 let lower = name.to_ascii_lowercase();
530 lower.ends_with(".step") || lower.ends_with(".stp")
531}
532
533fn is_iges_name(name: &str) -> bool {
536 let lower = name.to_ascii_lowercase();
537 lower.ends_with(".iges") || lower.ends_with(".igs")
538}
539
540fn is_stl_name(name: &str) -> bool {
541 name.to_ascii_lowercase().ends_with(".stl")
542}
543
544fn is_obj_name(name: &str) -> bool {
545 name.to_ascii_lowercase().ends_with(".obj")
546}
547
548fn is_3mf_name(name: &str) -> bool {
551 name.to_ascii_lowercase().ends_with(".3mf")
552}
553
554mod component_part;
555
556pub struct FileDialog {
559 pending_open: Option<(String, u64)>,
562 pending_insert: Option<(String, u64)>,
564 component_part: Option<component_part::Recovery>,
565 explorer: FileExplorer,
567 workspace: crate::panels::plm_workspace::WorkspaceBrowser,
570 name_buf: String,
572 pub(crate) status: String,
574 open: bool,
576 mode: Mode,
578 want_focus: bool,
580 pending_component_import: bool,
584 pending_close: Option<usize>,
586 pending_step_import: Option<PendingStepImport>,
591 pending_step_probe: Option<PendingStepProbe>,
594 pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
597 save_generation: u64,
601 plm_save_request: Option<u64>,
602 plm_save_as: Option<plm_save_as::SaveAs>,
603 member_new_part: bool,
607 hits: HashMap<String, egui::Rect>,
609 kicad: KicadImport,
611 folders: HashMap<Purpose, String>,
614 pick_request: Option<(String, String)>,
617 pick_folder: Option<String>,
618 picked: Option<(String, PickedFile)>,
619 plm_new_part: bool,
622 save_class: DocumentClass,
624 pick_member: Option<PickMember>,
626 plm_work: Option<PlmWork>,
627 spin_out: Option<SpinOut>,
629 member_prompt: Option<MemberPrompt>,
631 member_ack: HashSet<u64>,
634 last_generate: Option<family_table::GenerateReport>,
636}
637
638impl FileDialog {
639 pub fn new() -> Self {
642 Self {
643 pending_open: None,
644 pending_insert: None,
645 component_part: None,
646 explorer: FileExplorer::new(),
647 workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
648 name_buf: String::new(),
649 status: String::new(),
650 open: false,
651 mode: Mode::Open,
652 want_focus: false,
653 pending_component_import: false,
654 pending_close: None,
655 pending_step_import: None,
656 pending_step_probe: None,
657 pending_stl_import: None,
658 save_generation: 0,
659 plm_save_request: None,
660 plm_save_as: None,
661 member_new_part: false,
662 hits: HashMap::new(),
663 kicad: KicadImport::new(),
664 folders: HashMap::new(),
665 pick_request: None,
666 pick_folder: None,
667 picked: None,
668 plm_new_part: false,
669 save_class: DocumentClass::Normal,
670 pick_member: None,
671 plm_work: None,
672 spin_out: None,
673 member_prompt: None,
674 member_ack: HashSet::new(),
675 last_generate: None,
676 }
677 }
678
679 pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
682 let mut out = Vec::new();
683 if self.kicad.reading() {
684 out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
685 }
686 out
687 }
688
689 pub fn save_generation(&self) -> u64 {
692 self.save_generation
693 }
694
695 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
701 if matches!(action, FileAction::New | FileAction::NewOfClass(_) | FileAction::Open | FileAction::Import) {
702 self.pending_open = None;
703 self.pending_insert = None;
704 }
705 match action {
706 FileAction::New => self.new_document(docs, DocumentClass::Normal),
707 FileAction::NewOfClass(class) => self.new_document(docs, class),
708 FileAction::Open => {
709 self.use_folder(Purpose::Models, store);
710 self.open_modal(Mode::Open);
711 }
712 FileAction::Save => match docs.active().name().map(str::to_string) {
713 Some(name) if self.arm_member_prompt(docs, &name, true) => {}
716 Some(name) if store.plm_client().is_some() && crate::panels::plm_host::revision_of(&name).is_some() => {
717 self.plm_save_request = Some(docs.active_id());
718 }
719 Some(name) => {
721 let _ = self.save_to(docs, store, name);
722 }
723 None => self.dispatch(FileAction::SaveAs, docs, store),
725 },
726 FileAction::SaveAs => {
727 self.name_buf = document_class::strip_class_extension(&Self::document_name(docs)).to_string();
728 self.save_class = document_class::document_class(docs.engine());
729 self.use_folder(Purpose::Models, store);
730 self.open_modal(Mode::SaveAs);
731 }
732 FileAction::Import => {
733 self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
734 self.open_modal(Mode::Import);
735 }
736 FileAction::Export => {
737 self.name_buf = Self::document_name(docs);
738 self.open_modal(Mode::Export);
739 }
740 FileAction::ExportFlatPattern => {
741 self.name_buf = Self::document_name(docs);
742 self.open_modal(Mode::FlatPattern);
743 }
744 FileAction::InsertComponent => {
749 self.use_folder(Purpose::Models, store);
750 self.open_modal(Mode::InsertComponent);
751 }
752 FileAction::ImportKicad(kind) => {
753 self.kicad.start(kind, store);
754 self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
755 self.open_modal(Mode::Kicad);
756 }
757 }
758 }
759
760 pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
767 self.picked = None;
768 if let Some(local) = store.local_files() {
769 let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
770 if !arrived {
771 let _ = local.browser_home();
772 }
773 self.pick_request = Some((tag.to_string(), title.to_string()));
774 self.status.clear();
775 self.open_modal(Mode::PickFile);
776 return Ok(());
777 }
778 store.begin_pick_file()?;
779 self.pick_request = Some((tag.to_string(), title.to_string()));
780 Ok(())
781 }
782
783 pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
785 if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
786 return self.picked.take().map(|(_, file)| file);
787 }
788 None
789 }
790
791 pub fn picking(&self, tag: &str) -> bool {
794 self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
795 }
796
797 fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
799 let Some(local) = store.local_files() else {
800 self.open = false;
801 self.pick_request = None;
802 return;
803 };
804 let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
805 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
806 file_explorer::dialog_body(ui, |ui| {
807 ui.heading(&title);
808 ui.add_space(4.0);
809 file_explorer::dialog_footer(ui, "pickfile", |ui| {
810 if !self.status.is_empty() {
811 ui.add_space(4.0);
812 ui.weak(&self.status);
813 }
814 });
815 let options = FileExplorerOptions {
816 hit_prefix: "pickfile",
817 empty_label: "(this folder is empty)",
818 row_icon: "\u{1F5CE}",
819 current: None,
820 allow_delete: false,
821 allow_import: false,
822 import_label: "",
823 import_hit: "pickfile:upload",
824 show_cancel: true,
825 confirm_label: Some("Choose"),
826 extensions: &[],
827 };
828 let output = self.explorer.show_store(ui, local, options);
829 self.record_explorer_hits(&output.hits);
830 output
831 })
832 });
833 let should_close = modal.should_close();
834 let output = modal.inner;
835 self.pick_folder = Some(local.browser_location());
836 if let Some(path) = output.activated {
837 match local.read_external_file(&path) {
838 Some(bytes) => {
839 let name = std::path::Path::new(&path)
840 .file_name()
841 .map(|n| n.to_string_lossy().into_owned())
842 .unwrap_or_else(|| path.clone());
843 if let Some((tag, _)) = self.pick_request.take() {
844 self.status = format!("chose {name}");
845 self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
846 }
847 self.open = false;
848 }
849 None => self.status = format!("'{path}' could not be read"),
850 }
851 } else if output.cancel || should_close {
852 self.pick_request = None;
853 self.status.clear();
854 self.open = false;
855 }
856 }
857
858 fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
862 let remembered = self.folders.get(&purpose).cloned();
863 let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
864 if !arrived {
865 if let Some(models) = models_folder(store) {
866 let _ = store.browser_navigate(&models);
867 }
868 }
869 }
870
871 fn remember_folder(&mut self, store: &dyn ModelStore) {
873 if let Some(purpose) = self.mode.purpose() {
874 self.folders.insert(purpose, browse_store(purpose, store).browser_location());
875 }
876 }
877
878 fn open_modal(&mut self, mode: Mode) {
880 self.pending_insert = None;
884 self.explorer.clear_selection();
885 self.mode = mode;
886 self.open = true;
887 self.want_focus = true;
888 }
889
890 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
896 self.hits.clear();
897 self.poll_pending_open(ctx, docs, store);
898 self.poll_pending_insert(ctx, docs, store);
899 self.poll_plm_save_as(ctx, docs, store);
900 self.poll_plm_work(ctx, docs, store);
901 self.poll_open_workspace_pick(store);
902 if let Some(file) = store.take_picked_file() {
904 if let Some((tag, _)) = self.pick_request.take() {
905 self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
906 }
907 }
908
909 self.poll_step_probe(docs.engine_mut());
912
913 if let Some(imported) = store.take_import() {
921 self.cancel_pending_step_import(docs.engine_mut());
927 let name = imported.name;
928 let bytes = imported.bytes;
929 if KicadKind::of_file(&name).is_some() {
932 self.take_kicad_file(&name, &bytes, store);
933 return;
934 } else if is_step_name(&name) {
935 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
936 } else if is_iges_name(&name) {
937 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
938 } else if is_stl_name(&name) {
939 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes);
940 } else if is_3mf_name(&name) {
941 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes);
942 } else if is_obj_name(&name) {
943 self.import_obj(docs.engine_mut(), &name, &bytes);
944 } else if std::mem::take(&mut self.pending_component_import) {
945 match String::from_utf8(bytes) {
950 Ok(contents) => {
951 let assembly = docs.active().name().map(str::to_string);
952 if !self.begin_class_insert(store, &name, &contents, assembly) {
953 self.insert_component_document(docs.engine_mut(), &name, &contents)
954 }
955 if self.pick_member.is_some() || self.spin_out.is_some() {
956 return;
957 }
958 }
959 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
960 }
961 } else {
962 match String::from_utf8(bytes) {
963 Ok(contents) => self.load_document(docs, &name, &contents, store),
964 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
965 }
966 }
967 self.close_after_import();
968 }
969
970 if !self.open {
977 self.watch_member_edit(docs);
978 }
979
980 if !self.open {
981 return;
982 }
983 self.remember_folder(store);
984
985 match self.mode {
986 Mode::CreateComponentPart => self.show_create_component_part(ctx, docs, store),
987 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
988 Mode::SaveAs => self.show_save_as(ctx, docs, store),
989 Mode::Open => self.show_open(ctx, docs, store),
990 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
991 Mode::Export => self.show_export(ctx, docs, store),
992 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
993 Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
994 Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
995 Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
996 Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
997 Mode::PickFile => self.show_pick_file(ctx, store),
998 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
999 Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
1000 }
1001 }
1002
1003 fn close_after_import(&mut self) {
1009 if self.pending_step_import.is_some() {
1010 self.open_modal(Mode::StepAssembly);
1011 } else {
1012 self.open = false;
1013 }
1014 }
1015
1016 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
1021 if self.pending_step_import.take().is_some() {
1022 state.discard_probed_step_assembly();
1023 }
1024 }
1025
1026 fn show_import(
1029 &mut self,
1030 ctx: &egui::Context,
1031 state: &mut EngineState,
1032 store: &dyn ModelStore,
1033 ) {
1034 const EXTENSIONS: &[&str] =
1035 &["step", "stp", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
1036 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
1037 file_explorer::dialog_body(ui, |ui| {
1038 ui.heading("Import CAD file");
1039 ui.add_space(4.0);
1040 file_explorer::dialog_footer(ui, "import", |ui| {
1041 if !self.status.is_empty() {
1042 ui.add_space(4.0);
1043 ui.weak(&self.status);
1044 }
1045 });
1046 let options = FileExplorerOptions {
1047 hit_prefix: "import",
1048 empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1049 row_icon: "\u{1F5CE}",
1050 current: None,
1051 allow_delete: false,
1052 allow_import: store.supports_file_interchange(),
1053 import_label: "Upload\u{2026}",
1054 import_hit: "import:upload",
1055 show_cancel: true,
1056 confirm_label: Some("Import"),
1057 extensions: EXTENSIONS,
1058 };
1059 let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1060 self.record_explorer_hits(&output.hits);
1061 output
1062 })
1063 });
1064 let should_close = modal.should_close();
1065 let output = modal.inner;
1066 if let Some(name) = output.activated {
1067 match browse_store(Purpose::Imports, store).read_external_file(&name) {
1068 Some(bytes) if KicadKind::of_file(&name).is_some() => {
1069 self.take_kicad_file(&name, &bytes, store);
1071 return;
1072 }
1073 Some(bytes) if is_step_name(&name) => {
1074 self.import_text(state, &name, &bytes, Self::import_step)
1075 }
1076 Some(bytes) if is_iges_name(&name) => {
1077 self.import_text(state, &name, &bytes, Self::import_iges)
1078 }
1079 Some(bytes) if is_stl_name(&name) => {
1080 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes)
1081 }
1082 Some(bytes) if is_3mf_name(&name) => {
1083 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes)
1084 }
1085 Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
1086 Some(_) => self.status = format!("unsupported import file: {name}"),
1087 None => self.status = format!("import failed: '{name}' not found"),
1088 }
1089 self.close_after_import();
1090 } else if output.import {
1091 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1092 Ok(()) => {
1093 self.status =
1094 "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1095 self.open = false;
1096 }
1097 Err(e) => self.status = format!("import failed: {e}"),
1098 }
1099 } else if output.cancel || should_close {
1100 self.open = false;
1101 }
1102 }
1103
1104 fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1109 if !(self.open && self.mode == Mode::Kicad) {
1110 if let Some(kind) = KicadKind::of_file(name) {
1111 self.kicad.start(kind, store);
1112 }
1113 }
1114 self.kicad.take_file(name, bytes, &DiskFiles);
1115 self.open_modal(Mode::Kicad);
1116 }
1117
1118 fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1121 if !self.kicad.wants_file() {
1122 match self.kicad.show(ctx, state, store, &mut self.hits) {
1123 kicad_import::Outcome::Open => {}
1124 kicad_import::Outcome::Closed => self.open = false,
1125 kicad_import::Outcome::Imported(imported) => {
1126 self.status = imported.summary.clone();
1129 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1130 const SHOWN: usize = 5;
1131 let hidden = imported.notes.len().saturating_sub(SHOWN);
1132 for note in imported.notes.into_iter().take(SHOWN) {
1133 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1134 }
1135 if hidden > 0 {
1136 state.push_notice_as(
1137 brep_render::engine_state::NoticeSeverity::Warning,
1138 format!("and {hidden} more notes on this import"),
1139 );
1140 }
1141 self.open = false;
1142 }
1143 }
1144 return;
1145 }
1146 let extension = self.kicad.kind().extension();
1147 let heading = match self.kicad.kind() {
1148 KicadKind::Symbol => "Import a symbol from KiCad",
1149 KicadKind::Footprint => "Import a footprint from KiCad",
1150 };
1151 let extensions = [extension];
1152 let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1153 file_explorer::dialog_body(ui, |ui| {
1154 ui.heading(heading);
1155 ui.add_space(4.0);
1156 file_explorer::dialog_footer(ui, "kicad", |ui| {
1157 let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1158 if !status.is_empty() {
1159 ui.add_space(4.0);
1160 ui.weak(status);
1161 }
1162 });
1163 let empty_label = format!("(no .{extension} files)");
1164 let options = FileExplorerOptions {
1165 hit_prefix: "kicad",
1166 empty_label: &empty_label,
1167 row_icon: "\u{1F5CE}",
1168 current: None,
1169 allow_delete: false,
1170 allow_import: store.supports_file_interchange(),
1171 import_label: "Upload\u{2026}",
1172 import_hit: "kicad:upload",
1173 show_cancel: true,
1174 confirm_label: Some("Open"),
1175 extensions: &extensions,
1176 };
1177 let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1178 self.record_explorer_hits(&output.hits);
1179 output
1180 })
1181 });
1182 let should_close = modal.should_close();
1183 let output = modal.inner;
1184 if let Some(name) = output.activated {
1185 match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1186 Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1187 None => self.kicad.status = format!("import failed: '{name}' not found"),
1188 }
1189 } else if output.import {
1190 match store.begin_import_filtered(("KiCad", &extensions)) {
1191 Ok(()) => {
1192 self.status = format!("choose a .{extension} file\u{2026}");
1193 self.open = false;
1194 }
1195 Err(e) => self.kicad.status = format!("import failed: {e}"),
1196 }
1197 } else if output.cancel || should_close {
1198 self.open = false;
1199 }
1200 }
1201
1202 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1208 let dirty = docs.get(index).is_some_and(Document::is_dirty);
1209 if dirty {
1210 self.pending_close = Some(index);
1211 self.open_modal(Mode::ConfirmClose);
1212 } else {
1213 docs.close(index);
1214 }
1215 }
1216
1217 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1221 let Some(title) = self
1224 .pending_close
1225 .and_then(|index| docs.get(index))
1226 .map(Document::title)
1227 else {
1228 self.pending_close = None;
1229 self.open = false;
1230 return;
1231 };
1232 let mut discard = false;
1233 let mut cancel = false;
1234 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1235 ui.set_width(340.0);
1236 ui.heading("Discard unsaved changes?");
1237 ui.add_space(4.0);
1238 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1239 ui.add_space(6.0);
1240 ui.horizontal(|ui| {
1241 let d = ui.button("Discard and close");
1242 self.hit("confirm:discard", &d);
1243 if d.clicked() {
1244 discard = true;
1245 }
1246 let c = ui.button("Cancel");
1247 self.hit("confirm:cancel", &c);
1248 if c.clicked() {
1249 cancel = true;
1250 }
1251 });
1252 });
1253 if discard {
1254 if let Some(index) = self.pending_close.take() {
1255 docs.close(index);
1256 }
1257 self.open = false;
1258 } else if cancel || modal.should_close() {
1259 self.pending_close = None;
1260 self.open = false;
1261 }
1262 }
1263
1264 fn show_step_assembly(
1283 &mut self,
1284 ctx: &egui::Context,
1285 state: &mut EngineState,
1286 store: &dyn ModelStore,
1287 ) {
1288 let Some(pending) = self.pending_step_import.as_ref() else {
1291 self.open = false;
1292 return;
1293 };
1294 let name = pending.name.clone();
1295 let probe = pending.probe;
1296 let mut flatten = pending.flatten;
1297 let mut choice: Option<StepChoice> = None;
1298 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1299 ui.set_width(360.0);
1300 ui.heading(format!("\"{name}\" contains an assembly"));
1301 ui.add_space(4.0);
1302 ui.label(format!(
1303 "{} part{}, {} instance{}{}.",
1304 probe.parts,
1305 plural(probe.parts),
1306 probe.instances,
1307 plural(probe.instances),
1308 match probe.nested_depth {
1309 0 | 1 => String::new(),
1310 depth => format!(", {depth} levels deep"),
1311 }
1312 ));
1313 if probe.nested_depth > 1 {
1314 ui.add_space(4.0);
1315 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1316 self.hit("stepassembly:flatten", &check);
1317 check.on_hover_text(
1318 "Off: each sub-assembly becomes one rigid component you can \
1319 expand in the structure tree.\nOn: every part is placed \
1320 directly in this document at its world position.",
1321 );
1322 }
1323 ui.add_space(6.0);
1324 ui.horizontal(|ui| {
1325 let a = ui.button("Import as assembly");
1326 self.hit("stepassembly:assembly", &a);
1327 if a.clicked() {
1328 choice = Some(StepChoice::Assembly);
1329 }
1330 let b = ui.button("Import as bodies");
1331 self.hit("stepassembly:bodies", &b);
1332 if b.clicked() {
1333 choice = Some(StepChoice::Bodies);
1334 }
1335 let c = ui.button("Cancel");
1336 self.hit("stepassembly:cancel", &c);
1337 if c.clicked() {
1338 choice = Some(StepChoice::Cancel);
1339 }
1340 });
1341 });
1342 if let Some(pending) = self.pending_step_import.as_mut() {
1344 pending.flatten = flatten;
1345 }
1346 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1349 let Some(choice) = choice else { return };
1350 match choice {
1351 StepChoice::Assembly => self.step_assembly_import(state, store),
1352 StepChoice::Bodies => self.step_assembly_bodies(state),
1353 StepChoice::Cancel => self.step_assembly_cancel(state),
1354 }
1355 self.open = false;
1356 }
1357
1358 pub fn take_plm_new_part(&mut self) -> bool {
1361 std::mem::take(&mut self.plm_new_part)
1362 }
1363
1364 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1366 if let Some(client) = store.plm_client() {
1368 self.show_save_as_plm(ctx, docs, store, client);
1369 return;
1370 }
1371 let current = docs.active().name().map(str::to_string);
1372 let mut do_save = false;
1373 let mut cancel = false;
1374 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1375 file_explorer::dialog_body(ui, |ui| {
1376 ui.heading("Save model as");
1377 ui.add_space(4.0);
1378 file_explorer::dialog_footer(ui, "saveas", |ui| {
1381 ui.label("File name");
1382 let field = ui.add(
1383 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1384 .hint_text("model name")
1385 .desired_width(f32::INFINITY),
1386 );
1387 self.hit("field:name", &field);
1388 if self.want_focus {
1389 field.request_focus();
1390 self.want_focus = false;
1391 }
1392 let enter =
1393 field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1394 ui.horizontal(|ui| {
1399 ui.label("Save as");
1400 for class in DocumentClass::ALL {
1401 let text = format!("{} ({})", class.label(), class.extension());
1402 let chip = ui.selectable_label(self.save_class == class, text);
1403 self.hit(&format!("saveas:class:{}", class.slug()), &chip);
1404 if chip.clicked() {
1405 self.save_class = class;
1406 }
1407 }
1408 });
1409 ui.add_space(6.0);
1410 ui.horizontal(|ui| {
1411 let save = ui.button("Save");
1412 self.hit("save", &save);
1413 if save.clicked() || enter {
1414 do_save = true;
1415 }
1416 let c = ui.button("Cancel");
1417 self.hit("cancel", &c);
1418 if c.clicked() {
1419 cancel = true;
1420 }
1421 });
1422 if !self.status.is_empty() {
1423 ui.add_space(4.0);
1424 ui.weak(&self.status);
1425 }
1426 });
1427 let options = FileExplorerOptions {
1428 hit_prefix: "saveas:file",
1429 empty_label: "(no saved models)",
1430 row_icon: "\u{1F5CE}",
1431 current: current.as_deref(),
1432 allow_delete: false,
1433 allow_import: false,
1434 import_label: "",
1435 import_hit: "saveas:upload",
1436 show_cancel: false,
1437 confirm_label: None,
1438 extensions: MODEL_EXTENSIONS,
1439 };
1440 let output = self.explorer.show_store(ui, store, options);
1441 self.record_explorer_hits(&output.hits);
1442 if let Some(name) = output.picked.or(output.activated) {
1448 self.name_buf =
1449 document_class::strip_class_extension(&model_display_name(&name)).to_string();
1450 if let Some(class) = DocumentClass::of_name(&name) {
1451 self.save_class = class;
1452 }
1453 }
1454 })
1455 });
1456 if do_save {
1457 let name = self.save_class.file_name(self.name_buf.trim());
1458 if self.save_to_browser(docs, store, name) {
1459 self.open = false;
1460 }
1461 } else if cancel || modal.should_close() {
1462 self.open = false;
1463 }
1464 }
1465
1466 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1475 let has_bom = !docs.engine().component_ids().is_empty()
1481 || !docs.engine().wire_harness_state().connections.is_empty();
1482 let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1486 let has_board = super::fabrication_export::has_board(docs.engine());
1489 let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1490 let mut chosen: Option<&'static str> = None;
1491 let mut bom_chosen: Option<&'static str> = None;
1492 let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1493 let mut cancel = false;
1494 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1495 ui.set_width(440.0);
1498 ui.heading("Export model");
1499 ui.add_space(4.0);
1500 Self::preview_export_line(ui, docs);
1501 let field = ui.add(
1502 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1503 .hint_text("file name")
1504 .desired_width(f32::INFINITY),
1505 );
1506 self.hit("field:name", &field);
1507 if self.want_focus {
1508 field.request_focus();
1509 self.want_focus = false;
1510 }
1511 let group = |ui: &mut egui::Ui, title: &str| {
1515 ui.add_space(6.0);
1516 ui.label(egui::RichText::new(title).weak());
1517 };
1518 group(ui, "3D model");
1519 ui.horizontal_wrapped(|ui| {
1520 let step = ui.button("STEP (.step)");
1521 self.hit("export:step", &step);
1522 if step.clicked() {
1523 chosen = Some("step");
1524 }
1525 let iges = ui.button("IGES (.igs)");
1526 self.hit("export:iges", &iges);
1527 if iges.clicked() {
1528 chosen = Some("iges");
1529 }
1530 let stl = ui.button("STL (.stl)");
1531 self.hit("export:stl", &stl);
1532 if stl.clicked() {
1533 chosen = Some("stl");
1534 }
1535 let obj = ui.button("OBJ (.obj)");
1536 self.hit("export:obj", &obj);
1537 if obj.clicked() {
1538 chosen = Some("obj");
1539 }
1540 let glb = ui.button("GLB (.glb)");
1541 self.hit("export:glb", &glb);
1542 if glb.clicked() {
1543 chosen = Some("glb");
1544 }
1545 let json = ui.button("Model (.nbrep)");
1549 self.hit("export:json", &json);
1550 if json.clicked() {
1551 chosen = Some("json");
1552 }
1553 });
1554 group(ui, "Bill of materials");
1555 ui.horizontal_wrapped(|ui| {
1556 let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1560 self.hit("export:bomcsv", &csv);
1561 if csv.clicked() {
1562 bom_chosen = Some("csv");
1563 }
1564 let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1565 self.hit("export:bomjson", &json);
1566 if json.clicked() {
1567 bom_chosen = Some("json");
1568 }
1569 });
1570 group(ui, "Drawing");
1571 ui.horizontal_wrapped(|ui| {
1572 let sheet = ui
1575 .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1576 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1577 self.hit("export:sheetsvg", &sheet);
1578 if sheet.clicked() {
1579 chosen = Some("sheetsvg");
1580 }
1581 let pdf = ui
1585 .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1586 .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1587 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1588 self.hit("export:sheetpdf", &pdf);
1589 if pdf.clicked() {
1590 chosen = Some("sheetpdf");
1591 }
1592 let pdf3d = ui
1597 .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1598 .on_hover_text("The drawing set with the live 3D model: Acrobat and Foxit turn and zoom it and switch PMI views; other PDF readers show the drawings")
1599 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1600 self.hit("export:sheetpdf3d", &pdf3d);
1601 if pdf3d.clicked() {
1602 chosen = Some("sheetpdf3d");
1603 }
1604 });
1605 group(ui, "Electronics");
1606 ui.horizontal_wrapped(|ui| {
1607 use super::fabrication_export::Output;
1611 let no_board = "Place the schematic's parts on the PCB board first";
1612 for (key, label, hover, output) in [
1613 ("export:fabrication", "Fabrication (zip)", "Gerber X2 layers, Excellon drills, pick and place, the parts BOM and a README of the layers, in one zip for the board house", Output::Bundle),
1614 ("export:pickplace", "Pick & place (CSV)", "One row per placed part: Designator, Val, Package, Mid X, Mid Y, Rotation, Layer (JLCPCB's columns), in mm from the board's lower-left corner", Output::PickPlace),
1615 ("export:ecadbom", "Parts BOM (CSV)", "The electronic parts: Comment (the value), Designator, Footprint, Quantity (JLCPCB's columns), one line per value and footprint", Output::Bom),
1616 ("export:kicadnet", "Netlist (KiCad)", "The PCB schematic's nets as a KiCad .net file, the netlist Pcbnew and other tools read: every part with its value and footprint, every net with its pins, under the names the Connectivity panel lists", Output::Netlist),
1617 ] {
1618 let (enabled, why) = match output {
1619 Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
1620 _ => (has_board, no_board),
1621 };
1622 let button = ui
1623 .add_enabled(enabled, egui::Button::new(label))
1624 .on_hover_text(hover)
1625 .on_disabled_hover_text(why);
1626 self.hit(key, &button);
1627 if button.clicked() {
1628 fab_chosen = Some(output);
1629 }
1630 }
1631 });
1632 if !self.status.is_empty() {
1633 ui.add_space(4.0);
1634 ui.weak(&self.status);
1635 }
1636 ui.add_space(6.0);
1637 ui.separator();
1638 let c = ui.button("Cancel");
1639 self.hit("cancel", &c);
1640 if c.clicked() {
1641 cancel = true;
1642 }
1643 });
1644 if let Some(format) = chosen {
1645 if self.export_as(docs, store, format) {
1646 if format != "json" {
1649 self.say_previewed_export(docs);
1650 }
1651 self.open = false;
1652 }
1653 } else if let Some(format) = bom_chosen {
1654 if self.export_bom_as(docs, store, format) {
1655 self.open = false;
1656 }
1657 } else if let Some(output) = fab_chosen {
1658 if self.export_fabrication_as(docs, store, output) {
1659 self.open = false;
1660 }
1661 } else if cancel || modal.should_close() {
1662 self.open = false;
1663 }
1664 }
1665
1666 fn show_flat_pattern(
1672 &mut self,
1673 ctx: &egui::Context,
1674 docs: &mut Documents,
1675 store: &dyn ModelStore,
1676 ) {
1677 let mut chosen: Option<&'static str> = None;
1678 let mut cancel = false;
1679 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
1680 ui.set_width(340.0);
1681 ui.heading("Export flat pattern");
1682 ui.add_space(2.0);
1683 Self::preview_export_line(ui, docs);
1684 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
1685 ui.add_space(4.0);
1686 let field = ui.add(
1687 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1688 .hint_text("file name")
1689 .desired_width(f32::INFINITY),
1690 );
1691 self.hit("field:name", &field);
1692 if self.want_focus {
1693 field.request_focus();
1694 self.want_focus = false;
1695 }
1696 ui.add_space(6.0);
1697 ui.horizontal(|ui| {
1698 let dxf = ui.button("DXF (.dxf)");
1699 self.hit("flat:dxf", &dxf);
1700 if dxf.clicked() {
1701 chosen = Some("dxf");
1702 }
1703 let svg = ui.button("SVG (.svg)");
1704 self.hit("flat:svg", &svg);
1705 if svg.clicked() {
1706 chosen = Some("svg");
1707 }
1708 let c = ui.button("Cancel");
1709 self.hit("cancel", &c);
1710 if c.clicked() {
1711 cancel = true;
1712 }
1713 });
1714 if !self.status.is_empty() {
1715 ui.add_space(4.0);
1716 ui.weak(&self.status);
1717 }
1718 });
1719 if let Some(format) = chosen {
1720 if self.export_flat_pattern_as(docs, store, format) {
1721 self.say_previewed_export(docs);
1722 self.open = false;
1723 }
1724 } else if cancel || modal.should_close() {
1725 self.open = false;
1726 }
1727 }
1728
1729 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1732 if let Some(client) = store.plm_client() {
1734 self.show_open_workspace(ctx, docs, store, client);
1735 return;
1736 }
1737 let current = docs.active().name().map(str::to_string);
1738 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1739 file_explorer::dialog_body(ui, |ui| {
1740 ui.heading("Open model");
1741 ui.add_space(4.0);
1742 file_explorer::dialog_footer(ui, "open", |ui| {
1743 if !self.status.is_empty() {
1744 ui.add_space(4.0);
1745 ui.weak(&self.status);
1746 }
1747 });
1748 let mut options = FileExplorerOptions::open(current.as_deref());
1749 options.allow_import = store.supports_file_interchange();
1750 let output = self.explorer.show_store(ui, store, options);
1751 self.record_explorer_hits(&output.hits);
1752 output
1753 })
1754 });
1755 let should_close = modal.should_close();
1756 let output = modal.inner;
1757 if let Some(name) = output.activated {
1758 self.open_document(docs, store, &name);
1759 self.open = false;
1760 } else if let Some(name) = output.remove {
1761 let _ = store.remove(&name);
1762 self.status = format!("removed {name}");
1763 if let Some(index) = docs.index_of(&name) {
1767 if let Some(doc) = docs.get_mut(index) {
1768 doc.set_name(None);
1769 }
1770 }
1771 } else if output.import {
1772 match store.begin_import() {
1775 Ok(()) => {
1776 self.status = "choose a file…".into();
1777 self.open = false;
1778 }
1779 Err(e) => self.status = format!("import failed: {e}"),
1780 }
1781 } else if output.cancel || should_close {
1782 self.open = false;
1783 }
1784 }
1785
1786 fn show_open_workspace(
1791 &mut self,
1792 ctx: &egui::Context,
1793 docs: &mut Documents,
1794 store: &dyn ModelStore,
1795 client: std::rc::Rc<crate::plm::client::PlmClient>,
1796 ) {
1797 use crate::panels::plm_workspace::{document_name, load_pins, save_pins, PlmWorkspaces};
1798 let plm = PlmWorkspaces { client };
1799 if self.workspace.pins.is_none() {
1800 self.workspace.pins = Some(load_pins(store));
1801 }
1802 self.workspace.browse_only = true;
1803 let mut cancel = false;
1804 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1805 file_explorer::dialog_body(ui, |ui| {
1806 ui.heading("Open from the PLM");
1807 if let Some(url) = crate::panels::plm_host::web_url(store, "#/workspace") {
1808 crate::panels::plm_host::web_link(ui, "Manage workspace in PLM", url);
1809 }
1810 ui.add_space(4.0);
1811 file_explorer::dialog_footer(ui, "open", |ui| {
1812 if !self.status.is_empty() {
1813 ui.add_space(4.0);
1814 ui.weak(&self.status);
1815 }
1816 let button = ui.button("Cancel");
1817 self.hits.insert("open:cancel".into(), button.rect);
1818 cancel = button.clicked();
1819 });
1820 self.workspace.show(ui, &plm, "open:")
1821 })
1822 });
1823 let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
1824 self.record_explorer_hits(&hits);
1825 let should_close = modal.should_close();
1826 let outcome = modal.inner;
1827 if let Some(pins) = &outcome.pins {
1828 if let Err(e) = save_pins(store, pins) {
1829 self.status = format!("could not save the pinned folders: {e}");
1830 }
1831 }
1832 if let Some((name, bytes)) = outcome.downloaded {
1833 self.status = match store.export_file_named_bytes(&name, &bytes) {
1834 Ok(()) => format!("saved {name}"),
1835 Err(e) => format!("could not save {name}: {e}"),
1836 };
1837 }
1838 if outcome.pick_file {
1839 if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
1842 self.status = error;
1843 }
1844 return;
1845 }
1846 if let Some(key) = outcome.open {
1847 let name = document_name(store, &key);
1848 self.open_document(docs, store, &name);
1849 self.open = false;
1850 } else if cancel || should_close {
1851 self.open = false;
1852 }
1853 }
1854
1855 fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
1858 let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
1859 if let Some(client) = store.plm_client() {
1860 let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
1861 self.workspace.add_file(&plm, &file.name, file.bytes);
1862 self.open_modal(Mode::Open);
1863 }
1864 }
1865
1866 fn show_insert_component(
1872 &mut self,
1873 ctx: &egui::Context,
1874 docs: &mut Documents,
1875 store: &dyn ModelStore,
1876 ) {
1877 let library = docs.engine_mut().parts_library_names();
1878 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1879 file_explorer::dialog_body(ui, |ui| {
1880 ui.heading("Insert component");
1881 ui.add_space(4.0);
1882 file_explorer::dialog_footer(ui, "insert", |ui| {
1883 if !self.status.is_empty() {
1884 ui.add_space(4.0);
1885 ui.weak(&self.status);
1886 }
1887 });
1888 let mut chosen_library = None;
1889 if !library.is_empty() {
1890 ui.weak("In this document (parts library)");
1891 for name in &library {
1892 let row = ui.add_sized(
1893 egui::vec2(ui.available_width(), 18.0),
1894 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
1895 .frame(false),
1896 );
1897 self.hit(&format!("insert:lib:{name}"), &row);
1898 if row.clicked() {
1899 chosen_library = Some(name.clone());
1900 }
1901 }
1902 ui.add_space(4.0);
1903 }
1904 let options = FileExplorerOptions {
1905 hit_prefix: "insert:model",
1906 empty_label: "(no saved models)",
1907 row_icon: "\u{1F5CE}",
1908 current: None,
1909 allow_delete: false,
1910 allow_import: store.supports_file_interchange(),
1911 import_label: "Upload\u{2026}",
1912 import_hit: "insert:upload",
1913 show_cancel: true,
1914 confirm_label: Some("Insert"),
1915 extensions: INSERT_EXTENSIONS,
1916 };
1917 let output = self.explorer.show_store(ui, store, options);
1918 self.record_explorer_hits(&output.hits);
1919 (chosen_library, output)
1920 })
1921 });
1922 let should_close = modal.should_close();
1923 let (chosen_library, output) = modal.inner;
1924 if let Some(part_name) = chosen_library {
1925 match docs.engine_mut().insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1927 Ok(id) => {
1928 self.status = format!("inserted {part_name} ({id})");
1929 self.open = false;
1930 }
1931 Err(e) => self.status = format!("insert failed: {e}"),
1932 }
1933 } else if let Some(name) = output.activated.filter(|name| {
1934 let listed = store
1938 .browser_entries(INSERT_EXTENSIONS)
1939 .iter()
1940 .any(|entry| !entry.is_dir && entry.identity == *name);
1941 if !listed {
1942 self.status = format!(
1943 "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
1944 );
1945 }
1946 listed
1947 }) {
1948 self.request_component_document(docs, store, &name);
1949 if self.pending_insert.is_some() {
1950 ctx.request_repaint_after(std::time::Duration::from_millis(100));
1951 }
1952 } else if output.import {
1953 match store.begin_import() {
1954 Ok(()) => {
1955 self.pending_component_import = true;
1957 self.status = "choose a part file…".into();
1958 self.open = false;
1959 }
1960 Err(e) => self.status = format!("insert failed: {e}"),
1961 }
1962 } else if output.cancel || should_close {
1963 self.open = false;
1964 }
1965 }
1966
1967 fn request_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1968 self.pending_insert = None;
1969 match crate::store::read_now(store, name) {
1970 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, name, &contents),
1971 crate::store::ReadNow::Loading => {
1972 self.pending_insert = Some((name.to_owned(), docs.active_id()));
1973 self.status = format!("'{name}' is still loading");
1974 }
1975 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
1976 }
1977 }
1978
1979 fn finish_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str, contents: &str) {
1980 let assembly = docs.active().name().map(str::to_string);
1981 if !self.begin_class_insert(store, name, contents, assembly) {
1982 self.insert_component_document(docs.engine_mut(), name, contents);
1983 self.open = false;
1984 }
1985 }
1986
1987 fn poll_pending_insert(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1988 let Some((name, requester)) = self.pending_insert.take() else { return };
1989 if !self.open || self.mode != Mode::InsertComponent || docs.active_id() != requester {
1990 return;
1991 }
1992 match crate::store::read_now(store, &name) {
1993 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, &name, &contents),
1994 crate::store::ReadNow::Loading => {
1995 self.pending_insert = Some((name, requester));
1996 ctx.request_repaint_after(std::time::Duration::from_millis(100));
1997 }
1998 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
1999 }
2000 }
2001
2002 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2010 self.insert_component_named(state, name, &model_display_name(name), contents);
2011 }
2012
2013 fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2016 let display = display.to_string();
2017 match state.insert_component(ComponentInsert::New {
2018 name: &display,
2019 source_key: name,
2020 source_signature: &document_signature(contents),
2021 document_json: contents,
2022 }) {
2023 Ok(id) => self.status = format!("inserted {display} ({id})"),
2024 Err(e) => self.status = format!("insert failed: {e}"),
2025 }
2026 }
2027
2028 fn effective_name(&self, docs: &Documents) -> String {
2033 let field = self.name_buf.trim();
2034 if !field.is_empty() {
2035 field.to_string()
2036 } else {
2037 Self::document_name(docs)
2038 }
2039 }
2040
2041 fn document_name(docs: &Documents) -> String {
2047 docs.active().name().map(model_display_name).unwrap_or_else(|| docs.active().untitled_title())
2048 }
2049
2050 fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2053 docs.open_document(Document::fresh(docs.spawn_engine(), class));
2054 self.name_buf.clear();
2055 self.status = match class {
2056 DocumentClass::Normal => "new model".into(),
2057 _ => format!("new {}", class.label().to_ascii_lowercase()),
2058 };
2059 }
2060
2061 fn name_is_free(&mut self, docs: &mut Documents, name: &str) -> bool {
2073 let display = model_display_name(name);
2074 let target_revision = crate::plm::uses::revision_key_in(name);
2075 let taken = docs.iter().enumerate().any(|(index, doc)| {
2076 index != docs.active_index()
2077 && doc
2078 .name()
2079 .is_some_and(|open| {
2080 match (&target_revision, crate::plm::uses::revision_key_in(open)) {
2081 (Some(target), Some(other)) => *target == other,
2082 (None, None) => model_display_name(open) == display,
2083 _ => false,
2084 }
2085 })
2086 });
2087 if taken {
2088 let identity = target_revision.as_deref().unwrap_or(&display);
2089 self.status = format!("Save refused: '{identity}' is already open in another tab");
2090 docs.engine_mut().push_notice(&self.status);
2091 }
2092 !taken
2093 }
2094
2095 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2098 let name = name.trim().to_string();
2099 if name.is_empty() {
2100 self.status = "enter a name to save".into();
2101 return false;
2102 }
2103 if !self.name_is_free(docs, &name) {
2104 return false;
2105 }
2106 let document = docs.engine().history_request_json();
2107 crate::plm::thumbnail::stage_save(store, &name, &document, docs.engine(), docs.active_id());
2108 match store.write(&name, &document) {
2109 Ok(()) => {
2110 self.name_buf = model_display_name(&name);
2113 let doc = docs.active_mut();
2114 doc.set_name(Some(name.clone()));
2115 doc.mark_clean();
2116 self.save_generation += 1;
2117 remember_document(store, &name);
2118 self.status = format!("saved {name}");
2119 true
2120 }
2121 Err(e) => {
2122 self.status = format!("save failed: {e}");
2123 docs.engine_mut().push_notice(&self.status);
2124 false
2125 }
2126 }
2127 }
2128
2129 pub(crate) fn take_plm_save_request(&mut self) -> Option<u64> {
2130 self.plm_save_request.take()
2131 }
2132
2133 pub(crate) fn finish_plm_save(&mut self, docs: &mut Documents, store: &dyn ModelStore) {
2134 if let Some(name) = docs.active().name().map(str::to_string) {
2135 self.save_to(docs, store, name);
2136 }
2137 }
2138
2139 fn save_to_browser(
2143 &mut self,
2144 docs: &mut Documents,
2145 store: &dyn ModelStore,
2146 name: String,
2147 ) -> bool {
2148 let name = name.trim().to_string();
2149 if name.is_empty() {
2150 self.status = "enter a name to save".into();
2151 return false;
2152 }
2153 if !self.name_is_free(docs, &name) {
2154 return false;
2155 }
2156 let class = DocumentClass::of_name(&name).unwrap_or_default();
2161 adopt_class(docs.engine_mut(), class);
2162 if docs.engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
2163 docs.engine_mut()
2164 .history
2165 .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
2166 }
2167 match store.browser_write(&name, &docs.engine().history_request_json()) {
2168 Ok(identity) => {
2169 crate::plm::thumbnail::stage_recent(&identity, docs.engine(), docs.active_id());
2172 self.name_buf = model_display_name(&identity);
2173 let doc = docs.active_mut();
2174 self.member_ack.remove(&doc.id());
2175 doc.set_name(Some(identity.clone()));
2176 doc.mark_clean();
2177 self.save_generation += 1;
2178 remember_document(store, &identity);
2179 self.status = format!("saved {identity}");
2180 true
2181 }
2182 Err(e) => {
2183 self.status = format!("save failed: {e}");
2184 false
2185 }
2186 }
2187 }
2188
2189 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2193 self.pending_open = None;
2194 if docs.focus_named(name) {
2195 remember_document(store, name);
2196 self.status = format!("{name} is already open");
2197 return;
2198 }
2199 match crate::store::read_now(store, name) {
2200 crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store),
2201 crate::store::ReadNow::Loading => {
2204 self.pending_open = Some((name.to_owned(), docs.active_id()));
2205 self.status = format!("'{name}' is still loading");
2206 }
2207 crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
2208 }
2209 }
2210
2211 fn poll_pending_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2214 let Some((name, requester)) = self.pending_open.take() else { return };
2215 if docs.active_id() != requester {
2216 return;
2217 }
2218 if docs.focus_named(&name) {
2219 self.status = format!("{name} is already open");
2220 return;
2221 }
2222 match crate::store::read_now(store, &name) {
2223 crate::store::ReadNow::Ready(contents) => {
2224 self.load_document(docs, &name, &contents, store);
2225 }
2226 crate::store::ReadNow::Loading => {
2227 self.pending_open = Some((name, requester));
2228 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2229 }
2230 crate::store::ReadNow::Absent => {
2231 self.status = format!("open failed: '{name}' not found");
2232 }
2233 }
2234 }
2235
2236 fn say_previewed_export(&mut self, docs: &mut Documents) {
2247 let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
2248 return;
2249 };
2250 let note = format!("exported {label} (preview): the family's own values are not in this file");
2251 self.status = format!("{} — {label} (preview)", self.status);
2252 docs.engine_mut().push_notice(note);
2253 }
2254
2255 fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
2257 if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
2258 let text = format!(
2259 "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
2260 not the family's own values. The .nbrep recipe keeps the family's own."
2261 );
2262 ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
2263 ui.add_space(4.0);
2264 }
2265 }
2266
2267 fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
2268 let name = self.effective_name(docs);
2269 if format == "glb" {
2273 return match docs.engine().export_glb_bytes(&name) {
2276 Ok(bytes) => match store.export_file_named_bytes(&format!("{name}.glb"), &bytes) {
2277 Ok(()) => {
2278 self.status = format!("exported {name}.glb");
2279 true
2280 }
2281 Err(e) => {
2282 self.status = format!("export failed: {e}");
2283 false
2284 }
2285 },
2286 Err(e) => {
2287 self.status = format!("export failed: {e}");
2288 false
2289 }
2290 };
2291 }
2292 let state = docs.engine_mut();
2293 if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
2302 let pdf = format != "sheetsvg";
2303 let three_d = format == "sheetpdf3d";
2304 let (file, written) = if pdf {
2305 let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
2306 let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
2307 let written = bytes.and_then(|bytes| store.export_file_named_bytes(&file, &bytes).map(|()| ()));
2308 (file, written)
2309 } else {
2310 let sheet_name = state
2311 .resolve_sheet("")
2312 .ok()
2313 .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
2314 .unwrap_or_default();
2315 let file = format!("{name}-{}.svg", slug(&sheet_name));
2316 let written = state.resolve_sheet("").and_then(|id| {
2317 state
2318 .export_sheet_svg(&id)
2319 .and_then(|text| store.export_file_named(&file, &text).map(|()| ()))
2320 });
2321 (file, written)
2322 };
2323 let pages = state.sheet_state().sheets.len() + usize::from(three_d);
2324 return match written {
2325 Ok(()) => {
2326 self.status = if pdf {
2327 format!("exported {file} ({pages} page{})", if pages == 1 { "" } else { "s" })
2328 } else {
2329 format!("exported {file}")
2330 };
2331 true
2332 }
2333 Err(e) => {
2334 self.status = format!("export failed: {e}");
2335 false
2336 }
2337 };
2338 }
2339 let (text, ext) = match format {
2340 "stl" => (state.export_stl_text(), "stl"),
2341 "obj" => (state.export_obj_text(), "obj"),
2342 "iges" => (state.export_iges_text(), "igs"),
2343 "json" => (Ok(state.history_request_json()), "nbrep"),
2344 _ => (state.export_step_text_named(&name), "step"),
2347 };
2348 match text {
2349 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2350 Ok(()) => {
2351 self.status = format!("exported {name}.{ext}");
2352 true
2353 }
2354 Err(e) => {
2355 self.status = format!("export failed: {e}");
2356 false
2357 }
2358 },
2359 Err(e) => {
2360 self.status = format!("export failed: {e}");
2361 false
2362 }
2363 }
2364 }
2365
2366 fn export_flat_pattern_as(
2372 &mut self,
2373 docs: &mut Documents,
2374 store: &dyn ModelStore,
2375 format: &str,
2376 ) -> bool {
2377 let name = self.effective_name(docs);
2378 let state = docs.engine_mut();
2379 let (text, ext) = match format {
2380 "svg" => (state.export_flat_pattern_svg(), "svg"),
2381 _ => (state.export_flat_pattern_dxf(), "dxf"),
2382 };
2383 match text {
2384 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2385 Ok(()) => {
2386 self.status = format!("exported {name}.{ext}");
2387 true
2388 }
2389 Err(e) => {
2390 self.status = format!("flat-pattern export failed: {e}");
2391 false
2392 }
2393 },
2394 Err(e) => {
2395 self.status = format!("flat-pattern export failed: {e}");
2396 state.push_notice(format!("Flat pattern: {e}"));
2397 false
2398 }
2399 }
2400 }
2401
2402 fn export_bom_as(
2408 &mut self,
2409 docs: &mut Documents,
2410 store: &dyn ModelStore,
2411 format: &str,
2412 ) -> bool {
2413 let name = self.effective_name(docs);
2414 let state = docs.engine_mut();
2415 let (text, ext) = match format {
2416 "json" => (state.export_bom_json(), "bom.json"),
2417 _ => (state.export_bom_csv(), "bom.csv"),
2418 };
2419 match text {
2420 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2421 Ok(()) => {
2422 self.status = format!("exported {name}.{ext}");
2423 true
2424 }
2425 Err(e) => {
2426 self.status = format!("BOM export failed: {e}");
2427 false
2428 }
2429 },
2430 Err(e) => {
2431 self.status = format!("BOM export failed: {e}");
2432 state.push_notice(format!("BOM export: {e}"));
2433 false
2434 }
2435 }
2436 }
2437
2438 fn export_fabrication_as(
2445 &mut self,
2446 docs: &mut Documents,
2447 store: &dyn ModelStore,
2448 output: super::fabrication_export::Output,
2449 ) -> bool {
2450 use super::fabrication_export::{self as fab, Output};
2451 let name = self.effective_name(docs);
2452 let file = output.file_name(&name);
2453 let state = docs.engine_mut();
2454 let (written, warnings) = match output {
2455 Output::Bundle => match fab::bundle(state, &name) {
2456 Ok(bundle) => (
2457 store.export_file_named_bytes(&file, &bundle.zip()).map(|()| bundle.files.len()),
2458 bundle.warnings,
2459 ),
2460 Err(e) => (Err(e), vec![]),
2461 },
2462 _ => (
2463 fab::text(state, output, &name).and_then(|text| store.export_file_named(&file, &text).map(|()| 1)),
2464 vec![],
2465 ),
2466 };
2467 match written {
2468 Ok(count) => {
2469 let into = models_folder(store).map(|folder| format!(" into {folder}")).unwrap_or_default();
2472 self.status = if output == Output::Bundle {
2473 format!("exported {file} ({count} files){into}")
2474 } else {
2475 format!("exported {file}{into}")
2476 };
2477 if warnings.is_empty() {
2478 state.push_notice_as(
2479 brep_render::engine_state::NoticeSeverity::Info,
2480 format!("{}: {}", output.label(), self.status),
2481 );
2482 return true;
2483 }
2484 self.status = format!("{}. Before ordering: {}", self.status, warnings.join(" "));
2485 state.push_notice_as(
2486 brep_render::engine_state::NoticeSeverity::Warning,
2487 format!("{}: {}", output.label(), warnings.join(" ")),
2488 );
2489 false
2490 }
2491 Err(e) => {
2492 self.status = format!("{} export failed: {e}", output.label());
2493 state.push_notice(format!("{} export: {e}", output.label()));
2494 false
2495 }
2496 }
2497 }
2498
2499 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2512 self.pending_step_import = None;
2518 let id = state.submit_step_probe(contents);
2519 self.pending_step_probe = Some(PendingStepProbe {
2520 id,
2521 name: name.to_string(),
2522 text: contents.to_string(),
2523 });
2524 self.status = format!("reading \"{name}\"\u{2026}");
2525 self.poll_step_probe(state);
2528 }
2529
2530 fn poll_step_probe(&mut self, state: &mut EngineState) {
2535 while let Some((id, outcome)) = state.take_step_probe() {
2536 let Some(pending) = self.pending_step_probe.as_ref() else {
2537 continue;
2538 };
2539 if pending.id != id {
2540 continue; }
2542 let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
2543 match outcome {
2544 StepProbeOutcome::Structure(probe) => {
2545 self.status = format!(
2546 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
2547 probe.parts,
2548 plural(probe.parts),
2549 probe.instances,
2550 plural(probe.instances)
2551 );
2552 self.pending_step_import = Some(PendingStepImport {
2553 name,
2554 text,
2555 probe,
2556 flatten: false,
2559 });
2560 self.open_modal(Mode::StepAssembly);
2566 }
2567 StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
2568 self.import_step_flat(state, &name, &text);
2569 }
2570 }
2571 }
2572 if self.pending_step_probe.is_some() && !state.step_probes_pending() {
2573 let pending = self.pending_step_probe.take().unwrap();
2577 self.status = format!("import of \"{}\" was cancelled", pending.name);
2578 }
2579 }
2580
2581 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2587 match state.import_step_feature(contents) {
2588 Ok(_) => self.status = format!("imported {name}"),
2592 Err(e) => self.status = format!("import failed: {e}"),
2593 }
2594 }
2595
2596 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
2606 let Some(pending) = self.pending_step_import.take() else {
2607 return;
2608 };
2609 let was_assembly = state.history_has_assembly();
2612 let doc_name = step_document_name(&pending.name);
2613 let opts = StepAssemblyImport {
2616 nested: !pending.flatten,
2617 };
2618 let mut sink = StorePartSink::new(store, &doc_name);
2625 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
2626 Ok(report) => {
2627 if !was_assembly {
2628 self.enter_assembly_workbench(state);
2629 }
2630 for failure in &sink.failures {
2633 state.push_notice(format!("part not saved — {failure}"));
2634 }
2635 let saved = sink.written;
2636 let base = assembly_import_message(&pending.name, &report);
2637 match (saved, sink.failures.len()) {
2638 (0, _) => base,
2639 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
2640 (saved, failed) => {
2641 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
2642 }
2643 }
2644 }
2645 Err(error) => match state.import_step_feature(&pending.text) {
2646 Ok(_) => format!(
2647 "imported {} as bodies — the assembly structure could not be built ({error})",
2648 pending.name
2649 ),
2650 Err(flat) => format!("import failed: {flat}"),
2651 },
2652 };
2653 self.status = message.clone();
2656 state.push_notice(message);
2657 }
2658
2659 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
2663 let Some(pending) = self.pending_step_import.take() else {
2664 return;
2665 };
2666 state.discard_probed_step_assembly();
2667 self.import_step_flat(state, &pending.name, &pending.text);
2668 }
2669
2670 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
2672 let Some(pending) = self.pending_step_import.take() else {
2673 return;
2674 };
2675 state.discard_probed_step_assembly();
2676 self.status = format!("import cancelled: {}", pending.name);
2677 }
2678
2679 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
2693 if crate::workbench::panel_visible(
2694 &state.settings.workbench,
2695 crate::workbench::assembly::BOM_PANEL_ID,
2696 &crate::workbench::ButtonState::of(state),
2697 ) {
2698 return;
2699 }
2700 let _ = state.apply_settings_json(
2701 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
2702 .to_string(),
2703 );
2704 }
2705
2706 fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2709 match state.import_iges_feature(contents) {
2710 Ok(_) => self.status = format!("imported {name}"),
2712 Err(e) => self.status = format!("import failed: {e}"),
2713 }
2714 }
2715
2716 fn stage_mesh_preview(
2717 &mut self,
2718 format: MeshImportFormat,
2719 name: String,
2720 contents: Vec<u8>,
2721 ) {
2722 self.pending_stl_import = Some((format, name, contents));
2723 self.status.clear();
2724 }
2725
2726 pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
2729 self.pending_stl_import.take()
2730 }
2731
2732 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
2733 match state.import_obj_bytes_feature(contents) {
2734 Ok(_) => self.status = format!("reconstructing {name} in background…"),
2735 Err(e) => self.status = format!("import failed: {e}"),
2736 }
2737 }
2738
2739 fn import_text(
2740 &mut self,
2741 state: &mut EngineState,
2742 name: &str,
2743 bytes: &[u8],
2744 importer: fn(&mut Self, &mut EngineState, &str, &str),
2745 ) {
2746 match std::str::from_utf8(bytes) {
2747 Ok(contents) => importer(self, state, name, contents),
2748 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
2749 }
2750 }
2751
2752 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, store: &dyn ModelStore) {
2757 let plm = store.plm_client().is_some();
2758 let mut engine = docs.spawn_engine();
2759 match engine.load_model_and_fit(contents) {
2760 Ok(_) => {
2761 if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
2768 document_class::set_document_class(&mut engine, class);
2769 }
2770 let mut doc = Document::new(engine);
2774 doc.set_name(Some(name.to_string()));
2775 docs.open_document(doc);
2776 self.name_buf = model_display_name(name);
2777 self.status = format!("opened {name}");
2778 remember_document(store, name);
2779 }
2780 Err(e) => self.status = format!("open failed: {e}"),
2781 }
2782 }
2783
2784 fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
2789 let client = store.plm_client()?;
2790 let key = crate::plm::family::revision_key(identity)?;
2791 Some((client, key))
2792 }
2793
2794 fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2796 let Some(work) = self.plm_work.as_mut() else { return };
2797 ctx.request_repaint_after(std::time::Duration::from_millis(30));
2798 match work {
2799 PlmWork::Members(pending) => {
2800 let Some(result) = pending.poll() else { return };
2801 self.plm_work = None;
2802 let Some(pick) = self.pick_member.as_mut() else { return };
2803 pick.loading = false;
2804 match result {
2805 Ok(members) => {
2806 pick.rows = members
2807 .into_iter()
2808 .map(|member| MemberChoice {
2809 why_not: member.not_placeable(),
2810 exists: member.not_placeable().is_none(),
2811 identity: member.key(),
2812 description: member.name.clone(),
2813 part_number: member.number,
2814 })
2815 .collect();
2816 }
2817 Err(error) => self.status = format!("the members could not be read: {error}"),
2818 }
2819 }
2820 PlmWork::Place(pending) => {
2821 let Some(result) = pending.poll() else { return };
2822 self.plm_work = None;
2823 match result {
2824 Ok((key, text, number)) => {
2825 self.insert_component_named(docs.engine_mut(), &key, &number, &text);
2826 self.pick_member = None;
2827 self.spin_out = None;
2828 self.open = false;
2829 }
2830 Err(error) => self.status = error,
2831 }
2832 }
2833 PlmWork::OpenFamily(pending) => {
2834 let Some(result) = pending.poll() else { return };
2835 self.plm_work = None;
2836 match result {
2837 Ok(key) => self.open_document(docs, store, &key),
2838 Err(error) => self.status = format!("the family could not be opened: {error}"),
2839 }
2840 }
2841 }
2842 }
2843
2844 fn begin_class_insert(
2850 &mut self,
2851 store: &dyn ModelStore,
2852 identity: &str,
2853 contents: &str,
2854 assembly: Option<String>,
2855 ) -> bool {
2856 let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
2857 let class = DocumentClass::of_name(identity)
2858 .filter(|class| *class != DocumentClass::Normal)
2859 .unwrap_or_else(|| DocumentClass::of_document(&document));
2860 let file = crate::store::file_name_of(identity);
2861 match class {
2862 DocumentClass::Normal => false,
2863 DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
2864 let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
2865 let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
2866 return false;
2867 };
2868 self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
2869 crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
2870 }))));
2871 self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
2872 self.status.clear();
2873 self.open_modal(Mode::PickMember);
2874 true
2875 }
2876 DocumentClass::Family => {
2877 let table = family_table::read_table(contents);
2878 let rows = table
2879 .rows
2880 .iter()
2881 .map(|row| {
2882 let identity = family_table::member_identity(Some(identity), &row.part_number);
2883 let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
2884 MemberChoice {
2885 part_number: row.part_number.clone(),
2886 description: row.description.clone(),
2887 identity,
2888 exists,
2889 why_not: None,
2890 }
2891 })
2892 .collect();
2893 self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
2894 self.status.clear();
2895 self.open_modal(Mode::PickMember);
2896 true
2897 }
2898 DocumentClass::Template => {
2899 let inputs = template::inputs_of(&document);
2900 let values = inputs
2901 .iter()
2902 .map(|input| (input.name.clone(), template::default_value(&document, input)))
2903 .collect();
2904 let stem = document_class::strip_class_extension(&file).to_string();
2905 let plm_template = self
2906 .plm_revision(store, identity)
2907 .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
2908 self.spin_out = Some(SpinOut {
2909 template_file: file,
2910 template: document,
2911 inputs,
2912 name: format!("{stem}-1"),
2913 values,
2914 assembly,
2915 plm_template,
2916 number: String::new(),
2917 });
2918 self.status.clear();
2919 self.open_modal(Mode::SpinOut);
2920 true
2921 }
2922 }
2923 }
2924
2925 fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
2929 let Some(pick) = self.pick_member.take() else {
2930 self.open = false;
2931 return;
2932 };
2933 let mut chosen = None;
2934 let mut cancel = false;
2935 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
2936 ui.set_width(420.0);
2937 ui.heading(format!("Insert a member of {}", pick.family_file));
2938 ui.add_space(2.0);
2939 ui.weak("A family is never placed itself: choose the member to place.");
2940 ui.add_space(6.0);
2941 if pick.loading {
2942 ui.label("Reading the family's members from the PLM\u{2026}");
2943 } else if pick.rows.is_empty() {
2944 ui.label("This family's table has no rows yet.");
2945 }
2946 egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
2947 for (index, row) in pick.rows.iter().enumerate() {
2948 ui.horizontal(|ui| {
2949 let text = if row.description.trim().is_empty() {
2950 row.part_number.clone()
2951 } else {
2952 format!("{} \u{2014} {}", row.part_number, row.description)
2953 };
2954 let button = ui.add_enabled(
2955 row.exists,
2956 egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
2957 );
2958 self.hit(&format!("member:{}", row.part_number), &button);
2959 if button.clicked() {
2960 chosen = Some(index);
2961 }
2962 if let Some(why) = &row.why_not {
2963 ui.weak(format!("({why})"));
2964 } else if row.identity.is_none() {
2965 ui.weak("(the part number is not a file name)");
2966 } else if !row.exists {
2967 ui.weak("(not generated yet: open the family and Generate)");
2968 }
2969 });
2970 }
2971 });
2972 ui.add_space(6.0);
2973 if !self.status.is_empty() {
2974 ui.weak(&self.status);
2975 ui.add_space(4.0);
2976 }
2977 let c = ui.button("Cancel");
2978 self.hit("member:cancel", &c);
2979 cancel = c.clicked();
2980 });
2981 let should_close = modal.should_close();
2982 if let (Some(index), true) = (chosen, pick.plm) {
2983 let row = &pick.rows[index];
2984 if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
2985 self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
2986 self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
2987 }
2988 } else if let Some(index) = chosen {
2989 let row = &pick.rows[index];
2990 let identity = row.identity.clone().unwrap_or_default();
2991 match store.read(&identity) {
2992 Some(contents) => {
2993 self.insert_component_document(state, &identity, &contents);
2994 self.open = false;
2995 return;
2996 }
2997 None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
2998 }
2999 } else if cancel || should_close {
3000 self.status.clear();
3001 self.open = false;
3002 return;
3003 }
3004 self.pick_member = Some(pick);
3005 }
3006
3007 fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
3013 let Some(mut spin) = self.spin_out.take() else {
3014 self.open = false;
3015 return;
3016 };
3017 let mut create = false;
3018 let mut cancel = false;
3019 let problems: Vec<Option<String>> = spin
3020 .inputs
3021 .iter()
3022 .map(|input| {
3023 let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
3024 template::value_problem(&spin.template, input, text)
3025 })
3026 .collect();
3027 let name_problem = template::name_problem(&spin.name);
3028 let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
3029 ui.set_width(440.0);
3030 ui.heading(format!("New part from {}", spin.template_file));
3031 ui.add_space(2.0);
3032 ui.weak("A template is never placed itself: this makes a new part from it and places that.");
3033 ui.add_space(6.0);
3034 egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
3035 ui.label("New part name");
3036 let field = ui.add(egui::TextEdit::singleline(&mut spin.name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
3037 self.hit("spinout:name", &field);
3038 ui.end_row();
3039 if spin.plm_template.is_some() {
3040 ui.label("Part number");
3041 let field = ui.add(
3042 egui::TextEdit::singleline(&mut spin.number).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
3043 .hint_text("empty: the part type numbers it")
3044 .desired_width(240.0),
3045 );
3046 self.hit("spinout:number", &field);
3047 ui.end_row();
3048 }
3049 for (input, problem) in spin.inputs.iter().zip(&problems) {
3050 ui.label(input.shown());
3051 ui.vertical(|ui| {
3052 let value = spin.values.entry(input.name.clone()).or_default();
3053 if input.choices.is_empty() {
3054 let field = ui.add(egui::TextEdit::singleline(value).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
3055 self.hit(&format!("spinout:input:{}", input.name), &field);
3056 } else {
3057 ui.horizontal_wrapped(|ui| {
3058 for choice in &input.choices {
3059 let chip = ui.selectable_label(value.trim() == choice.trim(), choice);
3060 self.hit(&format!("spinout:choice:{}:{}", input.name, choice.trim()), &chip);
3061 if chip.clicked() {
3062 *value = choice.trim().to_string();
3063 }
3064 }
3065 });
3066 }
3067 let limits = match (input.min, input.max) {
3068 (Some(min), Some(max)) => format!("{min} to {max}"),
3069 (Some(min), None) => format!("at least {min}"),
3070 (None, Some(max)) => format!("at most {max}"),
3071 (None, None) => String::new(),
3072 };
3073 if !limits.is_empty() {
3074 ui.weak(limits);
3075 }
3076 if let Some(problem) = problem {
3077 ui.colored_label(ui.visuals().warn_fg_color, problem);
3078 }
3079 });
3080 ui.end_row();
3081 }
3082 });
3083 if spin.inputs.is_empty() {
3084 ui.weak("This template marks no inputs; the copy is the template as it is.");
3085 }
3086 ui.add_space(4.0);
3087 let file = crate::store::file_name_of(&spin_out_target(&spin));
3088 ui.weak(match &spin.assembly {
3089 _ if spin.plm_template.is_some() => {
3090 "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
3091 }
3092 Some(assembly) => format!(
3093 "Saved as {file}, in the folder of {}",
3094 crate::store::file_name_of(assembly)
3095 ),
3096 None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
3097 });
3098 if let Some(problem) = &name_problem {
3099 ui.colored_label(ui.visuals().warn_fg_color, problem);
3100 }
3101 if !self.status.is_empty() {
3102 ui.weak(&self.status);
3103 }
3104 ui.add_space(6.0);
3105 ui.horizontal(|ui| {
3106 let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
3107 let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
3108 self.hit("spinout:create", &c);
3109 create = c.clicked();
3110 let x = ui.button("Cancel");
3111 self.hit("spinout:cancel", &x);
3112 cancel = x.clicked();
3113 });
3114 });
3115 let should_close = modal.should_close();
3116 if create {
3117 match self.create_spin_out(state, store, &spin) {
3118 Ok(()) => {
3119 self.open = false;
3120 return;
3121 }
3122 Err(error) => self.status = error,
3123 }
3124 } else if cancel || should_close {
3125 self.status.clear();
3126 self.open = false;
3127 return;
3128 }
3129 self.spin_out = Some(spin);
3130 }
3131
3132 fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
3134 if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
3135 let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
3138 self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
3139 let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
3140 .await
3141 .map_err(|e| e.to_string())?;
3142 let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
3143 let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
3144 format!("{} was made, but has no document yet", spun.number)
3145 })?;
3146 Ok((spun.key, text, spun.number))
3147 }))));
3148 return Err("making the part on the PLM\u{2026}".into());
3149 }
3150 if let Some(problem) = template::name_problem(&spin.name) {
3151 return Err(problem);
3152 }
3153 let target = spin_out_target(spin);
3154 if store.read(&target).is_some() {
3155 return Err(format!("'{target}' already exists: choose another name"));
3156 }
3157 let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
3158 let text = copy.to_string();
3159 store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
3160 self.save_generation += 1;
3161 self.insert_component_document(state, &target, &text);
3162 if spin.assembly.is_none() {
3163 self.status = format!(
3164 "{} — saved to the models folder as {target}, because the assembly is not saved yet",
3165 self.status
3166 );
3167 }
3168 Ok(())
3169 }
3170
3171 fn watch_member_edit(&mut self, docs: &Documents) {
3176 let doc = docs.active();
3177 if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
3178 return;
3179 }
3180 if let Some(name) = doc.name().map(str::to_string) {
3181 self.arm_member_prompt(docs, &name, false);
3182 }
3183 }
3184
3185 fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
3186 let Some(source) = family_table::engine_family_source(docs.engine()) else {
3187 return false;
3188 };
3189 self.member_prompt = Some(MemberPrompt {
3190 doc_id: docs.active().id(),
3191 family_identity: sibling_identity(name, &source.family),
3192 source,
3193 from_save,
3194 });
3195 self.status.clear();
3196 self.open_modal(Mode::MemberEdit);
3197 true
3198 }
3199
3200 fn revert_member_edit(docs: &mut Documents) {
3203 for _ in 0..64 {
3204 if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
3205 break;
3206 }
3207 docs.engine_mut().undo();
3208 }
3209 }
3210
3211 fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3214 let Some(prompt) = self.member_prompt.take() else {
3215 self.open = false;
3216 return;
3217 };
3218 if docs.active().id() != prompt.doc_id {
3219 self.open = false;
3220 return;
3221 }
3222 let mut choice = None;
3223 let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
3224 let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
3225 ui.set_width(440.0);
3226 ui.heading("This part is a family member");
3227 ui.add_space(4.0);
3228 ui.label(format!(
3229 "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
3230 prompt.source.part_number, prompt.source.family
3231 ));
3232 ui.add_space(4.0);
3233 ui.weak(if prompt.from_save {
3234 "Save it as a new part, or open the family and make the change there."
3235 } else {
3236 "Keep the change in a new part, or open the family and make it there."
3237 });
3238 if !self.status.is_empty() {
3239 ui.add_space(4.0);
3240 ui.weak(&self.status);
3241 }
3242 ui.add_space(8.0);
3243 ui.horizontal(|ui| {
3244 let a = ui.button("Save as a new part\u{2026}");
3245 self.hit("member-edit:saveas", &a);
3246 if a.clicked() {
3247 choice = Some("saveas");
3248 }
3249 let offer_family = match &plm {
3251 Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
3252 None => true,
3253 };
3254 if offer_family {
3255 let b = ui.button("Open the family");
3256 self.hit("member-edit:family", &b);
3257 if b.clicked() {
3258 choice = Some("family");
3259 }
3260 }
3261 let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
3262 self.hit("member-edit:cancel", &c);
3263 if c.clicked() {
3264 choice = Some("cancel");
3265 }
3266 });
3267 });
3268 let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
3269 match choice {
3270 Some("saveas") => {
3271 self.member_ack.insert(prompt.doc_id);
3275 self.member_new_part = store.plm_client().is_some();
3276 self.status.clear();
3277 self.dispatch(FileAction::SaveAs, docs, store);
3278 }
3279 Some("family") if plm.is_some() => {
3280 let (client, _) = plm.unwrap_or_else(|| unreachable!());
3281 let number = document_class::strip_class_extension(&prompt.source.family).to_string();
3282 Self::revert_member_edit(docs);
3283 self.status = format!("opening the family {number}\u{2026}");
3284 self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
3285 let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
3286 let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
3288 Ok(crate::plm::identity::document_key(&head.id, &newest.id))
3289 }))));
3290 self.open = false;
3291 }
3292 Some("family") => {
3293 if store.read(&prompt.family_identity).is_none() {
3294 self.status = format!("the family file is not there: {}", prompt.family_identity);
3295 self.member_prompt = Some(prompt);
3296 return;
3297 }
3298 Self::revert_member_edit(docs);
3299 self.open = false;
3300 self.open_document(docs, store, &prompt.family_identity);
3301 }
3302 Some(_) => {
3303 if !prompt.from_save {
3304 Self::revert_member_edit(docs);
3305 }
3306 self.status.clear();
3307 self.open = false;
3308 }
3309 None => self.member_prompt = Some(prompt),
3310 }
3311 }
3312
3313 pub fn generate_active_family(
3323 &mut self,
3324 docs: &mut Documents,
3325 store: &dyn ModelStore,
3326 save: bool,
3327 ) -> Result<family_table::GenerateReport, String> {
3328 let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
3329 let report = match (store.plm_client(), plm_key) {
3330 (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
3331 _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
3332 };
3333 Ok(self.record_generate(report))
3334 }
3335
3336 #[cfg(not(target_arch = "wasm32"))]
3340 fn generate_on_plm(
3341 &mut self,
3342 docs: &mut Documents,
3343 store: &dyn ModelStore,
3344 client: &std::rc::Rc<crate::plm::client::PlmClient>,
3345 key: &str,
3346 save: bool,
3347 ) -> Result<family_table::GenerateReport, String> {
3348 let json = docs.engine().history_request_json();
3349 if docs.active().is_dirty() {
3350 if !save {
3351 return Err(crate::plm::family::UNSAVED_FAMILY.into());
3352 }
3353 store.write(key, &json)?;
3354 docs.active_mut().mark_clean();
3355 self.save_generation += 1;
3356 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3358 while store.pending_writes() > 0 {
3359 if std::time::Instant::now() > deadline {
3360 return Err("the family's save did not reach the server within 60 s".into());
3361 }
3362 crate::plm::native::run_pending();
3363 std::thread::sleep(std::time::Duration::from_millis(5));
3364 }
3365 if let Some(error) = store.take_persistence_errors().into_iter().next() {
3366 return Err(format!("the family was not saved, so nothing was generated: {error}"));
3367 }
3368 }
3369 crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
3370 .ok_or("the PLM server did not answer Generate within 5 minutes")?
3371 .map_err(|e| e.to_string())
3372 }
3373
3374 #[cfg(target_arch = "wasm32")]
3375 fn generate_on_plm(
3376 &mut self,
3377 _: &mut Documents,
3378 _: &dyn ModelStore,
3379 _: &std::rc::Rc<crate::plm::client::PlmClient>,
3380 _: &str,
3381 _: bool,
3382 ) -> Result<family_table::GenerateReport, String> {
3383 Err("in the browser, Generate a PLM family from the family table's Generate button".into())
3384 }
3385
3386 fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
3387 if report.rows.iter().any(|row| row.status() == "written") {
3388 self.save_generation += 1;
3389 }
3390 self.status = format!("generate: {}", report.summary());
3391 self.last_generate = Some(report.clone());
3392 report
3393 }
3394
3395 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
3400 serde_json::json!({
3401 "name": docs.active().name(),
3402 "nameBuf": self.name_buf,
3403 "selected": self.explorer.selected(),
3404 "location": store.browser_location(),
3405 "folders": Purpose::ALL
3408 .iter()
3409 .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
3410 .collect::<serde_json::Map<_, _>>(),
3411 "dirty": docs.active().dirty_marker(),
3419 "backend": store.backend_label(),
3420 "interchange": store.supports_file_interchange(),
3421 "list": store.list(),
3422 "status": self.status,
3423 "open": self.open,
3424 "mode": match self.mode {
3425 Mode::CreateComponentPart => "createcomponentpart",
3426 Mode::Open => "open",
3427 Mode::SaveAs => "saveas",
3428 Mode::ConfirmClose => "confirmclose",
3429 Mode::Export => "export",
3430 Mode::Import => "import",
3431 Mode::FlatPattern => "flatpattern",
3432 Mode::InsertComponent => "insertcomponent",
3433 Mode::StepAssembly => "stepassembly",
3434 Mode::Kicad => "kicad",
3435 Mode::PickMember => "pickmember",
3436 Mode::SpinOut => "spinout",
3437 Mode::MemberEdit => "memberedit",
3438 Mode::PickFile => "pickfile",
3439 },
3440 "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
3441 "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
3442 "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
3443 })),
3444 "class": document_class::document_class(docs.engine()).slug(),
3447 "saveClass": self.save_class.slug(),
3448 "plmSaveAs": self.plm_save_as.as_ref().map(|state| state.state_json()),
3449 "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
3450 "family": source.family,
3451 "partNumber": source.part_number,
3452 "valuesHash": source.values_hash,
3453 "historyHash": source.history_hash,
3454 })),
3455 "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
3458 "family": pick.family_file,
3459 "plm": pick.plm,
3460 "loading": pick.loading,
3461 "rows": pick.rows.iter().map(|row| serde_json::json!({
3462 "partNumber": row.part_number,
3463 "identity": row.identity,
3464 "exists": row.exists,
3465 "whyNot": row.why_not,
3466 })).collect::<Vec<_>>(),
3467 })),
3468 "plmBusy": self.plm_work.is_some(),
3469 "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
3472 "template": spin.template_file,
3473 "name": spin.name,
3474 "values": spin.values,
3475 "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
3476 "target": spin_out_target(spin),
3477 "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
3478 &spin.template,
3479 input,
3480 spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
3481 )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
3482 })),
3483 "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
3484 "partNumber": prompt.source.part_number,
3485 "family": prompt.source.family,
3486 "familyIdentity": prompt.family_identity,
3487 "fromSave": prompt.from_save,
3488 })),
3489 "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
3490 "summary": report.summary(),
3491 "rows": report.to_json(),
3492 })),
3493 "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
3494 "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
3500 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
3501 serde_json::json!({
3502 "name": pending.name,
3503 "parts": pending.probe.parts,
3504 "instances": pending.probe.instances,
3505 "nestedDepth": pending.probe.nested_depth,
3506 "flatten": pending.flatten,
3509 })
3510 }),
3511 })
3512 .to_string()
3513 }
3514
3515 pub fn hits_json(&self) -> String {
3517 crate::automation::hit_rects::hits_json(&self.hits)
3518 }
3519
3520 fn hit(&mut self, key: &str, resp: &egui::Response) {
3523 self.hits.insert(key.to_string(), resp.rect);
3524 }
3525
3526 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
3527 self.hits.extend(hits.iter().cloned());
3528 }
3529}
3530
3531
3532fn slug(name: &str) -> String {
3536 let cleaned: String = name
3537 .chars()
3538 .map(|c| if c.is_alphanumeric() { c } else { '-' })
3539 .collect();
3540 let trimmed = cleaned.trim_matches('-').to_string();
3541 if trimmed.is_empty() { "sheet".into() } else { trimmed }
3542}
3543pub static HIT_KEYS: &[HitKeyDoc] = &[
3545 HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
3546 HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
3547 HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
3548 HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
3549 HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
3550 HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:iges, export:stl, export:obj, export:glb, export:json, export:bomcsv, export:bomjson, the open DRAWING SHEET as export:sheetsvg, every drawing sheet as ONE multi-page PDF as export:sheetpdf, and the same drawing set with the live 3D model as export:sheetpdf3d; for a document whose PCB board has parts, its manufacturing files: the Gerber/drill/pick-and-place/BOM zip as export:fabrication, the pick-and-place CSV as export:pickplace, the electronics BOM CSV as export:ecadbom)", command: None },
3551 HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
3552 HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
3553 HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
3554 HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
3555 HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "PLM Save As destinations (new-revision, new-part, existing), destination fields (part, revision, name, number), save and cancel", command: None },
3556 HitKeyDoc { panel: "file", prefix: "saveas:class:", meaning: "Save As's class chooser: saveas:class:normal (.nbrep), saveas:class:family (.fbrep), saveas:class:template (.tbrep)", command: None },
3557 HitKeyDoc { panel: "file", prefix: "member:", meaning: "the member picker Insert component raises for a family: member:<part number> places that member (disabled until Generate has written it), member:cancel", command: None },
3558 HitKeyDoc { panel: "file", prefix: "spinout:", meaning: "the template spin-out prompt: spinout:name, spinout:input:<name> (a value field), spinout:choice:<name>:<value> (a fixed-list input), spinout:create, spinout:cancel", command: None },
3559 HitKeyDoc { panel: "file", prefix: "member-edit:", meaning: "the hand-edit prompt on a family member: member-edit:saveas, member-edit:family (open the family), member-edit:cancel (undo the change)", command: None },
3560 HitKeyDoc { panel: "file", prefix: "pickfile:", meaning: "the file chooser (any file on this machine, for a panel's Add file…): the explorer's key set under the `pickfile` prefix — pickfile:<file name> (a row), pickfile:confirm, pickfile:path-edit-toggle, …", command: Some("file_pick") },
3561 HitKeyDoc { panel: "file", prefix: "kicad:", meaning: "the KiCad import: its explorer (kicad:<file name>, kicad:confirm, kicad:upload and the explorer's other keys), then kicad:symbol:<library:name>, kicad:next, kicad:footprint:<library:name>, kicad:use-footprint, kicad:no-footprint, kicad:search, kicad:field:footprints, kicad:field:models, kicad:apply-folders, kicad:import, kicad:cancel", command: None },
3562];
3563
3564fn fetch_document(
3566 client: std::rc::Rc<crate::plm::client::PlmClient>,
3567 key: String,
3568 number: String,
3569) -> crate::panels::plm_family::Pending<(String, String, String)> {
3570 crate::panels::plm_family::Pending::new(Box::pin(async move {
3571 let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
3572 let text = bytes
3573 .map(|b| String::from_utf8_lossy(&b).into_owned())
3574 .ok_or_else(|| format!("{key} has no document yet"))?;
3575 Ok((key, text, number))
3576 }))
3577}
3578
3579use crate::store::RECENT_DOCUMENTS_KEY;
3581pub fn recent_documents(store: &dyn ModelStore) -> Vec<String> {
3582 store
3583 .read(RECENT_DOCUMENTS_KEY)
3584 .and_then(|s| serde_json::from_str(&s).ok())
3585 .unwrap_or_default()
3586}
3587fn remember_document(store: &dyn ModelStore, name: &str) {
3588 let mut recent = recent_documents(store);
3589 recent.retain(|item| item != name);
3590 recent.insert(0, name.into());
3591 recent.truncate(20);
3592 let _ = store.write(
3593 RECENT_DOCUMENTS_KEY,
3594 &serde_json::to_string(&recent).unwrap(),
3595 );
3596}
3597
3598#[derive(Default)]
3599pub struct FileMenuOutcome {
3600 pub action: Option<FileAction>,
3601 pub recent: Option<String>,
3602 pub plugins: bool,
3604 pub javascript: bool,
3606}
3607
3608pub const RECENT_THUMB_SIDE: f32 = crate::plm::thumbnail::RECENT_SIDE;
3610
3611const RECENT_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect x="1.5" y="1.5" width="37" height="37" rx="4" fill="#8a8a8a" fill-opacity="0.16" stroke="#8a8a8a" stroke-opacity="0.45"/></svg>"##;
3615
3616pub fn file_menu(
3621 ui: &mut egui::Ui,
3622 state: &mut EngineState,
3623 store: &dyn ModelStore,
3624 settings_open: &mut bool,
3625 properties_open: &mut bool,
3626 hits: &mut HashMap<String, egui::Rect>,
3627) -> FileMenuOutcome {
3628 use crate::panels::toolbar_button;
3629 let mut outcome = FileMenuOutcome::default();
3630 let presentation = crate::workbench::file_presentation;
3631 egui_extras::install_image_loaders(ui.ctx());
3632 let menu = ui.menu_button("File", |ui| {
3633 let plugin_glyph = presentation("workbench:manage:plugins").glyph;
3636 let dynamic = crate::workbench::owned_workbenches(state);
3637 let workbench_rows: Vec<(&str, &str)> = crate::workbench::WORKBENCHES
3638 .iter()
3639 .map(|w| (w.glyph, w.label))
3640 .chain(dynamic.iter().map(|w| (plugin_glyph, w.label.as_str())))
3641 .collect();
3642 let file_rows: Vec<_> = crate::workbench::FILE_COMMANDS
3643 .iter()
3644 .map(|p| (p.glyph, p.label()))
3645 .collect();
3646 let left_width = toolbar_button::menu_width_salted(ui, "workbench-measure", &workbench_rows);
3650 let right_width = toolbar_button::menu_width(ui, &file_rows);
3651 let gap = ui.spacing().item_spacing.x;
3652 let compact = crate::viewport::touch::compact(ui.ctx());
3653 let width = if compact { left_width.max(right_width).min((ui.ctx().content_rect().width() - 32.0).max(100.0)) }
3654 else { left_width + gap + right_width };
3655 let (left_width, right_width) = if compact { (width, width) } else { (left_width, right_width) };
3656 ui.set_width(width);
3657 let max_height = ui.ctx().content_rect().height() * 0.8;
3658 ui.set_max_height(max_height);
3659 let cap_bottom = ui.max_rect().bottom();
3664 let body = egui::ScrollArea::vertical()
3665 .id_salt("file-menu-body")
3666 .auto_shrink([false, true])
3667 .max_height(max_height)
3668 .show(ui, |ui| {
3669 let columns = |ui: &mut egui::Ui| {
3670 ui.vertical(|ui| {
3672 ui.set_width(left_width);
3673 ui.weak("Workbench");
3674 let current = crate::workbench::resolved_id(state, &state.settings.workbench);
3675 if current != state.settings.workbench {
3676 ui.add(
3677 egui::Label::new(
3678 egui::RichText::new(format!(
3679 "Unavailable workbench: {}; using Modeling",
3680 state.settings.workbench
3681 ))
3682 .weak(),
3683 )
3684 .wrap(),
3685 );
3686 }
3687 let options = crate::workbench::WORKBENCHES
3688 .iter()
3689 .map(|w| (w.id, w.label, w.glyph))
3690 .chain(dynamic.iter().map(|w| (w.id.as_str(), w.label.as_str(), plugin_glyph)));
3691 for (id, label, glyph) in options {
3692 let row = toolbar_button::menu_row(ui, glyph, label, current == id, left_width);
3693 hits.insert(format!("workbench:item:{id}"), row.rect);
3694 if row.clicked() {
3695 let _ = state
3696 .apply_settings_json(&serde_json::json!({"workbench": id}).to_string());
3697 let _ = store.write(crate::store::SETTINGS_KEY, &state.settings_json());
3698 ui.close();
3699 }
3700 }
3701 });
3702 ui.vertical(|ui| {
3704 ui.set_width(right_width);
3705 ui.weak("Document");
3706 for (class, p) in DocumentClass::ALL
3707 .into_iter()
3708 .zip(crate::workbench::NEW_DOCUMENT_COMMANDS)
3709 {
3710 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
3711 hits.insert(p.id.into(), row.rect);
3712 if class == DocumentClass::Normal {
3713 hits.insert("file:new".into(), row.rect);
3714 hits.insert("file:newmenu".into(), row.rect);
3715 }
3716 if row.clicked() {
3717 outcome.action = Some(FileAction::NewOfClass(class));
3718 ui.close();
3719 }
3720 }
3721 for (id, action) in [
3722 ("file:open", FileAction::Open),
3723 ("file:save", FileAction::Save),
3724 ("file:saveas", FileAction::SaveAs),
3725 ("file:import", FileAction::Import),
3726 ("file:export", FileAction::Export),
3727 ] {
3728 let p = presentation(id);
3729 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
3730 hits.insert(id.into(), row.rect);
3731 if row.clicked() {
3732 outcome.action = Some(action);
3733 ui.close();
3734 }
3735 }
3736 let properties = toolbar_button::menu_row(
3737 ui,
3738 presentation("properties").glyph,
3739 presentation("properties").label(),
3740 *properties_open,
3741 right_width,
3742 );
3743 hits.insert("properties".into(), properties.rect);
3744 if properties.clicked() {
3745 *properties_open = !*properties_open;
3746 ui.close();
3747 }
3748 let settings = toolbar_button::menu_row(
3749 ui,
3750 presentation("settings").glyph,
3751 presentation("settings").label(),
3752 *settings_open,
3753 right_width,
3754 );
3755 hits.insert("settings".into(), settings.rect);
3756 if settings.clicked() {
3757 *settings_open = !*settings_open;
3758 ui.close();
3759 }
3760 for (id, flag) in [
3763 ("workbench:manage:plugins", &mut outcome.plugins),
3764 ("workbench:manage:javascript", &mut outcome.javascript),
3765 ] {
3766 let row = toolbar_button::menu_row(
3767 ui,
3768 presentation(id).glyph,
3769 presentation(id).label(),
3770 false,
3771 right_width,
3772 );
3773 hits.insert(id.into(), row.rect);
3774 if row.clicked() {
3775 *flag = true;
3776 ui.close();
3777 }
3778 }
3779 });
3780 };
3781 if compact { ui.vertical(columns); } else { ui.horizontal_top(columns); }
3782
3783 let recent = recent_documents(store);
3786 if !recent.is_empty() {
3787 ui.separator();
3788 let caption = ui.weak("Recent Documents");
3789 hits.insert("file:recent".into(), caption.rect);
3790 let thumbs = crate::plm::thumbnail::recent_textures(ui.ctx(), store);
3791 let side = egui::Vec2::splat(RECENT_THUMB_SIDE);
3792 let row_height = RECENT_THUMB_SIDE + 2.0 * ui.spacing().button_padding.y;
3793 let room = cap_bottom - ui.cursor().top();
3796 let list_height = room.max(2.0 * row_height + ui.spacing().item_spacing.y);
3797 egui::ScrollArea::vertical()
3798 .id_salt("file-recent")
3799 .auto_shrink([false, true])
3800 .max_height(list_height)
3801 .show(ui, |ui| {
3802 for name in &recent {
3803 let image = match thumbs.get(name) {
3804 Some((_, texture)) => egui::Image::from_texture((texture.id(), side)),
3805 None => egui::Image::new(egui::ImageSource::Bytes {
3806 uri: "bytes://brep-recent-thumb/placeholder.svg".into(),
3807 bytes: egui::load::Bytes::Static(RECENT_PLACEHOLDER_SVG.as_bytes()),
3808 }),
3809 }
3810 .fit_to_exact_size(side);
3811 let row = ui
3812 .add(
3813 egui::Button::new((image, model_display_name(name)))
3814 .wrap_mode(egui::TextWrapMode::Truncate)
3815 .min_size(egui::vec2(width, row_height)),
3816 )
3817 .on_hover_text(name);
3818 hits.insert(format!("file:recent:{name}"), row.rect);
3819 let padding = ui.spacing().button_padding;
3820 let thumb = egui::Rect::from_min_size(
3821 row.rect.min + egui::vec2(padding.x, (row.rect.height() - RECENT_THUMB_SIDE) / 2.0),
3822 side,
3823 );
3824 hits.insert(format!("file:recent:thumb:{name}"), thumb);
3825 if row.clicked() {
3826 outcome.recent = Some(name.clone());
3827 ui.close();
3828 }
3829 }
3830 });
3831 }
3832 });
3833 if compact { hits.insert("file:clip".into(), body.inner_rect); }
3834 });
3835 hits.insert("file:menu".into(), menu.response.rect);
3836 hits.insert("workbench".into(), menu.response.rect);
3838 outcome
3839}
3840