1use crate::automation::hit_keys::HitKeyDoc;
30use crate::document::{Document, Documents, EMPTY_DOCUMENT};
31use crate::document_class::{self, DocumentClass};
32use crate::family_table::{self, FamilySource};
33use crate::template::{self, TemplateInput};
34use crate::panels::parts_library::document_signature;
35use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
36use crate::panels::kicad_import::{self, DiskFiles, KicadImport, KicadKind};
37use crate::store::{model_display_name, sibling_identity, ModelStore, PlaceKind, MODEL_EXTENSIONS};
38use brep_render::engine_state::{
39 ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
40 StepAssemblyReport, StepProbeOutcome,
41};
42use brep_render::runner::MeshImportFormat;
43use eframe::egui;
44use std::collections::{BTreeMap, HashMap, HashSet};
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum FileAction {
50 New,
51 NewOfClass(DocumentClass),
54 Open,
55 Save,
56 SaveAs,
57 Import,
60 Export,
63 ExportFlatPattern,
66 InsertComponent,
72 ImportKicad(KicadKind),
77}
78
79#[derive(Clone, Copy, PartialEq, Eq)]
81enum Mode {
82 Open,
84 SaveAs,
86 ConfirmClose,
90 Export,
92 Import,
94 FlatPattern,
96 InsertComponent,
99 StepAssembly,
103 Kicad,
106 PickMember,
109 SpinOut,
112 MemberEdit,
115 PickFile,
119}
120
121const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
123
124#[derive(Clone, Debug, PartialEq)]
126pub struct PickedFile {
127 pub name: String,
129 pub path: Option<String>,
131 pub bytes: Vec<u8>,
132}
133
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140enum Purpose {
141 Models,
143 Imports,
145 Kicad,
147}
148
149impl Purpose {
150 const ALL: [Purpose; 3] = [Purpose::Models, Purpose::Imports, Purpose::Kicad];
151
152 fn slug(self) -> &'static str {
153 match self {
154 Purpose::Models => "models",
155 Purpose::Imports => "imports",
156 Purpose::Kicad => "kicad",
157 }
158 }
159}
160
161impl Mode {
162 fn purpose(self) -> Option<Purpose> {
164 match self {
165 Mode::Open | Mode::SaveAs | Mode::InsertComponent => Some(Purpose::Models),
166 Mode::Import => Some(Purpose::Imports),
167 Mode::Kicad => Some(Purpose::Kicad),
168 Mode::ConfirmClose
169 | Mode::Export
170 | Mode::FlatPattern
171 | Mode::StepAssembly
172 | Mode::PickMember
173 | Mode::SpinOut
174 | Mode::MemberEdit
175 | Mode::PickFile => None,
176 }
177 }
178}
179
180fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
185 match purpose {
186 Purpose::Imports | Purpose::Kicad => store.local_files().unwrap_or(store),
187 Purpose::Models => store,
188 }
189}
190
191fn models_folder(store: &dyn ModelStore) -> Option<String> {
195 store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
196}
197
198struct PendingStepImport {
208 name: String,
210 text: String,
213 probe: StepAssemblyProbe,
215 flatten: bool,
220}
221
222struct PendingStepProbe {
228 id: u64,
230 name: String,
231 text: String,
233}
234
235#[derive(Clone, Copy, PartialEq, Eq)]
237enum StepChoice {
238 Assembly,
240 Bodies,
242 Cancel,
244}
245
246const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
249
250struct MemberChoice {
252 part_number: String,
253 description: String,
254 identity: Option<String>,
257 exists: bool,
259 why_not: Option<String>,
262}
263
264struct PickMember {
266 family_file: String,
268 rows: Vec<MemberChoice>,
269 plm: bool,
273 loading: bool,
274}
275
276struct SpinOut {
279 template_file: String,
280 template: serde_json::Value,
281 inputs: Vec<TemplateInput>,
282 name: String,
284 values: BTreeMap<String, String>,
286 assembly: Option<String>,
289 plm_template: Option<String>,
292 number: String,
293}
294
295enum PlmWork {
298 Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
300 Place(crate::panels::plm_family::Pending<(String, String, String)>),
303 OpenFamily(crate::panels::plm_family::Pending<String>),
305}
306
307struct MemberPrompt {
309 doc_id: u64,
310 source: FamilySource,
311 family_identity: String,
313 from_save: bool,
315}
316
317fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
322 document_class::set_document_class(engine, class);
323 let drop: &[&str] = match class {
324 DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
325 DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
326 DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
327 };
328 for key in drop {
329 if engine.history.document_block(key).is_some() {
330 engine.history.set_document_block_no_undo(key, None);
331 }
332 }
333}
334
335fn spin_out_target(spin: &SpinOut) -> String {
338 let file = DocumentClass::Normal.file_name(spin.name.trim());
339 match &spin.assembly {
340 Some(assembly) => sibling_identity(assembly, &file),
341 None => file,
342 }
343}
344
345fn plural(count: usize) -> &'static str {
348 if count == 1 {
349 ""
350 } else {
351 "s"
352 }
353}
354
355fn step_document_name(file_name: &str) -> String {
359 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
360 let cut = base
361 .rfind('.')
362 .filter(|dot| is_step_name(&base[*dot..]))
363 .unwrap_or(base.len());
364 let stem = &base[..cut];
365 if stem.is_empty() {
366 base.to_string()
367 } else {
368 stem.to_string()
369 }
370}
371
372struct StorePartSink<'a> {
420 store: &'a dyn ModelStore,
421 prefix: String,
423 taken: std::collections::HashSet<String>,
427 written: usize,
428 failures: Vec<String>,
429}
430
431impl<'a> StorePartSink<'a> {
432 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
433 Self {
434 store,
435 prefix: sanitize_file_stem(prefix),
436 taken: std::collections::HashSet::new(),
437 written: 0,
438 failures: Vec::new(),
439 }
440 }
441}
442
443impl PartSink for StorePartSink<'_> {
444 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
445 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
446 let mut name = base.clone();
447 let mut suffix = 2;
448 while !self.taken.insert(name.clone()) {
449 name = format!("{base}-{suffix}");
450 suffix += 1;
451 }
452 match self.store.browser_write(&name, document_json) {
453 Ok(identity) => {
454 self.written += 1;
455 Some(identity)
456 }
457 Err(error) => {
458 self.failures.push(format!("{name}: {error}"));
459 None
460 }
461 }
462 }
463}
464
465fn sanitize_file_stem(name: &str) -> String {
471 let mut out = String::with_capacity(name.len());
472 for ch in name.chars() {
473 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
474 out.push(ch);
475 } else if !out.ends_with('_') {
476 out.push('_');
477 }
478 }
479 let trimmed = out.trim_matches('_');
480 if trimmed.is_empty() {
481 "part".to_string()
482 } else {
483 trimmed.to_string()
484 }
485}
486
487fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
488 let mut message = format!(
489 "imported {name} \u{2014} {} part{}, {} component{}",
490 report.parts,
491 plural(report.parts),
492 report.instances,
493 plural(report.instances)
494 );
495 if report.baked_nonrigid > 0 {
496 message.push_str(&format!(
497 " ({} mirrored instance{} baked)",
498 report.baked_nonrigid,
499 plural(report.baked_nonrigid)
500 ));
501 }
502 if report.failed_products > 0 {
503 message.push_str(&format!(
504 "; {} product{} could not be built",
505 report.failed_products,
506 plural(report.failed_products)
507 ));
508 }
509 if report.flat_fallback {
510 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
511 }
512 if let Some(error) = &report.first_error {
513 message.push_str(&format!(" [{error}]"));
514 }
515 message
516}
517
518fn is_step_name(name: &str) -> bool {
523 let lower = name.to_ascii_lowercase();
524 lower.ends_with(".step") || lower.ends_with(".stp")
525}
526
527fn is_iges_name(name: &str) -> bool {
530 let lower = name.to_ascii_lowercase();
531 lower.ends_with(".iges") || lower.ends_with(".igs")
532}
533
534fn is_stl_name(name: &str) -> bool {
535 name.to_ascii_lowercase().ends_with(".stl")
536}
537
538fn is_obj_name(name: &str) -> bool {
539 name.to_ascii_lowercase().ends_with(".obj")
540}
541
542fn is_3mf_name(name: &str) -> bool {
545 name.to_ascii_lowercase().ends_with(".3mf")
546}
547
548pub struct FileDialog {
551 explorer: FileExplorer,
553 workspace: crate::panels::plm_workspace::WorkspaceBrowser,
556 name_buf: String,
558 pub(crate) status: String,
560 open: bool,
562 mode: Mode,
564 want_focus: bool,
566 pending_component_import: bool,
570 pending_close: Option<usize>,
572 pending_step_import: Option<PendingStepImport>,
577 pending_step_probe: Option<PendingStepProbe>,
580 pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
583 save_generation: u64,
587 hits: HashMap<String, egui::Rect>,
589 kicad: KicadImport,
591 folders: HashMap<Purpose, String>,
594 pick_request: Option<(String, String)>,
597 pick_folder: Option<String>,
598 picked: Option<(String, PickedFile)>,
599 plm_new_part: bool,
602 plm_new_revision: Option<crate::panels::plm_parts::Pending<String>>,
604 save_class: DocumentClass,
606 pick_member: Option<PickMember>,
608 plm_work: Option<PlmWork>,
609 spin_out: Option<SpinOut>,
611 member_prompt: Option<MemberPrompt>,
613 member_ack: HashSet<u64>,
616 last_generate: Option<family_table::GenerateReport>,
618}
619
620impl FileDialog {
621 pub fn new() -> Self {
624 Self {
625 explorer: FileExplorer::new(),
626 workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
627 name_buf: String::new(),
628 status: String::new(),
629 open: false,
630 mode: Mode::Open,
631 want_focus: false,
632 pending_component_import: false,
633 pending_close: None,
634 pending_step_import: None,
635 pending_step_probe: None,
636 pending_stl_import: None,
637 save_generation: 0,
638 hits: HashMap::new(),
639 kicad: KicadImport::new(),
640 folders: HashMap::new(),
641 pick_request: None,
642 pick_folder: None,
643 picked: None,
644 plm_new_part: false,
645 plm_new_revision: None,
646 save_class: DocumentClass::Normal,
647 pick_member: None,
648 plm_work: None,
649 spin_out: None,
650 member_prompt: None,
651 member_ack: HashSet::new(),
652 last_generate: None,
653 }
654 }
655
656 pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
659 let mut out = Vec::new();
660 if self.kicad.reading() {
661 out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
662 }
663 out
664 }
665
666 pub fn save_generation(&self) -> u64 {
669 self.save_generation
670 }
671
672 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
678 match action {
679 FileAction::New => self.new_document(docs, DocumentClass::Normal),
680 FileAction::NewOfClass(class) => self.new_document(docs, class),
681 FileAction::Open => {
682 self.use_folder(Purpose::Models, store);
683 self.open_modal(Mode::Open);
684 }
685 FileAction::Save => match docs.active().name().map(str::to_string) {
686 Some(name) if self.arm_member_prompt(docs, &name, true) => {}
689 Some(name) => {
691 let _ = self.save_to(docs, store, name);
692 }
693 None => self.dispatch(FileAction::SaveAs, docs, store),
695 },
696 FileAction::SaveAs => {
697 self.name_buf = document_class::strip_class_extension(&Self::document_name(docs)).to_string();
698 self.save_class = document_class::document_class(docs.engine());
699 self.use_folder(Purpose::Models, store);
700 self.open_modal(Mode::SaveAs);
701 }
702 FileAction::Import => {
703 self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
704 self.open_modal(Mode::Import);
705 }
706 FileAction::Export => {
707 self.name_buf = Self::document_name(docs);
708 self.open_modal(Mode::Export);
709 }
710 FileAction::ExportFlatPattern => {
711 self.name_buf = Self::document_name(docs);
712 self.open_modal(Mode::FlatPattern);
713 }
714 FileAction::InsertComponent => {
719 self.use_folder(Purpose::Models, store);
720 self.open_modal(Mode::InsertComponent);
721 }
722 FileAction::ImportKicad(kind) => {
723 self.kicad.start(kind, store);
724 self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
725 self.open_modal(Mode::Kicad);
726 }
727 }
728 }
729
730 pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
737 self.picked = None;
738 if let Some(local) = store.local_files() {
739 let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
740 if !arrived {
741 let _ = local.browser_home();
742 }
743 self.pick_request = Some((tag.to_string(), title.to_string()));
744 self.status.clear();
745 self.open_modal(Mode::PickFile);
746 return Ok(());
747 }
748 store.begin_pick_file()?;
749 self.pick_request = Some((tag.to_string(), title.to_string()));
750 Ok(())
751 }
752
753 pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
755 if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
756 return self.picked.take().map(|(_, file)| file);
757 }
758 None
759 }
760
761 pub fn picking(&self, tag: &str) -> bool {
764 self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
765 }
766
767 fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
769 let Some(local) = store.local_files() else {
770 self.open = false;
771 self.pick_request = None;
772 return;
773 };
774 let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
775 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
776 file_explorer::dialog_body(ui, |ui| {
777 ui.heading(&title);
778 ui.add_space(4.0);
779 file_explorer::dialog_footer(ui, "pickfile", |ui| {
780 if !self.status.is_empty() {
781 ui.add_space(4.0);
782 ui.weak(&self.status);
783 }
784 });
785 let options = FileExplorerOptions {
786 hit_prefix: "pickfile",
787 empty_label: "(this folder is empty)",
788 row_icon: "\u{1F5CE}",
789 current: None,
790 allow_delete: false,
791 allow_import: false,
792 import_label: "",
793 import_hit: "pickfile:upload",
794 show_cancel: true,
795 confirm_label: Some("Choose"),
796 extensions: &[],
797 };
798 let output = self.explorer.show_store(ui, local, options);
799 self.record_explorer_hits(&output.hits);
800 output
801 })
802 });
803 let should_close = modal.should_close();
804 let output = modal.inner;
805 self.pick_folder = Some(local.browser_location());
806 if let Some(path) = output.activated {
807 match local.read_external_file(&path) {
808 Some(bytes) => {
809 let name = std::path::Path::new(&path)
810 .file_name()
811 .map(|n| n.to_string_lossy().into_owned())
812 .unwrap_or_else(|| path.clone());
813 if let Some((tag, _)) = self.pick_request.take() {
814 self.status = format!("chose {name}");
815 self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
816 }
817 self.open = false;
818 }
819 None => self.status = format!("'{path}' could not be read"),
820 }
821 } else if output.cancel || should_close {
822 self.pick_request = None;
823 self.status.clear();
824 self.open = false;
825 }
826 }
827
828 fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
832 let remembered = self.folders.get(&purpose).cloned();
833 let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
834 if !arrived {
835 if let Some(models) = models_folder(store) {
836 let _ = store.browser_navigate(&models);
837 }
838 }
839 }
840
841 fn remember_folder(&mut self, store: &dyn ModelStore) {
843 if let Some(purpose) = self.mode.purpose() {
844 self.folders.insert(purpose, browse_store(purpose, store).browser_location());
845 }
846 }
847
848 fn open_modal(&mut self, mode: Mode) {
850 self.explorer.clear_selection();
854 self.mode = mode;
855 self.open = true;
856 self.want_focus = true;
857 }
858
859 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
865 self.hits.clear();
866 self.poll_plm_work(ctx, docs, store);
867 self.poll_open_workspace_pick(store);
868 if let Some(file) = store.take_picked_file() {
870 if let Some((tag, _)) = self.pick_request.take() {
871 self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
872 }
873 }
874
875 self.poll_step_probe(docs.engine_mut());
878
879 if let Some(imported) = store.take_import() {
887 self.cancel_pending_step_import(docs.engine_mut());
893 let name = imported.name;
894 let bytes = imported.bytes;
895 if KicadKind::of_file(&name).is_some() {
898 self.take_kicad_file(&name, &bytes, store);
899 return;
900 } else if is_step_name(&name) {
901 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
902 } else if is_iges_name(&name) {
903 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
904 } else if is_stl_name(&name) {
905 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes);
906 } else if is_3mf_name(&name) {
907 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes);
908 } else if is_obj_name(&name) {
909 self.import_obj(docs.engine_mut(), &name, &bytes);
910 } else if std::mem::take(&mut self.pending_component_import) {
911 match String::from_utf8(bytes) {
916 Ok(contents) => {
917 let assembly = docs.active().name().map(str::to_string);
918 if !self.begin_class_insert(store, &name, &contents, assembly) {
919 self.insert_component_document(docs.engine_mut(), &name, &contents)
920 }
921 if self.pick_member.is_some() || self.spin_out.is_some() {
922 return;
923 }
924 }
925 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
926 }
927 } else {
928 match String::from_utf8(bytes) {
929 Ok(contents) => self.load_document(docs, &name, &contents, store.plm_client().is_some()),
930 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
931 }
932 }
933 self.close_after_import();
934 }
935
936 if !self.open {
939 self.watch_member_edit(docs);
940 }
941
942 if !self.open {
943 return;
944 }
945 self.remember_folder(store);
946
947 match self.mode {
948 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
949 Mode::SaveAs => self.show_save_as(ctx, docs, store),
950 Mode::Open => self.show_open(ctx, docs, store),
951 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
952 Mode::Export => self.show_export(ctx, docs, store),
953 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
954 Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
955 Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
956 Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
957 Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
958 Mode::PickFile => self.show_pick_file(ctx, store),
959 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
960 Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
961 }
962 }
963
964 fn close_after_import(&mut self) {
970 if self.pending_step_import.is_some() {
971 self.open_modal(Mode::StepAssembly);
972 } else {
973 self.open = false;
974 }
975 }
976
977 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
982 if self.pending_step_import.take().is_some() {
983 state.discard_probed_step_assembly();
984 }
985 }
986
987 fn show_import(
990 &mut self,
991 ctx: &egui::Context,
992 state: &mut EngineState,
993 store: &dyn ModelStore,
994 ) {
995 const EXTENSIONS: &[&str] =
996 &["step", "stp", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
997 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
998 file_explorer::dialog_body(ui, |ui| {
999 ui.heading("Import CAD file");
1000 ui.add_space(4.0);
1001 file_explorer::dialog_footer(ui, "import", |ui| {
1002 if !self.status.is_empty() {
1003 ui.add_space(4.0);
1004 ui.weak(&self.status);
1005 }
1006 });
1007 let options = FileExplorerOptions {
1008 hit_prefix: "import",
1009 empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1010 row_icon: "\u{1F5CE}",
1011 current: None,
1012 allow_delete: false,
1013 allow_import: store.supports_file_interchange(),
1014 import_label: "Upload\u{2026}",
1015 import_hit: "import:upload",
1016 show_cancel: true,
1017 confirm_label: Some("Import"),
1018 extensions: EXTENSIONS,
1019 };
1020 let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1021 self.record_explorer_hits(&output.hits);
1022 output
1023 })
1024 });
1025 let should_close = modal.should_close();
1026 let output = modal.inner;
1027 if let Some(name) = output.activated {
1028 match browse_store(Purpose::Imports, store).read_external_file(&name) {
1029 Some(bytes) if KicadKind::of_file(&name).is_some() => {
1030 self.take_kicad_file(&name, &bytes, store);
1032 return;
1033 }
1034 Some(bytes) if is_step_name(&name) => {
1035 self.import_text(state, &name, &bytes, Self::import_step)
1036 }
1037 Some(bytes) if is_iges_name(&name) => {
1038 self.import_text(state, &name, &bytes, Self::import_iges)
1039 }
1040 Some(bytes) if is_stl_name(&name) => {
1041 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes)
1042 }
1043 Some(bytes) if is_3mf_name(&name) => {
1044 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes)
1045 }
1046 Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
1047 Some(_) => self.status = format!("unsupported import file: {name}"),
1048 None => self.status = format!("import failed: '{name}' not found"),
1049 }
1050 self.close_after_import();
1051 } else if output.import {
1052 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1053 Ok(()) => {
1054 self.status =
1055 "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1056 self.open = false;
1057 }
1058 Err(e) => self.status = format!("import failed: {e}"),
1059 }
1060 } else if output.cancel || should_close {
1061 self.open = false;
1062 }
1063 }
1064
1065 fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1070 if !(self.open && self.mode == Mode::Kicad) {
1071 if let Some(kind) = KicadKind::of_file(name) {
1072 self.kicad.start(kind, store);
1073 }
1074 }
1075 self.kicad.take_file(name, bytes, &DiskFiles);
1076 self.open_modal(Mode::Kicad);
1077 }
1078
1079 fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1082 if !self.kicad.wants_file() {
1083 match self.kicad.show(ctx, state, store, &mut self.hits) {
1084 kicad_import::Outcome::Open => {}
1085 kicad_import::Outcome::Closed => self.open = false,
1086 kicad_import::Outcome::Imported(imported) => {
1087 self.status = imported.summary.clone();
1090 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1091 const SHOWN: usize = 5;
1092 let hidden = imported.notes.len().saturating_sub(SHOWN);
1093 for note in imported.notes.into_iter().take(SHOWN) {
1094 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1095 }
1096 if hidden > 0 {
1097 state.push_notice_as(
1098 brep_render::engine_state::NoticeSeverity::Warning,
1099 format!("and {hidden} more notes on this import"),
1100 );
1101 }
1102 self.open = false;
1103 }
1104 }
1105 return;
1106 }
1107 let extension = self.kicad.kind().extension();
1108 let heading = match self.kicad.kind() {
1109 KicadKind::Symbol => "Import a symbol from KiCad",
1110 KicadKind::Footprint => "Import a footprint from KiCad",
1111 };
1112 let extensions = [extension];
1113 let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1114 file_explorer::dialog_body(ui, |ui| {
1115 ui.heading(heading);
1116 ui.add_space(4.0);
1117 file_explorer::dialog_footer(ui, "kicad", |ui| {
1118 let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1119 if !status.is_empty() {
1120 ui.add_space(4.0);
1121 ui.weak(status);
1122 }
1123 });
1124 let empty_label = format!("(no .{extension} files)");
1125 let options = FileExplorerOptions {
1126 hit_prefix: "kicad",
1127 empty_label: &empty_label,
1128 row_icon: "\u{1F5CE}",
1129 current: None,
1130 allow_delete: false,
1131 allow_import: store.supports_file_interchange(),
1132 import_label: "Upload\u{2026}",
1133 import_hit: "kicad:upload",
1134 show_cancel: true,
1135 confirm_label: Some("Open"),
1136 extensions: &extensions,
1137 };
1138 let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1139 self.record_explorer_hits(&output.hits);
1140 output
1141 })
1142 });
1143 let should_close = modal.should_close();
1144 let output = modal.inner;
1145 if let Some(name) = output.activated {
1146 match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1147 Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1148 None => self.kicad.status = format!("import failed: '{name}' not found"),
1149 }
1150 } else if output.import {
1151 match store.begin_import_filtered(("KiCad", &extensions)) {
1152 Ok(()) => {
1153 self.status = format!("choose a .{extension} file\u{2026}");
1154 self.open = false;
1155 }
1156 Err(e) => self.kicad.status = format!("import failed: {e}"),
1157 }
1158 } else if output.cancel || should_close {
1159 self.open = false;
1160 }
1161 }
1162
1163 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1169 let dirty = docs.get(index).is_some_and(Document::is_dirty);
1170 if dirty {
1171 self.pending_close = Some(index);
1172 self.open_modal(Mode::ConfirmClose);
1173 } else {
1174 docs.close(index);
1175 }
1176 }
1177
1178 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1182 let Some(title) = self
1185 .pending_close
1186 .and_then(|index| docs.get(index))
1187 .map(Document::title)
1188 else {
1189 self.pending_close = None;
1190 self.open = false;
1191 return;
1192 };
1193 let mut discard = false;
1194 let mut cancel = false;
1195 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1196 ui.set_width(340.0);
1197 ui.heading("Discard unsaved changes?");
1198 ui.add_space(4.0);
1199 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1200 ui.add_space(6.0);
1201 ui.horizontal(|ui| {
1202 let d = ui.button("Discard and close");
1203 self.hit("confirm:discard", &d);
1204 if d.clicked() {
1205 discard = true;
1206 }
1207 let c = ui.button("Cancel");
1208 self.hit("confirm:cancel", &c);
1209 if c.clicked() {
1210 cancel = true;
1211 }
1212 });
1213 });
1214 if discard {
1215 if let Some(index) = self.pending_close.take() {
1216 docs.close(index);
1217 }
1218 self.open = false;
1219 } else if cancel || modal.should_close() {
1220 self.pending_close = None;
1221 self.open = false;
1222 }
1223 }
1224
1225 fn show_step_assembly(
1244 &mut self,
1245 ctx: &egui::Context,
1246 state: &mut EngineState,
1247 store: &dyn ModelStore,
1248 ) {
1249 let Some(pending) = self.pending_step_import.as_ref() else {
1252 self.open = false;
1253 return;
1254 };
1255 let name = pending.name.clone();
1256 let probe = pending.probe;
1257 let mut flatten = pending.flatten;
1258 let mut choice: Option<StepChoice> = None;
1259 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1260 ui.set_width(360.0);
1261 ui.heading(format!("\"{name}\" contains an assembly"));
1262 ui.add_space(4.0);
1263 ui.label(format!(
1264 "{} part{}, {} instance{}{}.",
1265 probe.parts,
1266 plural(probe.parts),
1267 probe.instances,
1268 plural(probe.instances),
1269 match probe.nested_depth {
1270 0 | 1 => String::new(),
1271 depth => format!(", {depth} levels deep"),
1272 }
1273 ));
1274 if probe.nested_depth > 1 {
1275 ui.add_space(4.0);
1276 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1277 self.hit("stepassembly:flatten", &check);
1278 check.on_hover_text(
1279 "Off: each sub-assembly becomes one rigid component you can \
1280 expand in the structure tree.\nOn: every part is placed \
1281 directly in this document at its world position.",
1282 );
1283 }
1284 ui.add_space(6.0);
1285 ui.horizontal(|ui| {
1286 let a = ui.button("Import as assembly");
1287 self.hit("stepassembly:assembly", &a);
1288 if a.clicked() {
1289 choice = Some(StepChoice::Assembly);
1290 }
1291 let b = ui.button("Import as bodies");
1292 self.hit("stepassembly:bodies", &b);
1293 if b.clicked() {
1294 choice = Some(StepChoice::Bodies);
1295 }
1296 let c = ui.button("Cancel");
1297 self.hit("stepassembly:cancel", &c);
1298 if c.clicked() {
1299 choice = Some(StepChoice::Cancel);
1300 }
1301 });
1302 });
1303 if let Some(pending) = self.pending_step_import.as_mut() {
1305 pending.flatten = flatten;
1306 }
1307 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1310 let Some(choice) = choice else { return };
1311 match choice {
1312 StepChoice::Assembly => self.step_assembly_import(state, store),
1313 StepChoice::Bodies => self.step_assembly_bodies(state),
1314 StepChoice::Cancel => self.step_assembly_cancel(state),
1315 }
1316 self.open = false;
1317 }
1318
1319 pub fn take_plm_new_part(&mut self) -> bool {
1322 std::mem::take(&mut self.plm_new_part)
1323 }
1324
1325 fn show_save_as_plm(
1332 &mut self,
1333 ctx: &egui::Context,
1334 docs: &mut Documents,
1335 store: &dyn ModelStore,
1336 client: std::rc::Rc<crate::plm::client::PlmClient>,
1337 ) {
1338 use crate::panels::plm_parts::{save_as_new_revision, Pending, SaveAsChooser, SaveAsDecision};
1339 let identity = docs
1340 .active()
1341 .name()
1342 .and_then(crate::plm::uses::revision_key_in)
1343 .and_then(|key| crate::store::DocumentIdentity::parse_revision_key(&key));
1344 let choices = SaveAsChooser::choices(identity.as_ref());
1345 let saving = self.plm_new_revision.is_some();
1346 let mut chosen = None;
1347 let mut cancel = false;
1348 let mut choice_hits = HashMap::new();
1349 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1350 ui.heading("Save as");
1351 ui.add_space(4.0);
1352 if saving {
1353 ui.label("Saving a new revision…");
1354 } else {
1355 chosen = SaveAsChooser::show(ui, &choices, &mut choice_hits);
1356 }
1357 if !self.status.is_empty() {
1358 ui.weak(&self.status);
1359 }
1360 let button = ui.add_enabled(!saving, egui::Button::new("Cancel"));
1361 self.hits.insert("saveas:cancel".into(), button.rect);
1362 cancel = button.clicked();
1363 });
1364 for (key, rect) in choice_hits {
1367 self.hits.insert(key.strip_prefix("plm_parts:").unwrap_or(&key).to_string(), rect);
1368 }
1369 match chosen {
1370 Some(SaveAsDecision::NewPart) => {
1371 self.plm_new_part = true;
1372 self.status = "fill in the new part in the PLM pane".into();
1373 self.open = false;
1374 }
1375 Some(SaveAsDecision::NewRevision { part }) => {
1376 let document = docs.engine().history_request_json();
1377 let future: crate::plm::PlmFuture<String> =
1378 Box::pin(async move { save_as_new_revision(&client, &part, "", &document).await });
1379 self.status.clear();
1380 self.plm_new_revision = Some(Pending::new(future));
1381 }
1382 None => {}
1383 }
1384 if let Some(pending) = self.plm_new_revision.as_mut() {
1385 let waker = {
1386 struct Repaint(egui::Context);
1387 impl std::task::Wake for Repaint {
1388 fn wake(self: std::sync::Arc<Self>) {
1389 self.0.request_repaint();
1390 }
1391 }
1392 std::task::Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
1393 };
1394 if let Some(answer) = pending.poll(&waker) {
1395 self.plm_new_revision = None;
1396 match answer {
1397 Ok(key) => {
1398 let name = store.canonical_identity(&key);
1399 self.open_document(docs, store, &name);
1400 self.status = format!("saved as a new revision: {name}");
1401 self.open = false;
1402 }
1403 Err(problem) => self.status = problem,
1404 }
1405 } else {
1406 ctx.request_repaint();
1407 }
1408 }
1409 if (cancel || modal.should_close()) && self.plm_new_revision.is_none() {
1410 self.open = false;
1411 }
1412 }
1413
1414 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1416 if let Some(client) = store.plm_client() {
1418 self.show_save_as_plm(ctx, docs, store, client);
1419 return;
1420 }
1421 let current = docs.active().name().map(str::to_string);
1422 let mut do_save = false;
1423 let mut cancel = false;
1424 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1425 file_explorer::dialog_body(ui, |ui| {
1426 ui.heading("Save model as");
1427 ui.add_space(4.0);
1428 file_explorer::dialog_footer(ui, "saveas", |ui| {
1431 ui.label("File name");
1432 let field = ui.add(
1433 egui::TextEdit::singleline(&mut self.name_buf)
1434 .hint_text("model name")
1435 .desired_width(f32::INFINITY),
1436 );
1437 self.hit("field:name", &field);
1438 if self.want_focus {
1439 field.request_focus();
1440 self.want_focus = false;
1441 }
1442 let enter =
1443 field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1444 ui.horizontal(|ui| {
1449 ui.label("Save as");
1450 for class in DocumentClass::ALL {
1451 let text = format!("{} ({})", class.label(), class.extension());
1452 let chip = ui.selectable_label(self.save_class == class, text);
1453 self.hit(&format!("saveas:class:{}", class.slug()), &chip);
1454 if chip.clicked() {
1455 self.save_class = class;
1456 }
1457 }
1458 });
1459 ui.add_space(6.0);
1460 ui.horizontal(|ui| {
1461 let save = ui.button("Save");
1462 self.hit("save", &save);
1463 if save.clicked() || enter {
1464 do_save = true;
1465 }
1466 let c = ui.button("Cancel");
1467 self.hit("cancel", &c);
1468 if c.clicked() {
1469 cancel = true;
1470 }
1471 });
1472 if !self.status.is_empty() {
1473 ui.add_space(4.0);
1474 ui.weak(&self.status);
1475 }
1476 });
1477 let options = FileExplorerOptions {
1478 hit_prefix: "saveas:file",
1479 empty_label: "(no saved models)",
1480 row_icon: "\u{1F5CE}",
1481 current: current.as_deref(),
1482 allow_delete: false,
1483 allow_import: false,
1484 import_label: "",
1485 import_hit: "saveas:upload",
1486 show_cancel: false,
1487 confirm_label: None,
1488 extensions: MODEL_EXTENSIONS,
1489 };
1490 let output = self.explorer.show_store(ui, store, options);
1491 self.record_explorer_hits(&output.hits);
1492 if let Some(name) = output.picked.or(output.activated) {
1498 self.name_buf =
1499 document_class::strip_class_extension(&model_display_name(&name)).to_string();
1500 if let Some(class) = DocumentClass::of_name(&name) {
1501 self.save_class = class;
1502 }
1503 }
1504 })
1505 });
1506 if do_save {
1507 let name = self.save_class.file_name(self.name_buf.trim());
1508 if self.save_to_browser(docs, store, name) {
1509 self.open = false;
1510 }
1511 } else if cancel || modal.should_close() {
1512 self.open = false;
1513 }
1514 }
1515
1516 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1525 let has_bom = !docs.engine().component_ids().is_empty()
1531 || !docs.engine().wire_harness_state().connections.is_empty();
1532 let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1536 let has_board = super::fabrication_export::has_board(docs.engine());
1539 let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1540 let mut chosen: Option<&'static str> = None;
1541 let mut bom_chosen: Option<&'static str> = None;
1542 let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1543 let mut cancel = false;
1544 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1545 ui.set_width(320.0);
1546 ui.heading("Export model");
1547 ui.add_space(4.0);
1548 Self::preview_export_line(ui, docs);
1549 let field = ui.add(
1550 egui::TextEdit::singleline(&mut self.name_buf)
1551 .hint_text("file name")
1552 .desired_width(f32::INFINITY),
1553 );
1554 self.hit("field:name", &field);
1555 if self.want_focus {
1556 field.request_focus();
1557 self.want_focus = false;
1558 }
1559 ui.add_space(6.0);
1560 ui.horizontal(|ui| {
1561 let step = ui.button("STEP (.step)");
1562 self.hit("export:step", &step);
1563 if step.clicked() {
1564 chosen = Some("step");
1565 }
1566 let iges = ui.button("IGES (.igs)");
1567 self.hit("export:iges", &iges);
1568 if iges.clicked() {
1569 chosen = Some("iges");
1570 }
1571 let stl = ui.button("STL (.stl)");
1572 self.hit("export:stl", &stl);
1573 if stl.clicked() {
1574 chosen = Some("stl");
1575 }
1576 let obj = ui.button("OBJ (.obj)");
1577 self.hit("export:obj", &obj);
1578 if obj.clicked() {
1579 chosen = Some("obj");
1580 }
1581 let glb = ui.button("GLB (.glb)");
1582 self.hit("export:glb", &glb);
1583 if glb.clicked() {
1584 chosen = Some("glb");
1585 }
1586 let json = ui.button("Model (.nbrep)");
1590 self.hit("export:json", &json);
1591 if json.clicked() {
1592 chosen = Some("json");
1593 }
1594 let c = ui.button("Cancel");
1595 self.hit("cancel", &c);
1596 if c.clicked() {
1597 cancel = true;
1598 }
1599 });
1600 ui.add_space(4.0);
1601 ui.horizontal(|ui| {
1602 let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1606 self.hit("export:bomcsv", &csv);
1607 if csv.clicked() {
1608 bom_chosen = Some("csv");
1609 }
1610 let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1611 self.hit("export:bomjson", &json);
1612 if json.clicked() {
1613 bom_chosen = Some("json");
1614 }
1615 let sheet = ui
1618 .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1619 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1620 self.hit("export:sheetsvg", &sheet);
1621 if sheet.clicked() {
1622 chosen = Some("sheetsvg");
1623 }
1624 let pdf = ui
1628 .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1629 .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1630 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1631 self.hit("export:sheetpdf", &pdf);
1632 if pdf.clicked() {
1633 chosen = Some("sheetpdf");
1634 }
1635 });
1636 ui.horizontal(|ui| {
1637 let pdf3d = ui
1642 .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1643 .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")
1644 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1645 self.hit("export:sheetpdf3d", &pdf3d);
1646 if pdf3d.clicked() {
1647 chosen = Some("sheetpdf3d");
1648 }
1649 });
1650 ui.horizontal(|ui| {
1651 use super::fabrication_export::Output;
1655 let no_board = "Place the schematic's parts on the PCB board first";
1656 for (key, label, hover, output) in [
1657 ("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),
1658 ("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),
1659 ("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),
1660 ("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),
1661 ] {
1662 let (enabled, why) = match output {
1663 Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
1664 _ => (has_board, no_board),
1665 };
1666 let button = ui
1667 .add_enabled(enabled, egui::Button::new(label))
1668 .on_hover_text(hover)
1669 .on_disabled_hover_text(why);
1670 self.hit(key, &button);
1671 if button.clicked() {
1672 fab_chosen = Some(output);
1673 }
1674 }
1675 });
1676 if !self.status.is_empty() {
1677 ui.add_space(4.0);
1678 ui.weak(&self.status);
1679 }
1680 });
1681 if let Some(format) = chosen {
1682 if self.export_as(docs, store, format) {
1683 if format != "json" {
1686 self.say_previewed_export(docs);
1687 }
1688 self.open = false;
1689 }
1690 } else if let Some(format) = bom_chosen {
1691 if self.export_bom_as(docs, store, format) {
1692 self.open = false;
1693 }
1694 } else if let Some(output) = fab_chosen {
1695 if self.export_fabrication_as(docs, store, output) {
1696 self.open = false;
1697 }
1698 } else if cancel || modal.should_close() {
1699 self.open = false;
1700 }
1701 }
1702
1703 fn show_flat_pattern(
1709 &mut self,
1710 ctx: &egui::Context,
1711 docs: &mut Documents,
1712 store: &dyn ModelStore,
1713 ) {
1714 let mut chosen: Option<&'static str> = None;
1715 let mut cancel = false;
1716 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
1717 ui.set_width(340.0);
1718 ui.heading("Export flat pattern");
1719 ui.add_space(2.0);
1720 Self::preview_export_line(ui, docs);
1721 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
1722 ui.add_space(4.0);
1723 let field = ui.add(
1724 egui::TextEdit::singleline(&mut self.name_buf)
1725 .hint_text("file name")
1726 .desired_width(f32::INFINITY),
1727 );
1728 self.hit("field:name", &field);
1729 if self.want_focus {
1730 field.request_focus();
1731 self.want_focus = false;
1732 }
1733 ui.add_space(6.0);
1734 ui.horizontal(|ui| {
1735 let dxf = ui.button("DXF (.dxf)");
1736 self.hit("flat:dxf", &dxf);
1737 if dxf.clicked() {
1738 chosen = Some("dxf");
1739 }
1740 let svg = ui.button("SVG (.svg)");
1741 self.hit("flat:svg", &svg);
1742 if svg.clicked() {
1743 chosen = Some("svg");
1744 }
1745 let c = ui.button("Cancel");
1746 self.hit("cancel", &c);
1747 if c.clicked() {
1748 cancel = true;
1749 }
1750 });
1751 if !self.status.is_empty() {
1752 ui.add_space(4.0);
1753 ui.weak(&self.status);
1754 }
1755 });
1756 if let Some(format) = chosen {
1757 if self.export_flat_pattern_as(docs, store, format) {
1758 self.say_previewed_export(docs);
1759 self.open = false;
1760 }
1761 } else if cancel || modal.should_close() {
1762 self.open = false;
1763 }
1764 }
1765
1766 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1769 if let Some(client) = store.plm_client() {
1771 self.show_open_workspace(ctx, docs, store, client);
1772 return;
1773 }
1774 let current = docs.active().name().map(str::to_string);
1775 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1776 file_explorer::dialog_body(ui, |ui| {
1777 ui.heading("Open model");
1778 ui.add_space(4.0);
1779 file_explorer::dialog_footer(ui, "open", |ui| {
1780 if !self.status.is_empty() {
1781 ui.add_space(4.0);
1782 ui.weak(&self.status);
1783 }
1784 });
1785 let mut options = FileExplorerOptions::open(current.as_deref());
1786 options.allow_import = store.supports_file_interchange();
1787 let output = self.explorer.show_store(ui, store, options);
1788 self.record_explorer_hits(&output.hits);
1789 output
1790 })
1791 });
1792 let should_close = modal.should_close();
1793 let output = modal.inner;
1794 if let Some(name) = output.activated {
1795 self.open_document(docs, store, &name);
1796 self.open = false;
1797 } else if let Some(name) = output.remove {
1798 let _ = store.remove(&name);
1799 self.status = format!("removed {name}");
1800 if let Some(index) = docs.index_of(&name) {
1804 if let Some(doc) = docs.get_mut(index) {
1805 doc.set_name(None);
1806 }
1807 }
1808 } else if output.import {
1809 match store.begin_import() {
1812 Ok(()) => {
1813 self.status = "choose a file…".into();
1814 self.open = false;
1815 }
1816 Err(e) => self.status = format!("import failed: {e}"),
1817 }
1818 } else if output.cancel || should_close {
1819 self.open = false;
1820 }
1821 }
1822
1823 fn show_open_workspace(
1828 &mut self,
1829 ctx: &egui::Context,
1830 docs: &mut Documents,
1831 store: &dyn ModelStore,
1832 client: std::rc::Rc<crate::plm::client::PlmClient>,
1833 ) {
1834 use crate::panels::plm_workspace::{document_name, dropped, load_pins, save_pins, PlmWorkspaces};
1835 let plm = PlmWorkspaces { client };
1836 if self.workspace.pins.is_none() {
1837 self.workspace.pins = Some(load_pins(store));
1838 }
1839 for (name, bytes) in dropped(ctx) {
1840 self.workspace.add_file(&plm, &name, bytes);
1841 }
1842 let mut cancel = false;
1843 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1844 file_explorer::dialog_body(ui, |ui| {
1845 ui.heading("Open from the PLM");
1846 ui.add_space(4.0);
1847 file_explorer::dialog_footer(ui, "open", |ui| {
1848 if !self.status.is_empty() {
1849 ui.add_space(4.0);
1850 ui.weak(&self.status);
1851 }
1852 let button = ui.button("Cancel");
1853 self.hits.insert("open:cancel".into(), button.rect);
1854 cancel = button.clicked();
1855 });
1856 self.workspace.show(ui, &plm, "open:")
1857 })
1858 });
1859 let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
1860 self.record_explorer_hits(&hits);
1861 let should_close = modal.should_close();
1862 let outcome = modal.inner;
1863 if let Some(pins) = &outcome.pins {
1864 if let Err(e) = save_pins(store, pins) {
1865 self.status = format!("could not save the pinned folders: {e}");
1866 }
1867 }
1868 if let Some((name, bytes)) = outcome.downloaded {
1869 self.status = match store.export_file_named_bytes(&name, &bytes) {
1870 Ok(()) => format!("saved {name}"),
1871 Err(e) => format!("could not save {name}: {e}"),
1872 };
1873 }
1874 if outcome.pick_file {
1875 if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
1878 self.status = error;
1879 }
1880 return;
1881 }
1882 if let Some(key) = outcome.open {
1883 let name = document_name(store, &key);
1884 self.open_document(docs, store, &name);
1885 self.open = false;
1886 } else if cancel || should_close {
1887 self.open = false;
1888 }
1889 }
1890
1891 fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
1894 let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
1895 if let Some(client) = store.plm_client() {
1896 let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
1897 self.workspace.add_file(&plm, &file.name, file.bytes);
1898 self.open_modal(Mode::Open);
1899 }
1900 }
1901
1902 fn show_insert_component(
1908 &mut self,
1909 ctx: &egui::Context,
1910 docs: &mut Documents,
1911 store: &dyn ModelStore,
1912 ) {
1913 let assembly = docs.active().name().map(str::to_string);
1914 let state = docs.engine_mut();
1915 let library = state.parts_library_names();
1916 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1917 file_explorer::dialog_body(ui, |ui| {
1918 ui.heading("Insert component");
1919 ui.add_space(4.0);
1920 file_explorer::dialog_footer(ui, "insert", |ui| {
1921 if !self.status.is_empty() {
1922 ui.add_space(4.0);
1923 ui.weak(&self.status);
1924 }
1925 });
1926 let mut chosen_library = None;
1927 if !library.is_empty() {
1928 ui.weak("In this document (parts library)");
1929 for name in &library {
1930 let row = ui.add_sized(
1931 egui::vec2(ui.available_width(), 18.0),
1932 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
1933 .frame(false),
1934 );
1935 self.hit(&format!("insert:lib:{name}"), &row);
1936 if row.clicked() {
1937 chosen_library = Some(name.clone());
1938 }
1939 }
1940 ui.add_space(4.0);
1941 }
1942 let options = FileExplorerOptions {
1943 hit_prefix: "insert:model",
1944 empty_label: "(no saved models)",
1945 row_icon: "\u{1F5CE}",
1946 current: None,
1947 allow_delete: false,
1948 allow_import: store.supports_file_interchange(),
1949 import_label: "Upload\u{2026}",
1950 import_hit: "insert:upload",
1951 show_cancel: true,
1952 confirm_label: Some("Insert"),
1953 extensions: INSERT_EXTENSIONS,
1954 };
1955 let output = self.explorer.show_store(ui, store, options);
1956 self.record_explorer_hits(&output.hits);
1957 (chosen_library, output)
1958 })
1959 });
1960 let should_close = modal.should_close();
1961 let (chosen_library, output) = modal.inner;
1962 if let Some(part_name) = chosen_library {
1963 match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1965 Ok(id) => {
1966 self.status = format!("inserted {part_name} ({id})");
1967 self.open = false;
1968 }
1969 Err(e) => self.status = format!("insert failed: {e}"),
1970 }
1971 } else if let Some(name) = output.activated.filter(|name| {
1972 let listed = store
1976 .browser_entries(INSERT_EXTENSIONS)
1977 .iter()
1978 .any(|entry| !entry.is_dir && entry.identity == *name);
1979 if !listed {
1980 self.status = format!(
1981 "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
1982 );
1983 }
1984 listed
1985 }) {
1986 match store.read(&name) {
1987 Some(contents) if self.begin_class_insert(store, &name, &contents, assembly) => {}
1990 Some(contents) => {
1991 self.insert_component_document(state, &name, &contents);
1992 self.open = false;
1993 }
1994 None => self.status = format!("insert failed: '{name}' not found"),
1995 }
1996 } else if output.import {
1997 match store.begin_import() {
1998 Ok(()) => {
1999 self.pending_component_import = true;
2001 self.status = "choose a part file…".into();
2002 self.open = false;
2003 }
2004 Err(e) => self.status = format!("insert failed: {e}"),
2005 }
2006 } else if output.cancel || should_close {
2007 self.open = false;
2008 }
2009 }
2010
2011 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2019 self.insert_component_named(state, name, &model_display_name(name), contents);
2020 }
2021
2022 fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2025 let display = display.to_string();
2026 match state.insert_component(ComponentInsert::New {
2027 name: &display,
2028 source_key: name,
2029 source_signature: &document_signature(contents),
2030 document_json: contents,
2031 }) {
2032 Ok(id) => self.status = format!("inserted {display} ({id})"),
2033 Err(e) => self.status = format!("insert failed: {e}"),
2034 }
2035 }
2036
2037 fn effective_name(&self, docs: &Documents) -> String {
2042 let field = self.name_buf.trim();
2043 if !field.is_empty() {
2044 field.to_string()
2045 } else {
2046 Self::document_name(docs)
2047 }
2048 }
2049
2050 fn document_name(docs: &Documents) -> String {
2056 docs.active().name().map(model_display_name).unwrap_or_else(|| "untitled".into())
2057 }
2058
2059 fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2062 let mut engine = docs.spawn_engine();
2063 let _ = engine.set_history_json(EMPTY_DOCUMENT);
2064 document_class::set_document_class(&mut engine, class);
2066 docs.open_document(Document::new(engine));
2067 self.name_buf.clear();
2068 self.status = match class {
2069 DocumentClass::Normal => "new (empty) model".into(),
2070 _ => format!("new (empty) {}", class.label().to_ascii_lowercase()),
2071 };
2072 }
2073
2074 fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
2085 let display = model_display_name(name);
2086 let taken = docs.iter().enumerate().any(|(index, doc)| {
2087 index != docs.active_index()
2088 && doc
2089 .name()
2090 .is_some_and(|open| model_display_name(open) == display)
2091 });
2092 if taken {
2093 self.status = format!("'{display}' is already open in another tab");
2094 }
2095 !taken
2096 }
2097
2098 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2101 let name = name.trim().to_string();
2102 if name.is_empty() {
2103 self.status = "enter a name to save".into();
2104 return false;
2105 }
2106 if !self.name_is_free(docs, &name) {
2107 return false;
2108 }
2109 let document = docs.engine().history_request_json();
2110 crate::plm::thumbnail::stage_save(store, &name, &document, docs.engine(), docs.active_id());
2111 match store.write(&name, &document) {
2112 Ok(()) => {
2113 self.name_buf = model_display_name(&name);
2116 let doc = docs.active_mut();
2117 doc.set_name(Some(name.clone()));
2118 doc.mark_clean();
2119 self.save_generation += 1;
2120 self.status = format!("saved {name}");
2121 true
2122 }
2123 Err(e) => {
2124 self.status = format!("save failed: {e}");
2125 false
2126 }
2127 }
2128 }
2129
2130 fn save_to_browser(
2134 &mut self,
2135 docs: &mut Documents,
2136 store: &dyn ModelStore,
2137 name: String,
2138 ) -> bool {
2139 let name = name.trim().to_string();
2140 if name.is_empty() {
2141 self.status = "enter a name to save".into();
2142 return false;
2143 }
2144 if !self.name_is_free(docs, &name) {
2145 return false;
2146 }
2147 let class = DocumentClass::of_name(&name).unwrap_or_default();
2152 adopt_class(docs.engine_mut(), class);
2153 if docs.engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
2154 docs.engine_mut()
2155 .history
2156 .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
2157 }
2158 match store.browser_write(&name, &docs.engine().history_request_json()) {
2159 Ok(identity) => {
2160 self.name_buf = model_display_name(&identity);
2161 let doc = docs.active_mut();
2162 self.member_ack.remove(&doc.id());
2163 doc.set_name(Some(identity.clone()));
2164 doc.mark_clean();
2165 self.save_generation += 1;
2166 self.status = format!("saved {identity}");
2167 true
2168 }
2169 Err(e) => {
2170 self.status = format!("save failed: {e}");
2171 false
2172 }
2173 }
2174 }
2175
2176 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2180 if docs.focus_named(name) {
2181 self.status = format!("{name} is already open");
2182 return;
2183 }
2184 match crate::store::read_now(store, name) {
2185 crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store.plm_client().is_some()),
2186 crate::store::ReadNow::Loading => {
2189 self.status = format!("'{name}' is still loading — open it again in a moment")
2190 }
2191 crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
2192 }
2193 }
2194
2195 fn say_previewed_export(&mut self, docs: &mut Documents) {
2206 let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
2207 return;
2208 };
2209 let note = format!("exported {label} (preview): the family's own values are not in this file");
2210 self.status = format!("{} — {label} (preview)", self.status);
2211 docs.engine_mut().push_notice(note);
2212 }
2213
2214 fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
2216 if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
2217 let text = format!(
2218 "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
2219 not the family's own values. The .nbrep recipe keeps the family's own."
2220 );
2221 ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
2222 ui.add_space(4.0);
2223 }
2224 }
2225
2226 fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
2227 let name = self.effective_name(docs);
2228 if format == "glb" {
2232 return match docs.engine().export_glb_bytes(&name) {
2235 Ok(bytes) => match store.export_file_named_bytes(&format!("{name}.glb"), &bytes) {
2236 Ok(()) => {
2237 self.status = format!("exported {name}.glb");
2238 true
2239 }
2240 Err(e) => {
2241 self.status = format!("export failed: {e}");
2242 false
2243 }
2244 },
2245 Err(e) => {
2246 self.status = format!("export failed: {e}");
2247 false
2248 }
2249 };
2250 }
2251 let state = docs.engine_mut();
2252 if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
2261 let pdf = format != "sheetsvg";
2262 let three_d = format == "sheetpdf3d";
2263 let (file, written) = if pdf {
2264 let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
2265 let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
2266 let written = bytes.and_then(|bytes| store.export_file_named_bytes(&file, &bytes).map(|()| ()));
2267 (file, written)
2268 } else {
2269 let sheet_name = state
2270 .resolve_sheet("")
2271 .ok()
2272 .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
2273 .unwrap_or_default();
2274 let file = format!("{name}-{}.svg", slug(&sheet_name));
2275 let written = state.resolve_sheet("").and_then(|id| {
2276 state
2277 .export_sheet_svg(&id)
2278 .and_then(|text| store.export_file_named(&file, &text).map(|()| ()))
2279 });
2280 (file, written)
2281 };
2282 let pages = state.sheet_state().sheets.len() + usize::from(three_d);
2283 return match written {
2284 Ok(()) => {
2285 self.status = if pdf {
2286 format!("exported {file} ({pages} page{})", if pages == 1 { "" } else { "s" })
2287 } else {
2288 format!("exported {file}")
2289 };
2290 true
2291 }
2292 Err(e) => {
2293 self.status = format!("export failed: {e}");
2294 false
2295 }
2296 };
2297 }
2298 let (text, ext) = match format {
2299 "stl" => (state.export_stl_text(), "stl"),
2300 "obj" => (state.export_obj_text(), "obj"),
2301 "iges" => (state.export_iges_text(), "igs"),
2302 "json" => (Ok(state.history_request_json()), "nbrep"),
2303 _ => (state.export_step_text_named(&name), "step"),
2306 };
2307 match text {
2308 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2309 Ok(()) => {
2310 self.status = format!("exported {name}.{ext}");
2311 true
2312 }
2313 Err(e) => {
2314 self.status = format!("export failed: {e}");
2315 false
2316 }
2317 },
2318 Err(e) => {
2319 self.status = format!("export failed: {e}");
2320 false
2321 }
2322 }
2323 }
2324
2325 fn export_flat_pattern_as(
2331 &mut self,
2332 docs: &mut Documents,
2333 store: &dyn ModelStore,
2334 format: &str,
2335 ) -> bool {
2336 let name = self.effective_name(docs);
2337 let state = docs.engine_mut();
2338 let (text, ext) = match format {
2339 "svg" => (state.export_flat_pattern_svg(), "svg"),
2340 _ => (state.export_flat_pattern_dxf(), "dxf"),
2341 };
2342 match text {
2343 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2344 Ok(()) => {
2345 self.status = format!("exported {name}.{ext}");
2346 true
2347 }
2348 Err(e) => {
2349 self.status = format!("flat-pattern export failed: {e}");
2350 false
2351 }
2352 },
2353 Err(e) => {
2354 self.status = format!("flat-pattern export failed: {e}");
2355 state.push_notice(format!("Flat pattern: {e}"));
2356 false
2357 }
2358 }
2359 }
2360
2361 fn export_bom_as(
2367 &mut self,
2368 docs: &mut Documents,
2369 store: &dyn ModelStore,
2370 format: &str,
2371 ) -> bool {
2372 let name = self.effective_name(docs);
2373 let state = docs.engine_mut();
2374 let (text, ext) = match format {
2375 "json" => (state.export_bom_json(), "bom.json"),
2376 _ => (state.export_bom_csv(), "bom.csv"),
2377 };
2378 match text {
2379 Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2380 Ok(()) => {
2381 self.status = format!("exported {name}.{ext}");
2382 true
2383 }
2384 Err(e) => {
2385 self.status = format!("BOM export failed: {e}");
2386 false
2387 }
2388 },
2389 Err(e) => {
2390 self.status = format!("BOM export failed: {e}");
2391 state.push_notice(format!("BOM export: {e}"));
2392 false
2393 }
2394 }
2395 }
2396
2397 fn export_fabrication_as(
2404 &mut self,
2405 docs: &mut Documents,
2406 store: &dyn ModelStore,
2407 output: super::fabrication_export::Output,
2408 ) -> bool {
2409 use super::fabrication_export::{self as fab, Output};
2410 let name = self.effective_name(docs);
2411 let file = output.file_name(&name);
2412 let state = docs.engine_mut();
2413 let (written, warnings) = match output {
2414 Output::Bundle => match fab::bundle(state, &name) {
2415 Ok(bundle) => (
2416 store.export_file_named_bytes(&file, &bundle.zip()).map(|()| bundle.files.len()),
2417 bundle.warnings,
2418 ),
2419 Err(e) => (Err(e), vec![]),
2420 },
2421 _ => (
2422 fab::text(state, output, &name).and_then(|text| store.export_file_named(&file, &text).map(|()| 1)),
2423 vec![],
2424 ),
2425 };
2426 match written {
2427 Ok(count) => {
2428 let into = models_folder(store).map(|folder| format!(" into {folder}")).unwrap_or_default();
2431 self.status = if output == Output::Bundle {
2432 format!("exported {file} ({count} files){into}")
2433 } else {
2434 format!("exported {file}{into}")
2435 };
2436 if warnings.is_empty() {
2437 state.push_notice_as(
2438 brep_render::engine_state::NoticeSeverity::Info,
2439 format!("{}: {}", output.label(), self.status),
2440 );
2441 return true;
2442 }
2443 self.status = format!("{}. Before ordering: {}", self.status, warnings.join(" "));
2444 state.push_notice_as(
2445 brep_render::engine_state::NoticeSeverity::Warning,
2446 format!("{}: {}", output.label(), warnings.join(" ")),
2447 );
2448 false
2449 }
2450 Err(e) => {
2451 self.status = format!("{} export failed: {e}", output.label());
2452 state.push_notice(format!("{} export: {e}", output.label()));
2453 false
2454 }
2455 }
2456 }
2457
2458 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2471 self.pending_step_import = None;
2477 let id = state.submit_step_probe(contents);
2478 self.pending_step_probe = Some(PendingStepProbe {
2479 id,
2480 name: name.to_string(),
2481 text: contents.to_string(),
2482 });
2483 self.status = format!("reading \"{name}\"\u{2026}");
2484 self.poll_step_probe(state);
2487 }
2488
2489 fn poll_step_probe(&mut self, state: &mut EngineState) {
2494 while let Some((id, outcome)) = state.take_step_probe() {
2495 let Some(pending) = self.pending_step_probe.as_ref() else {
2496 continue;
2497 };
2498 if pending.id != id {
2499 continue; }
2501 let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
2502 match outcome {
2503 StepProbeOutcome::Structure(probe) => {
2504 self.status = format!(
2505 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
2506 probe.parts,
2507 plural(probe.parts),
2508 probe.instances,
2509 plural(probe.instances)
2510 );
2511 self.pending_step_import = Some(PendingStepImport {
2512 name,
2513 text,
2514 probe,
2515 flatten: false,
2518 });
2519 self.open_modal(Mode::StepAssembly);
2525 }
2526 StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
2527 self.import_step_flat(state, &name, &text);
2528 }
2529 }
2530 }
2531 if self.pending_step_probe.is_some() && !state.step_probes_pending() {
2532 let pending = self.pending_step_probe.take().unwrap();
2536 self.status = format!("import of \"{}\" was cancelled", pending.name);
2537 }
2538 }
2539
2540 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2546 match state.import_step_feature(contents) {
2547 Ok(_) => self.status = format!("imported {name}"),
2551 Err(e) => self.status = format!("import failed: {e}"),
2552 }
2553 }
2554
2555 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
2565 let Some(pending) = self.pending_step_import.take() else {
2566 return;
2567 };
2568 let was_assembly = state.history_has_assembly();
2571 let doc_name = step_document_name(&pending.name);
2572 let opts = StepAssemblyImport {
2575 nested: !pending.flatten,
2576 };
2577 let mut sink = StorePartSink::new(store, &doc_name);
2584 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
2585 Ok(report) => {
2586 if !was_assembly {
2587 self.enter_assembly_workbench(state);
2588 }
2589 for failure in &sink.failures {
2592 state.push_notice(format!("part not saved — {failure}"));
2593 }
2594 let saved = sink.written;
2595 let base = assembly_import_message(&pending.name, &report);
2596 match (saved, sink.failures.len()) {
2597 (0, _) => base,
2598 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
2599 (saved, failed) => {
2600 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
2601 }
2602 }
2603 }
2604 Err(error) => match state.import_step_feature(&pending.text) {
2605 Ok(_) => format!(
2606 "imported {} as bodies — the assembly structure could not be built ({error})",
2607 pending.name
2608 ),
2609 Err(flat) => format!("import failed: {flat}"),
2610 },
2611 };
2612 self.status = message.clone();
2615 state.push_notice(message);
2616 }
2617
2618 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
2622 let Some(pending) = self.pending_step_import.take() else {
2623 return;
2624 };
2625 state.discard_probed_step_assembly();
2626 self.import_step_flat(state, &pending.name, &pending.text);
2627 }
2628
2629 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
2631 let Some(pending) = self.pending_step_import.take() else {
2632 return;
2633 };
2634 state.discard_probed_step_assembly();
2635 self.status = format!("import cancelled: {}", pending.name);
2636 }
2637
2638 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
2652 if crate::workbench::panel_visible(
2653 &state.settings.workbench,
2654 crate::workbench::assembly::BOM_PANEL_ID,
2655 &crate::workbench::ButtonState::of(state),
2656 ) {
2657 return;
2658 }
2659 let _ = state.apply_settings_json(
2660 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
2661 .to_string(),
2662 );
2663 }
2664
2665 fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2668 match state.import_iges_feature(contents) {
2669 Ok(_) => self.status = format!("imported {name}"),
2671 Err(e) => self.status = format!("import failed: {e}"),
2672 }
2673 }
2674
2675 fn stage_mesh_preview(
2676 &mut self,
2677 format: MeshImportFormat,
2678 name: String,
2679 contents: Vec<u8>,
2680 ) {
2681 self.pending_stl_import = Some((format, name, contents));
2682 self.status.clear();
2683 }
2684
2685 pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
2688 self.pending_stl_import.take()
2689 }
2690
2691 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
2692 match state.import_obj_bytes_feature(contents) {
2693 Ok(_) => self.status = format!("reconstructing {name} in background…"),
2694 Err(e) => self.status = format!("import failed: {e}"),
2695 }
2696 }
2697
2698 fn import_text(
2699 &mut self,
2700 state: &mut EngineState,
2701 name: &str,
2702 bytes: &[u8],
2703 importer: fn(&mut Self, &mut EngineState, &str, &str),
2704 ) {
2705 match std::str::from_utf8(bytes) {
2706 Ok(contents) => importer(self, state, name, contents),
2707 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
2708 }
2709 }
2710
2711 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, plm: bool) {
2716 let mut engine = docs.spawn_engine();
2717 match engine.load_model_and_fit(contents) {
2718 Ok(_) => {
2719 if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
2726 document_class::set_document_class(&mut engine, class);
2727 }
2728 let mut doc = Document::new(engine);
2732 doc.set_name(Some(name.to_string()));
2733 docs.open_document(doc);
2734 self.name_buf = model_display_name(name);
2735 self.status = format!("opened {name}");
2736 }
2737 Err(e) => self.status = format!("open failed: {e}"),
2738 }
2739 }
2740
2741 fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
2746 let client = store.plm_client()?;
2747 let key = crate::plm::family::revision_key(identity)?;
2748 Some((client, key))
2749 }
2750
2751 fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2753 let Some(work) = self.plm_work.as_mut() else { return };
2754 ctx.request_repaint_after(std::time::Duration::from_millis(30));
2755 match work {
2756 PlmWork::Members(pending) => {
2757 let Some(result) = pending.poll() else { return };
2758 self.plm_work = None;
2759 let Some(pick) = self.pick_member.as_mut() else { return };
2760 pick.loading = false;
2761 match result {
2762 Ok(members) => {
2763 pick.rows = members
2764 .into_iter()
2765 .map(|member| MemberChoice {
2766 why_not: member.not_placeable(),
2767 exists: member.not_placeable().is_none(),
2768 identity: member.key(),
2769 description: member.name.clone(),
2770 part_number: member.number,
2771 })
2772 .collect();
2773 }
2774 Err(error) => self.status = format!("the members could not be read: {error}"),
2775 }
2776 }
2777 PlmWork::Place(pending) => {
2778 let Some(result) = pending.poll() else { return };
2779 self.plm_work = None;
2780 match result {
2781 Ok((key, text, number)) => {
2782 self.insert_component_named(docs.engine_mut(), &key, &number, &text);
2783 self.pick_member = None;
2784 self.spin_out = None;
2785 self.open = false;
2786 }
2787 Err(error) => self.status = error,
2788 }
2789 }
2790 PlmWork::OpenFamily(pending) => {
2791 let Some(result) = pending.poll() else { return };
2792 self.plm_work = None;
2793 match result {
2794 Ok(key) => self.open_document(docs, store, &key),
2795 Err(error) => self.status = format!("the family could not be opened: {error}"),
2796 }
2797 }
2798 }
2799 }
2800
2801 fn begin_class_insert(
2807 &mut self,
2808 store: &dyn ModelStore,
2809 identity: &str,
2810 contents: &str,
2811 assembly: Option<String>,
2812 ) -> bool {
2813 let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
2814 let class = DocumentClass::of_name(identity)
2815 .filter(|class| *class != DocumentClass::Normal)
2816 .unwrap_or_else(|| DocumentClass::of_document(&document));
2817 let file = crate::store::file_name_of(identity);
2818 match class {
2819 DocumentClass::Normal => false,
2820 DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
2821 let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
2822 let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
2823 return false;
2824 };
2825 self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
2826 crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
2827 }))));
2828 self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
2829 self.status.clear();
2830 self.open_modal(Mode::PickMember);
2831 true
2832 }
2833 DocumentClass::Family => {
2834 let table = family_table::read_table(contents);
2835 let rows = table
2836 .rows
2837 .iter()
2838 .map(|row| {
2839 let identity = family_table::member_identity(Some(identity), &row.part_number);
2840 let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
2841 MemberChoice {
2842 part_number: row.part_number.clone(),
2843 description: row.description.clone(),
2844 identity,
2845 exists,
2846 why_not: None,
2847 }
2848 })
2849 .collect();
2850 self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
2851 self.status.clear();
2852 self.open_modal(Mode::PickMember);
2853 true
2854 }
2855 DocumentClass::Template => {
2856 let inputs = template::inputs_of(&document);
2857 let values = inputs
2858 .iter()
2859 .map(|input| (input.name.clone(), template::default_value(&document, input)))
2860 .collect();
2861 let stem = document_class::strip_class_extension(&file).to_string();
2862 let plm_template = self
2863 .plm_revision(store, identity)
2864 .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
2865 self.spin_out = Some(SpinOut {
2866 template_file: file,
2867 template: document,
2868 inputs,
2869 name: format!("{stem}-1"),
2870 values,
2871 assembly,
2872 plm_template,
2873 number: String::new(),
2874 });
2875 self.status.clear();
2876 self.open_modal(Mode::SpinOut);
2877 true
2878 }
2879 }
2880 }
2881
2882 fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
2886 let Some(pick) = self.pick_member.take() else {
2887 self.open = false;
2888 return;
2889 };
2890 let mut chosen = None;
2891 let mut cancel = false;
2892 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
2893 ui.set_width(420.0);
2894 ui.heading(format!("Insert a member of {}", pick.family_file));
2895 ui.add_space(2.0);
2896 ui.weak("A family is never placed itself: choose the member to place.");
2897 ui.add_space(6.0);
2898 if pick.loading {
2899 ui.label("Reading the family's members from the PLM\u{2026}");
2900 } else if pick.rows.is_empty() {
2901 ui.label("This family's table has no rows yet.");
2902 }
2903 egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
2904 for (index, row) in pick.rows.iter().enumerate() {
2905 ui.horizontal(|ui| {
2906 let text = if row.description.trim().is_empty() {
2907 row.part_number.clone()
2908 } else {
2909 format!("{} \u{2014} {}", row.part_number, row.description)
2910 };
2911 let button = ui.add_enabled(
2912 row.exists,
2913 egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
2914 );
2915 self.hit(&format!("member:{}", row.part_number), &button);
2916 if button.clicked() {
2917 chosen = Some(index);
2918 }
2919 if let Some(why) = &row.why_not {
2920 ui.weak(format!("({why})"));
2921 } else if row.identity.is_none() {
2922 ui.weak("(the part number is not a file name)");
2923 } else if !row.exists {
2924 ui.weak("(not generated yet: open the family and Generate)");
2925 }
2926 });
2927 }
2928 });
2929 ui.add_space(6.0);
2930 if !self.status.is_empty() {
2931 ui.weak(&self.status);
2932 ui.add_space(4.0);
2933 }
2934 let c = ui.button("Cancel");
2935 self.hit("member:cancel", &c);
2936 cancel = c.clicked();
2937 });
2938 let should_close = modal.should_close();
2939 if let (Some(index), true) = (chosen, pick.plm) {
2940 let row = &pick.rows[index];
2941 if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
2942 self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
2943 self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
2944 }
2945 } else if let Some(index) = chosen {
2946 let row = &pick.rows[index];
2947 let identity = row.identity.clone().unwrap_or_default();
2948 match store.read(&identity) {
2949 Some(contents) => {
2950 self.insert_component_document(state, &identity, &contents);
2951 self.open = false;
2952 return;
2953 }
2954 None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
2955 }
2956 } else if cancel || should_close {
2957 self.status.clear();
2958 self.open = false;
2959 return;
2960 }
2961 self.pick_member = Some(pick);
2962 }
2963
2964 fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
2970 let Some(mut spin) = self.spin_out.take() else {
2971 self.open = false;
2972 return;
2973 };
2974 let mut create = false;
2975 let mut cancel = false;
2976 let problems: Vec<Option<String>> = spin
2977 .inputs
2978 .iter()
2979 .map(|input| {
2980 let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
2981 template::value_problem(&spin.template, input, text)
2982 })
2983 .collect();
2984 let name_problem = template::name_problem(&spin.name);
2985 let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
2986 ui.set_width(440.0);
2987 ui.heading(format!("New part from {}", spin.template_file));
2988 ui.add_space(2.0);
2989 ui.weak("A template is never placed itself: this makes a new part from it and places that.");
2990 ui.add_space(6.0);
2991 egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
2992 ui.label("New part name");
2993 let field = ui.add(egui::TextEdit::singleline(&mut spin.name).desired_width(240.0));
2994 self.hit("spinout:name", &field);
2995 ui.end_row();
2996 if spin.plm_template.is_some() {
2997 ui.label("Part number");
2998 let field = ui.add(
2999 egui::TextEdit::singleline(&mut spin.number)
3000 .hint_text("empty: the part type numbers it")
3001 .desired_width(240.0),
3002 );
3003 self.hit("spinout:number", &field);
3004 ui.end_row();
3005 }
3006 for (input, problem) in spin.inputs.iter().zip(&problems) {
3007 ui.label(input.shown());
3008 ui.vertical(|ui| {
3009 let value = spin.values.entry(input.name.clone()).or_default();
3010 if input.choices.is_empty() {
3011 let field = ui.add(egui::TextEdit::singleline(value).desired_width(240.0));
3012 self.hit(&format!("spinout:input:{}", input.name), &field);
3013 } else {
3014 ui.horizontal_wrapped(|ui| {
3015 for choice in &input.choices {
3016 let chip = ui.selectable_label(value.trim() == choice.trim(), choice);
3017 self.hit(&format!("spinout:choice:{}:{}", input.name, choice.trim()), &chip);
3018 if chip.clicked() {
3019 *value = choice.trim().to_string();
3020 }
3021 }
3022 });
3023 }
3024 let limits = match (input.min, input.max) {
3025 (Some(min), Some(max)) => format!("{min} to {max}"),
3026 (Some(min), None) => format!("at least {min}"),
3027 (None, Some(max)) => format!("at most {max}"),
3028 (None, None) => String::new(),
3029 };
3030 if !limits.is_empty() {
3031 ui.weak(limits);
3032 }
3033 if let Some(problem) = problem {
3034 ui.colored_label(ui.visuals().warn_fg_color, problem);
3035 }
3036 });
3037 ui.end_row();
3038 }
3039 });
3040 if spin.inputs.is_empty() {
3041 ui.weak("This template marks no inputs; the copy is the template as it is.");
3042 }
3043 ui.add_space(4.0);
3044 let file = crate::store::file_name_of(&spin_out_target(&spin));
3045 ui.weak(match &spin.assembly {
3046 _ if spin.plm_template.is_some() => {
3047 "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
3048 }
3049 Some(assembly) => format!(
3050 "Saved as {file}, in the folder of {}",
3051 crate::store::file_name_of(assembly)
3052 ),
3053 None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
3054 });
3055 if let Some(problem) = &name_problem {
3056 ui.colored_label(ui.visuals().warn_fg_color, problem);
3057 }
3058 if !self.status.is_empty() {
3059 ui.weak(&self.status);
3060 }
3061 ui.add_space(6.0);
3062 ui.horizontal(|ui| {
3063 let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
3064 let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
3065 self.hit("spinout:create", &c);
3066 create = c.clicked();
3067 let x = ui.button("Cancel");
3068 self.hit("spinout:cancel", &x);
3069 cancel = x.clicked();
3070 });
3071 });
3072 let should_close = modal.should_close();
3073 if create {
3074 match self.create_spin_out(state, store, &spin) {
3075 Ok(()) => {
3076 self.open = false;
3077 return;
3078 }
3079 Err(error) => self.status = error,
3080 }
3081 } else if cancel || should_close {
3082 self.status.clear();
3083 self.open = false;
3084 return;
3085 }
3086 self.spin_out = Some(spin);
3087 }
3088
3089 fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
3091 if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
3092 let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
3095 self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
3096 let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
3097 .await
3098 .map_err(|e| e.to_string())?;
3099 let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
3100 let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
3101 format!("{} was made, but has no document yet", spun.number)
3102 })?;
3103 Ok((spun.key, text, spun.number))
3104 }))));
3105 return Err("making the part on the PLM\u{2026}".into());
3106 }
3107 if let Some(problem) = template::name_problem(&spin.name) {
3108 return Err(problem);
3109 }
3110 let target = spin_out_target(spin);
3111 if store.read(&target).is_some() {
3112 return Err(format!("'{target}' already exists: choose another name"));
3113 }
3114 let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
3115 let text = copy.to_string();
3116 store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
3117 self.save_generation += 1;
3118 self.insert_component_document(state, &target, &text);
3119 if spin.assembly.is_none() {
3120 self.status = format!(
3121 "{} — saved to the models folder as {target}, because the assembly is not saved yet",
3122 self.status
3123 );
3124 }
3125 Ok(())
3126 }
3127
3128 fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
3131 let Some(source) = family_table::engine_family_source(docs.engine()) else {
3132 return false;
3133 };
3134 self.member_prompt = Some(MemberPrompt {
3135 doc_id: docs.active().id(),
3136 family_identity: sibling_identity(name, &source.family),
3137 source,
3138 from_save,
3139 });
3140 self.status.clear();
3141 self.open_modal(Mode::MemberEdit);
3142 true
3143 }
3144
3145 fn watch_member_edit(&mut self, docs: &Documents) {
3148 let doc = docs.active();
3149 if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
3150 return;
3151 }
3152 if let Some(name) = doc.name().map(str::to_string) {
3153 self.arm_member_prompt(docs, &name, false);
3154 }
3155 }
3156
3157 fn revert_member_edit(docs: &mut Documents) {
3160 for _ in 0..64 {
3161 if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
3162 break;
3163 }
3164 docs.engine_mut().undo();
3165 }
3166 }
3167
3168 fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3171 let Some(prompt) = self.member_prompt.take() else {
3172 self.open = false;
3173 return;
3174 };
3175 if docs.active().id() != prompt.doc_id {
3176 self.open = false;
3177 return;
3178 }
3179 let mut choice = None;
3180 let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
3181 let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
3182 ui.set_width(440.0);
3183 ui.heading("This part is a family member");
3184 ui.add_space(4.0);
3185 ui.label(format!(
3186 "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
3187 prompt.source.part_number, prompt.source.family
3188 ));
3189 ui.add_space(4.0);
3190 ui.weak(if prompt.from_save {
3191 "Save it as a new part, or open the family and make the change there."
3192 } else {
3193 "Keep the change in a new part, or open the family and make it there."
3194 });
3195 if !self.status.is_empty() {
3196 ui.add_space(4.0);
3197 ui.weak(&self.status);
3198 }
3199 ui.add_space(8.0);
3200 ui.horizontal(|ui| {
3201 let a = ui.button("Save as a new part\u{2026}");
3202 self.hit("member-edit:saveas", &a);
3203 if a.clicked() {
3204 choice = Some("saveas");
3205 }
3206 let offer_family = match &plm {
3208 Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
3209 None => true,
3210 };
3211 if offer_family {
3212 let b = ui.button("Open the family");
3213 self.hit("member-edit:family", &b);
3214 if b.clicked() {
3215 choice = Some("family");
3216 }
3217 }
3218 let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
3219 self.hit("member-edit:cancel", &c);
3220 if c.clicked() {
3221 choice = Some("cancel");
3222 }
3223 });
3224 });
3225 let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
3226 match choice {
3227 Some("saveas") => {
3228 self.member_ack.insert(prompt.doc_id);
3231 self.status.clear();
3232 self.dispatch(FileAction::SaveAs, docs, store);
3233 }
3234 Some("family") if plm.is_some() => {
3235 let (client, _) = plm.unwrap_or_else(|| unreachable!());
3236 let number = document_class::strip_class_extension(&prompt.source.family).to_string();
3237 Self::revert_member_edit(docs);
3238 self.status = format!("opening the family {number}\u{2026}");
3239 self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
3240 let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
3241 let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
3243 Ok(format!("part/{}/rev/{}", head.id, newest.id))
3244 }))));
3245 self.open = false;
3246 }
3247 Some("family") => {
3248 if store.read(&prompt.family_identity).is_none() {
3249 self.status = format!("the family file is not there: {}", prompt.family_identity);
3250 self.member_prompt = Some(prompt);
3251 return;
3252 }
3253 Self::revert_member_edit(docs);
3254 self.open = false;
3255 self.open_document(docs, store, &prompt.family_identity);
3256 }
3257 Some(_) => {
3258 if !prompt.from_save {
3259 Self::revert_member_edit(docs);
3260 }
3261 self.status.clear();
3262 self.open = false;
3263 }
3264 None => self.member_prompt = Some(prompt),
3265 }
3266 }
3267
3268 pub fn generate_active_family(
3278 &mut self,
3279 docs: &mut Documents,
3280 store: &dyn ModelStore,
3281 save: bool,
3282 ) -> Result<family_table::GenerateReport, String> {
3283 let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
3284 let report = match (store.plm_client(), plm_key) {
3285 (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
3286 _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
3287 };
3288 Ok(self.record_generate(report))
3289 }
3290
3291 #[cfg(not(target_arch = "wasm32"))]
3295 fn generate_on_plm(
3296 &mut self,
3297 docs: &mut Documents,
3298 store: &dyn ModelStore,
3299 client: &std::rc::Rc<crate::plm::client::PlmClient>,
3300 key: &str,
3301 save: bool,
3302 ) -> Result<family_table::GenerateReport, String> {
3303 let json = docs.engine().history_request_json();
3304 if docs.active().is_dirty() {
3305 if !save {
3306 return Err(crate::plm::family::UNSAVED_FAMILY.into());
3307 }
3308 store.write(key, &json)?;
3309 docs.active_mut().mark_clean();
3310 self.save_generation += 1;
3311 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3313 while store.pending_writes() > 0 {
3314 if std::time::Instant::now() > deadline {
3315 return Err("the family's save did not reach the server within 60 s".into());
3316 }
3317 crate::plm::native::run_pending();
3318 std::thread::sleep(std::time::Duration::from_millis(5));
3319 }
3320 if let Some(error) = store.take_persistence_errors().into_iter().next() {
3321 return Err(format!("the family was not saved, so nothing was generated: {error}"));
3322 }
3323 }
3324 crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
3325 .ok_or("the PLM server did not answer Generate within 5 minutes")?
3326 .map_err(|e| e.to_string())
3327 }
3328
3329 #[cfg(target_arch = "wasm32")]
3330 fn generate_on_plm(
3331 &mut self,
3332 _: &mut Documents,
3333 _: &dyn ModelStore,
3334 _: &std::rc::Rc<crate::plm::client::PlmClient>,
3335 _: &str,
3336 _: bool,
3337 ) -> Result<family_table::GenerateReport, String> {
3338 Err("in the browser, Generate a PLM family from the family table's Generate button".into())
3339 }
3340
3341 fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
3342 if report.rows.iter().any(|row| row.status() == "written") {
3343 self.save_generation += 1;
3344 }
3345 self.status = format!("generate: {}", report.summary());
3346 self.last_generate = Some(report.clone());
3347 report
3348 }
3349
3350 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
3355 serde_json::json!({
3356 "name": docs.active().name(),
3357 "nameBuf": self.name_buf,
3358 "selected": self.explorer.selected(),
3359 "location": store.browser_location(),
3360 "folders": Purpose::ALL
3363 .iter()
3364 .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
3365 .collect::<serde_json::Map<_, _>>(),
3366 "dirty": docs.active().dirty_marker(),
3374 "backend": store.backend_label(),
3375 "interchange": store.supports_file_interchange(),
3376 "list": store.list(),
3377 "status": self.status,
3378 "open": self.open,
3379 "mode": match self.mode {
3380 Mode::Open => "open",
3381 Mode::SaveAs => "saveas",
3382 Mode::ConfirmClose => "confirmclose",
3383 Mode::Export => "export",
3384 Mode::Import => "import",
3385 Mode::FlatPattern => "flatpattern",
3386 Mode::InsertComponent => "insertcomponent",
3387 Mode::StepAssembly => "stepassembly",
3388 Mode::Kicad => "kicad",
3389 Mode::PickMember => "pickmember",
3390 Mode::SpinOut => "spinout",
3391 Mode::MemberEdit => "memberedit",
3392 Mode::PickFile => "pickfile",
3393 },
3394 "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
3395 "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
3396 "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
3397 })),
3398 "class": document_class::document_class(docs.engine()).slug(),
3401 "saveClass": self.save_class.slug(),
3402 "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
3403 "family": source.family,
3404 "partNumber": source.part_number,
3405 "valuesHash": source.values_hash,
3406 "historyHash": source.history_hash,
3407 })),
3408 "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
3411 "family": pick.family_file,
3412 "plm": pick.plm,
3413 "loading": pick.loading,
3414 "rows": pick.rows.iter().map(|row| serde_json::json!({
3415 "partNumber": row.part_number,
3416 "identity": row.identity,
3417 "exists": row.exists,
3418 "whyNot": row.why_not,
3419 })).collect::<Vec<_>>(),
3420 })),
3421 "plmBusy": self.plm_work.is_some(),
3422 "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
3425 "template": spin.template_file,
3426 "name": spin.name,
3427 "values": spin.values,
3428 "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
3429 "target": spin_out_target(spin),
3430 "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
3431 &spin.template,
3432 input,
3433 spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
3434 )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
3435 })),
3436 "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
3437 "partNumber": prompt.source.part_number,
3438 "family": prompt.source.family,
3439 "familyIdentity": prompt.family_identity,
3440 "fromSave": prompt.from_save,
3441 })),
3442 "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
3443 "summary": report.summary(),
3444 "rows": report.to_json(),
3445 })),
3446 "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
3447 "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
3453 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
3454 serde_json::json!({
3455 "name": pending.name,
3456 "parts": pending.probe.parts,
3457 "instances": pending.probe.instances,
3458 "nestedDepth": pending.probe.nested_depth,
3459 "flatten": pending.flatten,
3462 })
3463 }),
3464 })
3465 .to_string()
3466 }
3467
3468 pub fn hits_json(&self) -> String {
3470 crate::automation::hit_rects::hits_json(&self.hits)
3471 }
3472
3473 fn hit(&mut self, key: &str, resp: &egui::Response) {
3476 self.hits.insert(key.to_string(), resp.rect);
3477 }
3478
3479 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
3480 self.hits.extend(hits.iter().cloned());
3481 }
3482}
3483
3484
3485fn slug(name: &str) -> String {
3489 let cleaned: String = name
3490 .chars()
3491 .map(|c| if c.is_alphanumeric() { c } else { '-' })
3492 .collect();
3493 let trimmed = cleaned.trim_matches('-').to_string();
3494 if trimmed.is_empty() { "sheet".into() } else { trimmed }
3495}
3496pub static HIT_KEYS: &[HitKeyDoc] = &[
3498 HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
3499 HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
3500 HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
3501 HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
3502 HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
3503 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 },
3504 HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
3505 HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
3506 HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
3507 HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
3508 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 },
3509 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 },
3510 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 },
3511 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 },
3512 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") },
3513 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 },
3514];
3515
3516fn fetch_document(
3518 client: std::rc::Rc<crate::plm::client::PlmClient>,
3519 key: String,
3520 number: String,
3521) -> crate::panels::plm_family::Pending<(String, String, String)> {
3522 crate::panels::plm_family::Pending::new(Box::pin(async move {
3523 let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
3524 let text = bytes
3525 .map(|b| String::from_utf8_lossy(&b).into_owned())
3526 .ok_or_else(|| format!("{key} has no document yet"))?;
3527 Ok((key, text, number))
3528 }))
3529}