1mod plm_save_as;
30
31use crate::automation::hit_keys::HitKeyDoc;
32use crate::document::{Document, Documents};
33use crate::document_class::{self, DocumentClass};
34use crate::family_table::{self, FamilySource};
35use crate::template::{self, TemplateInput};
36use crate::panels::parts_library::document_signature;
37use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
38use crate::panels::kicad_import::{self, DiskFiles, KicadImport, KicadKind};
39use crate::store::{model_display_name, sibling_identity, ModelStore, PlaceKind, MODEL_EXTENSIONS};
40use brep_render::engine_state::{
41 ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
42 StepAssemblyReport, StepProbeOutcome,
43};
44use brep_render::runner::MeshImportFormat;
45use eframe::egui;
46use std::collections::{BTreeMap, HashMap, HashSet};
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FileAction {
52 New,
53 NewOfClass(DocumentClass),
56 Open,
57 Save,
58 SaveAs,
59 Import,
62 Export,
65 ExportFlatPattern,
68 InsertComponent,
74 ImportKicad(KicadKind),
79}
80
81#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84 CreateComponentPart,
86 Open,
88 SaveAs,
90 ConfirmClose,
95 Export,
97 Import,
99 FlatPattern,
101 InsertComponent,
104 StepAssembly,
108 Kicad,
111 PickMember,
114 SpinOut,
117 MemberEdit,
120 PickFile,
124 OwnedSave,
128 Retarget,
131 SaveFile,
137}
138
139#[derive(Clone, Debug, PartialEq)]
143pub struct SaveRequest {
144 pub title: String,
146 pub file_name: String,
149 pub body: SaveBody,
150}
151
152#[derive(Clone, Debug, PartialEq)]
156pub enum SaveBody {
157 Text(String),
158 Bytes(Vec<u8>),
159}
160
161impl SaveBody {
162 fn as_bytes(&self) -> &[u8] {
163 match self {
164 SaveBody::Text(text) => text.as_bytes(),
165 SaveBody::Bytes(bytes) => bytes,
166 }
167 }
168}
169
170impl SaveRequest {
171 pub fn text(title: impl Into<String>, file_name: impl Into<String>, text: String) -> Self {
172 Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Text(text) }
173 }
174
175 pub fn bytes(title: impl Into<String>, file_name: impl Into<String>, bytes: Vec<u8>) -> Self {
176 Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Bytes(bytes) }
177 }
178}
179
180struct PendingSave {
185 request: SaveRequest,
186 detail: String,
188 label: Option<String>,
192 warnings: Vec<String>,
196 preview: bool,
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203enum Delivery {
204 Done,
206 Chooser,
208 Failed,
210}
211
212const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
214
215#[derive(Clone, Debug, PartialEq)]
217pub struct PickedFile {
218 pub name: String,
220 pub path: Option<String>,
222 pub bytes: Vec<u8>,
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
231enum Purpose {
232 Models,
234 Imports,
236 Kicad,
238 Exports,
240}
241
242impl Purpose {
243 const ALL: [Purpose; 4] = [Purpose::Models, Purpose::Imports, Purpose::Kicad, Purpose::Exports];
244
245 fn slug(self) -> &'static str {
246 match self {
247 Purpose::Models => "models",
248 Purpose::Imports => "imports",
249 Purpose::Kicad => "kicad",
250 Purpose::Exports => "exports",
251 }
252 }
253}
254
255impl Mode {
256 fn purpose(self) -> Option<Purpose> {
258 match self {
259 Mode::Open | Mode::SaveAs | Mode::InsertComponent | Mode::CreateComponentPart => {
260 Some(Purpose::Models)
261 }
262 Mode::Import => Some(Purpose::Imports),
263 Mode::Kicad => Some(Purpose::Kicad),
264 Mode::SaveFile => Some(Purpose::Exports),
265 Mode::ConfirmClose
266 | Mode::Export
267 | Mode::FlatPattern
268 | Mode::StepAssembly
269 | Mode::PickMember
270 | Mode::SpinOut
271 | Mode::MemberEdit
272 | Mode::OwnedSave
273 | Mode::Retarget
274 | Mode::PickFile => None,
275 }
276 }
277}
278
279fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
285 match purpose {
286 Purpose::Imports | Purpose::Kicad | Purpose::Exports => store.local_files().unwrap_or(store),
287 Purpose::Models => store,
288 }
289}
290
291fn models_folder(store: &dyn ModelStore) -> Option<String> {
295 store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
296}
297
298struct PendingStepImport {
308 name: String,
310 text: String,
313 probe: StepAssemblyProbe,
315 flatten: bool,
320}
321
322struct PendingStepProbe {
328 id: u64,
330 name: String,
331 text: String,
333}
334
335#[derive(Clone, Copy, PartialEq, Eq)]
337enum StepChoice {
338 Assembly,
340 Bodies,
342 Cancel,
344}
345
346const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
349
350struct MemberChoice {
352 part_number: String,
353 description: String,
354 identity: Option<String>,
357 exists: bool,
359 why_not: Option<String>,
362}
363
364struct PickMember {
366 family_file: String,
368 rows: Vec<MemberChoice>,
369 plm: bool,
373 loading: bool,
374}
375
376struct SpinOut {
379 template_file: String,
380 template: serde_json::Value,
381 inputs: Vec<TemplateInput>,
382 name: String,
384 values: BTreeMap<String, String>,
386 assembly: Option<String>,
389 plm_template: Option<String>,
392 number: String,
393}
394
395enum PlmWork {
398 Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
400 Place(crate::panels::plm_family::Pending<(String, String, String)>),
403 OpenFamily(crate::panels::plm_family::Pending<String>),
405}
406
407struct Retarget {
409 old: String,
411 new_key: String,
412 text: String,
414 tabs: Vec<(u64, String)>,
415}
416
417struct OwnedPrompt {
419 plan: crate::document::owned_save::OwnedSavePlan,
420 name: String,
422 browser: bool,
424}
425
426struct MemberPrompt {
428 doc_id: u64,
429 source: FamilySource,
430 family_identity: String,
432 from_save: bool,
434}
435
436pub(crate) fn stamp_save_as_lineage(docs: &mut Documents, store: &dyn ModelStore, new_name: &str) {
445 let Some(old) = docs.active().name().map(str::to_string) else { return };
446 let old = store.canonical_identity(&old);
447 if old == store.canonical_identity(new_name) {
448 return;
449 }
450 let engine = docs.tab_engine_mut();
451 let mut document = serde_json::json!({});
452 for key in [brep_render::engine_state::replace_component::DERIVED_FROM_KEY, family_table::FAMILY_SOURCE_KEY] {
453 if let Some(block) = engine.history.document_block(key) {
454 document[key] = block.clone();
455 }
456 }
457 brep_render::engine_state::replace_component::stamp_derived_from(&mut document, &old);
458 engine.history.set_document_block_no_undo(
459 brep_render::engine_state::replace_component::DERIVED_FROM_KEY,
460 Some(document[brep_render::engine_state::replace_component::DERIVED_FROM_KEY].clone()),
461 );
462}
463
464fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
465 document_class::set_document_class(engine, class);
466 let drop: &[&str] = match class {
467 DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
468 DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
469 DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
470 DocumentClass::Instructions => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
471 };
472 for key in drop {
473 if engine.history.document_block(key).is_some() {
474 engine.history.set_document_block_no_undo(key, None);
475 }
476 }
477}
478
479fn spin_out_target(spin: &SpinOut) -> String {
482 let file = DocumentClass::Normal.file_name(spin.name.trim());
483 match &spin.assembly {
484 Some(assembly) => sibling_identity(assembly, &file),
485 None => file,
486 }
487}
488
489fn plural(count: usize) -> &'static str {
492 if count == 1 {
493 ""
494 } else {
495 "s"
496 }
497}
498
499fn file_stem_of_title(title: &str) -> String {
506 let stem: String = title.chars().map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '-' } else { c }).collect();
507 stem.trim_matches(|c: char| c == '.' || c == ' ' || c == '-').to_string()
508}
509
510fn step_document_name(file_name: &str) -> String {
511 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
512 let cut = base
513 .rfind('.')
514 .filter(|dot| is_step_name(&base[*dot..]) || is_iges_name(&base[*dot..]))
515 .unwrap_or(base.len());
516 let stem = &base[..cut];
517 if stem.is_empty() {
518 base.to_string()
519 } else {
520 stem.to_string()
521 }
522}
523
524struct StorePartSink<'a> {
572 store: &'a dyn ModelStore,
573 prefix: String,
575 taken: std::collections::HashSet<String>,
579 written: usize,
580 failures: Vec<String>,
581 plm: bool,
587 deferred: usize,
588}
589
590impl<'a> StorePartSink<'a> {
591 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
592 Self {
593 store,
594 prefix: sanitize_file_stem(prefix),
595 taken: std::collections::HashSet::new(),
596 written: 0,
597 failures: Vec::new(),
598 plm: store.plm_client().is_some(),
599 deferred: 0,
600 }
601 }
602}
603
604impl PartSink for StorePartSink<'_> {
605 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
606 if self.plm {
607 self.deferred += 1;
608 return None;
609 }
610 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
611 let mut name = base.clone();
612 let mut suffix = 2;
613 while !self.taken.insert(name.clone()) {
614 name = format!("{base}-{suffix}");
615 suffix += 1;
616 }
617 match self.store.browser_write(&name, document_json) {
618 Ok(identity) => {
619 self.written += 1;
620 Some(identity)
621 }
622 Err(error) => {
623 self.failures.push(format!("{name}: {error}"));
624 None
625 }
626 }
627 }
628}
629
630fn top_checkout_row(docs: &Documents, store: &dyn ModelStore, name: &str) -> Option<crate::document::owned_save::Planned> {
640 use crate::document::owned_save::{Issue, OwnedWrite, Planned};
641 let client = store.plm_client()?;
642 let (part, revision) = crate::panels::plm_host::revision_of(name)?;
643 let doc = docs.active();
644 if matches!(doc.save_access(), crate::document::Access::Editable) && doc.has_plm_save_access() {
645 return None;
646 }
647 let viewer = client.can_author() == Some(false);
648 let issue = match store.plm_revision(&crate::plm::identity::document_key(&part, &revision)) {
649 Some(entry) if entry.editable && entry.locked_by_me => return None,
650 _ if viewer => Issue::Blocked(crate::document::owned_save::VIEW_ONLY.into()),
651 Some(entry) if !entry.editable => {
652 Issue::Blocked(format!("PLM revision {} is {} and not editable", entry.revision_label, entry.lifecycle))
653 }
654 Some(entry) if entry.locked_by.is_some() => Issue::Blocked(format!("checked out by {}", entry.locked_by.unwrap_or_default())),
655 _ => Issue::CheckOut { part, revision, newer: false },
656 };
657 let write = OwnedWrite { key: name.to_string(), names: Vec::new(), depth: 0, text: String::new(), synced: String::new(), variants: Vec::new() };
658 Some(Planned { write, issue: Some(issue), proceed: true, choice: 0, edited_last: None })
659}
660
661fn write_late_marks(docs: &mut Documents, store: &dyn ModelStore, id: u64, was_clean: bool) {
666 if let Err(error) = docs.write_unsaved_marks(store, id, was_clean) {
667 docs.engine_mut().push_notice(format!("Save the assembly again: the change it keeps could not be marked in it ({error})"));
668 }
669}
670
671fn plm_promotion_requests(current: &serde_json::Value, plan: &crate::document::owned_save::OwnedSavePlan) -> Vec<(String, String, String)> {
676 let mut requests: Vec<(String, String, String)> = Vec::new();
677 for promotion in &plan.promotions {
678 if requests.iter().any(|(key, ..)| *key == promotion.new_key) {
679 continue;
680 }
681 let mut document = current;
682 for name in &promotion.names {
683 document = &document["partsLibrary"][name]["document"];
684 }
685 let part = promotion.names.last().cloned().unwrap_or_default();
686 requests.push((promotion.new_key.clone(), part, crate::plm::promote::part_type_of(document).to_string()));
687 }
688 requests
689}
690
691fn assign_plm_keys(plan: &mut crate::document::owned_save::OwnedSavePlan, keys: &[String]) {
693 let mut order: Vec<String> = Vec::new();
694 for promotion in &plan.promotions {
695 if !order.contains(&promotion.new_key) {
696 order.push(promotion.new_key.clone());
697 }
698 }
699 for promotion in &mut plan.promotions {
700 if let Some(key) = order.iter().position(|k| *k == promotion.new_key).and_then(|i| keys.get(i)) {
701 promotion.new_key = key.clone();
702 }
703 }
704}
705
706pub(crate) fn sanitize_file_stem(name: &str) -> String {
707 let mut out = String::with_capacity(name.len());
708 for ch in name.chars() {
709 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
710 out.push(ch);
711 } else if !out.ends_with('_') {
712 out.push('_');
713 }
714 }
715 let trimmed = out.trim_matches('_');
716 if trimmed.is_empty() {
717 "part".to_string()
718 } else {
719 trimmed.to_string()
720 }
721}
722
723fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
724 let mut message = format!(
725 "imported {name} \u{2014} {} part{}, {} component{}",
726 report.parts,
727 plural(report.parts),
728 report.instances,
729 plural(report.instances)
730 );
731 if report.baked_nonrigid > 0 {
732 message.push_str(&format!(
733 " ({} mirrored instance{} baked)",
734 report.baked_nonrigid,
735 plural(report.baked_nonrigid)
736 ));
737 }
738 if report.failed_products > 0 {
739 message.push_str(&format!(
740 "; {} product{} could not be built",
741 report.failed_products,
742 plural(report.failed_products)
743 ));
744 }
745 if report.flat_fallback {
746 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
747 }
748 if let Some(error) = &report.first_error {
749 message.push_str(&format!(" [{error}]"));
750 }
751 message
752}
753
754#[derive(Clone, Copy, Debug, PartialEq, Eq)]
760pub(crate) enum ImportKind {
761 Kicad,
763 Step,
765 Iges,
767 Stl,
769 ThreeMf,
771 Obj,
773}
774
775pub(crate) const IMPORT_TABLE: &[(&str, ImportKind)] = &[
778 ("step", ImportKind::Step),
779 ("stp", ImportKind::Step),
780 ("p21", ImportKind::Step),
781 ("iges", ImportKind::Iges),
782 ("igs", ImportKind::Iges),
783 ("stl", ImportKind::Stl),
784 ("3mf", ImportKind::ThreeMf),
785 ("obj", ImportKind::Obj),
786 ("kicad_sym", ImportKind::Kicad),
787 ("kicad_mod", ImportKind::Kicad),
788];
789
790const IMPORT_EXTENSIONS: &[&str] =
793 &["step", "stp", "p21", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
794
795pub(crate) fn import_kind(name: &str) -> Option<ImportKind> {
800 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
801 let (_, ext) = base.rsplit_once('.')?;
802 let ext = ext.to_ascii_lowercase();
803 IMPORT_TABLE.iter().find(|(e, _)| *e == ext).map(|(_, kind)| *kind)
804}
805
806fn is_step_name(name: &str) -> bool {
809 import_kind(name) == Some(ImportKind::Step)
810}
811
812fn is_iges_name(name: &str) -> bool {
814 import_kind(name) == Some(ImportKind::Iges)
815}
816
817pub(crate) fn import_document_name(file_name: &str) -> String {
820 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
821 match base.rsplit_once('.') {
822 Some((stem, _)) if !stem.is_empty() && import_kind(base).is_some() => stem.to_string(),
823 _ => base.to_string(),
824 }
825}
826
827mod component_part;
828
829struct OwnedRevisions {
831 doc: u64,
833 requests: Vec<crate::document::owned_save::CheckoutRequest>,
835 future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>>,
837}
838
839struct OwnedFollowup {
841 doc: u64,
842 keys: Vec<String>,
844 polls: u32,
847}
848
849struct PendingInstructionsPdf {
850 document: u64,
851 name: String,
852 preview: bool,
853 job: brep_render::engine_state::InstructionsPdfExport,
854}
855
856pub struct FileDialog {
859 pending_open: Option<(String, u64)>,
862 pending_insert: Option<(String, u64)>,
864 component_part: Option<component_part::Recovery>,
865 explorer: FileExplorer,
867 workspace: crate::panels::plm_workspace::WorkspaceBrowser,
870 name_buf: String,
872 pub(crate) status: String,
874 open: bool,
876 mode: Mode,
878 want_focus: bool,
880 pending_component_import: bool,
884 pending_close: Option<(u64, u64)>,
886 pending_step_import: Option<PendingStepImport>,
891 pending_step_probe: Option<PendingStepProbe>,
894 pending_instructions_export: Option<String>,
897 instructions_pdf: Option<PendingInstructionsPdf>,
899 pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
902 save_generation: u64,
906 plm_save_as: Option<plm_save_as::SaveAs>,
907 member_new_part: bool,
911 hits: HashMap<String, egui::Rect>,
913 kicad: KicadImport,
915 folders: HashMap<Purpose, String>,
918 pick_request: Option<(String, String)>,
921 pick_folder: Option<String>,
922 picked: Option<(String, PickedFile)>,
923 plm_new_part: bool,
926 save_class: DocumentClass,
928 pick_member: Option<PickMember>,
930 plm_work: Option<PlmWork>,
931 spin_out: Option<SpinOut>,
933 member_prompt: Option<MemberPrompt>,
935 owned_prompt: Option<OwnedPrompt>,
938 owned_state: serde_json::Value,
941 owned_checkouts: Vec<(crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
944 owned_revisions: Option<OwnedRevisions>,
948 owned_followup: Option<OwnedFollowup>,
952 #[allow(clippy::type_complexity)]
961 owned_promoting: Option<(OwnedPrompt, crate::plm::PlmFuture<(Vec<String>, Option<(String, String)>)>)>,
962 owned_loading: Option<(u64, String, Vec<String>, u32)>,
966 owned_loaded: bool,
969 owned_top: Option<(u64, String, crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
973 retarget: Option<Retarget>,
976 checkout_remembered: bool,
979 member_ack: HashSet<u64>,
982 last_generate: Option<family_table::GenerateReport>,
984 save_file: Option<PendingSave>,
988 save_name: String,
989}
990
991impl FileDialog {
992 pub fn new() -> Self {
995 Self {
996 pending_open: None,
997 pending_insert: None,
998 component_part: None,
999 explorer: FileExplorer::new(),
1000 workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
1001 name_buf: String::new(),
1002 status: String::new(),
1003 open: false,
1004 mode: Mode::Open,
1005 want_focus: false,
1006 pending_component_import: false,
1007 pending_close: None,
1008 pending_step_import: None,
1009 pending_step_probe: None,
1010 pending_instructions_export: None,
1011 instructions_pdf: None,
1012 pending_stl_import: None,
1013 save_generation: 0,
1014 plm_save_as: None,
1015 member_new_part: false,
1016 hits: HashMap::new(),
1017 kicad: KicadImport::new(),
1018 folders: HashMap::new(),
1019 pick_request: None,
1020 pick_folder: None,
1021 picked: None,
1022 plm_new_part: false,
1023 save_class: DocumentClass::Normal,
1024 pick_member: None,
1025 plm_work: None,
1026 spin_out: None,
1027 member_prompt: None,
1028 owned_prompt: None,
1029 owned_state: serde_json::Value::Null,
1030 owned_checkouts: Vec::new(),
1031 owned_revisions: None,
1032 owned_followup: None,
1033 owned_promoting: None,
1034 owned_loading: None,
1035 owned_loaded: false,
1036 owned_top: None,
1037 retarget: None,
1038 checkout_remembered: false,
1039 member_ack: HashSet::new(),
1040 last_generate: None,
1041 save_file: None,
1042 save_name: String::new(),
1043 }
1044 }
1045
1046 pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
1049 let mut out = Vec::new();
1050 if let Some(export) = &self.instructions_pdf {
1051 let (done, total) = export.job.progress();
1052 out.push(crate::panels::busy::Activity::new("instructionsPdf", "instructionsPdf", format!("Preparing instructions PDF: {done}/{total} pictures")));
1053 }
1054 if self.owned_loading.is_some() {
1055 out.push(crate::panels::busy::Activity::new("saveReading", "saveReading", "Reading parts from the PLM before saving"));
1056 }
1057 if self.kicad.reading() {
1058 out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
1059 }
1060 out
1061 }
1062
1063 pub fn instructions_pdf_pending(&self) -> bool {
1064 self.instructions_pdf.is_some()
1065 }
1066
1067 pub fn plm_save_as_pending(&self) -> bool {
1070 self.plm_save_as.as_ref().is_some_and(|state| state.saving())
1071 }
1072
1073 pub fn save_generation(&self) -> u64 {
1076 self.save_generation
1077 }
1078
1079 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
1085 if matches!(action, FileAction::New | FileAction::NewOfClass(_) | FileAction::Open | FileAction::Import) {
1086 self.pending_open = None;
1087 self.pending_insert = None;
1088 }
1089 match action {
1090 FileAction::New => self.new_document(docs, DocumentClass::Normal),
1091 FileAction::NewOfClass(class) => self.new_document(docs, class),
1092 FileAction::Open => {
1093 self.use_folder(Purpose::Models, store);
1094 if store.plm_client().is_some() {
1096 self.workspace.go_home();
1097 }
1098 self.open_modal(Mode::Open);
1099 }
1100 FileAction::Save => match docs.active().name().map(str::to_string) {
1101 Some(name) if self.arm_member_prompt(docs, &name, true) => {}
1104 Some(name) => {
1108 let _ = self.save_to(docs, store, name);
1109 }
1110 None => self.dispatch(FileAction::SaveAs, docs, store),
1112 },
1113 FileAction::SaveAs => {
1114 self.name_buf = Self::proposed_name(docs);
1115 self.save_class = document_class::document_class(docs.engine());
1116 self.use_folder(Purpose::Models, store);
1117 self.open_modal(Mode::SaveAs);
1118 }
1119 FileAction::Import => {
1120 self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
1121 self.open_modal(Mode::Import);
1122 }
1123 FileAction::Export => {
1126 self.name_buf = Self::proposed_name(docs);
1127 self.open_modal(Mode::Export);
1128 }
1129 FileAction::ExportFlatPattern => {
1130 self.name_buf = Self::proposed_name(docs);
1131 self.open_modal(Mode::FlatPattern);
1132 }
1133 FileAction::InsertComponent => {
1138 self.use_folder(Purpose::Models, store);
1139 self.open_modal(Mode::InsertComponent);
1140 }
1141 FileAction::ImportKicad(kind) => {
1142 self.kicad.start(kind, store);
1143 self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
1144 self.open_modal(Mode::Kicad);
1145 }
1146 }
1147 }
1148
1149 pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
1156 self.picked = None;
1157 if let Some(local) = store.local_files() {
1158 let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
1159 if !arrived {
1160 let _ = local.browser_home();
1161 }
1162 self.pick_request = Some((tag.to_string(), title.to_string()));
1163 self.status.clear();
1164 self.open_modal(Mode::PickFile);
1165 return Ok(());
1166 }
1167 store.begin_pick_file()?;
1168 self.pick_request = Some((tag.to_string(), title.to_string()));
1169 Ok(())
1170 }
1171
1172 pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
1174 if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
1175 return self.picked.take().map(|(_, file)| file);
1176 }
1177 None
1178 }
1179
1180 pub fn picking(&self, tag: &str) -> bool {
1183 self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
1184 }
1185
1186 fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
1188 let Some(local) = store.local_files() else {
1189 self.open = false;
1190 self.pick_request = None;
1191 return;
1192 };
1193 let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
1194 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
1195 file_explorer::dialog_body(ui, |ui| {
1196 ui.heading(&title);
1197 ui.add_space(4.0);
1198 file_explorer::dialog_footer(ui, "pickfile", |ui| {
1199 if !self.status.is_empty() {
1200 ui.add_space(4.0);
1201 ui.weak(&self.status);
1202 }
1203 });
1204 let options = FileExplorerOptions {
1205 hit_prefix: "pickfile",
1206 empty_label: "(this folder is empty)",
1207 row_icon: "\u{1F5CE}",
1208 current: None,
1209 allow_delete: false,
1210 allow_import: false,
1211 import_label: "",
1212 import_hit: "pickfile:upload",
1213 show_cancel: true,
1214 confirm_label: Some("Choose"),
1215 extensions: &[],
1216 };
1217 let output = self.explorer.show_store(ui, local, options);
1218 self.record_explorer_hits(&output.hits);
1219 output
1220 })
1221 });
1222 let should_close = modal.should_close();
1223 let output = modal.inner;
1224 self.pick_folder = Some(local.browser_location());
1225 if let Some(path) = output.activated {
1226 match local.read_external_file(&path) {
1227 Some(bytes) => {
1228 let name = std::path::Path::new(&path)
1229 .file_name()
1230 .map(|n| n.to_string_lossy().into_owned())
1231 .unwrap_or_else(|| path.clone());
1232 if let Some((tag, _)) = self.pick_request.take() {
1233 self.status = format!("chose {name}");
1234 self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
1235 }
1236 self.open = false;
1237 }
1238 None => self.status = format!("'{path}' could not be read"),
1239 }
1240 } else if output.cancel || should_close {
1241 self.pick_request = None;
1242 self.status.clear();
1243 self.open = false;
1244 }
1245 }
1246
1247 fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
1251 let remembered = self.folders.get(&purpose).cloned();
1252 let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
1253 if !arrived {
1254 if let Some(models) = models_folder(store) {
1255 let _ = store.browser_navigate(&models);
1256 }
1257 }
1258 }
1259
1260 fn remember_folder(&mut self, store: &dyn ModelStore) {
1262 if let Some(purpose) = self.mode.purpose() {
1263 self.folders.insert(purpose, browse_store(purpose, store).browser_location());
1264 }
1265 }
1266
1267 fn open_modal(&mut self, mode: Mode) {
1269 self.pending_insert = None;
1273 self.explorer.clear_selection();
1274 self.mode = mode;
1275 self.open = true;
1276 self.want_focus = true;
1277 }
1278
1279 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1285 self.hits.clear();
1286 self.poll_pending_open(ctx, docs, store);
1287 self.poll_pending_insert(ctx, docs, store);
1288 self.poll_plm_save_as(ctx, docs, store);
1289 self.poll_plm_work(ctx, docs, store);
1290 self.poll_open_workspace_pick(store);
1291 self.poll_owned_checkouts(ctx, docs, store);
1292 self.poll_owned_promotions(ctx, docs, store);
1293 self.poll_owned_loading(ctx, docs, store);
1294 if self.retarget.is_some() && !self.open {
1295 self.open_modal(Mode::Retarget);
1296 }
1297 if let Some(file) = store.take_picked_file() {
1299 if let Some((tag, _)) = self.pick_request.take() {
1300 self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
1301 }
1302 }
1303
1304 self.poll_step_probe(docs.engine_mut());
1307 self.poll_instructions_pdf(ctx, docs, store);
1308 if self.pending_instructions_export.is_some() && !docs.engine().topology_pending() && crate::plm::tool_pictures::pending() == 0 {
1311 if let Some(format) = self.pending_instructions_export.take() {
1312 self.export_as(docs, store, &format);
1313 }
1314 }
1315
1316 if let Some(imported) = store.take_import() {
1324 self.cancel_pending_step_import(docs.engine_mut());
1330 let name = imported.name;
1331 let bytes = imported.bytes;
1332 if let Some(kind) = import_kind(&name) {
1335 if !self.import_file(kind, docs.engine_mut(), store, name, bytes) {
1336 return;
1337 }
1338 } else if std::mem::take(&mut self.pending_component_import) {
1339 match String::from_utf8(bytes) {
1344 Ok(contents) => {
1345 let assembly = docs.active().name().map(str::to_string);
1346 if !self.begin_class_insert(store, &name, &contents, assembly) {
1347 self.insert_component_document(docs.engine_mut(), &name, &contents)
1348 }
1349 if self.pick_member.is_some() || self.spin_out.is_some() {
1350 return;
1351 }
1352 }
1353 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1354 }
1355 } else {
1356 match String::from_utf8(bytes) {
1357 Ok(contents) => self.load_document(docs, &name, &contents, store),
1358 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1359 }
1360 }
1361 self.close_after_import();
1362 }
1363
1364 if !self.open {
1371 self.watch_member_edit(docs);
1372 }
1373
1374 if !self.open {
1375 return;
1376 }
1377 self.remember_folder(store);
1378
1379 match self.mode {
1380 Mode::CreateComponentPart => self.show_create_component_part(ctx, docs, store),
1381 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
1382 Mode::SaveAs => self.show_save_as(ctx, docs, store),
1383 Mode::Open => self.show_open(ctx, docs, store),
1384 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
1385 Mode::Export => self.show_export(ctx, docs, store),
1386 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
1387 Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
1388 Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
1389 Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
1390 Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
1391 Mode::OwnedSave => self.show_owned_save(ctx, docs, store),
1392 Mode::Retarget => self.show_retarget(ctx, docs),
1393 Mode::PickFile => self.show_pick_file(ctx, store),
1394 Mode::SaveFile => self.show_save_file(ctx, docs, store),
1395 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
1396 Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
1397 }
1398 }
1399
1400 fn close_after_import(&mut self) {
1406 if self.pending_step_import.is_some() {
1407 self.open_modal(Mode::StepAssembly);
1408 } else {
1409 self.open = false;
1410 }
1411 }
1412
1413 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
1418 if self.pending_step_import.take().is_some() {
1419 state.discard_probed_step_assembly();
1420 }
1421 }
1422
1423 fn show_import(
1426 &mut self,
1427 ctx: &egui::Context,
1428 state: &mut EngineState,
1429 store: &dyn ModelStore,
1430 ) {
1431 const EXTENSIONS: &[&str] = IMPORT_EXTENSIONS;
1432 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
1433 file_explorer::dialog_body(ui, |ui| {
1434 ui.heading("Import CAD file");
1435 ui.add_space(4.0);
1436 file_explorer::dialog_footer(ui, "import", |ui| {
1437 if !self.status.is_empty() {
1438 ui.add_space(4.0);
1439 ui.weak(&self.status);
1440 }
1441 });
1442 let options = FileExplorerOptions {
1443 hit_prefix: "import",
1444 empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1445 row_icon: "\u{1F5CE}",
1446 current: None,
1447 allow_delete: false,
1448 allow_import: store.supports_file_interchange(),
1449 import_label: "Upload\u{2026}",
1450 import_hit: "import:upload",
1451 show_cancel: true,
1452 confirm_label: Some("Import"),
1453 extensions: EXTENSIONS,
1454 };
1455 let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1456 self.record_explorer_hits(&output.hits);
1457 output
1458 })
1459 });
1460 let should_close = modal.should_close();
1461 let output = modal.inner;
1462 if let Some(name) = output.activated {
1463 match browse_store(Purpose::Imports, store).read_external_file(&name) {
1464 Some(bytes) => match import_kind(&name) {
1465 Some(kind) => {
1466 if !self.import_file(kind, state, store, name, bytes) {
1467 return;
1468 }
1469 }
1470 None => self.status = format!("unsupported import file: {name}"),
1471 },
1472 None => self.status = format!("import failed: '{name}' not found"),
1473 }
1474 self.close_after_import();
1475 } else if output.import {
1476 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1477 Ok(()) => {
1478 self.status =
1479 "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1480 self.open = false;
1481 }
1482 Err(e) => self.status = format!("import failed: {e}"),
1483 }
1484 } else if output.cancel || should_close {
1485 self.open = false;
1486 }
1487 }
1488
1489 fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1494 if !(self.open && self.mode == Mode::Kicad) {
1495 if let Some(kind) = KicadKind::of_file(name) {
1496 self.kicad.start(kind, store);
1497 }
1498 }
1499 self.kicad.take_file(name, bytes, &DiskFiles);
1500 self.open_modal(Mode::Kicad);
1501 }
1502
1503 fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1506 if !self.kicad.wants_file() {
1507 match self.kicad.show(ctx, state, store, &mut self.hits) {
1508 kicad_import::Outcome::Open => {}
1509 kicad_import::Outcome::Closed => self.open = false,
1510 kicad_import::Outcome::Imported(imported) => {
1511 self.status = imported.summary.clone();
1514 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1515 const SHOWN: usize = 5;
1516 let hidden = imported.notes.len().saturating_sub(SHOWN);
1517 for note in imported.notes.into_iter().take(SHOWN) {
1518 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1519 }
1520 if hidden > 0 {
1521 state.push_notice_as(
1522 brep_render::engine_state::NoticeSeverity::Warning,
1523 format!("and {hidden} more notes on this import"),
1524 );
1525 }
1526 self.open = false;
1527 }
1528 }
1529 return;
1530 }
1531 let extension = self.kicad.kind().extension();
1532 let heading = match self.kicad.kind() {
1533 KicadKind::Symbol => "Import a symbol from KiCad",
1534 KicadKind::Footprint => "Import a footprint from KiCad",
1535 };
1536 let extensions = [extension];
1537 let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1538 file_explorer::dialog_body(ui, |ui| {
1539 ui.heading(heading);
1540 ui.add_space(4.0);
1541 file_explorer::dialog_footer(ui, "kicad", |ui| {
1542 let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1543 if !status.is_empty() {
1544 ui.add_space(4.0);
1545 ui.weak(status);
1546 }
1547 });
1548 let empty_label = format!("(no .{extension} files)");
1549 let options = FileExplorerOptions {
1550 hit_prefix: "kicad",
1551 empty_label: &empty_label,
1552 row_icon: "\u{1F5CE}",
1553 current: None,
1554 allow_delete: false,
1555 allow_import: store.supports_file_interchange(),
1556 import_label: "Upload\u{2026}",
1557 import_hit: "kicad:upload",
1558 show_cancel: true,
1559 confirm_label: Some("Open"),
1560 extensions: &extensions,
1561 };
1562 let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1563 self.record_explorer_hits(&output.hits);
1564 output
1565 })
1566 });
1567 let should_close = modal.should_close();
1568 let output = modal.inner;
1569 if let Some(name) = output.activated {
1570 match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1571 Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1572 None => self.kicad.status = format!("import failed: '{name}' not found"),
1573 }
1574 } else if output.import {
1575 match store.begin_import_filtered(("KiCad", &extensions)) {
1576 Ok(()) => {
1577 self.status = format!("choose a .{extension} file\u{2026}");
1578 self.open = false;
1579 }
1580 Err(e) => self.kicad.status = format!("import failed: {e}"),
1581 }
1582 } else if output.cancel || should_close {
1583 self.open = false;
1584 }
1585 }
1586
1587 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1593 let dirty = docs.get(index).is_some_and(Document::is_dirty);
1594 if dirty {
1595 self.pending_close = docs.get(index).map(|doc| (doc.id(), doc.committed_revision()));
1596 self.open_modal(Mode::ConfirmClose);
1597 } else {
1598 docs.close(index);
1599 }
1600 }
1601
1602 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1606 let Some(title) = self
1612 .pending_close
1613 .and_then(|(id, revision)| docs.index_of_id(id).and_then(|i| docs.get(i)).filter(|doc| doc.committed_revision() == revision))
1614 .map(Document::title)
1615 else {
1616 self.pending_close = None;
1617 self.open = false;
1618 return;
1619 };
1620 let mut discard = false;
1621 let mut cancel = false;
1622 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1623 ui.set_width(340.0);
1624 ui.heading("Discard unsaved changes?");
1625 ui.add_space(4.0);
1626 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1627 ui.add_space(6.0);
1628 ui.horizontal(|ui| {
1629 let d = ui.button("Discard and close");
1630 self.hit("confirm:discard", &d);
1631 if d.clicked() {
1632 discard = true;
1633 }
1634 let c = ui.button("Cancel");
1635 self.hit("confirm:cancel", &c);
1636 if c.clicked() {
1637 cancel = true;
1638 }
1639 });
1640 });
1641 if discard {
1642 if let Some(index) = self.pending_close.take().and_then(|(id, _)| docs.index_of_id(id)) {
1643 docs.close(index);
1644 }
1645 self.open = false;
1646 } else if cancel || modal.should_close() {
1647 self.pending_close = None;
1648 self.open = false;
1649 }
1650 }
1651
1652 fn show_step_assembly(
1671 &mut self,
1672 ctx: &egui::Context,
1673 state: &mut EngineState,
1674 store: &dyn ModelStore,
1675 ) {
1676 let Some(pending) = self.pending_step_import.as_ref() else {
1679 self.open = false;
1680 return;
1681 };
1682 let name = pending.name.clone();
1683 let probe = pending.probe;
1684 let mut flatten = pending.flatten;
1685 let mut choice: Option<StepChoice> = None;
1686 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1687 ui.set_width(360.0);
1688 ui.heading(format!("\"{name}\" contains an assembly"));
1689 ui.add_space(4.0);
1690 ui.label(format!(
1691 "{} part{}, {} instance{}{}.",
1692 probe.parts,
1693 plural(probe.parts),
1694 probe.instances,
1695 plural(probe.instances),
1696 match probe.nested_depth {
1697 0 | 1 => String::new(),
1698 depth => format!(", {depth} levels deep"),
1699 }
1700 ));
1701 if probe.nested_depth > 1 {
1702 ui.add_space(4.0);
1703 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1704 self.hit("stepassembly:flatten", &check);
1705 check.on_hover_text(
1706 "Off: each sub-assembly becomes one rigid component you can \
1707 expand in the structure tree.\nOn: every part is placed \
1708 directly in this document at its world position.",
1709 );
1710 }
1711 ui.add_space(6.0);
1712 ui.horizontal(|ui| {
1713 let a = ui.button("Import as assembly");
1714 self.hit("stepassembly:assembly", &a);
1715 if a.clicked() {
1716 choice = Some(StepChoice::Assembly);
1717 }
1718 let b = ui.button("Import as bodies");
1719 self.hit("stepassembly:bodies", &b);
1720 if b.clicked() {
1721 choice = Some(StepChoice::Bodies);
1722 }
1723 let c = ui.button("Cancel");
1724 self.hit("stepassembly:cancel", &c);
1725 if c.clicked() {
1726 choice = Some(StepChoice::Cancel);
1727 }
1728 });
1729 });
1730 if let Some(pending) = self.pending_step_import.as_mut() {
1732 pending.flatten = flatten;
1733 }
1734 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1737 let Some(choice) = choice else { return };
1738 match choice {
1739 StepChoice::Assembly => self.step_assembly_import(state, store),
1740 StepChoice::Bodies => self.step_assembly_bodies(state),
1741 StepChoice::Cancel => self.step_assembly_cancel(state),
1742 }
1743 self.open = false;
1744 }
1745
1746 pub fn take_plm_new_part(&mut self) -> bool {
1749 std::mem::take(&mut self.plm_new_part)
1750 }
1751
1752 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1754 if let Some(client) = store.plm_client() {
1756 self.show_save_as_plm(ctx, docs, store, client);
1757 return;
1758 }
1759 let current = docs.active().name().map(str::to_string);
1760 let mut do_save = false;
1761 let mut cancel = false;
1762 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1763 file_explorer::dialog_body(ui, |ui| {
1764 ui.heading("Save model as");
1765 ui.add_space(4.0);
1766 file_explorer::dialog_footer(ui, "saveas", |ui| {
1769 ui.label("File name");
1770 let field = ui.add(
1771 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1772 .hint_text("model name")
1773 .desired_width(f32::INFINITY),
1774 );
1775 self.hit("field:name", &field);
1776 if self.want_focus {
1777 field.request_focus();
1778 self.want_focus = false;
1779 }
1780 let enter =
1781 field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1782 ui.horizontal(|ui| {
1787 ui.label("Save as");
1788 let texts: Vec<String> = DocumentClass::ALL.iter().map(|class| format!("{} ({})", class.label(), class.extension())).collect();
1791 let choices: Vec<(DocumentClass, &str, &str)> =
1792 DocumentClass::ALL.iter().zip(&texts).map(|(class, text)| (*class, class.slug(), text.as_str())).collect();
1793 crate::form::enum_dropdown(ui, "saveas:class", &choices, &mut self.save_class, None, Some(&mut self.hits));
1794 });
1795 ui.add_space(6.0);
1796 ui.horizontal(|ui| {
1797 let save = ui.button("Save");
1798 self.hit("save", &save);
1799 if save.clicked() || enter {
1800 do_save = true;
1801 }
1802 let c = ui.button("Cancel");
1803 self.hit("cancel", &c);
1804 if c.clicked() {
1805 cancel = true;
1806 }
1807 });
1808 if !self.status.is_empty() {
1809 ui.add_space(4.0);
1810 ui.weak(&self.status);
1811 }
1812 });
1813 let options = FileExplorerOptions {
1814 hit_prefix: "saveas:file",
1815 empty_label: "(no saved models)",
1816 row_icon: "\u{1F5CE}",
1817 current: current.as_deref(),
1818 allow_delete: false,
1819 allow_import: false,
1820 import_label: "",
1821 import_hit: "saveas:upload",
1822 show_cancel: false,
1823 confirm_label: None,
1824 extensions: MODEL_EXTENSIONS,
1825 };
1826 let output = self.explorer.show_store(ui, store, options);
1827 self.record_explorer_hits(&output.hits);
1828 if let Some(name) = output.picked.or(output.activated) {
1834 self.name_buf =
1835 document_class::strip_class_extension(&model_display_name(&name)).to_string();
1836 if let Some(class) = DocumentClass::of_name(&name) {
1837 self.save_class = class;
1838 }
1839 }
1840 })
1841 });
1842 if do_save {
1843 let name = self.save_class.file_name(self.name_buf.trim());
1844 if self.save_to_browser(docs, store, name) {
1845 self.open = false;
1846 }
1847 } else if cancel || modal.should_close() {
1848 self.open = false;
1849 }
1850 }
1851
1852 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1863 let has_bom = !docs.engine().component_ids().is_empty()
1869 || !docs.engine().wire_harness_state().connections.is_empty();
1870 let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1874 let has_instructions = docs.engine().is_instructions();
1875 let has_board = super::fabrication_export::has_board(docs.engine());
1878 let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1879 let mut chosen: Option<&'static str> = None;
1880 let mut bom_chosen: Option<&'static str> = None;
1881 let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1882 let mut cancel = false;
1883 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1884 ui.set_width(440.0);
1887 ui.heading("Export model");
1888 ui.add_space(4.0);
1889 Self::preview_export_line(ui, docs);
1890 let field = ui.add(
1891 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1892 .hint_text("file name")
1893 .desired_width(f32::INFINITY),
1894 );
1895 self.hit("field:name", &field);
1896 if self.want_focus {
1897 field.request_focus();
1898 self.want_focus = false;
1899 }
1900 let group = |ui: &mut egui::Ui, title: &str| {
1904 ui.add_space(6.0);
1905 ui.label(egui::RichText::new(title).weak());
1906 };
1907 group(ui, "3D model");
1908 ui.horizontal_wrapped(|ui| {
1909 let step = ui.button("STEP (.step)");
1910 self.hit("export:step", &step);
1911 if step.clicked() {
1912 chosen = Some("step");
1913 }
1914 let iges = ui.button("IGES (.igs)");
1915 self.hit("export:iges", &iges);
1916 if iges.clicked() {
1917 chosen = Some("iges");
1918 }
1919 let stl = ui.button("STL (.stl)");
1920 self.hit("export:stl", &stl);
1921 if stl.clicked() {
1922 chosen = Some("stl");
1923 }
1924 let obj = ui.button("OBJ (.obj)");
1925 self.hit("export:obj", &obj);
1926 if obj.clicked() {
1927 chosen = Some("obj");
1928 }
1929 let glb = ui.button("GLB (.glb)");
1930 self.hit("export:glb", &glb);
1931 if glb.clicked() {
1932 chosen = Some("glb");
1933 }
1934 let three_mf = ui
1938 .button("3MF (.3mf)")
1939 .on_hover_text("For slicers: one object per body, with the model's colours");
1940 self.hit("export:3mf", &three_mf);
1941 if three_mf.clicked() {
1942 chosen = Some("3mf");
1943 }
1944 let json = ui.button("Model (.nbrep)");
1948 self.hit("export:json", &json);
1949 if json.clicked() {
1950 chosen = Some("json");
1951 }
1952 });
1953 group(ui, "Bill of materials");
1954 ui.horizontal_wrapped(|ui| {
1955 let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1959 self.hit("export:bomcsv", &csv);
1960 if csv.clicked() {
1961 bom_chosen = Some("csv");
1962 }
1963 let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1964 self.hit("export:bomjson", &json);
1965 if json.clicked() {
1966 bom_chosen = Some("json");
1967 }
1968 });
1969 group(ui, "Drawing");
1970 ui.horizontal_wrapped(|ui| {
1971 let sheet = ui
1974 .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1975 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1976 self.hit("export:sheetsvg", &sheet);
1977 if sheet.clicked() {
1978 chosen = Some("sheetsvg");
1979 }
1980 let pdf = ui
1984 .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1985 .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1986 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1987 self.hit("export:sheetpdf", &pdf);
1988 if pdf.clicked() {
1989 chosen = Some("sheetpdf");
1990 }
1991 let pdf3d = ui
1996 .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1997 .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")
1998 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1999 self.hit("export:sheetpdf3d", &pdf3d);
2000 if pdf3d.clicked() {
2001 chosen = Some("sheetpdf3d");
2002 }
2003 });
2004 group(ui, "Electronics");
2005 ui.horizontal_wrapped(|ui| {
2006 use super::fabrication_export::Output;
2010 let no_board = "Place the schematic's parts on the PCB board first";
2011 for (key, label, hover, output) in [
2012 ("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),
2013 ("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),
2014 ("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),
2015 ("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),
2016 ] {
2017 let (enabled, why) = match output {
2018 Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
2019 _ => (has_board, no_board),
2020 };
2021 let button = ui
2022 .add_enabled(enabled, egui::Button::new(label))
2023 .on_hover_text(hover)
2024 .on_disabled_hover_text(why);
2025 self.hit(key, &button);
2026 if button.clicked() {
2027 fab_chosen = Some(output);
2028 }
2029 }
2030 });
2031 group(ui, "Instructions");
2032 ui.horizontal_wrapped(|ui| {
2033 let not_instructions = "Open manufacturing instructions first (New from assembly in the Instructions workbench)";
2036 let pdf = ui
2037 .add_enabled(has_instructions, egui::Button::new("Instructions (PDF)"))
2038 .on_hover_text("The cover, the parts list, the tools list and the steps, on the page template set in the Instructions pane")
2039 .on_disabled_hover_text(not_instructions);
2040 self.hit("export:instructionspdf", &pdf);
2041 if pdf.clicked() {
2042 chosen = Some("instructionspdf");
2043 }
2044 let images = ui
2045 .add_enabled(has_instructions, egui::Button::new("Step images"))
2046 .on_hover_text("Every step's picture as PNG and SVG, in one zip")
2047 .on_disabled_hover_text(not_instructions);
2048 self.hit("export:instructionsimages", &images);
2049 if images.clicked() {
2050 chosen = Some("instructionsimages");
2051 }
2052 let html = ui
2053 .add_enabled(has_instructions, egui::Button::new("Player (HTML)"))
2054 .on_hover_text("The interactive player in one self-contained page: Next/Back, a live 3D view per step and a step checklist, with no server")
2055 .on_disabled_hover_text(not_instructions);
2056 self.hit("export:instructionshtml", &html);
2057 if html.clicked() {
2058 chosen = Some("instructionshtml");
2059 }
2060 });
2061 if !self.status.is_empty() {
2062 ui.add_space(4.0);
2063 ui.weak(&self.status);
2064 }
2065 ui.add_space(6.0);
2066 ui.separator();
2067 let c = ui.button("Cancel");
2068 self.hit("cancel", &c);
2069 if c.clicked() {
2070 cancel = true;
2071 }
2072 });
2073 if let Some(format) = chosen {
2076 if self.export_as(docs, store, format) && self.mode == Mode::Export {
2077 self.open = false;
2078 }
2079 } else if let Some(format) = bom_chosen {
2080 if self.export_bom_as(docs, store, format) && self.mode == Mode::Export {
2081 self.open = false;
2082 }
2083 } else if let Some(output) = fab_chosen {
2084 if self.export_fabrication_as(docs, store, output) && self.mode == Mode::Export {
2085 self.open = false;
2086 }
2087 } else if cancel || modal.should_close() {
2088 self.open = false;
2089 }
2090 }
2091
2092 fn show_flat_pattern(
2098 &mut self,
2099 ctx: &egui::Context,
2100 docs: &mut Documents,
2101 store: &dyn ModelStore,
2102 ) {
2103 let mut chosen: Option<&'static str> = None;
2104 let mut cancel = false;
2105 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
2106 ui.set_width(340.0);
2107 ui.heading("Export flat pattern");
2108 ui.add_space(2.0);
2109 Self::preview_export_line(ui, docs);
2110 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
2111 ui.add_space(4.0);
2112 let field = ui.add(
2113 egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
2114 .hint_text("file name")
2115 .desired_width(f32::INFINITY),
2116 );
2117 self.hit("field:name", &field);
2118 if self.want_focus {
2119 field.request_focus();
2120 self.want_focus = false;
2121 }
2122 ui.add_space(6.0);
2123 ui.horizontal(|ui| {
2124 let dxf = ui.button("DXF (.dxf)");
2125 self.hit("flat:dxf", &dxf);
2126 if dxf.clicked() {
2127 chosen = Some("dxf");
2128 }
2129 let svg = ui.button("SVG (.svg)");
2130 self.hit("flat:svg", &svg);
2131 if svg.clicked() {
2132 chosen = Some("svg");
2133 }
2134 let c = ui.button("Cancel");
2135 self.hit("cancel", &c);
2136 if c.clicked() {
2137 cancel = true;
2138 }
2139 });
2140 if !self.status.is_empty() {
2141 ui.add_space(4.0);
2142 ui.weak(&self.status);
2143 }
2144 });
2145 if let Some(format) = chosen {
2146 if self.export_flat_pattern_as(docs, store, format) && self.mode == Mode::FlatPattern {
2147 self.open = false;
2148 }
2149 } else if cancel || modal.should_close() {
2150 self.open = false;
2151 }
2152 }
2153
2154 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2157 if let Some(client) = store.plm_client() {
2159 self.show_open_workspace(ctx, docs, store, client);
2160 return;
2161 }
2162 let current = docs.active().name().map(str::to_string);
2163 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2164 file_explorer::dialog_body(ui, |ui| {
2165 ui.heading("Open model");
2166 ui.add_space(4.0);
2167 file_explorer::dialog_footer(ui, "open", |ui| {
2168 if !self.status.is_empty() {
2169 ui.add_space(4.0);
2170 ui.weak(&self.status);
2171 }
2172 });
2173 let mut options = FileExplorerOptions::open(current.as_deref());
2174 options.allow_import = store.supports_file_interchange();
2175 let output = self.explorer.show_store(ui, store, options);
2176 self.record_explorer_hits(&output.hits);
2177 output
2178 })
2179 });
2180 let should_close = modal.should_close();
2181 let output = modal.inner;
2182 if let Some(name) = output.activated {
2183 self.open_document(docs, store, &name);
2184 self.open = false;
2185 } else if let Some(name) = output.remove {
2186 let _ = store.remove(&name);
2187 self.status = format!("removed {name}");
2188 if let Some(index) = docs.index_of(&name) {
2192 if let Some(doc) = docs.get_mut(index) {
2193 doc.set_name(None);
2194 }
2195 }
2196 } else if output.import {
2197 match store.begin_import() {
2200 Ok(()) => {
2201 self.status = "choose a file…".into();
2202 self.open = false;
2203 }
2204 Err(e) => self.status = format!("import failed: {e}"),
2205 }
2206 } else if output.cancel || should_close {
2207 self.open = false;
2208 }
2209 }
2210
2211 fn show_open_workspace(
2216 &mut self,
2217 ctx: &egui::Context,
2218 docs: &mut Documents,
2219 store: &dyn ModelStore,
2220 client: std::rc::Rc<crate::plm::client::PlmClient>,
2221 ) {
2222 use crate::panels::plm_workspace::{document_name, load_pins, save_pins, PlmWorkspaces};
2223 let plm = PlmWorkspaces { client };
2224 if self.workspace.pins.is_none() {
2225 self.workspace.pins = Some(load_pins(store));
2226 }
2227 self.workspace.browse_only = true;
2228 let mut cancel = false;
2229 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2230 file_explorer::dialog_body(ui, |ui| {
2231 ui.heading("Open from the PLM");
2232 if let Some(url) = crate::panels::plm_host::web_url(store, "#/workspace") {
2233 crate::panels::plm_host::web_link(ui, "Manage workspace in PLM", url);
2234 }
2235 ui.add_space(4.0);
2236 file_explorer::dialog_footer(ui, "open", |ui| {
2237 if !self.status.is_empty() {
2238 ui.add_space(4.0);
2239 ui.weak(&self.status);
2240 }
2241 let button = ui.button("Cancel");
2242 self.hits.insert("open:cancel".into(), button.rect);
2243 cancel = button.clicked();
2244 });
2245 self.workspace.show(ui, &plm, "open:")
2246 })
2247 });
2248 let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
2249 self.record_explorer_hits(&hits);
2250 let should_close = modal.should_close();
2251 let outcome = modal.inner;
2252 if let Some(pins) = &outcome.pins {
2253 if let Err(e) = save_pins(store, pins) {
2254 self.status = format!("could not save the pinned folders: {e}");
2255 }
2256 }
2257 if let Some((name, bytes)) = outcome.downloaded {
2258 self.status = match store.export_file_named_bytes(&name, &bytes) {
2259 Ok(()) => format!("saved {name}"),
2260 Err(e) => format!("could not save {name}: {e}"),
2261 };
2262 }
2263 if outcome.pick_file {
2264 if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
2267 self.status = error;
2268 }
2269 return;
2270 }
2271 if let Some(key) = outcome.open {
2272 let name = document_name(store, &key);
2273 self.open_document(docs, store, &name);
2274 self.open = false;
2275 } else if cancel || should_close {
2276 self.open = false;
2277 }
2278 }
2279
2280 fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
2283 let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
2284 if let Some(client) = store.plm_client() {
2285 let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
2286 self.workspace.add_file(&plm, &file.name, file.bytes);
2287 self.open_modal(Mode::Open);
2288 }
2289 }
2290
2291 fn show_insert_component(
2297 &mut self,
2298 ctx: &egui::Context,
2299 docs: &mut Documents,
2300 store: &dyn ModelStore,
2301 ) {
2302 let library = docs.engine_mut().parts_library_names();
2303 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
2304 file_explorer::dialog_body(ui, |ui| {
2305 ui.heading("Insert component");
2306 ui.add_space(4.0);
2307 file_explorer::dialog_footer(ui, "insert", |ui| {
2308 if !self.status.is_empty() {
2309 ui.add_space(4.0);
2310 ui.weak(&self.status);
2311 }
2312 });
2313 let mut chosen_library = None;
2314 if !library.is_empty() {
2315 ui.weak("In this document (parts library)");
2316 for name in &library {
2317 let row = ui.add_sized(
2318 egui::vec2(ui.available_width(), 18.0),
2319 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
2320 .frame(false),
2321 );
2322 self.hit(&format!("insert:lib:{name}"), &row);
2323 if row.clicked() {
2324 chosen_library = Some(name.clone());
2325 }
2326 }
2327 ui.add_space(4.0);
2328 }
2329 let options = FileExplorerOptions {
2330 hit_prefix: "insert:model",
2331 empty_label: "(no saved models)",
2332 row_icon: "\u{1F5CE}",
2333 current: None,
2334 allow_delete: false,
2335 allow_import: store.supports_file_interchange(),
2336 import_label: "Upload\u{2026}",
2337 import_hit: "insert:upload",
2338 show_cancel: true,
2339 confirm_label: Some("Insert"),
2340 extensions: INSERT_EXTENSIONS,
2341 };
2342 let output = self.explorer.show_store(ui, store, options);
2343 self.record_explorer_hits(&output.hits);
2344 (chosen_library, output)
2345 })
2346 });
2347 let should_close = modal.should_close();
2348 let (chosen_library, output) = modal.inner;
2349 if let Some(part_name) = chosen_library {
2350 match docs.engine_mut().insert_component(ComponentInsert::Existing { part_name: &part_name }) {
2352 Ok(id) => {
2353 self.status = format!("inserted {part_name} ({id})");
2354 self.open = false;
2355 }
2356 Err(e) => self.status = format!("insert failed: {e}"),
2357 }
2358 } else if let Some(name) = output.activated.filter(|name| {
2359 let listed = store
2363 .browser_entries(INSERT_EXTENSIONS)
2364 .iter()
2365 .any(|entry| !entry.is_dir && entry.identity == *name);
2366 if !listed {
2367 self.status = format!(
2368 "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
2369 );
2370 }
2371 listed
2372 }) {
2373 self.request_component_document(docs, store, &name);
2374 if self.pending_insert.is_some() {
2375 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2376 }
2377 } else if output.import {
2378 match store.begin_import() {
2379 Ok(()) => {
2380 self.pending_component_import = true;
2382 self.status = "choose a part file…".into();
2383 self.open = false;
2384 }
2385 Err(e) => self.status = format!("insert failed: {e}"),
2386 }
2387 } else if output.cancel || should_close {
2388 self.open = false;
2389 }
2390 }
2391
2392 fn request_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2393 self.pending_insert = None;
2394 match crate::store::read_now(store, name) {
2395 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, name, &contents),
2396 crate::store::ReadNow::Loading => {
2397 self.pending_insert = Some((name.to_owned(), docs.active_id()));
2398 self.status = format!("'{name}' is still loading");
2399 }
2400 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2401 }
2402 }
2403
2404 fn finish_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str, contents: &str) {
2405 let assembly = docs.active().name().map(str::to_string);
2406 if !self.begin_class_insert(store, name, contents, assembly) {
2407 self.insert_component_document(docs.engine_mut(), name, contents);
2408 self.open = false;
2409 }
2410 }
2411
2412 fn poll_pending_insert(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2413 let Some((name, requester)) = self.pending_insert.take() else { return };
2414 if !self.open || self.mode != Mode::InsertComponent || docs.active_id() != requester {
2415 return;
2416 }
2417 match crate::store::read_now(store, &name) {
2418 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, &name, &contents),
2419 crate::store::ReadNow::Loading => {
2420 self.pending_insert = Some((name, requester));
2421 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2422 }
2423 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2424 }
2425 }
2426
2427 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2435 self.insert_component_named(state, name, &model_display_name(name), contents);
2436 }
2437
2438 fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2441 let display = display.to_string();
2442 match state.insert_component(ComponentInsert::New {
2443 name: &display,
2444 source_key: name,
2445 source_signature: &document_signature(contents),
2446 document_json: contents,
2447 }) {
2448 Ok(id) => self.status = format!("inserted {display} ({id})"),
2449 Err(e) => self.status = format!("insert failed: {e}"),
2450 }
2451 }
2452
2453 fn effective_name(&self, docs: &Documents) -> String {
2458 let field = self.name_buf.trim();
2459 if !field.is_empty() {
2460 field.to_string()
2461 } else {
2462 Self::proposed_name(docs)
2463 }
2464 }
2465
2466 fn proposed_name(docs: &Documents) -> String {
2471 if docs.active().name().is_none() {
2472 if let Ok(Some(block)) = docs.engine().instructions() {
2473 let title = file_stem_of_title(&block.front.title);
2474 if !title.is_empty() {
2475 return title;
2476 }
2477 }
2478 }
2479 document_class::strip_class_extension(&Self::document_name(docs)).to_string()
2480 }
2481
2482 fn document_name(docs: &Documents) -> String {
2489 docs.active().name().map(model_display_name).unwrap_or_else(|| file_stem_of_title(&docs.active().title()))
2490 }
2491
2492 fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2495 docs.open_document(Document::fresh(docs.spawn_engine(), class));
2496 self.name_buf.clear();
2497 self.status = match class {
2498 DocumentClass::Normal => "new model".into(),
2499 _ => format!("new {}", class.label().to_ascii_lowercase()),
2500 };
2501 }
2502
2503 fn name_is_free(&mut self, docs: &mut Documents, name: &str) -> bool {
2515 let display = model_display_name(name);
2516 let target_revision = crate::plm::uses::revision_key_in(name);
2517 let taken = docs.iter().enumerate().any(|(index, doc)| {
2518 index != docs.active_index()
2519 && doc
2520 .name()
2521 .is_some_and(|open| {
2522 match (&target_revision, crate::plm::uses::revision_key_in(open)) {
2523 (Some(target), Some(other)) => *target == other,
2524 (None, None) => model_display_name(open) == display,
2525 _ => false,
2526 }
2527 })
2528 });
2529 if taken {
2530 let identity = target_revision.as_deref().unwrap_or(&display);
2531 self.status = format!("Save refused: '{identity}' is already open in another tab");
2532 docs.engine_mut().push_notice(&self.status);
2533 }
2534 !taken
2535 }
2536
2537 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2540 let name = name.trim().to_string();
2541 if name.is_empty() {
2542 self.status = "enter a name to save".into();
2543 return false;
2544 }
2545 if !self.name_is_free(docs, &name) {
2546 return false;
2547 }
2548 docs.flush_work_part();
2551 let mut plan = docs.plan_owned_save(store, &name);
2552 if !std::mem::take(&mut self.owned_loaded) {
2556 let loading: Vec<String> = plan
2557 .rows
2558 .iter()
2559 .map(|row| row.write.key.clone())
2560 .filter(|key| store.residency(key) == crate::store::Residency::OnDisk)
2561 .collect();
2562 for key in &loading {
2563 let _ = store.read(key);
2566 }
2567 if !loading.is_empty() {
2568 self.status = format!("Reading {} part(s) from the PLM before saving…", loading.len());
2569 self.owned_loading = Some((docs.active_id(), name, loading, 0));
2570 return false;
2571 }
2572 }
2573 match top_checkout_row(docs, store, &name) {
2574 Some(row) if matches!(row.issue, Some(crate::document::owned_save::Issue::Blocked(_))) => {
2575 let Some(crate::document::owned_save::Issue::Blocked(why)) = &row.issue else { unreachable!() };
2582 if why == crate::document::owned_save::VIEW_ONLY {
2585 self.status = format!(
2586 "{} is not saved: {why}. Your changes are only in this tab: Export keeps a copy as a file.",
2587 docs.active().title()
2588 );
2589 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2590 return false;
2591 }
2592 let held = why.starts_with("checked out by");
2593 self.status = format!(
2594 "{} is not saved: {why}. Your changes are only in this tab: Save As keeps them as {}, or Save again once it can be written.",
2595 docs.active().title(),
2596 if held { "a new part" } else { "a new revision" }
2597 );
2598 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2599 let status = self.status.clone();
2600 self.member_new_part = held;
2601 self.dispatch(FileAction::SaveAs, docs, store);
2602 self.status = status;
2603 return false;
2604 }
2605 Some(row) => plan.rows.insert(0, row),
2606 None => {}
2607 }
2608 if self.hold_for_plm_items(docs, store, plan.clone(), &name, false) {
2609 return false;
2610 }
2611 if self.asks(&plan) {
2612 self.ask_owned_save(plan, name, false);
2613 return false;
2614 }
2615 self.commit_save(docs, store, name, plan)
2616 }
2617
2618 fn asks(&self, plan: &crate::document::owned_save::OwnedSavePlan) -> bool {
2621 use crate::document::owned_save::Issue;
2622 plan.rows
2623 .iter()
2624 .any(|row| row.asks() && !(self.checkout_remembered && matches!(row.issue, Some(Issue::CheckOut { newer: false, .. }))))
2625 }
2626
2627 fn hold_for_plm_items(&mut self, docs: &Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan, name: &str, browser: bool) -> bool {
2630 let Some(client) = store.plm_client() else { return false };
2631 if plan.promotions.is_empty() || crate::panels::plm_host::revision_of(name).is_none() {
2632 return false;
2633 }
2634 let current: serde_json::Value = serde_json::from_str(&docs.tab_engine().history_request_json()).unwrap_or_default();
2635 let requests = plm_promotion_requests(¤t, &plan);
2636 let count = requests.len();
2637 let opened = docs.active().plm_folder().cloned();
2641 let part = crate::panels::plm_host::revision_of(name).map(|(part, _)| part);
2642 let future = async move {
2643 let folder = match (opened, part) {
2644 (Some(folder), _) => Some(folder),
2645 (None, Some(part)) => crate::plm::promote::linking_folder(&client, &part).await.filter(|(id, _)| !id.is_empty()),
2646 (None, None) => None,
2647 };
2648 let keys = crate::plm::promote::make_items(client, requests, folder.as_ref().map_or(String::new(), |(id, _)| id.clone())).await?;
2649 Ok((keys, folder))
2650 };
2651 self.owned_promoting = Some((OwnedPrompt { plan, name: name.to_string(), browser }, Box::pin(future)));
2652 self.status = format!("Making PLM items for {count} part(s) with no document of their own…");
2653 true
2654 }
2655
2656 fn poll_owned_loading(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2660 const LOAD_WAIT_FRAMES: u32 = 400;
2661 let Some((_, _, keys, frames)) = self.owned_loading.as_mut() else { return };
2662 if *frames < LOAD_WAIT_FRAMES && keys.iter().any(|key| store.residency(key) == crate::store::Residency::OnDisk) {
2663 *frames += 1;
2664 if *frames % 40 == 0 {
2665 for key in keys.iter() {
2666 let _ = store.read(key);
2667 }
2668 }
2669 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2670 return;
2671 }
2672 let Some((doc, name, _, _)) = self.owned_loading.take() else { return };
2673 if doc != docs.active_id() {
2674 self.status = "Save cancelled because the active document changed".into();
2675 return;
2676 }
2677 self.owned_loaded = true;
2678 self.save_to(docs, store, name);
2679 }
2680
2681 fn poll_owned_promotions(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2684 let Some((_, future)) = self.owned_promoting.as_mut() else { return };
2685 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2686 let std::task::Poll::Ready(result) = std::future::Future::poll(future.as_mut(), &mut cx) else {
2687 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2688 return;
2689 };
2690 let Some((mut prompt, _)) = self.owned_promoting.take() else { return };
2691 if prompt.plan.doc_id != docs.active_id() {
2692 self.status = "Save cancelled because the active document changed".into();
2693 return;
2694 }
2695 match result {
2696 Ok((keys, folder)) => {
2697 let place = folder.map_or_else(|| "the top of your workspace".to_string(), |(_, name)| format!("your workspace folder '{name}'"));
2698 docs.engine_mut().push_notice_as(
2699 brep_render::engine_state::NoticeSeverity::Info,
2700 format!("Made {} PLM item(s) for parts with no document, linked in {place}", keys.len()),
2701 );
2702 assign_plm_keys(&mut prompt.plan, &keys);
2703 if self.asks(&prompt.plan) {
2704 self.ask_owned_save(prompt.plan, prompt.name, prompt.browser);
2705 } else if prompt.browser {
2706 self.commit_save_browser(docs, store, prompt.name, prompt.plan);
2707 } else {
2708 self.commit_save(docs, store, prompt.name, prompt.plan);
2709 }
2710 }
2711 Err(error) => {
2712 self.status = format!("Save stopped: the PLM could not make an item for a part with no document: {error}");
2713 docs.engine_mut().push_notice(&self.status);
2714 }
2715 }
2716 }
2717
2718 fn ask_owned_save(&mut self, plan: crate::document::owned_save::OwnedSavePlan, name: String, browser: bool) {
2721 self.owned_state = serde_json::json!({"prompt": plan.to_json()});
2722 self.owned_prompt = Some(OwnedPrompt { plan, name, browser });
2723 self.open_modal(Mode::OwnedSave);
2724 }
2725
2726 fn save_owned(&mut self, docs: &mut Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan) -> String {
2729 let (report, checkouts) = docs.execute_owned_save(store, plan);
2730 if let Some(client) = store.plm_client() {
2731 use crate::panels::plm::PlmLifecycle;
2732 let (revisions, checkouts): (Vec<_>, Vec<_>) = checkouts.into_iter().partition(|r| r.new_revision);
2733 for request in checkouts {
2734 let future = client.checkout(&request.part, &request.revision);
2735 self.owned_checkouts.push((request, future));
2736 }
2737 if !revisions.is_empty() {
2738 let client = client.clone();
2741 let requests = revisions.clone();
2742 let future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>> = Box::pin(async move {
2743 let mut done: Vec<(String, String, String)> = Vec::new();
2744 let mut answers = Vec::new();
2745 for request in &requests {
2746 let text = crate::document::owned_save::retarget_text(&request.text, &done);
2747 match crate::panels::plm_parts::save_as_new_revision(&client, &request.part, "", &text).await {
2748 Ok(new_key) => {
2749 done.push((request.key.clone(), new_key.clone(), text.clone()));
2750 answers.push(Ok((new_key, text)));
2751 }
2752 Err(why) => answers.push(Err(why)),
2753 }
2754 }
2755 Ok(answers)
2756 });
2757 self.owned_revisions = Some(OwnedRevisions { doc: docs.active_id(), requests: revisions, future });
2758 }
2759 }
2760 self.owned_state = serde_json::json!({"report": report.to_json()});
2761 let summary = report.summary();
2762 if !summary.is_empty() {
2763 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, format!("Save: {summary}"));
2764 }
2765 summary
2766 }
2767
2768 fn poll_owned_revisions(&mut self, docs: &mut Documents, store: &dyn ModelStore) {
2772 let Some(revisions) = self.owned_revisions.as_mut() else { return };
2773 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2774 let std::task::Poll::Ready(result) = std::future::Future::poll(revisions.future.as_mut(), &mut cx) else { return };
2775 let OwnedRevisions { doc, requests, .. } = self.owned_revisions.take().unwrap();
2776 let answers = result.unwrap_or_else(|why| requests.iter().map(|_| Err(why.clone())).collect());
2777 for (request, answer) in requests.iter().zip(answers) {
2778 match answer {
2779 Ok((new_key, text)) => match docs.retarget_revised_copies(doc, &request.key, &new_key, &text) {
2780 Ok(()) => {
2781 store.remember_saved_document(&store.canonical_identity(&new_key), &text);
2784 store.refresh_plm_index(std::slice::from_ref(&new_key), false);
2785 docs.engine_mut().push_notice_as(
2786 brep_render::engine_state::NoticeSeverity::Info,
2787 format!(
2788 "Made a new revision of {} and saved the change into it: {}",
2789 request.shown,
2790 crate::document::owned_save::shown(&new_key, &[])
2791 ),
2792 );
2793 let followup = self.owned_followup.get_or_insert(OwnedFollowup { doc, keys: Vec::new(), polls: 0 });
2794 followup.keys.push(new_key);
2795 }
2796 Err(why) => docs.engine_mut().push_notice(format!("'{new_key}' holds the change, but the assembly could not name it: {why}")),
2797 },
2798 Err(why) => {
2799 docs.engine_mut().push_notice(format!("{} NOT saved: {why} — the change is kept in the assembly", request.shown));
2800 let was_clean = docs.iter().find(|d| d.id() == doc).is_some_and(|d| !d.is_dirty());
2801 if docs.mark_unsaved_copies(doc, &request.key, &format!("a new revision could not be made: {why}")) {
2802 write_late_marks(docs, store, doc, was_clean);
2803 }
2804 }
2805 }
2806 }
2807 }
2808
2809 fn poll_owned_checkouts(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2811 if let Some((_, _, _, future)) = self.owned_top.as_mut() {
2812 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2813 match std::future::Future::poll(future.as_mut(), &mut cx) {
2814 std::task::Poll::Ready(result) => {
2815 if let Some((id, signature, request, _)) = self.owned_top.take() {
2816 let saved = crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result);
2817 if saved {
2818 if let Some(doc) = docs.iter_mut().find(|d| d.id() == id) {
2819 doc.mark_saved_signature(signature);
2820 doc.set_plm_save_access(crate::document::Access::Editable);
2821 }
2822 self.save_generation += 1;
2823 remember_document(store, &request.key);
2824 self.status = format!("saved {}", request.key);
2825 } else {
2826 self.status = format!("{} NOT saved: the checkout or the write failed; your edits are kept", request.key);
2827 }
2828 }
2829 }
2830 std::task::Poll::Pending => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
2831 }
2832 }
2833 self.poll_owned_revisions(docs, store);
2834 if self.owned_checkouts.is_empty() && self.owned_revisions.is_none() {
2835 if self.owned_top.is_some() {
2837 return;
2838 }
2839 if let Some(followup) = self.owned_followup.as_mut() {
2840 const INDEX_WAIT_POLLS: u32 = 200;
2842 if followup.polls < INDEX_WAIT_POLLS && followup.keys.iter().any(|key| store.plm_revision(key).is_none()) {
2843 followup.polls += 1;
2844 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2845 return;
2846 }
2847 }
2848 if let Some(OwnedFollowup { doc: id, .. }) = self.owned_followup.take() {
2849 if docs.active_id() == id {
2852 if let Some(name) = docs.active().name().map(str::to_string) {
2853 self.save_to(docs, store, name);
2854 }
2855 } else {
2856 docs.engine_mut().push_notice("Save the assembly again: it names the new revisions now");
2857 }
2858 }
2859 return;
2860 }
2861 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2862 let mut i = 0;
2863 while i < self.owned_checkouts.len() {
2864 match std::future::Future::poll(self.owned_checkouts[i].1.as_mut(), &mut cx) {
2865 std::task::Poll::Ready(result) => {
2866 let (request, _) = self.owned_checkouts.remove(i);
2867 let failure = result.as_ref().err().cloned();
2868 let id = docs.active_id();
2869 if crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result) {
2870 if docs.clear_unsaved_copies(id, &request.key) {
2871 self.owned_followup.get_or_insert(OwnedFollowup { doc: id, keys: Vec::new(), polls: 0 });
2875 }
2876 } else {
2877 let why = failure.unwrap_or_else(|| "the write after the checkout failed".into());
2880 let was_clean = docs.iter().find(|d| d.id() == id).is_some_and(|d| !d.is_dirty());
2881 if docs.mark_unsaved_copies(id, &request.key, &format!("checkout failed: {why}")) {
2882 write_late_marks(docs, store, id, was_clean);
2883 }
2884 }
2885 }
2886 std::task::Poll::Pending => i += 1,
2887 }
2888 }
2889 if !self.owned_checkouts.is_empty() || self.owned_revisions.is_some() {
2890 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2891 }
2892 }
2893
2894 fn promotion_failed(&mut self, docs: &mut Documents) -> bool {
2900 let failed: Vec<String> = self.owned_state["report"]["failedPromotions"]
2901 .as_array()
2902 .map(|keys| keys.iter().filter_map(|k| k.as_str().map(str::to_string)).collect())
2903 .unwrap_or_default();
2904 if failed.is_empty() {
2905 return false;
2906 }
2907 self.status = format!(
2908 "Save stopped: could not create {} — the assembly was not written, so no part is kept only inside it. Fix the problem and Save again.",
2909 failed.join(", ")
2910 );
2911 docs.engine_mut().push_notice(&self.status);
2912 true
2913 }
2914
2915 fn commit_save(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
2918 use crate::document::owned_save::{CheckoutRequest, Issue};
2919 let top = plan.rows.first().filter(|row| row.write.key == name).cloned();
2920 if let Some(row) = &top {
2921 if !row.proceed {
2922 self.status = format!("Save cancelled: {name} was not checked out");
2923 return false;
2924 }
2925 }
2926 let summary = self.save_owned(docs, store, plan);
2927 if self.promotion_failed(docs) {
2928 return false;
2929 }
2930 let document = docs.tab_engine().history_request_json();
2931 if let (Some(row), Some(client)) = (top, store.plm_client()) {
2932 if let Some(Issue::CheckOut { part, revision, .. }) = row.issue {
2933 use crate::panels::plm::PlmLifecycle;
2934 let signature = docs.active().save_signature();
2935 let future = client.checkout(&part, &revision);
2936 let shown = crate::document::owned_save::shown(&name, &[]);
2937 let request = CheckoutRequest { key: name.clone(), part, revision, text: document, shown: shown.clone(), new_revision: false };
2938 self.owned_top = Some((docs.active_id(), signature, request, future));
2939 self.status = if summary.is_empty() {
2940 format!("checking out and saving {shown}")
2941 } else {
2942 format!("checking out and saving {shown}; {summary}")
2943 };
2944 return true;
2945 }
2946 }
2947 crate::plm::thumbnail::stage_save(store, &name, &document, docs.tab_engine(), docs.active_id());
2948 match store.write(&name, &document) {
2949 Ok(()) => {
2950 self.name_buf = model_display_name(&name);
2953 let doc = docs.active_mut();
2954 doc.set_name(Some(name.clone()));
2955 doc.mark_clean();
2956 self.save_generation += 1;
2957 remember_document(store, &name);
2958 self.status = if summary.is_empty() { format!("saved {name}") } else { format!("saved {name}; {summary}") };
2959 true
2960 }
2961 Err(e) => {
2962 self.status = format!("save failed: {e}");
2963 docs.engine_mut().push_notice(&self.status);
2964 false
2965 }
2966 }
2967 }
2968
2969 fn save_to_browser(
2973 &mut self,
2974 docs: &mut Documents,
2975 store: &dyn ModelStore,
2976 name: String,
2977 ) -> bool {
2978 let name = name.trim().to_string();
2979 if name.is_empty() {
2980 self.status = "enter a name to save".into();
2981 return false;
2982 }
2983 if !self.name_is_free(docs, &name) {
2984 return false;
2985 }
2986 docs.flush_work_part();
2991 let plan = docs.plan_owned_save(store, &name);
2992 if self.hold_for_plm_items(docs, store, plan.clone(), &name, true) {
2993 return false;
2994 }
2995 if plan.needs_asking() {
2996 self.ask_owned_save(plan, name, true);
2997 return false;
2998 }
2999 self.commit_save_browser(docs, store, name, plan)
3000 }
3001
3002 fn commit_save_browser(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
3004 let summary = self.save_owned(docs, store, plan);
3005 if self.promotion_failed(docs) {
3006 return false;
3007 }
3008 let class = DocumentClass::of_name(&name).unwrap_or_default();
3009 adopt_class(docs.tab_engine_mut(), class);
3010 stamp_save_as_lineage(docs, store, &name);
3014 if docs.tab_engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
3015 docs.tab_engine_mut()
3016 .history
3017 .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
3018 }
3019 let previous = docs.active().name().map(str::to_string);
3020 let saved_text = docs.tab_engine().history_request_json();
3021 match store.browser_write(&name, &saved_text) {
3022 Ok(identity) => {
3023 if let Some(old) = previous.filter(|old| store.canonical_identity(old) != store.canonical_identity(&identity)) {
3026 let tabs = docs.tabs_holding(&old, docs.active_id());
3027 if !tabs.is_empty() {
3028 self.retarget = Some(Retarget { old, new_key: identity.clone(), text: saved_text.clone(), tabs });
3029 }
3030 }
3031 crate::plm::thumbnail::stage_recent(&identity, docs.tab_engine(), docs.active_id());
3034 self.name_buf = model_display_name(&identity);
3035 let doc = docs.active_mut();
3036 self.member_ack.remove(&doc.id());
3037 doc.set_name(Some(identity.clone()));
3038 doc.mark_clean();
3039 self.save_generation += 1;
3040 remember_document(store, &identity);
3041 self.status = if summary.is_empty() { format!("saved {identity}") } else { format!("saved {identity}; {summary}") };
3042 true
3043 }
3044 Err(e) => {
3045 self.status = format!("save failed: {e}");
3046 false
3047 }
3048 }
3049 }
3050
3051 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
3055 self.pending_open = None;
3056 if docs.focus_named(name) {
3057 remember_document(store, name);
3058 self.status = format!("{name} is already open");
3059 return;
3060 }
3061 match crate::store::read_now(store, name) {
3062 crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store),
3063 crate::store::ReadNow::Loading => {
3066 self.pending_open = Some((name.to_owned(), docs.active_id()));
3067 self.status = format!("'{name}' is still loading");
3068 }
3069 crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
3070 }
3071 }
3072
3073 fn poll_pending_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3076 let Some((name, requester)) = self.pending_open.take() else { return };
3077 if docs.active_id() != requester {
3078 return;
3079 }
3080 if docs.focus_named(&name) {
3081 self.status = format!("{name} is already open");
3082 return;
3083 }
3084 match crate::store::read_now(store, &name) {
3085 crate::store::ReadNow::Ready(contents) => {
3086 self.load_document(docs, &name, &contents, store);
3087 }
3088 crate::store::ReadNow::Loading => {
3089 self.pending_open = Some((name, requester));
3090 ctx.request_repaint_after(std::time::Duration::from_millis(100));
3091 }
3092 crate::store::ReadNow::Absent => {
3093 self.status = format!("open failed: '{name}' not found");
3094 }
3095 }
3096 }
3097
3098 fn say_previewed_export(&mut self, docs: &mut Documents) {
3109 let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
3110 return;
3111 };
3112 let note = format!("exported {label} (preview): the family's own values are not in this file");
3113 self.status = format!("{} — {label} (preview)", self.status);
3114 docs.engine_mut().push_notice(note);
3115 }
3116
3117 fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
3119 if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
3120 let text = format!(
3121 "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
3122 not the family's own values. The .nbrep recipe keeps the family's own."
3123 );
3124 ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
3125 ui.add_space(4.0);
3126 }
3127 }
3128
3129 fn poll_instructions_pdf(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3130 let Some(mut export) = self.instructions_pdf.take() else { return };
3131 if export.document != docs.view_id() {
3132 self.status = "PDF export cancelled: the active document changed".into();
3133 docs.engine_mut().push_notice(self.status.clone());
3134 return;
3135 }
3136 match docs.engine_mut().poll_instructions_pdf(&mut export.job) {
3137 Ok(None) => {
3138 self.instructions_pdf = Some(export);
3139 ctx.request_repaint_after(std::time::Duration::from_millis(16));
3140 }
3141 Ok(Some((bytes, pages))) => {
3142 let pending = PendingSave {
3143 request: SaveRequest::bytes("Export the instructions", format!("{}.pdf", export.name), bytes),
3144 detail: format!(" ({pages} page{})", if pages == 1 { "" } else { "s" }),
3145 label: None, warnings: vec![], preview: export.preview,
3146 };
3147 self.deliver(docs, store, pending);
3148 }
3149 Err(error) => {
3150 self.status = format!("PDF export failed: {error}");
3151 docs.engine_mut().push_notice(self.status.clone());
3152 }
3153 }
3154 }
3155
3156 fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
3157 let name = self.effective_name(docs);
3158 let preview = format != "json";
3162 let export = |request: SaveRequest| PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview };
3163 if format == "instructionshtml" {
3171 let ids = linked_tool_ids(docs.engine());
3172 if let Some(client) = store.plm_client().filter(|_| !ids.is_empty()) {
3173 crate::plm::tool_pictures::request(&client, &ids);
3174 if crate::plm::tool_pictures::pending_of(&ids) > 0 {
3175 self.pending_instructions_export = Some(format.to_string());
3176 self.status = "loading the tool pictures for the export…".into();
3177 return true;
3178 }
3179 }
3180 let made = docs.engine().instructions_player_data().map(|mut data| {
3181 data.inline_tool_pictures(&crate::plm::tool_pictures::fetched(&ids));
3182 brep_instructions::player::offline_html(&data, brep_instructions::player::Mode::Shop)
3183 });
3184 return match made {
3185 Ok(html) => {
3186 let pending = export(SaveRequest::text("Export the offline player", format!("{name}.html"), html));
3187 self.deliver(docs, store, pending) != Delivery::Failed
3188 }
3189 Err(e) => {
3190 self.status = format!("export failed: {e}");
3191 false
3192 }
3193 };
3194 }
3195 if format == "instructionspdf" {
3198 if self.instructions_pdf.is_some() {
3199 self.status = "an instructions PDF is already being prepared".into();
3200 return false;
3201 }
3202 return match docs.engine_mut().begin_instructions_pdf() {
3203 Ok(job) => {
3204 self.instructions_pdf = Some(PendingInstructionsPdf { document: docs.view_id(), name, preview, job });
3205 self.status = "preparing the instructions PDF…".into();
3206 true
3207 }
3208 Err(error) => { self.status = format!("export failed: {error}"); false }
3209 };
3210 }
3211 if format == "instructionsimages" {
3212 if !docs.engine_mut().instructions_topology_ready() {
3216 self.pending_instructions_export = Some(format.to_string());
3217 self.status = "loading the exact geometry for the export…".into();
3218 return true;
3219 }
3220 let made = super::instructions::images_zip(docs.engine_mut()).map(|bytes| (bytes, String::new()));
3221 return match made {
3222 Ok((bytes, detail)) => {
3223 let (title, file) = ("Export the step images", format!("{name}-steps.zip"));
3224 let pending = PendingSave { detail, ..export(SaveRequest::bytes(title, file, bytes)) };
3225 self.deliver(docs, store, pending) != Delivery::Failed
3226 }
3227 Err(e) => {
3228 self.status = format!("export failed: {e}");
3229 false
3230 }
3231 };
3232 }
3233 if format == "glb" || format == "3mf" {
3234 let written = if format == "glb" {
3237 docs.engine().export_glb_bytes(&name)
3238 } else {
3239 docs.engine().export_3mf_bytes(&name)
3240 };
3241 return match written {
3242 Ok(bytes) => {
3243 let title = if format == "glb" { "Export GLB" } else { "Export 3MF" };
3244 let request = SaveRequest::bytes(title, format!("{name}.{format}"), bytes);
3245 self.deliver(docs, store, export(request)) != Delivery::Failed
3246 }
3247 Err(e) => {
3248 self.status = format!("export failed: {e}");
3249 false
3250 }
3251 };
3252 }
3253 let state = docs.engine_mut();
3254 if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
3263 let pdf = format != "sheetsvg";
3264 let three_d = format == "sheetpdf3d";
3265 let (file, made) = if pdf {
3266 let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
3267 let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
3268 (file, bytes.map(SaveBody::Bytes))
3269 } else {
3270 let sheet_name = state
3271 .resolve_sheet("")
3272 .ok()
3273 .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
3274 .unwrap_or_default();
3275 let file = format!("{name}-{}.svg", slug(&sheet_name));
3276 let text = state.resolve_sheet("").and_then(|id| state.export_sheet_svg(&id));
3277 (file, text.map(SaveBody::Text))
3278 };
3279 let pages = state.sheet_state().sheets.len() + usize::from(three_d);
3280 return match made {
3281 Ok(body) => {
3282 let detail = if pdf {
3283 format!(" ({pages} page{})", if pages == 1 { "" } else { "s" })
3284 } else {
3285 String::new()
3286 };
3287 let title = if pdf { "Export the drawing set" } else { "Export the drawing sheet" };
3288 let request = SaveRequest { title: title.into(), file_name: file, body };
3289 let pending = PendingSave { detail, ..export(request) };
3290 self.deliver(docs, store, pending) != Delivery::Failed
3291 }
3292 Err(e) => {
3293 self.status = format!("export failed: {e}");
3294 false
3295 }
3296 };
3297 }
3298 let (text, ext) = match format {
3299 "stl" => (state.export_stl_text(), "stl"),
3300 "obj" => (state.export_obj_text(), "obj"),
3301 "iges" => (state.export_iges_text_selected(&name, None), "igs"),
3302 "json" => (Ok(state.history_request_json()), "nbrep"),
3303 _ => (state.export_step_text_named(&name), "step"),
3306 };
3307 match text {
3308 Ok(contents) => {
3309 let title = if format == "json" { "Export the model".to_string() } else { format!("Export {}", ext.to_ascii_uppercase()) };
3310 let request = SaveRequest::text(title, format!("{name}.{ext}"), contents);
3311 self.deliver(docs, store, export(request)) != Delivery::Failed
3312 }
3313 Err(e) => {
3314 self.status = format!("export failed: {e}");
3315 false
3316 }
3317 }
3318 }
3319
3320 fn deliver(&mut self, docs: &mut Documents, store: &dyn ModelStore, pending: PendingSave) -> Delivery {
3326 if let Some(local) = store.local_files() {
3327 self.save_name = pending.request.file_name.clone();
3328 self.save_file = Some(pending);
3329 self.use_folder(Purpose::Exports, local);
3330 self.status.clear();
3331 self.open_modal(Mode::SaveFile);
3332 return Delivery::Chooser;
3333 }
3334 let file = pending.request.file_name.clone();
3335 let written = match &pending.request.body {
3336 SaveBody::Text(text) => store.export_file_named(&file, text),
3337 SaveBody::Bytes(bytes) => store.export_file_named_bytes(&file, bytes),
3338 };
3339 match written {
3340 Ok(()) => {
3341 self.say_written(docs, &pending, &file, None);
3342 Delivery::Done
3343 }
3344 Err(e) => {
3345 self.status = match &pending.label {
3346 Some(label) => format!("{label} export failed: {e}"),
3347 None => format!("export failed: {e}"),
3348 };
3349 Delivery::Failed
3350 }
3351 }
3352 }
3353
3354 fn say_written(&mut self, docs: &mut Documents, pending: &PendingSave, file: &str, folder: Option<&str>) -> bool {
3362 let into = folder.map(|folder| format!(" into {folder}")).unwrap_or_default();
3363 self.status = format!("exported {file}{}{into}", pending.detail);
3364 if pending.preview {
3365 self.say_previewed_export(docs);
3366 }
3367 use brep_render::engine_state::NoticeSeverity;
3368 let state = docs.engine_mut();
3369 match &pending.label {
3370 Some(label) if !pending.warnings.is_empty() => {
3371 let warnings = pending.warnings.join(" ");
3372 self.status = format!("{}. Before ordering: {warnings}", self.status);
3373 state.push_notice_as(NoticeSeverity::Warning, format!("{label}: {warnings}"));
3374 true
3375 }
3376 Some(label) => {
3377 state.push_notice_as(NoticeSeverity::Info, format!("{label}: {}", self.status));
3378 false
3379 }
3380 None => {
3381 if folder.is_some() {
3382 state.push_notice_as(NoticeSeverity::Info, self.status.clone());
3383 }
3384 false
3385 }
3386 }
3387 }
3388
3389 pub fn request_save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore, request: SaveRequest) -> Result<(), String> {
3394 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3395 match self.deliver(docs, store, pending) {
3396 Delivery::Failed => Err(std::mem::take(&mut self.status)),
3397 Delivery::Done | Delivery::Chooser => Ok(()),
3398 }
3399 }
3400
3401 fn show_save_file(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3407 let Some(local) = store.local_files() else {
3408 self.save_file = None;
3409 self.open = false;
3410 return;
3411 };
3412 let title = self.save_file.as_ref().map(|pending| pending.request.title.clone()).unwrap_or_else(|| "Save file".into());
3413 let pending = self.save_file.is_some();
3414 let extension = std::path::Path::new(self.save_name.trim())
3418 .extension()
3419 .map(|extension| extension.to_string_lossy().into_owned());
3420 let extensions: Vec<&str> = extension.iter().map(String::as_str).collect();
3421 let mut do_save = false;
3422 let mut cancel = false;
3423 let modal = egui::Modal::new(egui::Id::new("brep-file-savefile")).show(ctx, |ui| {
3424 file_explorer::dialog_body(ui, |ui| {
3425 ui.heading(&title);
3426 ui.add_space(4.0);
3427 file_explorer::dialog_footer(ui, "savefile", |ui| {
3428 ui.label("File name");
3429 let field = ui.add(
3430 egui::TextEdit::singleline(&mut self.save_name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
3431 .hint_text("file name")
3432 .desired_width(f32::INFINITY),
3433 );
3434 self.hit("savefile:name", &field);
3435 if self.want_focus {
3436 field.request_focus();
3437 self.want_focus = false;
3438 }
3439 let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
3440 ui.add_space(6.0);
3441 ui.horizontal(|ui| {
3442 let save = ui.add_enabled(pending, egui::Button::new("Save"));
3445 self.hit("save", &save);
3446 if save.clicked() || (enter && pending) {
3447 do_save = true;
3448 }
3449 let c = ui.button(if pending { "Cancel" } else { "Close" });
3450 self.hit("cancel", &c);
3451 if c.clicked() {
3452 cancel = true;
3453 }
3454 });
3455 if !self.status.is_empty() {
3456 ui.add_space(4.0);
3457 ui.weak(&self.status);
3458 }
3459 });
3460 let options = FileExplorerOptions {
3461 hit_prefix: "savefile",
3462 empty_label: "(no files of this kind here)",
3463 row_icon: "\u{1F5CE}",
3464 current: None,
3465 allow_delete: false,
3466 allow_import: false,
3467 import_label: "",
3468 import_hit: "savefile:upload",
3469 show_cancel: false,
3470 confirm_label: None,
3471 extensions: &extensions,
3472 };
3473 let output = self.explorer.show_store(ui, local, options);
3474 self.record_explorer_hits(&output.hits);
3475 if let Some(identity) = output.picked.or(output.activated) {
3479 self.save_name = std::path::Path::new(&identity)
3480 .file_name()
3481 .map(|name| name.to_string_lossy().into_owned())
3482 .unwrap_or(identity);
3483 ui.ctx().request_repaint();
3484 }
3485 })
3486 });
3487 if do_save {
3488 if self.save_file(docs, store) {
3489 self.open = false;
3490 }
3491 } else if cancel || modal.should_close() {
3492 if self.save_file.take().is_some() {
3493 self.status = "export cancelled: nothing written".into();
3494 }
3495 self.open = false;
3496 }
3497 }
3498
3499 pub(crate) fn save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore) -> bool {
3505 let Some(local) = store.local_files() else {
3506 return false;
3507 };
3508 let name = self.save_name.trim().to_string();
3509 if name.is_empty() {
3510 self.status = "enter a file name".into();
3511 return false;
3512 }
3513 let Some(pending) = self.save_file.take() else {
3514 return false;
3515 };
3516 match local.browser_write_bytes(&name, pending.request.body.as_bytes()) {
3517 Ok(path) => {
3518 self.folders.insert(Purpose::Exports, local.browser_location());
3519 let path = std::path::Path::new(&path);
3520 let folder = path.parent().map(|folder| folder.display().to_string()).unwrap_or_else(|| local.browser_location());
3521 let file = path.file_name().map(|file| file.to_string_lossy().into_owned()).unwrap_or(name);
3522 !self.say_written(docs, &pending, &file, Some(&folder))
3523 }
3524 Err(e) => {
3525 self.status = format!("export failed: {e}");
3526 self.save_file = Some(pending);
3527 false
3528 }
3529 }
3530 }
3531
3532 fn export_flat_pattern_as(
3538 &mut self,
3539 docs: &mut Documents,
3540 store: &dyn ModelStore,
3541 format: &str,
3542 ) -> bool {
3543 let name = self.effective_name(docs);
3544 let state = docs.engine_mut();
3545 let (text, ext) = match format {
3546 "svg" => (state.export_flat_pattern_svg(), "svg"),
3547 _ => (state.export_flat_pattern_dxf(), "dxf"),
3548 };
3549 match text {
3550 Ok(contents) => {
3551 let request = SaveRequest::text("Export the flat pattern", format!("{name}.{ext}"), contents);
3552 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: true };
3553 self.deliver(docs, store, pending) != Delivery::Failed
3554 }
3555 Err(e) => {
3556 self.status = format!("flat-pattern export failed: {e}");
3557 state.push_notice(format!("Flat pattern: {e}"));
3558 false
3559 }
3560 }
3561 }
3562
3563 fn export_bom_as(
3569 &mut self,
3570 docs: &mut Documents,
3571 store: &dyn ModelStore,
3572 format: &str,
3573 ) -> bool {
3574 let name = self.effective_name(docs);
3575 let state = docs.engine_mut();
3576 let (text, ext) = match format {
3577 "json" => (state.export_bom_json(), "bom.json"),
3578 _ => (state.export_bom_csv(), "bom.csv"),
3579 };
3580 match text {
3581 Ok(contents) => {
3582 let request = SaveRequest::text("Export the bill of materials", format!("{name}.{ext}"), contents);
3583 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3584 self.deliver(docs, store, pending) != Delivery::Failed
3585 }
3586 Err(e) => {
3587 self.status = format!("BOM export failed: {e}");
3588 state.push_notice(format!("BOM export: {e}"));
3589 false
3590 }
3591 }
3592 }
3593
3594 fn export_fabrication_as(
3601 &mut self,
3602 docs: &mut Documents,
3603 store: &dyn ModelStore,
3604 output: super::fabrication_export::Output,
3605 ) -> bool {
3606 use super::fabrication_export::{self as fab, Output};
3607 let name = self.effective_name(docs);
3608 let file = output.file_name(&name);
3609 let state = docs.engine_mut();
3610 let made = match output {
3611 Output::Bundle => fab::bundle(state, &name).map(|bundle| {
3612 let detail = format!(" ({} files)", bundle.files.len());
3613 (SaveBody::Bytes(bundle.zip()), detail, bundle.warnings)
3614 }),
3615 _ => fab::text(state, output, &name).map(|text| (SaveBody::Text(text), String::new(), vec![])),
3616 };
3617 match made {
3618 Ok((body, detail, warnings)) => {
3619 let request = SaveRequest { title: format!("Export: {}", output.label()), file_name: file, body };
3623 let pending = PendingSave { request, detail, label: Some(output.label().to_string()), warnings, preview: false };
3624 match self.deliver(docs, store, pending) {
3625 Delivery::Done => !self.status.contains("Before ordering"),
3626 Delivery::Chooser => true,
3627 Delivery::Failed => {
3628 docs.engine_mut().push_notice(format!("{} export: {}", output.label(), self.status));
3629 false
3630 }
3631 }
3632 }
3633 Err(e) => {
3634 self.status = format!("{} export failed: {e}", output.label());
3635 state.push_notice(format!("{} export: {e}", output.label()));
3636 false
3637 }
3638 }
3639 }
3640
3641 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3654 self.pending_step_import = None;
3660 let id = state.submit_step_probe(contents);
3661 self.pending_step_probe = Some(PendingStepProbe {
3662 id,
3663 name: name.to_string(),
3664 text: contents.to_string(),
3665 });
3666 self.status = format!("reading \"{name}\"\u{2026}");
3667 self.poll_step_probe(state);
3670 }
3671
3672 fn poll_step_probe(&mut self, state: &mut EngineState) {
3677 while let Some((id, outcome)) = state.take_step_probe() {
3678 let Some(pending) = self.pending_step_probe.as_ref() else {
3679 continue;
3680 };
3681 if pending.id != id {
3682 continue; }
3684 let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
3685 match outcome {
3686 StepProbeOutcome::Structure(probe) => {
3687 self.status = format!(
3688 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
3689 probe.parts,
3690 plural(probe.parts),
3691 probe.instances,
3692 plural(probe.instances)
3693 );
3694 self.pending_step_import = Some(PendingStepImport {
3695 name,
3696 text,
3697 probe,
3698 flatten: false,
3701 });
3702 self.open_modal(Mode::StepAssembly);
3708 }
3709 StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
3710 self.import_step_flat(state, &name, &text);
3711 }
3712 }
3713 }
3714 if self.pending_step_probe.is_some() && !state.step_probes_pending() {
3715 let pending = self.pending_step_probe.take().unwrap();
3719 self.status = format!("import of \"{}\" was cancelled", pending.name);
3720 }
3721 }
3722
3723 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3729 match state.import_model_text_feature(contents) {
3731 Ok(_) => self.status = format!("imported {name}"),
3735 Err(e) => self.status = format!("import failed: {e}"),
3736 }
3737 }
3738
3739 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
3749 let Some(pending) = self.pending_step_import.take() else {
3750 return;
3751 };
3752 let was_assembly = state.history_has_assembly();
3755 let doc_name = step_document_name(&pending.name);
3756 let opts = StepAssemblyImport {
3759 nested: !pending.flatten,
3760 };
3761 let mut sink = StorePartSink::new(store, &doc_name);
3768 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
3769 Ok(report) => {
3770 if !was_assembly {
3771 self.enter_assembly_workbench(state);
3772 }
3773 for failure in &sink.failures {
3776 state.push_notice(format!("part not saved — {failure}"));
3777 }
3778 let saved = sink.written;
3779 let base = assembly_import_message(&pending.name, &report);
3780 match (saved, sink.failures.len()) {
3781 (0, _) if sink.deferred > 0 => format!(
3782 "{base}; its {} part documents become PLM items when you save the assembly",
3783 sink.deferred
3784 ),
3785 (0, _) => base,
3786 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
3787 (saved, failed) => {
3788 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
3789 }
3790 }
3791 }
3792 Err(error) => match state.import_step_feature(&pending.text) {
3793 Ok(_) => format!(
3794 "imported {} as bodies — the assembly structure could not be built ({error})",
3795 pending.name
3796 ),
3797 Err(flat) => format!("import failed: {flat}"),
3798 },
3799 };
3800 self.status = message.clone();
3803 state.push_notice(message);
3804 }
3805
3806 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
3810 let Some(pending) = self.pending_step_import.take() else {
3811 return;
3812 };
3813 state.discard_probed_step_assembly();
3814 self.import_step_flat(state, &pending.name, &pending.text);
3815 }
3816
3817 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
3819 let Some(pending) = self.pending_step_import.take() else {
3820 return;
3821 };
3822 state.discard_probed_step_assembly();
3823 self.status = format!("import cancelled: {}", pending.name);
3824 }
3825
3826 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
3840 if crate::workbench::panel_visible(
3841 &state.settings.workbench,
3842 crate::workbench::assembly::BOM_PANEL_ID,
3843 &crate::workbench::ButtonState::of(state),
3844 ) {
3845 return;
3846 }
3847 let _ = state.apply_settings_json(
3848 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
3849 .to_string(),
3850 );
3851 }
3852
3853 fn stage_mesh_preview(
3856 &mut self,
3857 format: MeshImportFormat,
3858 name: String,
3859 contents: Vec<u8>,
3860 ) {
3861 self.pending_stl_import = Some((format, name, contents));
3862 self.status.clear();
3863 }
3864
3865 pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
3868 self.pending_stl_import.take()
3869 }
3870
3871 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
3872 match state.import_obj_bytes_feature(contents) {
3873 Ok(_) => self.status = format!("reconstructing {name} in background…"),
3874 Err(e) => self.status = format!("import failed: {e}"),
3875 }
3876 }
3877
3878 fn import_file(
3882 &mut self,
3883 kind: ImportKind,
3884 state: &mut EngineState,
3885 store: &dyn ModelStore,
3886 name: String,
3887 bytes: Vec<u8>,
3888 ) -> bool {
3889 match kind {
3890 ImportKind::Kicad => {
3891 self.take_kicad_file(&name, &bytes, store);
3892 return false;
3893 }
3894 ImportKind::Step | ImportKind::Iges => self.import_text(state, &name, &bytes, Self::import_step),
3895 ImportKind::Stl => self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes),
3896 ImportKind::ThreeMf => self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes),
3897 ImportKind::Obj => self.import_obj(state, &name, &bytes),
3898 }
3899 true
3900 }
3901
3902 pub fn import_into_new_tab(
3913 &mut self,
3914 docs: &mut Documents,
3915 store: &dyn ModelStore,
3916 file_name: &str,
3917 bytes: Vec<u8>,
3918 ) -> Result<(), String> {
3919 let Some(kind) = import_kind(file_name) else {
3920 return Err(format!("{file_name} is not a BREP document or a file BREP imports"));
3921 };
3922 self.cancel_pending_step_import(docs.engine_mut());
3923 self.new_document(docs, DocumentClass::Normal);
3924 let title = import_document_name(file_name);
3925 docs.active_mut().set_label(Some(title.clone()));
3926 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name).to_string();
3927 self.status.clear();
3928 if self.import_file(kind, docs.engine_mut(), store, base, bytes) {
3929 self.close_after_import();
3930 }
3931 if self.status.starts_with("import failed") {
3932 docs.engine_mut().push_notice(self.status.clone());
3933 return Err(self.status.clone());
3934 }
3935 Ok(())
3936 }
3937
3938 fn import_text(
3939 &mut self,
3940 state: &mut EngineState,
3941 name: &str,
3942 bytes: &[u8],
3943 importer: fn(&mut Self, &mut EngineState, &str, &str),
3944 ) {
3945 match std::str::from_utf8(bytes) {
3946 Ok(contents) => importer(self, state, name, contents),
3947 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
3948 }
3949 }
3950
3951 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, store: &dyn ModelStore) {
3956 let plm = store.plm_client().is_some();
3957 let mut engine = docs.spawn_engine();
3958 match engine.load_model_and_fit(contents) {
3959 Ok(_) => {
3960 if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
3967 document_class::set_document_class(&mut engine, class);
3968 }
3969 let mut doc = Document::new(engine);
3973 doc.set_name(Some(name.to_string()));
3974 doc.note_opened_from(contents);
3975 docs.open_document(doc);
3976 self.name_buf = model_display_name(name);
3977 self.status = format!("opened {name}");
3978 remember_document(store, name);
3979 }
3980 Err(e) => self.status = format!("open failed: {e}"),
3981 }
3982 }
3983
3984 fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
3989 let client = store.plm_client()?;
3990 let key = crate::plm::family::revision_key(identity)?;
3991 Some((client, key))
3992 }
3993
3994 fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3996 let Some(work) = self.plm_work.as_mut() else { return };
3997 ctx.request_repaint_after(std::time::Duration::from_millis(30));
3998 match work {
3999 PlmWork::Members(pending) => {
4000 let Some(result) = pending.poll() else { return };
4001 self.plm_work = None;
4002 let Some(pick) = self.pick_member.as_mut() else { return };
4003 pick.loading = false;
4004 match result {
4005 Ok(members) => {
4006 pick.rows = members
4007 .into_iter()
4008 .map(|member| MemberChoice {
4009 why_not: member.not_placeable(),
4010 exists: member.not_placeable().is_none(),
4011 identity: member.key(),
4012 description: member.name.clone(),
4013 part_number: member.number,
4014 })
4015 .collect();
4016 }
4017 Err(error) => self.status = format!("the members could not be read: {error}"),
4018 }
4019 }
4020 PlmWork::Place(pending) => {
4021 let Some(result) = pending.poll() else { return };
4022 self.plm_work = None;
4023 match result {
4024 Ok((key, text, number)) => {
4025 self.insert_component_named(docs.engine_mut(), &key, &number, &text);
4026 self.pick_member = None;
4027 self.spin_out = None;
4028 self.open = false;
4029 }
4030 Err(error) => self.status = error,
4031 }
4032 }
4033 PlmWork::OpenFamily(pending) => {
4034 let Some(result) = pending.poll() else { return };
4035 self.plm_work = None;
4036 match result {
4037 Ok(key) => self.open_document(docs, store, &key),
4038 Err(error) => self.status = format!("the family could not be opened: {error}"),
4039 }
4040 }
4041 }
4042 }
4043
4044 fn begin_class_insert(
4050 &mut self,
4051 store: &dyn ModelStore,
4052 identity: &str,
4053 contents: &str,
4054 assembly: Option<String>,
4055 ) -> bool {
4056 let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
4057 let class = DocumentClass::of_name(identity)
4058 .filter(|class| *class != DocumentClass::Normal)
4059 .unwrap_or_else(|| DocumentClass::of_document(&document));
4060 let file = crate::store::file_name_of(identity);
4061 match class {
4062 DocumentClass::Normal | DocumentClass::Instructions => false,
4063 DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
4064 let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
4065 let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
4066 return false;
4067 };
4068 self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
4069 crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
4070 }))));
4071 self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
4072 self.status.clear();
4073 self.open_modal(Mode::PickMember);
4074 true
4075 }
4076 DocumentClass::Family => {
4077 let table = family_table::read_table(contents);
4078 let rows = table
4079 .rows
4080 .iter()
4081 .map(|row| {
4082 let identity = family_table::member_identity(Some(identity), &row.part_number);
4083 let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
4084 MemberChoice {
4085 part_number: row.part_number.clone(),
4086 description: row.description.clone(),
4087 identity,
4088 exists,
4089 why_not: None,
4090 }
4091 })
4092 .collect();
4093 self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
4094 self.status.clear();
4095 self.open_modal(Mode::PickMember);
4096 true
4097 }
4098 DocumentClass::Template => {
4099 let inputs = template::inputs_of(&document);
4100 let values = inputs
4101 .iter()
4102 .map(|input| (input.name.clone(), template::default_value(&document, input)))
4103 .collect();
4104 let stem = document_class::strip_class_extension(&file).to_string();
4105 let plm_template = self
4106 .plm_revision(store, identity)
4107 .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
4108 self.spin_out = Some(SpinOut {
4109 template_file: file,
4110 template: document,
4111 inputs,
4112 name: format!("{stem}-1"),
4113 values,
4114 assembly,
4115 plm_template,
4116 number: String::new(),
4117 });
4118 self.status.clear();
4119 self.open_modal(Mode::SpinOut);
4120 true
4121 }
4122 }
4123 }
4124
4125 fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4129 let Some(pick) = self.pick_member.take() else {
4130 self.open = false;
4131 return;
4132 };
4133 let mut chosen = None;
4134 let mut cancel = false;
4135 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
4136 ui.set_width(420.0);
4137 ui.heading(format!("Insert a member of {}", pick.family_file));
4138 ui.add_space(2.0);
4139 ui.weak("A family is never placed itself: choose the member to place.");
4140 ui.add_space(6.0);
4141 if pick.loading {
4142 ui.label("Reading the family's members from the PLM\u{2026}");
4143 } else if pick.rows.is_empty() {
4144 ui.label("This family's table has no rows yet.");
4145 }
4146 egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
4147 for (index, row) in pick.rows.iter().enumerate() {
4148 ui.horizontal(|ui| {
4149 let text = if row.description.trim().is_empty() {
4150 row.part_number.clone()
4151 } else {
4152 format!("{} \u{2014} {}", row.part_number, row.description)
4153 };
4154 let button = ui.add_enabled(
4155 row.exists,
4156 egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
4157 );
4158 self.hit(&format!("member:{}", row.part_number), &button);
4159 if button.clicked() {
4160 chosen = Some(index);
4161 }
4162 if let Some(why) = &row.why_not {
4163 ui.weak(format!("({why})"));
4164 } else if row.identity.is_none() {
4165 ui.weak("(the part number is not a file name)");
4166 } else if !row.exists {
4167 ui.weak("(not generated yet: open the family and Generate)");
4168 }
4169 });
4170 }
4171 });
4172 ui.add_space(6.0);
4173 if !self.status.is_empty() {
4174 ui.weak(&self.status);
4175 ui.add_space(4.0);
4176 }
4177 let c = ui.button("Cancel");
4178 self.hit("member:cancel", &c);
4179 cancel = c.clicked();
4180 });
4181 let should_close = modal.should_close();
4182 if let (Some(index), true) = (chosen, pick.plm) {
4183 let row = &pick.rows[index];
4184 if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
4185 self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
4186 self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
4187 }
4188 } else if let Some(index) = chosen {
4189 let row = &pick.rows[index];
4190 let identity = row.identity.clone().unwrap_or_default();
4191 match store.read(&identity) {
4192 Some(contents) => {
4193 self.insert_component_document(state, &identity, &contents);
4194 self.open = false;
4195 return;
4196 }
4197 None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
4198 }
4199 } else if cancel || should_close {
4200 self.status.clear();
4201 self.open = false;
4202 return;
4203 }
4204 self.pick_member = Some(pick);
4205 }
4206
4207 fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4213 let Some(mut spin) = self.spin_out.take() else {
4214 self.open = false;
4215 return;
4216 };
4217 let mut create = false;
4218 let mut cancel = false;
4219 let problems: Vec<Option<String>> = spin
4220 .inputs
4221 .iter()
4222 .map(|input| {
4223 let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
4224 template::value_problem(&spin.template, input, text)
4225 })
4226 .collect();
4227 let name_problem = template::name_problem(&spin.name);
4228 let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
4229 ui.set_width(440.0);
4230 ui.heading(format!("New part from {}", spin.template_file));
4231 ui.add_space(2.0);
4232 ui.weak("A template is never placed itself: this makes a new part from it and places that.");
4233 ui.add_space(6.0);
4234 egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
4235 ui.label("New part name");
4236 let field = ui.add(egui::TextEdit::singleline(&mut spin.name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
4237 self.hit("spinout:name", &field);
4238 ui.end_row();
4239 if spin.plm_template.is_some() {
4240 ui.label("Part number");
4241 let field = ui.add(
4242 egui::TextEdit::singleline(&mut spin.number).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
4243 .hint_text("empty: the part type numbers it")
4244 .desired_width(240.0),
4245 );
4246 self.hit("spinout:number", &field);
4247 ui.end_row();
4248 }
4249 for (input, problem) in spin.inputs.iter().zip(&problems) {
4250 ui.label(input.shown());
4251 ui.vertical(|ui| {
4252 let value = spin.values.entry(input.name.clone()).or_default();
4253 if input.choices.is_empty() {
4254 let field = ui.add(egui::TextEdit::singleline(value).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
4255 self.hit(&format!("spinout:input:{}", input.name), &field);
4256 } else {
4257 let trimmed: Vec<String> = input.choices.iter().map(|choice| choice.trim().to_string()).collect();
4260 let choices: Vec<(String, &str, &str)> = trimmed.iter().map(|choice| (choice.clone(), choice.as_str(), choice.as_str())).collect();
4261 let mut chosen = value.trim().to_string();
4262 let (changed, _) = crate::form::enum_dropdown(ui, &format!("spinout:choice:{}", input.name), &choices, &mut chosen, None, Some(&mut self.hits));
4263 if changed {
4264 *value = chosen;
4265 }
4266 }
4267 let limits = match (input.min, input.max) {
4268 (Some(min), Some(max)) => format!("{min} to {max}"),
4269 (Some(min), None) => format!("at least {min}"),
4270 (None, Some(max)) => format!("at most {max}"),
4271 (None, None) => String::new(),
4272 };
4273 if !limits.is_empty() {
4274 ui.weak(limits);
4275 }
4276 if let Some(problem) = problem {
4277 ui.colored_label(ui.visuals().warn_fg_color, problem);
4278 }
4279 });
4280 ui.end_row();
4281 }
4282 });
4283 if spin.inputs.is_empty() {
4284 ui.weak("This template marks no inputs; the copy is the template as it is.");
4285 }
4286 ui.add_space(4.0);
4287 let file = crate::store::file_name_of(&spin_out_target(&spin));
4288 ui.weak(match &spin.assembly {
4289 _ if spin.plm_template.is_some() => {
4290 "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
4291 }
4292 Some(assembly) => format!(
4293 "Saved as {file}, in the folder of {}",
4294 crate::store::file_name_of(assembly)
4295 ),
4296 None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
4297 });
4298 if let Some(problem) = &name_problem {
4299 ui.colored_label(ui.visuals().warn_fg_color, problem);
4300 }
4301 if !self.status.is_empty() {
4302 ui.weak(&self.status);
4303 }
4304 ui.add_space(6.0);
4305 ui.horizontal(|ui| {
4306 let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
4307 let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
4308 self.hit("spinout:create", &c);
4309 create = c.clicked();
4310 let x = ui.button("Cancel");
4311 self.hit("spinout:cancel", &x);
4312 cancel = x.clicked();
4313 });
4314 });
4315 let should_close = modal.should_close();
4316 if create {
4317 match self.create_spin_out(state, store, &spin) {
4318 Ok(()) => {
4319 self.open = false;
4320 return;
4321 }
4322 Err(error) => self.status = error,
4323 }
4324 } else if cancel || should_close {
4325 self.status.clear();
4326 self.open = false;
4327 return;
4328 }
4329 self.spin_out = Some(spin);
4330 }
4331
4332 fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
4334 if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
4335 let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
4338 self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
4339 let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
4340 .await
4341 .map_err(|e| e.to_string())?;
4342 let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
4343 let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
4344 format!("{} was made, but has no document yet", spun.number)
4345 })?;
4346 Ok((spun.key, text, spun.number))
4347 }))));
4348 return Err("making the part on the PLM\u{2026}".into());
4349 }
4350 if let Some(problem) = template::name_problem(&spin.name) {
4351 return Err(problem);
4352 }
4353 let target = spin_out_target(spin);
4354 if store.read(&target).is_some() {
4355 return Err(format!("'{target}' already exists: choose another name"));
4356 }
4357 let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
4358 let text = copy.to_string();
4359 store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
4360 self.save_generation += 1;
4361 self.insert_component_document(state, &target, &text);
4362 if spin.assembly.is_none() {
4363 self.status = format!(
4364 "{} — saved to the models folder as {target}, because the assembly is not saved yet",
4365 self.status
4366 );
4367 }
4368 Ok(())
4369 }
4370
4371 fn watch_member_edit(&mut self, docs: &Documents) {
4376 let doc = docs.active();
4377 if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
4378 return;
4379 }
4380 if let Some(name) = doc.name().map(str::to_string) {
4381 self.arm_member_prompt(docs, &name, false);
4382 }
4383 }
4384
4385 fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
4386 let Some(source) = family_table::engine_family_source(docs.engine()) else {
4387 return false;
4388 };
4389 self.member_prompt = Some(MemberPrompt {
4390 doc_id: docs.active().id(),
4391 family_identity: sibling_identity(name, &source.family),
4392 source,
4393 from_save,
4394 });
4395 self.status.clear();
4396 self.open_modal(Mode::MemberEdit);
4397 true
4398 }
4399
4400 fn revert_member_edit(docs: &mut Documents) {
4403 for _ in 0..64 {
4404 if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
4405 break;
4406 }
4407 docs.engine_mut().undo();
4408 }
4409 }
4410
4411 fn show_owned_save(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4416 let Some(mut prompt) = self.owned_prompt.take() else {
4417 self.open = false;
4418 return;
4419 };
4420 if docs.active_id() != prompt.plan.doc_id {
4421 self.status = "Save cancelled because the active document changed".into();
4422 self.open = false;
4423 return;
4424 }
4425 let mut choice = None;
4426 let modal = egui::Modal::new(egui::Id::new("brep-file-owned-save")).show(ctx, |ui| {
4427 ui.set_width(520.0);
4428 ui.heading("Save the parts this assembly changed");
4429 ui.add_space(4.0);
4430 ui.label("Saving writes these documents too. Untick any you do not want written; its change stays in the assembly.");
4431 ui.add_space(6.0);
4432 for (i, row) in prompt.plan.rows.iter_mut().enumerate() {
4433 if !row.asks() {
4434 if let Some(crate::document::owned_save::Issue::Blocked(_)) = row.issue {
4435 ui.weak(row.question());
4436 }
4437 continue;
4438 }
4439 let question = row.question();
4440 let tick = ui.checkbox(&mut row.proceed, question);
4441 self.hit(&format!("owned:row:{i}"), &tick);
4442 if row.issue == Some(crate::document::owned_save::Issue::Conflict) && row.proceed {
4443 ui.indent(("owned-conflict", i), |ui| {
4444 let labels: Vec<(String, String)> = row
4447 .write
4448 .variants
4449 .iter()
4450 .enumerate()
4451 .map(|(j, (names, _))| {
4452 let last = if row.edited_last == Some(j) { " (edited last)" } else { "" };
4453 (j.to_string(), format!("keep the copy in {}{last}", names.join(" > ")))
4454 })
4455 .collect();
4456 let choices: Vec<(usize, &str, &str)> = labels.iter().enumerate().map(|(j, (id, label))| (j, id.as_str(), label.as_str())).collect();
4457 crate::form::enum_dropdown(ui, &format!("owned:choice:{i}"), &choices, &mut row.choice, None, Some(&mut self.hits));
4458 });
4459 }
4460 }
4461 if prompt.plan.rows.iter().any(|row| matches!(row.issue, Some(crate::document::owned_save::Issue::CheckOut { .. }))) {
4462 ui.add_space(4.0);
4463 let remember = ui.checkbox(&mut self.checkout_remembered, "Check out without asking again in this session");
4464 self.hit("owned:remember", &remember);
4465 }
4466 for promotion in &prompt.plan.promotions {
4467 let part = promotion.names.last().map_or("", String::as_str);
4468 ui.weak(if promotion.recreates() {
4469 format!("{part}: its file is missing — re-created as {}", promotion.new_key)
4470 } else {
4471 format!("{part}: saved to a new part file {}", promotion.new_key)
4472 });
4473 }
4474 ui.add_space(8.0);
4475 ui.horizontal(|ui| {
4476 let save = ui.button("Save");
4477 self.hit("owned:save", &save);
4478 if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4479 choice = Some(true);
4480 }
4481 let cancel = ui.button("Cancel");
4482 self.hit("owned:cancel", &cancel);
4483 if cancel.clicked() {
4484 choice = Some(false);
4485 }
4486 });
4487 });
4488 let choice = choice.or_else(|| modal.should_close().then_some(false));
4489 self.owned_state = serde_json::json!({"prompt": prompt.plan.to_json()});
4490 match choice {
4491 Some(true) => {
4492 self.open = false;
4493 let OwnedPrompt { plan, name, browser } = prompt;
4494 if browser {
4495 self.commit_save_browser(docs, store, name, plan);
4496 } else {
4497 self.commit_save(docs, store, name, plan);
4498 }
4499 }
4500 Some(false) => {
4501 self.status = "Save cancelled".into();
4502 self.owned_state = serde_json::Value::Null;
4503 self.open = false;
4504 }
4505 None => self.owned_prompt = Some(prompt),
4506 }
4507 }
4508
4509 fn show_retarget(&mut self, ctx: &egui::Context, docs: &mut Documents) {
4514 let Some(retarget) = self.retarget.as_ref() else {
4515 self.open = false;
4516 return;
4517 };
4518 let mut choice = None;
4519 let new_name = model_display_name(&retarget.new_key);
4520 let old_name = model_display_name(&retarget.old);
4521 let titles: Vec<String> = retarget.tabs.iter().map(|(_, title)| title.clone()).collect();
4522 egui::Modal::new(egui::Id::new("brep-file-retarget")).show(ctx, |ui| {
4523 ui.set_width(480.0);
4524 ui.heading("Point the assemblies at the new file?");
4525 ui.label(format!(
4526 "{} {} '{old_name}'. Point {} at '{new_name}' too?",
4527 titles.join(", "),
4528 if titles.len() == 1 { "places" } else { "place" },
4529 if titles.len() == 1 { "it" } else { "them" },
4530 ));
4531 ui.add_space(8.0);
4532 ui.horizontal(|ui| {
4533 let yes = ui.button(format!("Yes, use '{new_name}'"));
4534 self.hit("retarget:yes", &yes);
4535 if yes.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4536 choice = Some(true);
4537 }
4538 let no = ui.button(format!("No, keep '{old_name}'"));
4539 self.hit("retarget:no", &no);
4540 if no.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
4541 choice = Some(false);
4542 }
4543 });
4544 });
4545 let Some(yes) = choice else { return };
4546 let Some(retarget) = self.retarget.take() else { return };
4547 self.open = false;
4548 if !yes {
4549 self.status = format!("kept the assemblies on '{old_name}'");
4550 return;
4551 }
4552 let mut moved = Vec::new();
4553 for (id, title) in &retarget.tabs {
4554 match docs.retarget_copies(*id, &retarget.old, &retarget.new_key, &retarget.text) {
4555 Ok(n) if n > 0 => moved.push(title.clone()),
4556 Ok(_) => {}
4557 Err(error) => docs.engine_mut().push_notice(format!("{title}: could not point it at '{new_name}': {error}")),
4558 }
4559 }
4560 self.status = format!("{} now {} '{new_name}' (unsaved)", moved.join(", "), if moved.len() == 1 { "uses" } else { "use" });
4561 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, self.status.clone());
4562 }
4563
4564 fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4567 let Some(prompt) = self.member_prompt.take() else {
4568 self.open = false;
4569 return;
4570 };
4571 if docs.active().id() != prompt.doc_id {
4572 self.open = false;
4573 return;
4574 }
4575 let mut choice = None;
4576 let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
4577 let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
4578 ui.set_width(440.0);
4579 ui.heading("This part is a family member");
4580 ui.add_space(4.0);
4581 ui.label(format!(
4582 "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
4583 prompt.source.part_number, prompt.source.family
4584 ));
4585 ui.add_space(4.0);
4586 ui.weak(if prompt.from_save {
4587 "Save it as a new part, or open the family and make the change there."
4588 } else {
4589 "Keep the change in a new part, or open the family and make it there."
4590 });
4591 if !self.status.is_empty() {
4592 ui.add_space(4.0);
4593 ui.weak(&self.status);
4594 }
4595 ui.add_space(8.0);
4596 ui.horizontal(|ui| {
4597 let a = ui.button("Save as a new part\u{2026}");
4598 self.hit("member-edit:saveas", &a);
4599 if a.clicked() {
4600 choice = Some("saveas");
4601 }
4602 let offer_family = match &plm {
4604 Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
4605 None => true,
4606 };
4607 if offer_family {
4608 let b = ui.button("Open the family");
4609 self.hit("member-edit:family", &b);
4610 if b.clicked() {
4611 choice = Some("family");
4612 }
4613 }
4614 let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
4615 self.hit("member-edit:cancel", &c);
4616 if c.clicked() {
4617 choice = Some("cancel");
4618 }
4619 });
4620 });
4621 let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
4622 match choice {
4623 Some("saveas") => {
4624 self.member_ack.insert(prompt.doc_id);
4628 self.member_new_part = store.plm_client().is_some();
4629 self.status.clear();
4630 self.dispatch(FileAction::SaveAs, docs, store);
4631 }
4632 Some("family") if plm.is_some() => {
4633 let (client, _) = plm.unwrap_or_else(|| unreachable!());
4634 let number = document_class::strip_class_extension(&prompt.source.family).to_string();
4635 Self::revert_member_edit(docs);
4636 self.status = format!("opening the family {number}\u{2026}");
4637 self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
4638 let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
4639 let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
4641 Ok(crate::plm::identity::document_key(&head.id, &newest.id))
4642 }))));
4643 self.open = false;
4644 }
4645 Some("family") => {
4646 if store.read(&prompt.family_identity).is_none() {
4647 self.status = format!("the family file is not there: {}", prompt.family_identity);
4648 self.member_prompt = Some(prompt);
4649 return;
4650 }
4651 Self::revert_member_edit(docs);
4652 self.open = false;
4653 self.open_document(docs, store, &prompt.family_identity);
4654 }
4655 Some(_) => {
4656 if !prompt.from_save {
4657 Self::revert_member_edit(docs);
4658 }
4659 self.status.clear();
4660 self.open = false;
4661 }
4662 None => self.member_prompt = Some(prompt),
4663 }
4664 }
4665
4666 pub fn generate_active_family(
4676 &mut self,
4677 docs: &mut Documents,
4678 store: &dyn ModelStore,
4679 save: bool,
4680 ) -> Result<family_table::GenerateReport, String> {
4681 let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
4682 let report = match (store.plm_client(), plm_key) {
4683 (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
4684 _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
4685 };
4686 Ok(self.record_generate(report))
4687 }
4688
4689 #[cfg(not(target_arch = "wasm32"))]
4693 fn generate_on_plm(
4694 &mut self,
4695 docs: &mut Documents,
4696 store: &dyn ModelStore,
4697 client: &std::rc::Rc<crate::plm::client::PlmClient>,
4698 key: &str,
4699 save: bool,
4700 ) -> Result<family_table::GenerateReport, String> {
4701 let json = docs.tab_engine().history_request_json();
4702 if docs.active().is_dirty() {
4703 if !save {
4704 return Err(crate::plm::family::UNSAVED_FAMILY.into());
4705 }
4706 store.write(key, &json)?;
4707 docs.active_mut().mark_clean();
4708 self.save_generation += 1;
4709 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
4711 while store.pending_writes() > 0 {
4712 if std::time::Instant::now() > deadline {
4713 return Err("the family's save did not reach the server within 60 s".into());
4714 }
4715 crate::plm::native::run_pending();
4716 std::thread::sleep(std::time::Duration::from_millis(5));
4717 }
4718 if let Some(error) = store.take_persistence_errors().into_iter().next() {
4719 return Err(format!("the family was not saved, so nothing was generated: {error}"));
4720 }
4721 }
4722 crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
4723 .ok_or("the PLM server did not answer Generate within 5 minutes")?
4724 .map_err(|e| e.to_string())
4725 }
4726
4727 #[cfg(target_arch = "wasm32")]
4728 fn generate_on_plm(
4729 &mut self,
4730 _: &mut Documents,
4731 _: &dyn ModelStore,
4732 _: &std::rc::Rc<crate::plm::client::PlmClient>,
4733 _: &str,
4734 _: bool,
4735 ) -> Result<family_table::GenerateReport, String> {
4736 Err("in the browser, Generate a PLM family from the family table's Generate button".into())
4737 }
4738
4739 pub(crate) fn begin_generate_active_family(
4746 &mut self,
4747 docs: &mut Documents,
4748 store: &dyn ModelStore,
4749 save: bool,
4750 ) -> Result<Result<family_table::GenerateReport, String>, String> {
4751 let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
4752 match (store.plm_client(), plm_key) {
4753 (Some(_), Some(key)) => {
4754 if docs.active().is_dirty() {
4755 if !save {
4756 return Err(crate::plm::family::UNSAVED_FAMILY.into());
4757 }
4758 store.write(&key, &docs.tab_engine().history_request_json())?;
4759 docs.active_mut().mark_clean();
4760 self.save_generation += 1;
4761 }
4762 Ok(Err(key))
4763 }
4764 _ => Ok(Ok(self.record_generate(family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json())))),
4765 }
4766 }
4767
4768 pub(crate) fn record_generate_report(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
4769 self.record_generate(report)
4770 }
4771
4772 fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
4773 if report.rows.iter().any(|row| row.status() == "written") {
4774 self.save_generation += 1;
4775 }
4776 self.status = format!("generate: {}", report.summary());
4777 self.last_generate = Some(report.clone());
4778 report
4779 }
4780
4781 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
4786 serde_json::json!({
4787 "name": docs.active().name(),
4788 "nameBuf": self.name_buf,
4789 "selected": self.explorer.selected(),
4790 "location": store.browser_location(),
4791 "folders": Purpose::ALL
4794 .iter()
4795 .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
4796 .collect::<serde_json::Map<_, _>>(),
4797 "dirty": docs.active().dirty_marker(),
4805 "backend": store.backend_label(),
4806 "interchange": store.supports_file_interchange(),
4807 "list": store.list(),
4808 "status": self.status,
4809 "ownedSave": self.owned_state,
4810 "ownedInFlight": self.owned_checkouts.len()
4814 + self.owned_revisions.as_ref().map_or(0, |r| r.requests.len())
4815 + usize::from(self.owned_followup.is_some())
4816 + usize::from(self.owned_top.is_some()),
4817 "retarget": self.retarget.as_ref().map(|r| serde_json::json!({
4818 "from": r.old,
4819 "to": r.new_key,
4820 "tabs": r.tabs.iter().map(|(_, title)| title.clone()).collect::<Vec<_>>(),
4821 })),
4822 "open": self.open,
4823 "mode": match self.mode {
4824 Mode::CreateComponentPart => "createcomponentpart",
4825 Mode::Open => "open",
4826 Mode::SaveAs => "saveas",
4827 Mode::ConfirmClose => "confirmclose",
4828 Mode::Export => "export",
4829 Mode::Import => "import",
4830 Mode::FlatPattern => "flatpattern",
4831 Mode::InsertComponent => "insertcomponent",
4832 Mode::StepAssembly => "stepassembly",
4833 Mode::Kicad => "kicad",
4834 Mode::PickMember => "pickmember",
4835 Mode::SpinOut => "spinout",
4836 Mode::MemberEdit => "memberedit",
4837 Mode::OwnedSave => "ownedsave",
4838 Mode::Retarget => "retarget",
4839 Mode::PickFile => "pickfile",
4840 Mode::SaveFile => "savefile",
4841 },
4842 "saveFile": self.save_file.as_ref().map(|pending| serde_json::json!({
4845 "title": pending.request.title,
4846 "name": self.save_name,
4847 "folder": store.local_files().map(|local| local.browser_location()),
4848 "bytes": pending.request.body.as_bytes().len(),
4849 })),
4850 "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
4851 "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
4852 "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
4853 })),
4854 "class": document_class::document_class(docs.engine()).slug(),
4857 "saveClass": self.save_class.slug(),
4858 "plmSaveAs": self.plm_save_as.as_ref().map(|state| state.state_json()),
4859 "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
4860 "family": source.family,
4861 "partNumber": source.part_number,
4862 "valuesHash": source.values_hash,
4863 "historyHash": source.history_hash,
4864 })),
4865 "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
4868 "family": pick.family_file,
4869 "plm": pick.plm,
4870 "loading": pick.loading,
4871 "rows": pick.rows.iter().map(|row| serde_json::json!({
4872 "partNumber": row.part_number,
4873 "identity": row.identity,
4874 "exists": row.exists,
4875 "whyNot": row.why_not,
4876 })).collect::<Vec<_>>(),
4877 })),
4878 "plmBusy": self.plm_work.is_some(),
4879 "workspaceProblem": self.workspace.problem_text(),
4881 "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
4884 "template": spin.template_file,
4885 "name": spin.name,
4886 "values": spin.values,
4887 "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
4888 "target": spin_out_target(spin),
4889 "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
4890 &spin.template,
4891 input,
4892 spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
4893 )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
4894 })),
4895 "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
4896 "partNumber": prompt.source.part_number,
4897 "family": prompt.source.family,
4898 "familyIdentity": prompt.family_identity,
4899 "fromSave": prompt.from_save,
4900 })),
4901 "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
4902 "summary": report.summary(),
4903 "rows": report.to_json(),
4904 })),
4905 "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
4906 "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
4912 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
4913 serde_json::json!({
4914 "name": pending.name,
4915 "parts": pending.probe.parts,
4916 "instances": pending.probe.instances,
4917 "nestedDepth": pending.probe.nested_depth,
4918 "flatten": pending.flatten,
4921 })
4922 }),
4923 })
4924 .to_string()
4925 }
4926
4927 pub fn hits_json(&self) -> String {
4929 crate::automation::hit_rects::hits_json(&self.hits)
4930 }
4931
4932 fn hit(&mut self, key: &str, resp: &egui::Response) {
4935 self.hits.insert(key.to_string(), resp.rect);
4936 }
4937
4938 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
4939 self.hits.extend(hits.iter().cloned());
4940 }
4941}
4942
4943
4944fn slug(name: &str) -> String {
4948 let cleaned: String = name
4949 .chars()
4950 .map(|c| if c.is_alphanumeric() { c } else { '-' })
4951 .collect();
4952 let trimmed = cleaned.trim_matches('-').to_string();
4953 if trimmed.is_empty() { "sheet".into() } else { trimmed }
4954}
4955pub static HIT_KEYS: &[HitKeyDoc] = &[
4957 HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
4958 HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
4959 HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
4960 HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
4961 HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
4962 HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:iges, export:stl, export:obj, export:glb, export:3mf, 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; for manufacturing instructions, the printed PDF as export:instructionspdf, every step's PNG and SVG as one zip as export:instructionsimages, and the self-contained offline player as export:instructionshtml)", command: None },
4963 HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
4964 HitKeyDoc { panel: "file", prefix: "savefile:", meaning: "the Save-file chooser a desktop export opens after its format click, for WHERE the file goes: savefile:name is the file name field, and its explorer over this machine's files publishes the explorer's key set under the `savefile` prefix (savefile:<file name> a row to overwrite, savefile:crumb:<i>, savefile:place:<label>, savefile:up, savefile:path-edit, …); `save` writes the file where the explorer is, `cancel` drops it. In the browser an export is a download and the chooser never opens", command: None },
4965 HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
4966 HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
4967 HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
4968 HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "PLM Save As: saveas:destination (one dropdown; its open entries saveas:destination#new-revision, #new-part, #existing), destination fields (part, revision, name, number), save and cancel", command: None },
4969 HitKeyDoc { panel: "file", prefix: "saveas:class", meaning: "Save As's class dropdown: saveas:class, its open entries saveas:class#normal (.nbrep), saveas:class#family (.fbrep), saveas:class#template (.tbrep)", command: None },
4970 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 },
4971 HitKeyDoc { panel: "file", prefix: "spinout:", meaning: "the template spin-out prompt: spinout:name, spinout:input:<name> (a value field), spinout:choice:<name> (a fixed-list input's dropdown; its open entries spinout:choice:<name>#<value>), spinout:create, spinout:cancel", command: None },
4972 HitKeyDoc { panel: "file", prefix: "retarget:", meaning: "after a part is saved under a new name, point the open assemblies that place it at the new file (retarget:yes, the default) or keep them on the old one (retarget:no)", command: None },
4973 HitKeyDoc { panel: "file", prefix: "owned:", meaning: "the assembly Save's questions about the documents it owns: owned:save (save, every ticked row), owned:cancel (save nothing), owned:row:<i> (tick/untick row i), owned:choice:<i> (which copy of a document changed differently to keep, a dropdown; its open entries owned:choice:<i>#<j>), owned:remember (check out without asking again in this session)", command: None },
4974 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 },
4975 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") },
4976 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 },
4977];
4978
4979fn fetch_document(
4981 client: std::rc::Rc<crate::plm::client::PlmClient>,
4982 key: String,
4983 number: String,
4984) -> crate::panels::plm_family::Pending<(String, String, String)> {
4985 crate::panels::plm_family::Pending::new(Box::pin(async move {
4986 let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
4987 let text = bytes
4988 .map(|b| String::from_utf8_lossy(&b).into_owned())
4989 .ok_or_else(|| format!("{key} has no document yet"))?;
4990 Ok((key, text, number))
4991 }))
4992}
4993
4994use crate::store::RECENT_DOCUMENTS_KEY;
4996pub fn recent_documents(store: &dyn ModelStore) -> Vec<String> {
4997 store
4998 .read(RECENT_DOCUMENTS_KEY)
4999 .and_then(|s| serde_json::from_str(&s).ok())
5000 .unwrap_or_default()
5001}
5002fn remember_document(store: &dyn ModelStore, name: &str) {
5003 let mut recent = recent_documents(store);
5004 recent.retain(|item| item != name);
5005 recent.insert(0, name.into());
5006 recent.truncate(20);
5007 let _ = store.write(
5008 RECENT_DOCUMENTS_KEY,
5009 &serde_json::to_string(&recent).unwrap(),
5010 );
5011}
5012
5013#[derive(Default)]
5014pub struct FileMenuOutcome {
5015 pub action: Option<FileAction>,
5016 pub recent: Option<String>,
5017 pub plugins: bool,
5019 pub javascript: bool,
5021}
5022
5023pub const RECENT_THUMB_SIDE: f32 = crate::plm::thumbnail::RECENT_SIDE;
5025
5026const RECENT_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect x="1.5" y="1.5" width="37" height="37" rx="4" fill="#8a8a8a" fill-opacity="0.16" stroke="#8a8a8a" stroke-opacity="0.45"/></svg>"##;
5030
5031pub fn file_menu(
5033 ui: &mut egui::Ui,
5034 _state: &mut EngineState,
5035 store: &dyn ModelStore,
5036 settings_open: &mut bool,
5037 properties_open: &mut bool,
5038 hits: &mut HashMap<String, egui::Rect>,
5039) -> FileMenuOutcome {
5040 use crate::panels::toolbar_button;
5041 let mut outcome = FileMenuOutcome::default();
5042 let presentation = crate::workbench::file_presentation;
5043 egui_extras::install_image_loaders(ui.ctx());
5044 let menu = ui.menu_button("File", |ui| {
5045 let file_rows: Vec<_> = crate::workbench::FILE_COMMANDS
5046 .iter()
5047 .map(|p| (p.glyph, p.label()))
5048 .collect();
5049 let compact = crate::viewport::touch::compact(ui.ctx());
5050 let width = toolbar_button::menu_width(ui, &file_rows)
5051 .min((ui.ctx().content_rect().width() - 32.0).max(100.0));
5052 let right_width = width;
5053 ui.set_width(width);
5054 let max_height = ui.ctx().content_rect().height() * 0.8;
5055 ui.set_max_height(max_height);
5056 let cap_bottom = ui.max_rect().bottom();
5061 let body = egui::ScrollArea::vertical()
5062 .id_salt("file-menu-body")
5063 .auto_shrink([false, true])
5064 .max_height(max_height)
5065 .show(ui, |ui| {
5066 let columns = |ui: &mut egui::Ui| {
5067 ui.vertical(|ui| {
5068 ui.set_width(right_width);
5069 ui.weak("Document");
5070 for (class, p) in DocumentClass::ALL
5071 .into_iter()
5072 .zip(crate::workbench::NEW_DOCUMENT_COMMANDS)
5073 {
5074 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5075 hits.insert(p.id.into(), row.rect);
5076 if class == DocumentClass::Normal {
5077 hits.insert("file:new".into(), row.rect);
5078 hits.insert("file:newmenu".into(), row.rect);
5079 }
5080 if row.clicked() {
5081 outcome.action = Some(FileAction::NewOfClass(class));
5082 ui.close();
5083 }
5084 }
5085 for (id, action) in [
5086 ("file:open", FileAction::Open),
5087 ("file:save", FileAction::Save),
5088 ("file:saveas", FileAction::SaveAs),
5089 ("file:import", FileAction::Import),
5090 ("file:export", FileAction::Export),
5091 ] {
5092 let p = presentation(id);
5093 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5094 hits.insert(id.into(), row.rect);
5095 if row.clicked() {
5096 outcome.action = Some(action);
5097 ui.close();
5098 }
5099 }
5100 let properties = toolbar_button::menu_row(
5101 ui,
5102 presentation("properties").glyph,
5103 presentation("properties").label(),
5104 *properties_open,
5105 right_width,
5106 );
5107 hits.insert("properties".into(), properties.rect);
5108 if properties.clicked() {
5109 *properties_open = !*properties_open;
5110 ui.close();
5111 }
5112 let settings = toolbar_button::menu_row(
5113 ui,
5114 presentation("settings").glyph,
5115 presentation("settings").label(),
5116 *settings_open,
5117 right_width,
5118 );
5119 hits.insert("settings".into(), settings.rect);
5120 if settings.clicked() {
5121 *settings_open = !*settings_open;
5122 ui.close();
5123 }
5124 for (id, flag) in [
5127 ("workbench:manage:plugins", &mut outcome.plugins),
5128 ("workbench:manage:javascript", &mut outcome.javascript),
5129 ] {
5130 let row = toolbar_button::menu_row(
5131 ui,
5132 presentation(id).glyph,
5133 presentation(id).label(),
5134 false,
5135 right_width,
5136 );
5137 hits.insert(id.into(), row.rect);
5138 if row.clicked() {
5139 *flag = true;
5140 ui.close();
5141 }
5142 }
5143 });
5144 };
5145 if compact { ui.vertical(columns); } else { ui.horizontal_top(columns); }
5146
5147 let recent = recent_documents(store);
5150 if !recent.is_empty() {
5151 ui.separator();
5152 let caption = ui.weak("Recent Documents");
5153 hits.insert("file:recent".into(), caption.rect);
5154 let thumbs = crate::plm::thumbnail::recent_textures(ui.ctx(), store);
5155 let side = egui::Vec2::splat(RECENT_THUMB_SIDE);
5156 let row_height = RECENT_THUMB_SIDE + 2.0 * ui.spacing().button_padding.y;
5157 let room = cap_bottom - ui.cursor().top();
5160 let list_height = room.max(2.0 * row_height + ui.spacing().item_spacing.y);
5161 egui::ScrollArea::vertical()
5162 .id_salt("file-recent")
5163 .auto_shrink([false, true])
5164 .max_height(list_height)
5165 .show(ui, |ui| {
5166 for name in &recent {
5167 let image = match thumbs.get(name) {
5168 Some((_, texture)) => egui::Image::from_texture((texture.id(), side)),
5169 None => egui::Image::new(egui::ImageSource::Bytes {
5170 uri: "bytes://brep-recent-thumb/placeholder.svg".into(),
5171 bytes: egui::load::Bytes::Static(RECENT_PLACEHOLDER_SVG.as_bytes()),
5172 }),
5173 }
5174 .fit_to_exact_size(side);
5175 let row = ui
5176 .add(
5177 egui::Button::new((image, model_display_name(name)))
5178 .wrap_mode(egui::TextWrapMode::Truncate)
5179 .min_size(egui::vec2(width, row_height)),
5180 )
5181 .on_hover_text(name);
5182 hits.insert(format!("file:recent:{name}"), row.rect);
5183 let padding = ui.spacing().button_padding;
5184 let thumb = egui::Rect::from_min_size(
5185 row.rect.min + egui::vec2(padding.x, (row.rect.height() - RECENT_THUMB_SIDE) / 2.0),
5186 side,
5187 );
5188 hits.insert(format!("file:recent:thumb:{name}"), thumb);
5189 if row.clicked() {
5190 outcome.recent = Some(name.clone());
5191 ui.close();
5192 }
5193 }
5194 });
5195 }
5196 });
5197 if compact { hits.insert("file:clip".into(), body.inner_rect); }
5198 });
5199 hits.insert("file:menu".into(), menu.response.rect);
5200 outcome
5201}
5202
5203
5204pub fn linked_tool_ids(engine: &EngineState) -> Vec<String> {
5207 engine
5208 .instructions()
5209 .ok()
5210 .flatten()
5211 .map(|block| block.tools_list().into_iter().filter_map(|(tool, _)| tool.tool_id).collect())
5212 .unwrap_or_default()
5213}