1mod plm_save_as;
30
31use crate::automation::hit_keys::HitKeyDoc;
32use crate::document::{Document, Documents};
33use crate::document_class::{self, DocumentClass};
34use crate::family_table::{self, FamilySource};
35use crate::template::{self, TemplateInput};
36use crate::panels::parts_library::document_signature;
37use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
38use crate::panels::kicad_import::{self, DiskFiles, KicadImport, KicadKind};
39use crate::store::{model_display_name, sibling_identity, ModelStore, PlaceKind, MODEL_EXTENSIONS};
40use brep_render::engine_state::{
41 ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
42 StepAssemblyReport, StepProbeOutcome,
43};
44use brep_render::runner::MeshImportFormat;
45use eframe::egui;
46use std::collections::{BTreeMap, HashMap, HashSet};
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FileAction {
52 New,
53 NewOfClass(DocumentClass),
56 Open,
57 Save,
58 SaveAs,
59 Import,
62 Export,
65 ExportFlatPattern,
68 InsertComponent,
74 ImportKicad(KicadKind),
79}
80
81#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84 CreateComponentPart,
86 Open,
88 SaveAs,
90 ConfirmClose,
94 Export,
96 Import,
98 FlatPattern,
100 InsertComponent,
103 StepAssembly,
107 Kicad,
110 PickMember,
113 SpinOut,
116 MemberEdit,
119 PickFile,
123 OwnedSave,
127 Retarget,
130 SaveFile,
136}
137
138#[derive(Clone, Debug, PartialEq)]
142pub struct SaveRequest {
143 pub title: String,
145 pub file_name: String,
148 pub body: SaveBody,
149}
150
151#[derive(Clone, Debug, PartialEq)]
155pub enum SaveBody {
156 Text(String),
157 Bytes(Vec<u8>),
158}
159
160impl SaveBody {
161 fn as_bytes(&self) -> &[u8] {
162 match self {
163 SaveBody::Text(text) => text.as_bytes(),
164 SaveBody::Bytes(bytes) => bytes,
165 }
166 }
167}
168
169impl SaveRequest {
170 pub fn text(title: impl Into<String>, file_name: impl Into<String>, text: String) -> Self {
171 Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Text(text) }
172 }
173
174 pub fn bytes(title: impl Into<String>, file_name: impl Into<String>, bytes: Vec<u8>) -> Self {
175 Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Bytes(bytes) }
176 }
177}
178
179struct PendingSave {
184 request: SaveRequest,
185 detail: String,
187 label: Option<String>,
191 warnings: Vec<String>,
195 preview: bool,
198}
199
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202enum Delivery {
203 Done,
205 Chooser,
207 Failed,
209}
210
211const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
213
214#[derive(Clone, Debug, PartialEq)]
216pub struct PickedFile {
217 pub name: String,
219 pub path: Option<String>,
221 pub bytes: Vec<u8>,
222}
223
224#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
230enum Purpose {
231 Models,
233 Imports,
235 Kicad,
237 Exports,
239}
240
241impl Purpose {
242 const ALL: [Purpose; 4] = [Purpose::Models, Purpose::Imports, Purpose::Kicad, Purpose::Exports];
243
244 fn slug(self) -> &'static str {
245 match self {
246 Purpose::Models => "models",
247 Purpose::Imports => "imports",
248 Purpose::Kicad => "kicad",
249 Purpose::Exports => "exports",
250 }
251 }
252}
253
254impl Mode {
255 fn purpose(self) -> Option<Purpose> {
257 match self {
258 Mode::Open | Mode::SaveAs | Mode::InsertComponent | Mode::CreateComponentPart => {
259 Some(Purpose::Models)
260 }
261 Mode::Import => Some(Purpose::Imports),
262 Mode::Kicad => Some(Purpose::Kicad),
263 Mode::SaveFile => Some(Purpose::Exports),
264 Mode::ConfirmClose
265 | Mode::Export
266 | Mode::FlatPattern
267 | Mode::StepAssembly
268 | Mode::PickMember
269 | Mode::SpinOut
270 | Mode::MemberEdit
271 | Mode::OwnedSave
272 | Mode::Retarget
273 | Mode::PickFile => None,
274 }
275 }
276}
277
278fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
284 match purpose {
285 Purpose::Imports | Purpose::Kicad | Purpose::Exports => store.local_files().unwrap_or(store),
286 Purpose::Models => store,
287 }
288}
289
290fn models_folder(store: &dyn ModelStore) -> Option<String> {
294 store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
295}
296
297struct PendingStepImport {
307 name: String,
309 text: String,
312 probe: StepAssemblyProbe,
314 flatten: bool,
319}
320
321struct PendingStepProbe {
327 id: u64,
329 name: String,
330 text: String,
332}
333
334#[derive(Clone, Copy, PartialEq, Eq)]
336enum StepChoice {
337 Assembly,
339 Bodies,
341 Cancel,
343}
344
345const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
348
349struct MemberChoice {
351 part_number: String,
352 description: String,
353 identity: Option<String>,
356 exists: bool,
358 why_not: Option<String>,
361}
362
363struct PickMember {
365 family_file: String,
367 rows: Vec<MemberChoice>,
368 plm: bool,
372 loading: bool,
373}
374
375struct SpinOut {
378 template_file: String,
379 template: serde_json::Value,
380 inputs: Vec<TemplateInput>,
381 name: String,
383 values: BTreeMap<String, String>,
385 assembly: Option<String>,
388 plm_template: Option<String>,
391 number: String,
392}
393
394enum PlmWork {
397 Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
399 Place(crate::panels::plm_family::Pending<(String, String, String)>),
402 OpenFamily(crate::panels::plm_family::Pending<String>),
404}
405
406struct Retarget {
408 old: String,
410 new_key: String,
411 text: String,
413 tabs: Vec<(u64, String)>,
414}
415
416struct OwnedPrompt {
418 plan: crate::document::owned_save::OwnedSavePlan,
419 name: String,
421 browser: bool,
423}
424
425struct MemberPrompt {
427 doc_id: u64,
428 source: FamilySource,
429 family_identity: String,
431 from_save: bool,
433}
434
435pub(crate) fn stamp_save_as_lineage(docs: &mut Documents, store: &dyn ModelStore, new_name: &str) {
444 let Some(old) = docs.active().name().map(str::to_string) else { return };
445 let old = store.canonical_identity(&old);
446 if old == store.canonical_identity(new_name) {
447 return;
448 }
449 let engine = docs.tab_engine_mut();
450 let mut document = serde_json::json!({});
451 for key in [brep_render::engine_state::replace_component::DERIVED_FROM_KEY, family_table::FAMILY_SOURCE_KEY] {
452 if let Some(block) = engine.history.document_block(key) {
453 document[key] = block.clone();
454 }
455 }
456 brep_render::engine_state::replace_component::stamp_derived_from(&mut document, &old);
457 engine.history.set_document_block_no_undo(
458 brep_render::engine_state::replace_component::DERIVED_FROM_KEY,
459 Some(document[brep_render::engine_state::replace_component::DERIVED_FROM_KEY].clone()),
460 );
461}
462
463fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
464 document_class::set_document_class(engine, class);
465 let drop: &[&str] = match class {
466 DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
467 DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
468 DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
469 DocumentClass::Instructions => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
470 };
471 for key in drop {
472 if engine.history.document_block(key).is_some() {
473 engine.history.set_document_block_no_undo(key, None);
474 }
475 }
476}
477
478fn spin_out_target(spin: &SpinOut) -> String {
481 let file = DocumentClass::Normal.file_name(spin.name.trim());
482 match &spin.assembly {
483 Some(assembly) => sibling_identity(assembly, &file),
484 None => file,
485 }
486}
487
488fn plural(count: usize) -> &'static str {
491 if count == 1 {
492 ""
493 } else {
494 "s"
495 }
496}
497
498fn file_stem_of_title(title: &str) -> String {
505 let stem: String = title.chars().map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '-' } else { c }).collect();
506 stem.trim_matches(|c: char| c == '.' || c == ' ' || c == '-').to_string()
507}
508
509fn step_document_name(file_name: &str) -> String {
510 let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
511 let cut = base
512 .rfind('.')
513 .filter(|dot| is_step_name(&base[*dot..]))
514 .unwrap_or(base.len());
515 let stem = &base[..cut];
516 if stem.is_empty() {
517 base.to_string()
518 } else {
519 stem.to_string()
520 }
521}
522
523struct StorePartSink<'a> {
571 store: &'a dyn ModelStore,
572 prefix: String,
574 taken: std::collections::HashSet<String>,
578 written: usize,
579 failures: Vec<String>,
580 plm: bool,
586 deferred: usize,
587}
588
589impl<'a> StorePartSink<'a> {
590 fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
591 Self {
592 store,
593 prefix: sanitize_file_stem(prefix),
594 taken: std::collections::HashSet::new(),
595 written: 0,
596 failures: Vec::new(),
597 plm: store.plm_client().is_some(),
598 deferred: 0,
599 }
600 }
601}
602
603impl PartSink for StorePartSink<'_> {
604 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
605 if self.plm {
606 self.deferred += 1;
607 return None;
608 }
609 let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
610 let mut name = base.clone();
611 let mut suffix = 2;
612 while !self.taken.insert(name.clone()) {
613 name = format!("{base}-{suffix}");
614 suffix += 1;
615 }
616 match self.store.browser_write(&name, document_json) {
617 Ok(identity) => {
618 self.written += 1;
619 Some(identity)
620 }
621 Err(error) => {
622 self.failures.push(format!("{name}: {error}"));
623 None
624 }
625 }
626 }
627}
628
629fn top_checkout_row(docs: &Documents, store: &dyn ModelStore, name: &str) -> Option<crate::document::owned_save::Planned> {
639 use crate::document::owned_save::{Issue, OwnedWrite, Planned};
640 let client = store.plm_client()?;
641 let (part, revision) = crate::panels::plm_host::revision_of(name)?;
642 let doc = docs.active();
643 if matches!(doc.save_access(), crate::document::Access::Editable) && doc.has_plm_save_access() {
644 return None;
645 }
646 let viewer = client.can_author() == Some(false);
647 let issue = match store.plm_revision(&crate::plm::identity::document_key(&part, &revision)) {
648 Some(entry) if entry.editable && entry.locked_by_me => return None,
649 _ if viewer => Issue::Blocked(crate::document::owned_save::VIEW_ONLY.into()),
650 Some(entry) if !entry.editable => {
651 Issue::Blocked(format!("PLM revision {} is {} and not editable", entry.revision_label, entry.lifecycle))
652 }
653 Some(entry) if entry.locked_by.is_some() => Issue::Blocked(format!("checked out by {}", entry.locked_by.unwrap_or_default())),
654 _ => Issue::CheckOut { part, revision, newer: false },
655 };
656 let write = OwnedWrite { key: name.to_string(), names: Vec::new(), depth: 0, text: String::new(), synced: String::new(), variants: Vec::new() };
657 Some(Planned { write, issue: Some(issue), proceed: true, choice: 0, edited_last: None })
658}
659
660fn write_late_marks(docs: &mut Documents, store: &dyn ModelStore, id: u64, was_clean: bool) {
665 if let Err(error) = docs.write_unsaved_marks(store, id, was_clean) {
666 docs.engine_mut().push_notice(format!("Save the assembly again: the change it keeps could not be marked in it ({error})"));
667 }
668}
669
670fn plm_promotion_requests(current: &serde_json::Value, plan: &crate::document::owned_save::OwnedSavePlan) -> Vec<(String, String, String)> {
675 let mut requests: Vec<(String, String, String)> = Vec::new();
676 for promotion in &plan.promotions {
677 if requests.iter().any(|(key, ..)| *key == promotion.new_key) {
678 continue;
679 }
680 let mut document = current;
681 for name in &promotion.names {
682 document = &document["partsLibrary"][name]["document"];
683 }
684 let part = promotion.names.last().cloned().unwrap_or_default();
685 requests.push((promotion.new_key.clone(), part, crate::plm::promote::part_type_of(document).to_string()));
686 }
687 requests
688}
689
690fn assign_plm_keys(plan: &mut crate::document::owned_save::OwnedSavePlan, keys: &[String]) {
692 let mut order: Vec<String> = Vec::new();
693 for promotion in &plan.promotions {
694 if !order.contains(&promotion.new_key) {
695 order.push(promotion.new_key.clone());
696 }
697 }
698 for promotion in &mut plan.promotions {
699 if let Some(key) = order.iter().position(|k| *k == promotion.new_key).and_then(|i| keys.get(i)) {
700 promotion.new_key = key.clone();
701 }
702 }
703}
704
705pub(crate) fn sanitize_file_stem(name: &str) -> String {
706 let mut out = String::with_capacity(name.len());
707 for ch in name.chars() {
708 if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
709 out.push(ch);
710 } else if !out.ends_with('_') {
711 out.push('_');
712 }
713 }
714 let trimmed = out.trim_matches('_');
715 if trimmed.is_empty() {
716 "part".to_string()
717 } else {
718 trimmed.to_string()
719 }
720}
721
722fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
723 let mut message = format!(
724 "imported {name} \u{2014} {} part{}, {} component{}",
725 report.parts,
726 plural(report.parts),
727 report.instances,
728 plural(report.instances)
729 );
730 if report.baked_nonrigid > 0 {
731 message.push_str(&format!(
732 " ({} mirrored instance{} baked)",
733 report.baked_nonrigid,
734 plural(report.baked_nonrigid)
735 ));
736 }
737 if report.failed_products > 0 {
738 message.push_str(&format!(
739 "; {} product{} could not be built",
740 report.failed_products,
741 plural(report.failed_products)
742 ));
743 }
744 if report.flat_fallback {
745 message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
746 }
747 if let Some(error) = &report.first_error {
748 message.push_str(&format!(" [{error}]"));
749 }
750 message
751}
752
753fn is_step_name(name: &str) -> bool {
758 let lower = name.to_ascii_lowercase();
759 lower.ends_with(".step") || lower.ends_with(".stp")
760}
761
762fn is_iges_name(name: &str) -> bool {
765 let lower = name.to_ascii_lowercase();
766 lower.ends_with(".iges") || lower.ends_with(".igs")
767}
768
769fn is_stl_name(name: &str) -> bool {
770 name.to_ascii_lowercase().ends_with(".stl")
771}
772
773fn is_obj_name(name: &str) -> bool {
774 name.to_ascii_lowercase().ends_with(".obj")
775}
776
777fn is_3mf_name(name: &str) -> bool {
780 name.to_ascii_lowercase().ends_with(".3mf")
781}
782
783mod component_part;
784
785struct OwnedRevisions {
787 doc: u64,
789 requests: Vec<crate::document::owned_save::CheckoutRequest>,
791 future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>>,
793}
794
795struct OwnedFollowup {
797 doc: u64,
798 keys: Vec<String>,
800 polls: u32,
803}
804
805struct PendingInstructionsPdf {
806 document: u64,
807 name: String,
808 preview: bool,
809 job: brep_render::engine_state::InstructionsPdfExport,
810}
811
812pub struct FileDialog {
815 pending_open: Option<(String, u64)>,
818 pending_insert: Option<(String, u64)>,
820 component_part: Option<component_part::Recovery>,
821 explorer: FileExplorer,
823 workspace: crate::panels::plm_workspace::WorkspaceBrowser,
826 name_buf: String,
828 pub(crate) status: String,
830 open: bool,
832 mode: Mode,
834 want_focus: bool,
836 pending_component_import: bool,
840 pending_close: Option<usize>,
842 pending_step_import: Option<PendingStepImport>,
847 pending_step_probe: Option<PendingStepProbe>,
850 pending_instructions_export: Option<String>,
853 instructions_pdf: Option<PendingInstructionsPdf>,
855 pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
858 save_generation: u64,
862 plm_save_as: Option<plm_save_as::SaveAs>,
863 member_new_part: bool,
867 hits: HashMap<String, egui::Rect>,
869 kicad: KicadImport,
871 folders: HashMap<Purpose, String>,
874 pick_request: Option<(String, String)>,
877 pick_folder: Option<String>,
878 picked: Option<(String, PickedFile)>,
879 plm_new_part: bool,
882 save_class: DocumentClass,
884 pick_member: Option<PickMember>,
886 plm_work: Option<PlmWork>,
887 spin_out: Option<SpinOut>,
889 member_prompt: Option<MemberPrompt>,
891 owned_prompt: Option<OwnedPrompt>,
894 owned_state: serde_json::Value,
897 owned_checkouts: Vec<(crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
900 owned_revisions: Option<OwnedRevisions>,
904 owned_followup: Option<OwnedFollowup>,
908 #[allow(clippy::type_complexity)]
917 owned_promoting: Option<(OwnedPrompt, crate::plm::PlmFuture<(Vec<String>, Option<(String, String)>)>)>,
918 owned_loading: Option<(u64, String, Vec<String>, u32)>,
922 owned_loaded: bool,
925 owned_top: Option<(u64, String, crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
929 retarget: Option<Retarget>,
932 checkout_remembered: bool,
935 member_ack: HashSet<u64>,
938 last_generate: Option<family_table::GenerateReport>,
940 save_file: Option<PendingSave>,
944 save_name: String,
945}
946
947impl FileDialog {
948 pub fn new() -> Self {
951 Self {
952 pending_open: None,
953 pending_insert: None,
954 component_part: None,
955 explorer: FileExplorer::new(),
956 workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
957 name_buf: String::new(),
958 status: String::new(),
959 open: false,
960 mode: Mode::Open,
961 want_focus: false,
962 pending_component_import: false,
963 pending_close: None,
964 pending_step_import: None,
965 pending_step_probe: None,
966 pending_instructions_export: None,
967 instructions_pdf: None,
968 pending_stl_import: None,
969 save_generation: 0,
970 plm_save_as: None,
971 member_new_part: false,
972 hits: HashMap::new(),
973 kicad: KicadImport::new(),
974 folders: HashMap::new(),
975 pick_request: None,
976 pick_folder: None,
977 picked: None,
978 plm_new_part: false,
979 save_class: DocumentClass::Normal,
980 pick_member: None,
981 plm_work: None,
982 spin_out: None,
983 member_prompt: None,
984 owned_prompt: None,
985 owned_state: serde_json::Value::Null,
986 owned_checkouts: Vec::new(),
987 owned_revisions: None,
988 owned_followup: None,
989 owned_promoting: None,
990 owned_loading: None,
991 owned_loaded: false,
992 owned_top: None,
993 retarget: None,
994 checkout_remembered: false,
995 member_ack: HashSet::new(),
996 last_generate: None,
997 save_file: None,
998 save_name: String::new(),
999 }
1000 }
1001
1002 pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
1005 let mut out = Vec::new();
1006 if let Some(export) = &self.instructions_pdf {
1007 let (done, total) = export.job.progress();
1008 out.push(crate::panels::busy::Activity::new("instructionsPdf", "instructionsPdf", format!("Preparing instructions PDF: {done}/{total} pictures")));
1009 }
1010 if self.owned_loading.is_some() {
1011 out.push(crate::panels::busy::Activity::new("saveReading", "saveReading", "Reading parts from the PLM before saving"));
1012 }
1013 if self.kicad.reading() {
1014 out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
1015 }
1016 out
1017 }
1018
1019 pub fn instructions_pdf_pending(&self) -> bool {
1020 self.instructions_pdf.is_some()
1021 }
1022
1023 pub fn save_generation(&self) -> u64 {
1026 self.save_generation
1027 }
1028
1029 pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
1035 if matches!(action, FileAction::New | FileAction::NewOfClass(_) | FileAction::Open | FileAction::Import) {
1036 self.pending_open = None;
1037 self.pending_insert = None;
1038 }
1039 match action {
1040 FileAction::New => self.new_document(docs, DocumentClass::Normal),
1041 FileAction::NewOfClass(class) => self.new_document(docs, class),
1042 FileAction::Open => {
1043 self.use_folder(Purpose::Models, store);
1044 self.open_modal(Mode::Open);
1045 }
1046 FileAction::Save => match docs.active().name().map(str::to_string) {
1047 Some(name) if self.arm_member_prompt(docs, &name, true) => {}
1050 Some(name) => {
1054 let _ = self.save_to(docs, store, name);
1055 }
1056 None => self.dispatch(FileAction::SaveAs, docs, store),
1058 },
1059 FileAction::SaveAs => {
1060 self.name_buf = Self::proposed_name(docs);
1061 self.save_class = document_class::document_class(docs.engine());
1062 self.use_folder(Purpose::Models, store);
1063 self.open_modal(Mode::SaveAs);
1064 }
1065 FileAction::Import => {
1066 self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
1067 self.open_modal(Mode::Import);
1068 }
1069 FileAction::Export => {
1072 self.name_buf = Self::proposed_name(docs);
1073 self.open_modal(Mode::Export);
1074 }
1075 FileAction::ExportFlatPattern => {
1076 self.name_buf = Self::proposed_name(docs);
1077 self.open_modal(Mode::FlatPattern);
1078 }
1079 FileAction::InsertComponent => {
1084 self.use_folder(Purpose::Models, store);
1085 self.open_modal(Mode::InsertComponent);
1086 }
1087 FileAction::ImportKicad(kind) => {
1088 self.kicad.start(kind, store);
1089 self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
1090 self.open_modal(Mode::Kicad);
1091 }
1092 }
1093 }
1094
1095 pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
1102 self.picked = None;
1103 if let Some(local) = store.local_files() {
1104 let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
1105 if !arrived {
1106 let _ = local.browser_home();
1107 }
1108 self.pick_request = Some((tag.to_string(), title.to_string()));
1109 self.status.clear();
1110 self.open_modal(Mode::PickFile);
1111 return Ok(());
1112 }
1113 store.begin_pick_file()?;
1114 self.pick_request = Some((tag.to_string(), title.to_string()));
1115 Ok(())
1116 }
1117
1118 pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
1120 if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
1121 return self.picked.take().map(|(_, file)| file);
1122 }
1123 None
1124 }
1125
1126 pub fn picking(&self, tag: &str) -> bool {
1129 self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
1130 }
1131
1132 fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
1134 let Some(local) = store.local_files() else {
1135 self.open = false;
1136 self.pick_request = None;
1137 return;
1138 };
1139 let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
1140 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
1141 file_explorer::dialog_body(ui, |ui| {
1142 ui.heading(&title);
1143 ui.add_space(4.0);
1144 file_explorer::dialog_footer(ui, "pickfile", |ui| {
1145 if !self.status.is_empty() {
1146 ui.add_space(4.0);
1147 ui.weak(&self.status);
1148 }
1149 });
1150 let options = FileExplorerOptions {
1151 hit_prefix: "pickfile",
1152 empty_label: "(this folder is empty)",
1153 row_icon: "\u{1F5CE}",
1154 current: None,
1155 allow_delete: false,
1156 allow_import: false,
1157 import_label: "",
1158 import_hit: "pickfile:upload",
1159 show_cancel: true,
1160 confirm_label: Some("Choose"),
1161 extensions: &[],
1162 };
1163 let output = self.explorer.show_store(ui, local, options);
1164 self.record_explorer_hits(&output.hits);
1165 output
1166 })
1167 });
1168 let should_close = modal.should_close();
1169 let output = modal.inner;
1170 self.pick_folder = Some(local.browser_location());
1171 if let Some(path) = output.activated {
1172 match local.read_external_file(&path) {
1173 Some(bytes) => {
1174 let name = std::path::Path::new(&path)
1175 .file_name()
1176 .map(|n| n.to_string_lossy().into_owned())
1177 .unwrap_or_else(|| path.clone());
1178 if let Some((tag, _)) = self.pick_request.take() {
1179 self.status = format!("chose {name}");
1180 self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
1181 }
1182 self.open = false;
1183 }
1184 None => self.status = format!("'{path}' could not be read"),
1185 }
1186 } else if output.cancel || should_close {
1187 self.pick_request = None;
1188 self.status.clear();
1189 self.open = false;
1190 }
1191 }
1192
1193 fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
1197 let remembered = self.folders.get(&purpose).cloned();
1198 let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
1199 if !arrived {
1200 if let Some(models) = models_folder(store) {
1201 let _ = store.browser_navigate(&models);
1202 }
1203 }
1204 }
1205
1206 fn remember_folder(&mut self, store: &dyn ModelStore) {
1208 if let Some(purpose) = self.mode.purpose() {
1209 self.folders.insert(purpose, browse_store(purpose, store).browser_location());
1210 }
1211 }
1212
1213 fn open_modal(&mut self, mode: Mode) {
1215 self.pending_insert = None;
1219 self.explorer.clear_selection();
1220 self.mode = mode;
1221 self.open = true;
1222 self.want_focus = true;
1223 }
1224
1225 pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1231 self.hits.clear();
1232 self.poll_pending_open(ctx, docs, store);
1233 self.poll_pending_insert(ctx, docs, store);
1234 self.poll_plm_save_as(ctx, docs, store);
1235 self.poll_plm_work(ctx, docs, store);
1236 self.poll_open_workspace_pick(store);
1237 self.poll_owned_checkouts(ctx, docs, store);
1238 self.poll_owned_promotions(ctx, docs, store);
1239 self.poll_owned_loading(ctx, docs, store);
1240 if self.retarget.is_some() && !self.open {
1241 self.open_modal(Mode::Retarget);
1242 }
1243 if let Some(file) = store.take_picked_file() {
1245 if let Some((tag, _)) = self.pick_request.take() {
1246 self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
1247 }
1248 }
1249
1250 self.poll_step_probe(docs.engine_mut());
1253 self.poll_instructions_pdf(ctx, docs, store);
1254 if self.pending_instructions_export.is_some() && !docs.engine().topology_pending() && crate::plm::tool_pictures::pending() == 0 {
1257 if let Some(format) = self.pending_instructions_export.take() {
1258 self.export_as(docs, store, &format);
1259 }
1260 }
1261
1262 if let Some(imported) = store.take_import() {
1270 self.cancel_pending_step_import(docs.engine_mut());
1276 let name = imported.name;
1277 let bytes = imported.bytes;
1278 if KicadKind::of_file(&name).is_some() {
1281 self.take_kicad_file(&name, &bytes, store);
1282 return;
1283 } else if is_step_name(&name) {
1284 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
1285 } else if is_iges_name(&name) {
1286 self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
1287 } else if is_stl_name(&name) {
1288 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes);
1289 } else if is_3mf_name(&name) {
1290 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes);
1291 } else if is_obj_name(&name) {
1292 self.import_obj(docs.engine_mut(), &name, &bytes);
1293 } else if std::mem::take(&mut self.pending_component_import) {
1294 match String::from_utf8(bytes) {
1299 Ok(contents) => {
1300 let assembly = docs.active().name().map(str::to_string);
1301 if !self.begin_class_insert(store, &name, &contents, assembly) {
1302 self.insert_component_document(docs.engine_mut(), &name, &contents)
1303 }
1304 if self.pick_member.is_some() || self.spin_out.is_some() {
1305 return;
1306 }
1307 }
1308 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1309 }
1310 } else {
1311 match String::from_utf8(bytes) {
1312 Ok(contents) => self.load_document(docs, &name, &contents, store),
1313 Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1314 }
1315 }
1316 self.close_after_import();
1317 }
1318
1319 if !self.open {
1326 self.watch_member_edit(docs);
1327 }
1328
1329 if !self.open {
1330 return;
1331 }
1332 self.remember_folder(store);
1333
1334 match self.mode {
1335 Mode::CreateComponentPart => self.show_create_component_part(ctx, docs, store),
1336 Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
1337 Mode::SaveAs => self.show_save_as(ctx, docs, store),
1338 Mode::Open => self.show_open(ctx, docs, store),
1339 Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
1340 Mode::Export => self.show_export(ctx, docs, store),
1341 Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
1342 Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
1343 Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
1344 Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
1345 Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
1346 Mode::OwnedSave => self.show_owned_save(ctx, docs, store),
1347 Mode::Retarget => self.show_retarget(ctx, docs),
1348 Mode::PickFile => self.show_pick_file(ctx, store),
1349 Mode::SaveFile => self.show_save_file(ctx, docs, store),
1350 Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
1351 Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
1352 }
1353 }
1354
1355 fn close_after_import(&mut self) {
1361 if self.pending_step_import.is_some() {
1362 self.open_modal(Mode::StepAssembly);
1363 } else {
1364 self.open = false;
1365 }
1366 }
1367
1368 fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
1373 if self.pending_step_import.take().is_some() {
1374 state.discard_probed_step_assembly();
1375 }
1376 }
1377
1378 fn show_import(
1381 &mut self,
1382 ctx: &egui::Context,
1383 state: &mut EngineState,
1384 store: &dyn ModelStore,
1385 ) {
1386 const EXTENSIONS: &[&str] =
1387 &["step", "stp", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
1388 let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
1389 file_explorer::dialog_body(ui, |ui| {
1390 ui.heading("Import CAD file");
1391 ui.add_space(4.0);
1392 file_explorer::dialog_footer(ui, "import", |ui| {
1393 if !self.status.is_empty() {
1394 ui.add_space(4.0);
1395 ui.weak(&self.status);
1396 }
1397 });
1398 let options = FileExplorerOptions {
1399 hit_prefix: "import",
1400 empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1401 row_icon: "\u{1F5CE}",
1402 current: None,
1403 allow_delete: false,
1404 allow_import: store.supports_file_interchange(),
1405 import_label: "Upload\u{2026}",
1406 import_hit: "import:upload",
1407 show_cancel: true,
1408 confirm_label: Some("Import"),
1409 extensions: EXTENSIONS,
1410 };
1411 let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1412 self.record_explorer_hits(&output.hits);
1413 output
1414 })
1415 });
1416 let should_close = modal.should_close();
1417 let output = modal.inner;
1418 if let Some(name) = output.activated {
1419 match browse_store(Purpose::Imports, store).read_external_file(&name) {
1420 Some(bytes) if KicadKind::of_file(&name).is_some() => {
1421 self.take_kicad_file(&name, &bytes, store);
1423 return;
1424 }
1425 Some(bytes) if is_step_name(&name) => {
1426 self.import_text(state, &name, &bytes, Self::import_step)
1427 }
1428 Some(bytes) if is_iges_name(&name) => {
1429 self.import_text(state, &name, &bytes, Self::import_iges)
1430 }
1431 Some(bytes) if is_stl_name(&name) => {
1432 self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes)
1433 }
1434 Some(bytes) if is_3mf_name(&name) => {
1435 self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes)
1436 }
1437 Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
1438 Some(_) => self.status = format!("unsupported import file: {name}"),
1439 None => self.status = format!("import failed: '{name}' not found"),
1440 }
1441 self.close_after_import();
1442 } else if output.import {
1443 match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1444 Ok(()) => {
1445 self.status =
1446 "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1447 self.open = false;
1448 }
1449 Err(e) => self.status = format!("import failed: {e}"),
1450 }
1451 } else if output.cancel || should_close {
1452 self.open = false;
1453 }
1454 }
1455
1456 fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1461 if !(self.open && self.mode == Mode::Kicad) {
1462 if let Some(kind) = KicadKind::of_file(name) {
1463 self.kicad.start(kind, store);
1464 }
1465 }
1466 self.kicad.take_file(name, bytes, &DiskFiles);
1467 self.open_modal(Mode::Kicad);
1468 }
1469
1470 fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1473 if !self.kicad.wants_file() {
1474 match self.kicad.show(ctx, state, store, &mut self.hits) {
1475 kicad_import::Outcome::Open => {}
1476 kicad_import::Outcome::Closed => self.open = false,
1477 kicad_import::Outcome::Imported(imported) => {
1478 self.status = imported.summary.clone();
1481 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1482 const SHOWN: usize = 5;
1483 let hidden = imported.notes.len().saturating_sub(SHOWN);
1484 for note in imported.notes.into_iter().take(SHOWN) {
1485 state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1486 }
1487 if hidden > 0 {
1488 state.push_notice_as(
1489 brep_render::engine_state::NoticeSeverity::Warning,
1490 format!("and {hidden} more notes on this import"),
1491 );
1492 }
1493 self.open = false;
1494 }
1495 }
1496 return;
1497 }
1498 let extension = self.kicad.kind().extension();
1499 let heading = match self.kicad.kind() {
1500 KicadKind::Symbol => "Import a symbol from KiCad",
1501 KicadKind::Footprint => "Import a footprint from KiCad",
1502 };
1503 let extensions = [extension];
1504 let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1505 file_explorer::dialog_body(ui, |ui| {
1506 ui.heading(heading);
1507 ui.add_space(4.0);
1508 file_explorer::dialog_footer(ui, "kicad", |ui| {
1509 let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1510 if !status.is_empty() {
1511 ui.add_space(4.0);
1512 ui.weak(status);
1513 }
1514 });
1515 let empty_label = format!("(no .{extension} files)");
1516 let options = FileExplorerOptions {
1517 hit_prefix: "kicad",
1518 empty_label: &empty_label,
1519 row_icon: "\u{1F5CE}",
1520 current: None,
1521 allow_delete: false,
1522 allow_import: store.supports_file_interchange(),
1523 import_label: "Upload\u{2026}",
1524 import_hit: "kicad:upload",
1525 show_cancel: true,
1526 confirm_label: Some("Open"),
1527 extensions: &extensions,
1528 };
1529 let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1530 self.record_explorer_hits(&output.hits);
1531 output
1532 })
1533 });
1534 let should_close = modal.should_close();
1535 let output = modal.inner;
1536 if let Some(name) = output.activated {
1537 match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1538 Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1539 None => self.kicad.status = format!("import failed: '{name}' not found"),
1540 }
1541 } else if output.import {
1542 match store.begin_import_filtered(("KiCad", &extensions)) {
1543 Ok(()) => {
1544 self.status = format!("choose a .{extension} file\u{2026}");
1545 self.open = false;
1546 }
1547 Err(e) => self.kicad.status = format!("import failed: {e}"),
1548 }
1549 } else if output.cancel || should_close {
1550 self.open = false;
1551 }
1552 }
1553
1554 pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1560 let dirty = docs.get(index).is_some_and(Document::is_dirty);
1561 if dirty {
1562 self.pending_close = Some(index);
1563 self.open_modal(Mode::ConfirmClose);
1564 } else {
1565 docs.close(index);
1566 }
1567 }
1568
1569 fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1573 let Some(title) = self
1576 .pending_close
1577 .and_then(|index| docs.get(index))
1578 .map(Document::title)
1579 else {
1580 self.pending_close = None;
1581 self.open = false;
1582 return;
1583 };
1584 let mut discard = false;
1585 let mut cancel = false;
1586 let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1587 ui.set_width(340.0);
1588 ui.heading("Discard unsaved changes?");
1589 ui.add_space(4.0);
1590 ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1591 ui.add_space(6.0);
1592 ui.horizontal(|ui| {
1593 let d = ui.button("Discard and close");
1594 self.hit("confirm:discard", &d);
1595 if d.clicked() {
1596 discard = true;
1597 }
1598 let c = ui.button("Cancel");
1599 self.hit("confirm:cancel", &c);
1600 if c.clicked() {
1601 cancel = true;
1602 }
1603 });
1604 });
1605 if discard {
1606 if let Some(index) = self.pending_close.take() {
1607 docs.close(index);
1608 }
1609 self.open = false;
1610 } else if cancel || modal.should_close() {
1611 self.pending_close = None;
1612 self.open = false;
1613 }
1614 }
1615
1616 fn show_step_assembly(
1635 &mut self,
1636 ctx: &egui::Context,
1637 state: &mut EngineState,
1638 store: &dyn ModelStore,
1639 ) {
1640 let Some(pending) = self.pending_step_import.as_ref() else {
1643 self.open = false;
1644 return;
1645 };
1646 let name = pending.name.clone();
1647 let probe = pending.probe;
1648 let mut flatten = pending.flatten;
1649 let mut choice: Option<StepChoice> = None;
1650 let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1651 ui.set_width(360.0);
1652 ui.heading(format!("\"{name}\" contains an assembly"));
1653 ui.add_space(4.0);
1654 ui.label(format!(
1655 "{} part{}, {} instance{}{}.",
1656 probe.parts,
1657 plural(probe.parts),
1658 probe.instances,
1659 plural(probe.instances),
1660 match probe.nested_depth {
1661 0 | 1 => String::new(),
1662 depth => format!(", {depth} levels deep"),
1663 }
1664 ));
1665 if probe.nested_depth > 1 {
1666 ui.add_space(4.0);
1667 let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1668 self.hit("stepassembly:flatten", &check);
1669 check.on_hover_text(
1670 "Off: each sub-assembly becomes one rigid component you can \
1671 expand in the structure tree.\nOn: every part is placed \
1672 directly in this document at its world position.",
1673 );
1674 }
1675 ui.add_space(6.0);
1676 ui.horizontal(|ui| {
1677 let a = ui.button("Import as assembly");
1678 self.hit("stepassembly:assembly", &a);
1679 if a.clicked() {
1680 choice = Some(StepChoice::Assembly);
1681 }
1682 let b = ui.button("Import as bodies");
1683 self.hit("stepassembly:bodies", &b);
1684 if b.clicked() {
1685 choice = Some(StepChoice::Bodies);
1686 }
1687 let c = ui.button("Cancel");
1688 self.hit("stepassembly:cancel", &c);
1689 if c.clicked() {
1690 choice = Some(StepChoice::Cancel);
1691 }
1692 });
1693 });
1694 if let Some(pending) = self.pending_step_import.as_mut() {
1696 pending.flatten = flatten;
1697 }
1698 let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1701 let Some(choice) = choice else { return };
1702 match choice {
1703 StepChoice::Assembly => self.step_assembly_import(state, store),
1704 StepChoice::Bodies => self.step_assembly_bodies(state),
1705 StepChoice::Cancel => self.step_assembly_cancel(state),
1706 }
1707 self.open = false;
1708 }
1709
1710 pub fn take_plm_new_part(&mut self) -> bool {
1713 std::mem::take(&mut self.plm_new_part)
1714 }
1715
1716 fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1718 if let Some(client) = store.plm_client() {
1720 self.show_save_as_plm(ctx, docs, store, client);
1721 return;
1722 }
1723 let current = docs.active().name().map(str::to_string);
1724 let mut do_save = false;
1725 let mut cancel = false;
1726 let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1727 file_explorer::dialog_body(ui, |ui| {
1728 ui.heading("Save model as");
1729 ui.add_space(4.0);
1730 file_explorer::dialog_footer(ui, "saveas", |ui| {
1733 ui.label("File name");
1734 let field = ui.add(
1735 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))
1736 .hint_text("model name")
1737 .desired_width(f32::INFINITY),
1738 );
1739 self.hit("field:name", &field);
1740 if self.want_focus {
1741 field.request_focus();
1742 self.want_focus = false;
1743 }
1744 let enter =
1745 field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1746 ui.horizontal(|ui| {
1751 ui.label("Save as");
1752 let texts: Vec<String> = DocumentClass::ALL.iter().map(|class| format!("{} ({})", class.label(), class.extension())).collect();
1755 let choices: Vec<(DocumentClass, &str, &str)> =
1756 DocumentClass::ALL.iter().zip(&texts).map(|(class, text)| (*class, class.slug(), text.as_str())).collect();
1757 crate::form::enum_dropdown(ui, "saveas:class", &choices, &mut self.save_class, None, Some(&mut self.hits));
1758 });
1759 ui.add_space(6.0);
1760 ui.horizontal(|ui| {
1761 let save = ui.button("Save");
1762 self.hit("save", &save);
1763 if save.clicked() || enter {
1764 do_save = true;
1765 }
1766 let c = ui.button("Cancel");
1767 self.hit("cancel", &c);
1768 if c.clicked() {
1769 cancel = true;
1770 }
1771 });
1772 if !self.status.is_empty() {
1773 ui.add_space(4.0);
1774 ui.weak(&self.status);
1775 }
1776 });
1777 let options = FileExplorerOptions {
1778 hit_prefix: "saveas:file",
1779 empty_label: "(no saved models)",
1780 row_icon: "\u{1F5CE}",
1781 current: current.as_deref(),
1782 allow_delete: false,
1783 allow_import: false,
1784 import_label: "",
1785 import_hit: "saveas:upload",
1786 show_cancel: false,
1787 confirm_label: None,
1788 extensions: MODEL_EXTENSIONS,
1789 };
1790 let output = self.explorer.show_store(ui, store, options);
1791 self.record_explorer_hits(&output.hits);
1792 if let Some(name) = output.picked.or(output.activated) {
1798 self.name_buf =
1799 document_class::strip_class_extension(&model_display_name(&name)).to_string();
1800 if let Some(class) = DocumentClass::of_name(&name) {
1801 self.save_class = class;
1802 }
1803 }
1804 })
1805 });
1806 if do_save {
1807 let name = self.save_class.file_name(self.name_buf.trim());
1808 if self.save_to_browser(docs, store, name) {
1809 self.open = false;
1810 }
1811 } else if cancel || modal.should_close() {
1812 self.open = false;
1813 }
1814 }
1815
1816 fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1827 let has_bom = !docs.engine().component_ids().is_empty()
1833 || !docs.engine().wire_harness_state().connections.is_empty();
1834 let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1838 let has_instructions = docs.engine().is_instructions();
1839 let has_board = super::fabrication_export::has_board(docs.engine());
1842 let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1843 let mut chosen: Option<&'static str> = None;
1844 let mut bom_chosen: Option<&'static str> = None;
1845 let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1846 let mut cancel = false;
1847 let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1848 ui.set_width(440.0);
1851 ui.heading("Export model");
1852 ui.add_space(4.0);
1853 Self::preview_export_line(ui, docs);
1854 let field = ui.add(
1855 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))
1856 .hint_text("file name")
1857 .desired_width(f32::INFINITY),
1858 );
1859 self.hit("field:name", &field);
1860 if self.want_focus {
1861 field.request_focus();
1862 self.want_focus = false;
1863 }
1864 let group = |ui: &mut egui::Ui, title: &str| {
1868 ui.add_space(6.0);
1869 ui.label(egui::RichText::new(title).weak());
1870 };
1871 group(ui, "3D model");
1872 ui.horizontal_wrapped(|ui| {
1873 let step = ui.button("STEP (.step)");
1874 self.hit("export:step", &step);
1875 if step.clicked() {
1876 chosen = Some("step");
1877 }
1878 let iges = ui.button("IGES (.igs)");
1879 self.hit("export:iges", &iges);
1880 if iges.clicked() {
1881 chosen = Some("iges");
1882 }
1883 let stl = ui.button("STL (.stl)");
1884 self.hit("export:stl", &stl);
1885 if stl.clicked() {
1886 chosen = Some("stl");
1887 }
1888 let obj = ui.button("OBJ (.obj)");
1889 self.hit("export:obj", &obj);
1890 if obj.clicked() {
1891 chosen = Some("obj");
1892 }
1893 let glb = ui.button("GLB (.glb)");
1894 self.hit("export:glb", &glb);
1895 if glb.clicked() {
1896 chosen = Some("glb");
1897 }
1898 let three_mf = ui
1902 .button("3MF (.3mf)")
1903 .on_hover_text("For slicers: one object per body, with the model's colours");
1904 self.hit("export:3mf", &three_mf);
1905 if three_mf.clicked() {
1906 chosen = Some("3mf");
1907 }
1908 let json = ui.button("Model (.nbrep)");
1912 self.hit("export:json", &json);
1913 if json.clicked() {
1914 chosen = Some("json");
1915 }
1916 });
1917 group(ui, "Bill of materials");
1918 ui.horizontal_wrapped(|ui| {
1919 let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1923 self.hit("export:bomcsv", &csv);
1924 if csv.clicked() {
1925 bom_chosen = Some("csv");
1926 }
1927 let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1928 self.hit("export:bomjson", &json);
1929 if json.clicked() {
1930 bom_chosen = Some("json");
1931 }
1932 });
1933 group(ui, "Drawing");
1934 ui.horizontal_wrapped(|ui| {
1935 let sheet = ui
1938 .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1939 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1940 self.hit("export:sheetsvg", &sheet);
1941 if sheet.clicked() {
1942 chosen = Some("sheetsvg");
1943 }
1944 let pdf = ui
1948 .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1949 .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1950 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1951 self.hit("export:sheetpdf", &pdf);
1952 if pdf.clicked() {
1953 chosen = Some("sheetpdf");
1954 }
1955 let pdf3d = ui
1960 .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1961 .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")
1962 .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1963 self.hit("export:sheetpdf3d", &pdf3d);
1964 if pdf3d.clicked() {
1965 chosen = Some("sheetpdf3d");
1966 }
1967 });
1968 group(ui, "Electronics");
1969 ui.horizontal_wrapped(|ui| {
1970 use super::fabrication_export::Output;
1974 let no_board = "Place the schematic's parts on the PCB board first";
1975 for (key, label, hover, output) in [
1976 ("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),
1977 ("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),
1978 ("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),
1979 ("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),
1980 ] {
1981 let (enabled, why) = match output {
1982 Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
1983 _ => (has_board, no_board),
1984 };
1985 let button = ui
1986 .add_enabled(enabled, egui::Button::new(label))
1987 .on_hover_text(hover)
1988 .on_disabled_hover_text(why);
1989 self.hit(key, &button);
1990 if button.clicked() {
1991 fab_chosen = Some(output);
1992 }
1993 }
1994 });
1995 group(ui, "Instructions");
1996 ui.horizontal_wrapped(|ui| {
1997 let not_instructions = "Open manufacturing instructions first (New from assembly in the Instructions workbench)";
2000 let pdf = ui
2001 .add_enabled(has_instructions, egui::Button::new("PDF"))
2002 .on_hover_text("The cover, the parts list, the tools list and the steps, on the page template set in the Instructions pane")
2003 .on_disabled_hover_text(not_instructions);
2004 self.hit("export:instructionspdf", &pdf);
2005 if pdf.clicked() {
2006 chosen = Some("instructionspdf");
2007 }
2008 let images = ui
2009 .add_enabled(has_instructions, egui::Button::new("Step images"))
2010 .on_hover_text("Every step's picture as PNG and SVG, in one zip")
2011 .on_disabled_hover_text(not_instructions);
2012 self.hit("export:instructionsimages", &images);
2013 if images.clicked() {
2014 chosen = Some("instructionsimages");
2015 }
2016 let html = ui
2017 .add_enabled(has_instructions, egui::Button::new("Player (HTML)"))
2018 .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")
2019 .on_disabled_hover_text(not_instructions);
2020 self.hit("export:instructionshtml", &html);
2021 if html.clicked() {
2022 chosen = Some("instructionshtml");
2023 }
2024 });
2025 if !self.status.is_empty() {
2026 ui.add_space(4.0);
2027 ui.weak(&self.status);
2028 }
2029 ui.add_space(6.0);
2030 ui.separator();
2031 let c = ui.button("Cancel");
2032 self.hit("cancel", &c);
2033 if c.clicked() {
2034 cancel = true;
2035 }
2036 });
2037 if let Some(format) = chosen {
2040 if self.export_as(docs, store, format) && self.mode == Mode::Export {
2041 self.open = false;
2042 }
2043 } else if let Some(format) = bom_chosen {
2044 if self.export_bom_as(docs, store, format) && self.mode == Mode::Export {
2045 self.open = false;
2046 }
2047 } else if let Some(output) = fab_chosen {
2048 if self.export_fabrication_as(docs, store, output) && self.mode == Mode::Export {
2049 self.open = false;
2050 }
2051 } else if cancel || modal.should_close() {
2052 self.open = false;
2053 }
2054 }
2055
2056 fn show_flat_pattern(
2062 &mut self,
2063 ctx: &egui::Context,
2064 docs: &mut Documents,
2065 store: &dyn ModelStore,
2066 ) {
2067 let mut chosen: Option<&'static str> = None;
2068 let mut cancel = false;
2069 let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
2070 ui.set_width(340.0);
2071 ui.heading("Export flat pattern");
2072 ui.add_space(2.0);
2073 Self::preview_export_line(ui, docs);
2074 ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
2075 ui.add_space(4.0);
2076 let field = ui.add(
2077 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))
2078 .hint_text("file name")
2079 .desired_width(f32::INFINITY),
2080 );
2081 self.hit("field:name", &field);
2082 if self.want_focus {
2083 field.request_focus();
2084 self.want_focus = false;
2085 }
2086 ui.add_space(6.0);
2087 ui.horizontal(|ui| {
2088 let dxf = ui.button("DXF (.dxf)");
2089 self.hit("flat:dxf", &dxf);
2090 if dxf.clicked() {
2091 chosen = Some("dxf");
2092 }
2093 let svg = ui.button("SVG (.svg)");
2094 self.hit("flat:svg", &svg);
2095 if svg.clicked() {
2096 chosen = Some("svg");
2097 }
2098 let c = ui.button("Cancel");
2099 self.hit("cancel", &c);
2100 if c.clicked() {
2101 cancel = true;
2102 }
2103 });
2104 if !self.status.is_empty() {
2105 ui.add_space(4.0);
2106 ui.weak(&self.status);
2107 }
2108 });
2109 if let Some(format) = chosen {
2110 if self.export_flat_pattern_as(docs, store, format) && self.mode == Mode::FlatPattern {
2111 self.open = false;
2112 }
2113 } else if cancel || modal.should_close() {
2114 self.open = false;
2115 }
2116 }
2117
2118 fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2121 if let Some(client) = store.plm_client() {
2123 self.show_open_workspace(ctx, docs, store, client);
2124 return;
2125 }
2126 let current = docs.active().name().map(str::to_string);
2127 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2128 file_explorer::dialog_body(ui, |ui| {
2129 ui.heading("Open model");
2130 ui.add_space(4.0);
2131 file_explorer::dialog_footer(ui, "open", |ui| {
2132 if !self.status.is_empty() {
2133 ui.add_space(4.0);
2134 ui.weak(&self.status);
2135 }
2136 });
2137 let mut options = FileExplorerOptions::open(current.as_deref());
2138 options.allow_import = store.supports_file_interchange();
2139 let output = self.explorer.show_store(ui, store, options);
2140 self.record_explorer_hits(&output.hits);
2141 output
2142 })
2143 });
2144 let should_close = modal.should_close();
2145 let output = modal.inner;
2146 if let Some(name) = output.activated {
2147 self.open_document(docs, store, &name);
2148 self.open = false;
2149 } else if let Some(name) = output.remove {
2150 let _ = store.remove(&name);
2151 self.status = format!("removed {name}");
2152 if let Some(index) = docs.index_of(&name) {
2156 if let Some(doc) = docs.get_mut(index) {
2157 doc.set_name(None);
2158 }
2159 }
2160 } else if output.import {
2161 match store.begin_import() {
2164 Ok(()) => {
2165 self.status = "choose a file…".into();
2166 self.open = false;
2167 }
2168 Err(e) => self.status = format!("import failed: {e}"),
2169 }
2170 } else if output.cancel || should_close {
2171 self.open = false;
2172 }
2173 }
2174
2175 fn show_open_workspace(
2180 &mut self,
2181 ctx: &egui::Context,
2182 docs: &mut Documents,
2183 store: &dyn ModelStore,
2184 client: std::rc::Rc<crate::plm::client::PlmClient>,
2185 ) {
2186 use crate::panels::plm_workspace::{document_name, load_pins, save_pins, PlmWorkspaces};
2187 let plm = PlmWorkspaces { client };
2188 if self.workspace.pins.is_none() {
2189 self.workspace.pins = Some(load_pins(store));
2190 }
2191 self.workspace.browse_only = true;
2192 let mut cancel = false;
2193 let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2194 file_explorer::dialog_body(ui, |ui| {
2195 ui.heading("Open from the PLM");
2196 if let Some(url) = crate::panels::plm_host::web_url(store, "#/workspace") {
2197 crate::panels::plm_host::web_link(ui, "Manage workspace in PLM", url);
2198 }
2199 ui.add_space(4.0);
2200 file_explorer::dialog_footer(ui, "open", |ui| {
2201 if !self.status.is_empty() {
2202 ui.add_space(4.0);
2203 ui.weak(&self.status);
2204 }
2205 let button = ui.button("Cancel");
2206 self.hits.insert("open:cancel".into(), button.rect);
2207 cancel = button.clicked();
2208 });
2209 self.workspace.show(ui, &plm, "open:")
2210 })
2211 });
2212 let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
2213 self.record_explorer_hits(&hits);
2214 let should_close = modal.should_close();
2215 let outcome = modal.inner;
2216 if let Some(pins) = &outcome.pins {
2217 if let Err(e) = save_pins(store, pins) {
2218 self.status = format!("could not save the pinned folders: {e}");
2219 }
2220 }
2221 if let Some((name, bytes)) = outcome.downloaded {
2222 self.status = match store.export_file_named_bytes(&name, &bytes) {
2223 Ok(()) => format!("saved {name}"),
2224 Err(e) => format!("could not save {name}: {e}"),
2225 };
2226 }
2227 if outcome.pick_file {
2228 if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
2231 self.status = error;
2232 }
2233 return;
2234 }
2235 if let Some(key) = outcome.open {
2236 let name = document_name(store, &key);
2237 self.open_document(docs, store, &name);
2238 self.open = false;
2239 } else if cancel || should_close {
2240 self.open = false;
2241 }
2242 }
2243
2244 fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
2247 let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
2248 if let Some(client) = store.plm_client() {
2249 let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
2250 self.workspace.add_file(&plm, &file.name, file.bytes);
2251 self.open_modal(Mode::Open);
2252 }
2253 }
2254
2255 fn show_insert_component(
2261 &mut self,
2262 ctx: &egui::Context,
2263 docs: &mut Documents,
2264 store: &dyn ModelStore,
2265 ) {
2266 let library = docs.engine_mut().parts_library_names();
2267 let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
2268 file_explorer::dialog_body(ui, |ui| {
2269 ui.heading("Insert component");
2270 ui.add_space(4.0);
2271 file_explorer::dialog_footer(ui, "insert", |ui| {
2272 if !self.status.is_empty() {
2273 ui.add_space(4.0);
2274 ui.weak(&self.status);
2275 }
2276 });
2277 let mut chosen_library = None;
2278 if !library.is_empty() {
2279 ui.weak("In this document (parts library)");
2280 for name in &library {
2281 let row = ui.add_sized(
2282 egui::vec2(ui.available_width(), 18.0),
2283 crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
2284 .frame(false),
2285 );
2286 self.hit(&format!("insert:lib:{name}"), &row);
2287 if row.clicked() {
2288 chosen_library = Some(name.clone());
2289 }
2290 }
2291 ui.add_space(4.0);
2292 }
2293 let options = FileExplorerOptions {
2294 hit_prefix: "insert:model",
2295 empty_label: "(no saved models)",
2296 row_icon: "\u{1F5CE}",
2297 current: None,
2298 allow_delete: false,
2299 allow_import: store.supports_file_interchange(),
2300 import_label: "Upload\u{2026}",
2301 import_hit: "insert:upload",
2302 show_cancel: true,
2303 confirm_label: Some("Insert"),
2304 extensions: INSERT_EXTENSIONS,
2305 };
2306 let output = self.explorer.show_store(ui, store, options);
2307 self.record_explorer_hits(&output.hits);
2308 (chosen_library, output)
2309 })
2310 });
2311 let should_close = modal.should_close();
2312 let (chosen_library, output) = modal.inner;
2313 if let Some(part_name) = chosen_library {
2314 match docs.engine_mut().insert_component(ComponentInsert::Existing { part_name: &part_name }) {
2316 Ok(id) => {
2317 self.status = format!("inserted {part_name} ({id})");
2318 self.open = false;
2319 }
2320 Err(e) => self.status = format!("insert failed: {e}"),
2321 }
2322 } else if let Some(name) = output.activated.filter(|name| {
2323 let listed = store
2327 .browser_entries(INSERT_EXTENSIONS)
2328 .iter()
2329 .any(|entry| !entry.is_dir && entry.identity == *name);
2330 if !listed {
2331 self.status = format!(
2332 "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
2333 );
2334 }
2335 listed
2336 }) {
2337 self.request_component_document(docs, store, &name);
2338 if self.pending_insert.is_some() {
2339 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2340 }
2341 } else if output.import {
2342 match store.begin_import() {
2343 Ok(()) => {
2344 self.pending_component_import = true;
2346 self.status = "choose a part file…".into();
2347 self.open = false;
2348 }
2349 Err(e) => self.status = format!("insert failed: {e}"),
2350 }
2351 } else if output.cancel || should_close {
2352 self.open = false;
2353 }
2354 }
2355
2356 fn request_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2357 self.pending_insert = None;
2358 match crate::store::read_now(store, name) {
2359 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, name, &contents),
2360 crate::store::ReadNow::Loading => {
2361 self.pending_insert = Some((name.to_owned(), docs.active_id()));
2362 self.status = format!("'{name}' is still loading");
2363 }
2364 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2365 }
2366 }
2367
2368 fn finish_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str, contents: &str) {
2369 let assembly = docs.active().name().map(str::to_string);
2370 if !self.begin_class_insert(store, name, contents, assembly) {
2371 self.insert_component_document(docs.engine_mut(), name, contents);
2372 self.open = false;
2373 }
2374 }
2375
2376 fn poll_pending_insert(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2377 let Some((name, requester)) = self.pending_insert.take() else { return };
2378 if !self.open || self.mode != Mode::InsertComponent || docs.active_id() != requester {
2379 return;
2380 }
2381 match crate::store::read_now(store, &name) {
2382 crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, &name, &contents),
2383 crate::store::ReadNow::Loading => {
2384 self.pending_insert = Some((name, requester));
2385 ctx.request_repaint_after(std::time::Duration::from_millis(100));
2386 }
2387 crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2388 }
2389 }
2390
2391 fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2399 self.insert_component_named(state, name, &model_display_name(name), contents);
2400 }
2401
2402 fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2405 let display = display.to_string();
2406 match state.insert_component(ComponentInsert::New {
2407 name: &display,
2408 source_key: name,
2409 source_signature: &document_signature(contents),
2410 document_json: contents,
2411 }) {
2412 Ok(id) => self.status = format!("inserted {display} ({id})"),
2413 Err(e) => self.status = format!("insert failed: {e}"),
2414 }
2415 }
2416
2417 fn effective_name(&self, docs: &Documents) -> String {
2422 let field = self.name_buf.trim();
2423 if !field.is_empty() {
2424 field.to_string()
2425 } else {
2426 Self::proposed_name(docs)
2427 }
2428 }
2429
2430 fn proposed_name(docs: &Documents) -> String {
2435 if docs.active().name().is_none() {
2436 if let Ok(Some(block)) = docs.engine().instructions() {
2437 let title = file_stem_of_title(&block.front.title);
2438 if !title.is_empty() {
2439 return title;
2440 }
2441 }
2442 }
2443 document_class::strip_class_extension(&Self::document_name(docs)).to_string()
2444 }
2445
2446 fn document_name(docs: &Documents) -> String {
2452 docs.active().name().map(model_display_name).unwrap_or_else(|| docs.active().untitled_title())
2453 }
2454
2455 fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2458 docs.open_document(Document::fresh(docs.spawn_engine(), class));
2459 self.name_buf.clear();
2460 self.status = match class {
2461 DocumentClass::Normal => "new model".into(),
2462 _ => format!("new {}", class.label().to_ascii_lowercase()),
2463 };
2464 }
2465
2466 fn name_is_free(&mut self, docs: &mut Documents, name: &str) -> bool {
2478 let display = model_display_name(name);
2479 let target_revision = crate::plm::uses::revision_key_in(name);
2480 let taken = docs.iter().enumerate().any(|(index, doc)| {
2481 index != docs.active_index()
2482 && doc
2483 .name()
2484 .is_some_and(|open| {
2485 match (&target_revision, crate::plm::uses::revision_key_in(open)) {
2486 (Some(target), Some(other)) => *target == other,
2487 (None, None) => model_display_name(open) == display,
2488 _ => false,
2489 }
2490 })
2491 });
2492 if taken {
2493 let identity = target_revision.as_deref().unwrap_or(&display);
2494 self.status = format!("Save refused: '{identity}' is already open in another tab");
2495 docs.engine_mut().push_notice(&self.status);
2496 }
2497 !taken
2498 }
2499
2500 fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2503 let name = name.trim().to_string();
2504 if name.is_empty() {
2505 self.status = "enter a name to save".into();
2506 return false;
2507 }
2508 if !self.name_is_free(docs, &name) {
2509 return false;
2510 }
2511 docs.flush_work_part();
2514 let mut plan = docs.plan_owned_save(store, &name);
2515 if !std::mem::take(&mut self.owned_loaded) {
2519 let loading: Vec<String> = plan
2520 .rows
2521 .iter()
2522 .map(|row| row.write.key.clone())
2523 .filter(|key| store.residency(key) == crate::store::Residency::OnDisk)
2524 .collect();
2525 for key in &loading {
2526 let _ = store.read(key);
2529 }
2530 if !loading.is_empty() {
2531 self.status = format!("Reading {} part(s) from the PLM before saving…", loading.len());
2532 self.owned_loading = Some((docs.active_id(), name, loading, 0));
2533 return false;
2534 }
2535 }
2536 match top_checkout_row(docs, store, &name) {
2537 Some(row) if matches!(row.issue, Some(crate::document::owned_save::Issue::Blocked(_))) => {
2538 let Some(crate::document::owned_save::Issue::Blocked(why)) = &row.issue else { unreachable!() };
2545 if why == crate::document::owned_save::VIEW_ONLY {
2548 self.status = format!(
2549 "{} is not saved: {why}. Your changes are only in this tab: Export keeps a copy as a file.",
2550 docs.active().title()
2551 );
2552 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2553 return false;
2554 }
2555 let held = why.starts_with("checked out by");
2556 self.status = format!(
2557 "{} is not saved: {why}. Your changes are only in this tab: Save As keeps them as {}, or Save again once it can be written.",
2558 docs.active().title(),
2559 if held { "a new part" } else { "a new revision" }
2560 );
2561 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2562 let status = self.status.clone();
2563 self.member_new_part = held;
2564 self.dispatch(FileAction::SaveAs, docs, store);
2565 self.status = status;
2566 return false;
2567 }
2568 Some(row) => plan.rows.insert(0, row),
2569 None => {}
2570 }
2571 if self.hold_for_plm_items(docs, store, plan.clone(), &name, false) {
2572 return false;
2573 }
2574 if self.asks(&plan) {
2575 self.ask_owned_save(plan, name, false);
2576 return false;
2577 }
2578 self.commit_save(docs, store, name, plan)
2579 }
2580
2581 fn asks(&self, plan: &crate::document::owned_save::OwnedSavePlan) -> bool {
2584 use crate::document::owned_save::Issue;
2585 plan.rows
2586 .iter()
2587 .any(|row| row.asks() && !(self.checkout_remembered && matches!(row.issue, Some(Issue::CheckOut { newer: false, .. }))))
2588 }
2589
2590 fn hold_for_plm_items(&mut self, docs: &Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan, name: &str, browser: bool) -> bool {
2593 let Some(client) = store.plm_client() else { return false };
2594 if plan.promotions.is_empty() || crate::panels::plm_host::revision_of(name).is_none() {
2595 return false;
2596 }
2597 let current: serde_json::Value = serde_json::from_str(&docs.tab_engine().history_request_json()).unwrap_or_default();
2598 let requests = plm_promotion_requests(¤t, &plan);
2599 let count = requests.len();
2600 let opened = docs.active().plm_folder().cloned();
2604 let part = crate::panels::plm_host::revision_of(name).map(|(part, _)| part);
2605 let future = async move {
2606 let folder = match (opened, part) {
2607 (Some(folder), _) => Some(folder),
2608 (None, Some(part)) => crate::plm::promote::linking_folder(&client, &part).await.filter(|(id, _)| !id.is_empty()),
2609 (None, None) => None,
2610 };
2611 let keys = crate::plm::promote::make_items(client, requests, folder.as_ref().map_or(String::new(), |(id, _)| id.clone())).await?;
2612 Ok((keys, folder))
2613 };
2614 self.owned_promoting = Some((OwnedPrompt { plan, name: name.to_string(), browser }, Box::pin(future)));
2615 self.status = format!("Making PLM items for {count} part(s) with no document of their own…");
2616 true
2617 }
2618
2619 fn poll_owned_loading(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2623 const LOAD_WAIT_FRAMES: u32 = 400;
2624 let Some((_, _, keys, frames)) = self.owned_loading.as_mut() else { return };
2625 if *frames < LOAD_WAIT_FRAMES && keys.iter().any(|key| store.residency(key) == crate::store::Residency::OnDisk) {
2626 *frames += 1;
2627 if *frames % 40 == 0 {
2628 for key in keys.iter() {
2629 let _ = store.read(key);
2630 }
2631 }
2632 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2633 return;
2634 }
2635 let Some((doc, name, _, _)) = self.owned_loading.take() else { return };
2636 if doc != docs.active_id() {
2637 self.status = "Save cancelled because the active document changed".into();
2638 return;
2639 }
2640 self.owned_loaded = true;
2641 self.save_to(docs, store, name);
2642 }
2643
2644 fn poll_owned_promotions(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2647 let Some((_, future)) = self.owned_promoting.as_mut() else { return };
2648 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2649 let std::task::Poll::Ready(result) = std::future::Future::poll(future.as_mut(), &mut cx) else {
2650 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2651 return;
2652 };
2653 let Some((mut prompt, _)) = self.owned_promoting.take() else { return };
2654 if prompt.plan.doc_id != docs.active_id() {
2655 self.status = "Save cancelled because the active document changed".into();
2656 return;
2657 }
2658 match result {
2659 Ok((keys, folder)) => {
2660 let place = folder.map_or_else(|| "the top of your workspace".to_string(), |(_, name)| format!("your workspace folder '{name}'"));
2661 docs.engine_mut().push_notice_as(
2662 brep_render::engine_state::NoticeSeverity::Info,
2663 format!("Made {} PLM item(s) for parts with no document, linked in {place}", keys.len()),
2664 );
2665 assign_plm_keys(&mut prompt.plan, &keys);
2666 if self.asks(&prompt.plan) {
2667 self.ask_owned_save(prompt.plan, prompt.name, prompt.browser);
2668 } else if prompt.browser {
2669 self.commit_save_browser(docs, store, prompt.name, prompt.plan);
2670 } else {
2671 self.commit_save(docs, store, prompt.name, prompt.plan);
2672 }
2673 }
2674 Err(error) => {
2675 self.status = format!("Save stopped: the PLM could not make an item for a part with no document: {error}");
2676 docs.engine_mut().push_notice(&self.status);
2677 }
2678 }
2679 }
2680
2681 fn ask_owned_save(&mut self, plan: crate::document::owned_save::OwnedSavePlan, name: String, browser: bool) {
2684 self.owned_state = serde_json::json!({"prompt": plan.to_json()});
2685 self.owned_prompt = Some(OwnedPrompt { plan, name, browser });
2686 self.open_modal(Mode::OwnedSave);
2687 }
2688
2689 fn save_owned(&mut self, docs: &mut Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan) -> String {
2692 let (report, checkouts) = docs.execute_owned_save(store, plan);
2693 if let Some(client) = store.plm_client() {
2694 use crate::panels::plm::PlmLifecycle;
2695 let (revisions, checkouts): (Vec<_>, Vec<_>) = checkouts.into_iter().partition(|r| r.new_revision);
2696 for request in checkouts {
2697 let future = client.checkout(&request.part, &request.revision);
2698 self.owned_checkouts.push((request, future));
2699 }
2700 if !revisions.is_empty() {
2701 let client = client.clone();
2704 let requests = revisions.clone();
2705 let future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>> = Box::pin(async move {
2706 let mut done: Vec<(String, String, String)> = Vec::new();
2707 let mut answers = Vec::new();
2708 for request in &requests {
2709 let text = crate::document::owned_save::retarget_text(&request.text, &done);
2710 match crate::panels::plm_parts::save_as_new_revision(&client, &request.part, "", &text).await {
2711 Ok(new_key) => {
2712 done.push((request.key.clone(), new_key.clone(), text.clone()));
2713 answers.push(Ok((new_key, text)));
2714 }
2715 Err(why) => answers.push(Err(why)),
2716 }
2717 }
2718 Ok(answers)
2719 });
2720 self.owned_revisions = Some(OwnedRevisions { doc: docs.active_id(), requests: revisions, future });
2721 }
2722 }
2723 self.owned_state = serde_json::json!({"report": report.to_json()});
2724 let summary = report.summary();
2725 if !summary.is_empty() {
2726 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, format!("Save: {summary}"));
2727 }
2728 summary
2729 }
2730
2731 fn poll_owned_revisions(&mut self, docs: &mut Documents, store: &dyn ModelStore) {
2735 let Some(revisions) = self.owned_revisions.as_mut() else { return };
2736 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2737 let std::task::Poll::Ready(result) = std::future::Future::poll(revisions.future.as_mut(), &mut cx) else { return };
2738 let OwnedRevisions { doc, requests, .. } = self.owned_revisions.take().unwrap();
2739 let answers = result.unwrap_or_else(|why| requests.iter().map(|_| Err(why.clone())).collect());
2740 for (request, answer) in requests.iter().zip(answers) {
2741 match answer {
2742 Ok((new_key, text)) => match docs.retarget_revised_copies(doc, &request.key, &new_key, &text) {
2743 Ok(()) => {
2744 store.remember_saved_document(&store.canonical_identity(&new_key), &text);
2747 store.refresh_plm_index(std::slice::from_ref(&new_key), false);
2748 docs.engine_mut().push_notice_as(
2749 brep_render::engine_state::NoticeSeverity::Info,
2750 format!(
2751 "Made a new revision of {} and saved the change into it: {}",
2752 request.shown,
2753 crate::document::owned_save::shown(&new_key, &[])
2754 ),
2755 );
2756 let followup = self.owned_followup.get_or_insert(OwnedFollowup { doc, keys: Vec::new(), polls: 0 });
2757 followup.keys.push(new_key);
2758 }
2759 Err(why) => docs.engine_mut().push_notice(format!("'{new_key}' holds the change, but the assembly could not name it: {why}")),
2760 },
2761 Err(why) => {
2762 docs.engine_mut().push_notice(format!("{} NOT saved: {why} — the change is kept in the assembly", request.shown));
2763 let was_clean = docs.iter().find(|d| d.id() == doc).is_some_and(|d| !d.is_dirty());
2764 if docs.mark_unsaved_copies(doc, &request.key, &format!("a new revision could not be made: {why}")) {
2765 write_late_marks(docs, store, doc, was_clean);
2766 }
2767 }
2768 }
2769 }
2770 }
2771
2772 fn poll_owned_checkouts(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2774 if let Some((_, _, _, future)) = self.owned_top.as_mut() {
2775 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2776 match std::future::Future::poll(future.as_mut(), &mut cx) {
2777 std::task::Poll::Ready(result) => {
2778 if let Some((id, signature, request, _)) = self.owned_top.take() {
2779 let saved = crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result);
2780 if saved {
2781 if let Some(doc) = docs.iter_mut().find(|d| d.id() == id) {
2782 doc.mark_saved_signature(signature);
2783 doc.set_plm_save_access(crate::document::Access::Editable);
2784 }
2785 self.save_generation += 1;
2786 remember_document(store, &request.key);
2787 self.status = format!("saved {}", request.key);
2788 } else {
2789 self.status = format!("{} NOT saved: the checkout or the write failed; your edits are kept", request.key);
2790 }
2791 }
2792 }
2793 std::task::Poll::Pending => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
2794 }
2795 }
2796 self.poll_owned_revisions(docs, store);
2797 if self.owned_checkouts.is_empty() && self.owned_revisions.is_none() {
2798 if self.owned_top.is_some() {
2800 return;
2801 }
2802 if let Some(followup) = self.owned_followup.as_mut() {
2803 const INDEX_WAIT_POLLS: u32 = 200;
2805 if followup.polls < INDEX_WAIT_POLLS && followup.keys.iter().any(|key| store.plm_revision(key).is_none()) {
2806 followup.polls += 1;
2807 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2808 return;
2809 }
2810 }
2811 if let Some(OwnedFollowup { doc: id, .. }) = self.owned_followup.take() {
2812 if docs.active_id() == id {
2815 if let Some(name) = docs.active().name().map(str::to_string) {
2816 self.save_to(docs, store, name);
2817 }
2818 } else {
2819 docs.engine_mut().push_notice("Save the assembly again: it names the new revisions now");
2820 }
2821 }
2822 return;
2823 }
2824 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2825 let mut i = 0;
2826 while i < self.owned_checkouts.len() {
2827 match std::future::Future::poll(self.owned_checkouts[i].1.as_mut(), &mut cx) {
2828 std::task::Poll::Ready(result) => {
2829 let (request, _) = self.owned_checkouts.remove(i);
2830 let failure = result.as_ref().err().cloned();
2831 let id = docs.active_id();
2832 if crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result) {
2833 if docs.clear_unsaved_copies(id, &request.key) {
2834 self.owned_followup.get_or_insert(OwnedFollowup { doc: id, keys: Vec::new(), polls: 0 });
2838 }
2839 } else {
2840 let why = failure.unwrap_or_else(|| "the write after the checkout failed".into());
2843 let was_clean = docs.iter().find(|d| d.id() == id).is_some_and(|d| !d.is_dirty());
2844 if docs.mark_unsaved_copies(id, &request.key, &format!("checkout failed: {why}")) {
2845 write_late_marks(docs, store, id, was_clean);
2846 }
2847 }
2848 }
2849 std::task::Poll::Pending => i += 1,
2850 }
2851 }
2852 if !self.owned_checkouts.is_empty() || self.owned_revisions.is_some() {
2853 ctx.request_repaint_after(std::time::Duration::from_millis(50));
2854 }
2855 }
2856
2857 fn promotion_failed(&mut self, docs: &mut Documents) -> bool {
2863 let failed: Vec<String> = self.owned_state["report"]["failedPromotions"]
2864 .as_array()
2865 .map(|keys| keys.iter().filter_map(|k| k.as_str().map(str::to_string)).collect())
2866 .unwrap_or_default();
2867 if failed.is_empty() {
2868 return false;
2869 }
2870 self.status = format!(
2871 "Save stopped: could not create {} — the assembly was not written, so no part is kept only inside it. Fix the problem and Save again.",
2872 failed.join(", ")
2873 );
2874 docs.engine_mut().push_notice(&self.status);
2875 true
2876 }
2877
2878 fn commit_save(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
2881 use crate::document::owned_save::{CheckoutRequest, Issue};
2882 let top = plan.rows.first().filter(|row| row.write.key == name).cloned();
2883 if let Some(row) = &top {
2884 if !row.proceed {
2885 self.status = format!("Save cancelled: {name} was not checked out");
2886 return false;
2887 }
2888 }
2889 let summary = self.save_owned(docs, store, plan);
2890 if self.promotion_failed(docs) {
2891 return false;
2892 }
2893 let document = docs.tab_engine().history_request_json();
2894 if let (Some(row), Some(client)) = (top, store.plm_client()) {
2895 if let Some(Issue::CheckOut { part, revision, .. }) = row.issue {
2896 use crate::panels::plm::PlmLifecycle;
2897 let signature = docs.active().save_signature();
2898 let future = client.checkout(&part, &revision);
2899 let shown = crate::document::owned_save::shown(&name, &[]);
2900 let request = CheckoutRequest { key: name.clone(), part, revision, text: document, shown: shown.clone(), new_revision: false };
2901 self.owned_top = Some((docs.active_id(), signature, request, future));
2902 self.status = if summary.is_empty() {
2903 format!("checking out and saving {shown}")
2904 } else {
2905 format!("checking out and saving {shown}; {summary}")
2906 };
2907 return true;
2908 }
2909 }
2910 crate::plm::thumbnail::stage_save(store, &name, &document, docs.tab_engine(), docs.active_id());
2911 match store.write(&name, &document) {
2912 Ok(()) => {
2913 self.name_buf = model_display_name(&name);
2916 let doc = docs.active_mut();
2917 doc.set_name(Some(name.clone()));
2918 doc.mark_clean();
2919 self.save_generation += 1;
2920 remember_document(store, &name);
2921 self.status = if summary.is_empty() { format!("saved {name}") } else { format!("saved {name}; {summary}") };
2922 true
2923 }
2924 Err(e) => {
2925 self.status = format!("save failed: {e}");
2926 docs.engine_mut().push_notice(&self.status);
2927 false
2928 }
2929 }
2930 }
2931
2932 fn save_to_browser(
2936 &mut self,
2937 docs: &mut Documents,
2938 store: &dyn ModelStore,
2939 name: String,
2940 ) -> bool {
2941 let name = name.trim().to_string();
2942 if name.is_empty() {
2943 self.status = "enter a name to save".into();
2944 return false;
2945 }
2946 if !self.name_is_free(docs, &name) {
2947 return false;
2948 }
2949 docs.flush_work_part();
2954 let plan = docs.plan_owned_save(store, &name);
2955 if self.hold_for_plm_items(docs, store, plan.clone(), &name, true) {
2956 return false;
2957 }
2958 if plan.needs_asking() {
2959 self.ask_owned_save(plan, name, true);
2960 return false;
2961 }
2962 self.commit_save_browser(docs, store, name, plan)
2963 }
2964
2965 fn commit_save_browser(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
2967 let summary = self.save_owned(docs, store, plan);
2968 if self.promotion_failed(docs) {
2969 return false;
2970 }
2971 let class = DocumentClass::of_name(&name).unwrap_or_default();
2972 adopt_class(docs.tab_engine_mut(), class);
2973 stamp_save_as_lineage(docs, store, &name);
2977 if docs.tab_engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
2978 docs.tab_engine_mut()
2979 .history
2980 .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
2981 }
2982 let previous = docs.active().name().map(str::to_string);
2983 let saved_text = docs.tab_engine().history_request_json();
2984 match store.browser_write(&name, &saved_text) {
2985 Ok(identity) => {
2986 if let Some(old) = previous.filter(|old| store.canonical_identity(old) != store.canonical_identity(&identity)) {
2989 let tabs = docs.tabs_holding(&old, docs.active_id());
2990 if !tabs.is_empty() {
2991 self.retarget = Some(Retarget { old, new_key: identity.clone(), text: saved_text.clone(), tabs });
2992 }
2993 }
2994 crate::plm::thumbnail::stage_recent(&identity, docs.tab_engine(), docs.active_id());
2997 self.name_buf = model_display_name(&identity);
2998 let doc = docs.active_mut();
2999 self.member_ack.remove(&doc.id());
3000 doc.set_name(Some(identity.clone()));
3001 doc.mark_clean();
3002 self.save_generation += 1;
3003 remember_document(store, &identity);
3004 self.status = if summary.is_empty() { format!("saved {identity}") } else { format!("saved {identity}; {summary}") };
3005 true
3006 }
3007 Err(e) => {
3008 self.status = format!("save failed: {e}");
3009 false
3010 }
3011 }
3012 }
3013
3014 pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
3018 self.pending_open = None;
3019 if docs.focus_named(name) {
3020 remember_document(store, name);
3021 self.status = format!("{name} is already open");
3022 return;
3023 }
3024 match crate::store::read_now(store, name) {
3025 crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store),
3026 crate::store::ReadNow::Loading => {
3029 self.pending_open = Some((name.to_owned(), docs.active_id()));
3030 self.status = format!("'{name}' is still loading");
3031 }
3032 crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
3033 }
3034 }
3035
3036 fn poll_pending_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3039 let Some((name, requester)) = self.pending_open.take() else { return };
3040 if docs.active_id() != requester {
3041 return;
3042 }
3043 if docs.focus_named(&name) {
3044 self.status = format!("{name} is already open");
3045 return;
3046 }
3047 match crate::store::read_now(store, &name) {
3048 crate::store::ReadNow::Ready(contents) => {
3049 self.load_document(docs, &name, &contents, store);
3050 }
3051 crate::store::ReadNow::Loading => {
3052 self.pending_open = Some((name, requester));
3053 ctx.request_repaint_after(std::time::Duration::from_millis(100));
3054 }
3055 crate::store::ReadNow::Absent => {
3056 self.status = format!("open failed: '{name}' not found");
3057 }
3058 }
3059 }
3060
3061 fn say_previewed_export(&mut self, docs: &mut Documents) {
3072 let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
3073 return;
3074 };
3075 let note = format!("exported {label} (preview): the family's own values are not in this file");
3076 self.status = format!("{} — {label} (preview)", self.status);
3077 docs.engine_mut().push_notice(note);
3078 }
3079
3080 fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
3082 if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
3083 let text = format!(
3084 "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
3085 not the family's own values. The .nbrep recipe keeps the family's own."
3086 );
3087 ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
3088 ui.add_space(4.0);
3089 }
3090 }
3091
3092 fn poll_instructions_pdf(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3093 let Some(mut export) = self.instructions_pdf.take() else { return };
3094 if export.document != docs.view_id() {
3095 self.status = "PDF export cancelled: the active document changed".into();
3096 docs.engine_mut().push_notice(self.status.clone());
3097 return;
3098 }
3099 match docs.engine_mut().poll_instructions_pdf(&mut export.job) {
3100 Ok(None) => {
3101 self.instructions_pdf = Some(export);
3102 ctx.request_repaint_after(std::time::Duration::from_millis(16));
3103 }
3104 Ok(Some((bytes, pages))) => {
3105 let pending = PendingSave {
3106 request: SaveRequest::bytes("Export the instructions", format!("{}.pdf", export.name), bytes),
3107 detail: format!(" ({pages} page{})", if pages == 1 { "" } else { "s" }),
3108 label: None, warnings: vec![], preview: export.preview,
3109 };
3110 self.deliver(docs, store, pending);
3111 }
3112 Err(error) => {
3113 self.status = format!("PDF export failed: {error}");
3114 docs.engine_mut().push_notice(self.status.clone());
3115 }
3116 }
3117 }
3118
3119 fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
3120 let name = self.effective_name(docs);
3121 let preview = format != "json";
3125 let export = |request: SaveRequest| PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview };
3126 if format == "instructionshtml" {
3134 let ids = linked_tool_ids(docs.engine());
3135 if let Some(client) = store.plm_client().filter(|_| !ids.is_empty()) {
3136 crate::plm::tool_pictures::request(&client, &ids);
3137 if crate::plm::tool_pictures::pending_of(&ids) > 0 {
3138 self.pending_instructions_export = Some(format.to_string());
3139 self.status = "loading the tool pictures for the export…".into();
3140 return true;
3141 }
3142 }
3143 let made = docs.engine().instructions_player_data().map(|mut data| {
3144 data.inline_tool_pictures(&crate::plm::tool_pictures::fetched(&ids));
3145 brep_instructions::player::offline_html(&data, brep_instructions::player::Mode::Shop)
3146 });
3147 return match made {
3148 Ok(html) => {
3149 let pending = export(SaveRequest::text("Export the offline player", format!("{name}.html"), html));
3150 self.deliver(docs, store, pending) != Delivery::Failed
3151 }
3152 Err(e) => {
3153 self.status = format!("export failed: {e}");
3154 false
3155 }
3156 };
3157 }
3158 if format == "instructionspdf" {
3161 if self.instructions_pdf.is_some() {
3162 self.status = "an instructions PDF is already being prepared".into();
3163 return false;
3164 }
3165 return match docs.engine_mut().begin_instructions_pdf() {
3166 Ok(job) => {
3167 self.instructions_pdf = Some(PendingInstructionsPdf { document: docs.view_id(), name, preview, job });
3168 self.status = "preparing the instructions PDF…".into();
3169 true
3170 }
3171 Err(error) => { self.status = format!("export failed: {error}"); false }
3172 };
3173 }
3174 if format == "instructionsimages" {
3175 if !docs.engine_mut().instructions_topology_ready() {
3179 self.pending_instructions_export = Some(format.to_string());
3180 self.status = "loading the exact geometry for the export…".into();
3181 return true;
3182 }
3183 let made = super::instructions::images_zip(docs.engine_mut()).map(|bytes| (bytes, String::new()));
3184 return match made {
3185 Ok((bytes, detail)) => {
3186 let (title, file) = ("Export the step images", format!("{name}-steps.zip"));
3187 let pending = PendingSave { detail, ..export(SaveRequest::bytes(title, file, bytes)) };
3188 self.deliver(docs, store, pending) != Delivery::Failed
3189 }
3190 Err(e) => {
3191 self.status = format!("export failed: {e}");
3192 false
3193 }
3194 };
3195 }
3196 if format == "glb" || format == "3mf" {
3197 let written = if format == "glb" {
3200 docs.engine().export_glb_bytes(&name)
3201 } else {
3202 docs.engine().export_3mf_bytes(&name)
3203 };
3204 return match written {
3205 Ok(bytes) => {
3206 let title = if format == "glb" { "Export GLB" } else { "Export 3MF" };
3207 let request = SaveRequest::bytes(title, format!("{name}.{format}"), bytes);
3208 self.deliver(docs, store, export(request)) != Delivery::Failed
3209 }
3210 Err(e) => {
3211 self.status = format!("export failed: {e}");
3212 false
3213 }
3214 };
3215 }
3216 let state = docs.engine_mut();
3217 if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
3226 let pdf = format != "sheetsvg";
3227 let three_d = format == "sheetpdf3d";
3228 let (file, made) = if pdf {
3229 let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
3230 let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
3231 (file, bytes.map(SaveBody::Bytes))
3232 } else {
3233 let sheet_name = state
3234 .resolve_sheet("")
3235 .ok()
3236 .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
3237 .unwrap_or_default();
3238 let file = format!("{name}-{}.svg", slug(&sheet_name));
3239 let text = state.resolve_sheet("").and_then(|id| state.export_sheet_svg(&id));
3240 (file, text.map(SaveBody::Text))
3241 };
3242 let pages = state.sheet_state().sheets.len() + usize::from(three_d);
3243 return match made {
3244 Ok(body) => {
3245 let detail = if pdf {
3246 format!(" ({pages} page{})", if pages == 1 { "" } else { "s" })
3247 } else {
3248 String::new()
3249 };
3250 let title = if pdf { "Export the drawing set" } else { "Export the drawing sheet" };
3251 let request = SaveRequest { title: title.into(), file_name: file, body };
3252 let pending = PendingSave { detail, ..export(request) };
3253 self.deliver(docs, store, pending) != Delivery::Failed
3254 }
3255 Err(e) => {
3256 self.status = format!("export failed: {e}");
3257 false
3258 }
3259 };
3260 }
3261 let (text, ext) = match format {
3262 "stl" => (state.export_stl_text(), "stl"),
3263 "obj" => (state.export_obj_text(), "obj"),
3264 "iges" => (state.export_iges_text(), "igs"),
3265 "json" => (Ok(state.history_request_json()), "nbrep"),
3266 _ => (state.export_step_text_named(&name), "step"),
3269 };
3270 match text {
3271 Ok(contents) => {
3272 let title = if format == "json" { "Export the model".to_string() } else { format!("Export {}", ext.to_ascii_uppercase()) };
3273 let request = SaveRequest::text(title, format!("{name}.{ext}"), contents);
3274 self.deliver(docs, store, export(request)) != Delivery::Failed
3275 }
3276 Err(e) => {
3277 self.status = format!("export failed: {e}");
3278 false
3279 }
3280 }
3281 }
3282
3283 fn deliver(&mut self, docs: &mut Documents, store: &dyn ModelStore, pending: PendingSave) -> Delivery {
3289 if let Some(local) = store.local_files() {
3290 self.save_name = pending.request.file_name.clone();
3291 self.save_file = Some(pending);
3292 self.use_folder(Purpose::Exports, local);
3293 self.status.clear();
3294 self.open_modal(Mode::SaveFile);
3295 return Delivery::Chooser;
3296 }
3297 let file = pending.request.file_name.clone();
3298 let written = match &pending.request.body {
3299 SaveBody::Text(text) => store.export_file_named(&file, text),
3300 SaveBody::Bytes(bytes) => store.export_file_named_bytes(&file, bytes),
3301 };
3302 match written {
3303 Ok(()) => {
3304 self.say_written(docs, &pending, &file, None);
3305 Delivery::Done
3306 }
3307 Err(e) => {
3308 self.status = match &pending.label {
3309 Some(label) => format!("{label} export failed: {e}"),
3310 None => format!("export failed: {e}"),
3311 };
3312 Delivery::Failed
3313 }
3314 }
3315 }
3316
3317 fn say_written(&mut self, docs: &mut Documents, pending: &PendingSave, file: &str, folder: Option<&str>) -> bool {
3325 let into = folder.map(|folder| format!(" into {folder}")).unwrap_or_default();
3326 self.status = format!("exported {file}{}{into}", pending.detail);
3327 if pending.preview {
3328 self.say_previewed_export(docs);
3329 }
3330 use brep_render::engine_state::NoticeSeverity;
3331 let state = docs.engine_mut();
3332 match &pending.label {
3333 Some(label) if !pending.warnings.is_empty() => {
3334 let warnings = pending.warnings.join(" ");
3335 self.status = format!("{}. Before ordering: {warnings}", self.status);
3336 state.push_notice_as(NoticeSeverity::Warning, format!("{label}: {warnings}"));
3337 true
3338 }
3339 Some(label) => {
3340 state.push_notice_as(NoticeSeverity::Info, format!("{label}: {}", self.status));
3341 false
3342 }
3343 None => {
3344 if folder.is_some() {
3345 state.push_notice_as(NoticeSeverity::Info, self.status.clone());
3346 }
3347 false
3348 }
3349 }
3350 }
3351
3352 pub fn request_save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore, request: SaveRequest) -> Result<(), String> {
3357 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3358 match self.deliver(docs, store, pending) {
3359 Delivery::Failed => Err(std::mem::take(&mut self.status)),
3360 Delivery::Done | Delivery::Chooser => Ok(()),
3361 }
3362 }
3363
3364 fn show_save_file(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3370 let Some(local) = store.local_files() else {
3371 self.save_file = None;
3372 self.open = false;
3373 return;
3374 };
3375 let title = self.save_file.as_ref().map(|pending| pending.request.title.clone()).unwrap_or_else(|| "Save file".into());
3376 let pending = self.save_file.is_some();
3377 let extension = std::path::Path::new(self.save_name.trim())
3381 .extension()
3382 .map(|extension| extension.to_string_lossy().into_owned());
3383 let extensions: Vec<&str> = extension.iter().map(String::as_str).collect();
3384 let mut do_save = false;
3385 let mut cancel = false;
3386 let modal = egui::Modal::new(egui::Id::new("brep-file-savefile")).show(ctx, |ui| {
3387 file_explorer::dialog_body(ui, |ui| {
3388 ui.heading(&title);
3389 ui.add_space(4.0);
3390 file_explorer::dialog_footer(ui, "savefile", |ui| {
3391 ui.label("File name");
3392 let field = ui.add(
3393 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))
3394 .hint_text("file name")
3395 .desired_width(f32::INFINITY),
3396 );
3397 self.hit("savefile:name", &field);
3398 if self.want_focus {
3399 field.request_focus();
3400 self.want_focus = false;
3401 }
3402 let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
3403 ui.add_space(6.0);
3404 ui.horizontal(|ui| {
3405 let save = ui.add_enabled(pending, egui::Button::new("Save"));
3408 self.hit("save", &save);
3409 if save.clicked() || (enter && pending) {
3410 do_save = true;
3411 }
3412 let c = ui.button(if pending { "Cancel" } else { "Close" });
3413 self.hit("cancel", &c);
3414 if c.clicked() {
3415 cancel = true;
3416 }
3417 });
3418 if !self.status.is_empty() {
3419 ui.add_space(4.0);
3420 ui.weak(&self.status);
3421 }
3422 });
3423 let options = FileExplorerOptions {
3424 hit_prefix: "savefile",
3425 empty_label: "(no files of this kind here)",
3426 row_icon: "\u{1F5CE}",
3427 current: None,
3428 allow_delete: false,
3429 allow_import: false,
3430 import_label: "",
3431 import_hit: "savefile:upload",
3432 show_cancel: false,
3433 confirm_label: None,
3434 extensions: &extensions,
3435 };
3436 let output = self.explorer.show_store(ui, local, options);
3437 self.record_explorer_hits(&output.hits);
3438 if let Some(identity) = output.picked.or(output.activated) {
3442 self.save_name = std::path::Path::new(&identity)
3443 .file_name()
3444 .map(|name| name.to_string_lossy().into_owned())
3445 .unwrap_or(identity);
3446 ui.ctx().request_repaint();
3447 }
3448 })
3449 });
3450 if do_save {
3451 if self.save_file(docs, store) {
3452 self.open = false;
3453 }
3454 } else if cancel || modal.should_close() {
3455 if self.save_file.take().is_some() {
3456 self.status = "export cancelled: nothing written".into();
3457 }
3458 self.open = false;
3459 }
3460 }
3461
3462 pub(crate) fn save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore) -> bool {
3468 let Some(local) = store.local_files() else {
3469 return false;
3470 };
3471 let name = self.save_name.trim().to_string();
3472 if name.is_empty() {
3473 self.status = "enter a file name".into();
3474 return false;
3475 }
3476 let Some(pending) = self.save_file.take() else {
3477 return false;
3478 };
3479 match local.browser_write_bytes(&name, pending.request.body.as_bytes()) {
3480 Ok(path) => {
3481 self.folders.insert(Purpose::Exports, local.browser_location());
3482 let path = std::path::Path::new(&path);
3483 let folder = path.parent().map(|folder| folder.display().to_string()).unwrap_or_else(|| local.browser_location());
3484 let file = path.file_name().map(|file| file.to_string_lossy().into_owned()).unwrap_or(name);
3485 !self.say_written(docs, &pending, &file, Some(&folder))
3486 }
3487 Err(e) => {
3488 self.status = format!("export failed: {e}");
3489 self.save_file = Some(pending);
3490 false
3491 }
3492 }
3493 }
3494
3495 fn export_flat_pattern_as(
3501 &mut self,
3502 docs: &mut Documents,
3503 store: &dyn ModelStore,
3504 format: &str,
3505 ) -> bool {
3506 let name = self.effective_name(docs);
3507 let state = docs.engine_mut();
3508 let (text, ext) = match format {
3509 "svg" => (state.export_flat_pattern_svg(), "svg"),
3510 _ => (state.export_flat_pattern_dxf(), "dxf"),
3511 };
3512 match text {
3513 Ok(contents) => {
3514 let request = SaveRequest::text("Export the flat pattern", format!("{name}.{ext}"), contents);
3515 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: true };
3516 self.deliver(docs, store, pending) != Delivery::Failed
3517 }
3518 Err(e) => {
3519 self.status = format!("flat-pattern export failed: {e}");
3520 state.push_notice(format!("Flat pattern: {e}"));
3521 false
3522 }
3523 }
3524 }
3525
3526 fn export_bom_as(
3532 &mut self,
3533 docs: &mut Documents,
3534 store: &dyn ModelStore,
3535 format: &str,
3536 ) -> bool {
3537 let name = self.effective_name(docs);
3538 let state = docs.engine_mut();
3539 let (text, ext) = match format {
3540 "json" => (state.export_bom_json(), "bom.json"),
3541 _ => (state.export_bom_csv(), "bom.csv"),
3542 };
3543 match text {
3544 Ok(contents) => {
3545 let request = SaveRequest::text("Export the bill of materials", format!("{name}.{ext}"), contents);
3546 let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3547 self.deliver(docs, store, pending) != Delivery::Failed
3548 }
3549 Err(e) => {
3550 self.status = format!("BOM export failed: {e}");
3551 state.push_notice(format!("BOM export: {e}"));
3552 false
3553 }
3554 }
3555 }
3556
3557 fn export_fabrication_as(
3564 &mut self,
3565 docs: &mut Documents,
3566 store: &dyn ModelStore,
3567 output: super::fabrication_export::Output,
3568 ) -> bool {
3569 use super::fabrication_export::{self as fab, Output};
3570 let name = self.effective_name(docs);
3571 let file = output.file_name(&name);
3572 let state = docs.engine_mut();
3573 let made = match output {
3574 Output::Bundle => fab::bundle(state, &name).map(|bundle| {
3575 let detail = format!(" ({} files)", bundle.files.len());
3576 (SaveBody::Bytes(bundle.zip()), detail, bundle.warnings)
3577 }),
3578 _ => fab::text(state, output, &name).map(|text| (SaveBody::Text(text), String::new(), vec![])),
3579 };
3580 match made {
3581 Ok((body, detail, warnings)) => {
3582 let request = SaveRequest { title: format!("Export: {}", output.label()), file_name: file, body };
3586 let pending = PendingSave { request, detail, label: Some(output.label().to_string()), warnings, preview: false };
3587 match self.deliver(docs, store, pending) {
3588 Delivery::Done => !self.status.contains("Before ordering"),
3589 Delivery::Chooser => true,
3590 Delivery::Failed => {
3591 docs.engine_mut().push_notice(format!("{} export: {}", output.label(), self.status));
3592 false
3593 }
3594 }
3595 }
3596 Err(e) => {
3597 self.status = format!("{} export failed: {e}", output.label());
3598 state.push_notice(format!("{} export: {e}", output.label()));
3599 false
3600 }
3601 }
3602 }
3603
3604 fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3617 self.pending_step_import = None;
3623 let id = state.submit_step_probe(contents);
3624 self.pending_step_probe = Some(PendingStepProbe {
3625 id,
3626 name: name.to_string(),
3627 text: contents.to_string(),
3628 });
3629 self.status = format!("reading \"{name}\"\u{2026}");
3630 self.poll_step_probe(state);
3633 }
3634
3635 fn poll_step_probe(&mut self, state: &mut EngineState) {
3640 while let Some((id, outcome)) = state.take_step_probe() {
3641 let Some(pending) = self.pending_step_probe.as_ref() else {
3642 continue;
3643 };
3644 if pending.id != id {
3645 continue; }
3647 let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
3648 match outcome {
3649 StepProbeOutcome::Structure(probe) => {
3650 self.status = format!(
3651 "\"{name}\" contains an assembly — {} part{}, {} instance{}",
3652 probe.parts,
3653 plural(probe.parts),
3654 probe.instances,
3655 plural(probe.instances)
3656 );
3657 self.pending_step_import = Some(PendingStepImport {
3658 name,
3659 text,
3660 probe,
3661 flatten: false,
3664 });
3665 self.open_modal(Mode::StepAssembly);
3671 }
3672 StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
3673 self.import_step_flat(state, &name, &text);
3674 }
3675 }
3676 }
3677 if self.pending_step_probe.is_some() && !state.step_probes_pending() {
3678 let pending = self.pending_step_probe.take().unwrap();
3682 self.status = format!("import of \"{}\" was cancelled", pending.name);
3683 }
3684 }
3685
3686 fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3692 match state.import_step_feature(contents) {
3693 Ok(_) => self.status = format!("imported {name}"),
3697 Err(e) => self.status = format!("import failed: {e}"),
3698 }
3699 }
3700
3701 fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
3711 let Some(pending) = self.pending_step_import.take() else {
3712 return;
3713 };
3714 let was_assembly = state.history_has_assembly();
3717 let doc_name = step_document_name(&pending.name);
3718 let opts = StepAssemblyImport {
3721 nested: !pending.flatten,
3722 };
3723 let mut sink = StorePartSink::new(store, &doc_name);
3730 let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
3731 Ok(report) => {
3732 if !was_assembly {
3733 self.enter_assembly_workbench(state);
3734 }
3735 for failure in &sink.failures {
3738 state.push_notice(format!("part not saved — {failure}"));
3739 }
3740 let saved = sink.written;
3741 let base = assembly_import_message(&pending.name, &report);
3742 match (saved, sink.failures.len()) {
3743 (0, _) if sink.deferred > 0 => format!(
3744 "{base}; its {} part documents become PLM items when you save the assembly",
3745 sink.deferred
3746 ),
3747 (0, _) => base,
3748 (saved, 0) => format!("{base}; saved {saved} part file(s)"),
3749 (saved, failed) => {
3750 format!("{base}; saved {saved} part file(s), {failed} could not be saved")
3751 }
3752 }
3753 }
3754 Err(error) => match state.import_step_feature(&pending.text) {
3755 Ok(_) => format!(
3756 "imported {} as bodies — the assembly structure could not be built ({error})",
3757 pending.name
3758 ),
3759 Err(flat) => format!("import failed: {flat}"),
3760 },
3761 };
3762 self.status = message.clone();
3765 state.push_notice(message);
3766 }
3767
3768 fn step_assembly_bodies(&mut self, state: &mut EngineState) {
3772 let Some(pending) = self.pending_step_import.take() else {
3773 return;
3774 };
3775 state.discard_probed_step_assembly();
3776 self.import_step_flat(state, &pending.name, &pending.text);
3777 }
3778
3779 fn step_assembly_cancel(&mut self, state: &mut EngineState) {
3781 let Some(pending) = self.pending_step_import.take() else {
3782 return;
3783 };
3784 state.discard_probed_step_assembly();
3785 self.status = format!("import cancelled: {}", pending.name);
3786 }
3787
3788 fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
3802 if crate::workbench::panel_visible(
3803 &state.settings.workbench,
3804 crate::workbench::assembly::BOM_PANEL_ID,
3805 &crate::workbench::ButtonState::of(state),
3806 ) {
3807 return;
3808 }
3809 let _ = state.apply_settings_json(
3810 &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
3811 .to_string(),
3812 );
3813 }
3814
3815 fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3818 match state.import_iges_feature(contents) {
3819 Ok(_) => self.status = format!("imported {name}"),
3821 Err(e) => self.status = format!("import failed: {e}"),
3822 }
3823 }
3824
3825 fn stage_mesh_preview(
3826 &mut self,
3827 format: MeshImportFormat,
3828 name: String,
3829 contents: Vec<u8>,
3830 ) {
3831 self.pending_stl_import = Some((format, name, contents));
3832 self.status.clear();
3833 }
3834
3835 pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
3838 self.pending_stl_import.take()
3839 }
3840
3841 fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
3842 match state.import_obj_bytes_feature(contents) {
3843 Ok(_) => self.status = format!("reconstructing {name} in background…"),
3844 Err(e) => self.status = format!("import failed: {e}"),
3845 }
3846 }
3847
3848 fn import_text(
3849 &mut self,
3850 state: &mut EngineState,
3851 name: &str,
3852 bytes: &[u8],
3853 importer: fn(&mut Self, &mut EngineState, &str, &str),
3854 ) {
3855 match std::str::from_utf8(bytes) {
3856 Ok(contents) => importer(self, state, name, contents),
3857 Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
3858 }
3859 }
3860
3861 fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, store: &dyn ModelStore) {
3866 let plm = store.plm_client().is_some();
3867 let mut engine = docs.spawn_engine();
3868 match engine.load_model_and_fit(contents) {
3869 Ok(_) => {
3870 if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
3877 document_class::set_document_class(&mut engine, class);
3878 }
3879 let mut doc = Document::new(engine);
3883 doc.set_name(Some(name.to_string()));
3884 doc.note_opened_from(contents);
3885 docs.open_document(doc);
3886 self.name_buf = model_display_name(name);
3887 self.status = format!("opened {name}");
3888 remember_document(store, name);
3889 }
3890 Err(e) => self.status = format!("open failed: {e}"),
3891 }
3892 }
3893
3894 fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
3899 let client = store.plm_client()?;
3900 let key = crate::plm::family::revision_key(identity)?;
3901 Some((client, key))
3902 }
3903
3904 fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3906 let Some(work) = self.plm_work.as_mut() else { return };
3907 ctx.request_repaint_after(std::time::Duration::from_millis(30));
3908 match work {
3909 PlmWork::Members(pending) => {
3910 let Some(result) = pending.poll() else { return };
3911 self.plm_work = None;
3912 let Some(pick) = self.pick_member.as_mut() else { return };
3913 pick.loading = false;
3914 match result {
3915 Ok(members) => {
3916 pick.rows = members
3917 .into_iter()
3918 .map(|member| MemberChoice {
3919 why_not: member.not_placeable(),
3920 exists: member.not_placeable().is_none(),
3921 identity: member.key(),
3922 description: member.name.clone(),
3923 part_number: member.number,
3924 })
3925 .collect();
3926 }
3927 Err(error) => self.status = format!("the members could not be read: {error}"),
3928 }
3929 }
3930 PlmWork::Place(pending) => {
3931 let Some(result) = pending.poll() else { return };
3932 self.plm_work = None;
3933 match result {
3934 Ok((key, text, number)) => {
3935 self.insert_component_named(docs.engine_mut(), &key, &number, &text);
3936 self.pick_member = None;
3937 self.spin_out = None;
3938 self.open = false;
3939 }
3940 Err(error) => self.status = error,
3941 }
3942 }
3943 PlmWork::OpenFamily(pending) => {
3944 let Some(result) = pending.poll() else { return };
3945 self.plm_work = None;
3946 match result {
3947 Ok(key) => self.open_document(docs, store, &key),
3948 Err(error) => self.status = format!("the family could not be opened: {error}"),
3949 }
3950 }
3951 }
3952 }
3953
3954 fn begin_class_insert(
3960 &mut self,
3961 store: &dyn ModelStore,
3962 identity: &str,
3963 contents: &str,
3964 assembly: Option<String>,
3965 ) -> bool {
3966 let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
3967 let class = DocumentClass::of_name(identity)
3968 .filter(|class| *class != DocumentClass::Normal)
3969 .unwrap_or_else(|| DocumentClass::of_document(&document));
3970 let file = crate::store::file_name_of(identity);
3971 match class {
3972 DocumentClass::Normal | DocumentClass::Instructions => false,
3973 DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
3974 let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
3975 let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
3976 return false;
3977 };
3978 self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
3979 crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
3980 }))));
3981 self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
3982 self.status.clear();
3983 self.open_modal(Mode::PickMember);
3984 true
3985 }
3986 DocumentClass::Family => {
3987 let table = family_table::read_table(contents);
3988 let rows = table
3989 .rows
3990 .iter()
3991 .map(|row| {
3992 let identity = family_table::member_identity(Some(identity), &row.part_number);
3993 let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
3994 MemberChoice {
3995 part_number: row.part_number.clone(),
3996 description: row.description.clone(),
3997 identity,
3998 exists,
3999 why_not: None,
4000 }
4001 })
4002 .collect();
4003 self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
4004 self.status.clear();
4005 self.open_modal(Mode::PickMember);
4006 true
4007 }
4008 DocumentClass::Template => {
4009 let inputs = template::inputs_of(&document);
4010 let values = inputs
4011 .iter()
4012 .map(|input| (input.name.clone(), template::default_value(&document, input)))
4013 .collect();
4014 let stem = document_class::strip_class_extension(&file).to_string();
4015 let plm_template = self
4016 .plm_revision(store, identity)
4017 .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
4018 self.spin_out = Some(SpinOut {
4019 template_file: file,
4020 template: document,
4021 inputs,
4022 name: format!("{stem}-1"),
4023 values,
4024 assembly,
4025 plm_template,
4026 number: String::new(),
4027 });
4028 self.status.clear();
4029 self.open_modal(Mode::SpinOut);
4030 true
4031 }
4032 }
4033 }
4034
4035 fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4039 let Some(pick) = self.pick_member.take() else {
4040 self.open = false;
4041 return;
4042 };
4043 let mut chosen = None;
4044 let mut cancel = false;
4045 let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
4046 ui.set_width(420.0);
4047 ui.heading(format!("Insert a member of {}", pick.family_file));
4048 ui.add_space(2.0);
4049 ui.weak("A family is never placed itself: choose the member to place.");
4050 ui.add_space(6.0);
4051 if pick.loading {
4052 ui.label("Reading the family's members from the PLM\u{2026}");
4053 } else if pick.rows.is_empty() {
4054 ui.label("This family's table has no rows yet.");
4055 }
4056 egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
4057 for (index, row) in pick.rows.iter().enumerate() {
4058 ui.horizontal(|ui| {
4059 let text = if row.description.trim().is_empty() {
4060 row.part_number.clone()
4061 } else {
4062 format!("{} \u{2014} {}", row.part_number, row.description)
4063 };
4064 let button = ui.add_enabled(
4065 row.exists,
4066 egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
4067 );
4068 self.hit(&format!("member:{}", row.part_number), &button);
4069 if button.clicked() {
4070 chosen = Some(index);
4071 }
4072 if let Some(why) = &row.why_not {
4073 ui.weak(format!("({why})"));
4074 } else if row.identity.is_none() {
4075 ui.weak("(the part number is not a file name)");
4076 } else if !row.exists {
4077 ui.weak("(not generated yet: open the family and Generate)");
4078 }
4079 });
4080 }
4081 });
4082 ui.add_space(6.0);
4083 if !self.status.is_empty() {
4084 ui.weak(&self.status);
4085 ui.add_space(4.0);
4086 }
4087 let c = ui.button("Cancel");
4088 self.hit("member:cancel", &c);
4089 cancel = c.clicked();
4090 });
4091 let should_close = modal.should_close();
4092 if let (Some(index), true) = (chosen, pick.plm) {
4093 let row = &pick.rows[index];
4094 if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
4095 self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
4096 self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
4097 }
4098 } else if let Some(index) = chosen {
4099 let row = &pick.rows[index];
4100 let identity = row.identity.clone().unwrap_or_default();
4101 match store.read(&identity) {
4102 Some(contents) => {
4103 self.insert_component_document(state, &identity, &contents);
4104 self.open = false;
4105 return;
4106 }
4107 None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
4108 }
4109 } else if cancel || should_close {
4110 self.status.clear();
4111 self.open = false;
4112 return;
4113 }
4114 self.pick_member = Some(pick);
4115 }
4116
4117 fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4123 let Some(mut spin) = self.spin_out.take() else {
4124 self.open = false;
4125 return;
4126 };
4127 let mut create = false;
4128 let mut cancel = false;
4129 let problems: Vec<Option<String>> = spin
4130 .inputs
4131 .iter()
4132 .map(|input| {
4133 let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
4134 template::value_problem(&spin.template, input, text)
4135 })
4136 .collect();
4137 let name_problem = template::name_problem(&spin.name);
4138 let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
4139 ui.set_width(440.0);
4140 ui.heading(format!("New part from {}", spin.template_file));
4141 ui.add_space(2.0);
4142 ui.weak("A template is never placed itself: this makes a new part from it and places that.");
4143 ui.add_space(6.0);
4144 egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
4145 ui.label("New part name");
4146 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));
4147 self.hit("spinout:name", &field);
4148 ui.end_row();
4149 if spin.plm_template.is_some() {
4150 ui.label("Part number");
4151 let field = ui.add(
4152 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))
4153 .hint_text("empty: the part type numbers it")
4154 .desired_width(240.0),
4155 );
4156 self.hit("spinout:number", &field);
4157 ui.end_row();
4158 }
4159 for (input, problem) in spin.inputs.iter().zip(&problems) {
4160 ui.label(input.shown());
4161 ui.vertical(|ui| {
4162 let value = spin.values.entry(input.name.clone()).or_default();
4163 if input.choices.is_empty() {
4164 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));
4165 self.hit(&format!("spinout:input:{}", input.name), &field);
4166 } else {
4167 let trimmed: Vec<String> = input.choices.iter().map(|choice| choice.trim().to_string()).collect();
4170 let choices: Vec<(String, &str, &str)> = trimmed.iter().map(|choice| (choice.clone(), choice.as_str(), choice.as_str())).collect();
4171 let mut chosen = value.trim().to_string();
4172 let (changed, _) = crate::form::enum_dropdown(ui, &format!("spinout:choice:{}", input.name), &choices, &mut chosen, None, Some(&mut self.hits));
4173 if changed {
4174 *value = chosen;
4175 }
4176 }
4177 let limits = match (input.min, input.max) {
4178 (Some(min), Some(max)) => format!("{min} to {max}"),
4179 (Some(min), None) => format!("at least {min}"),
4180 (None, Some(max)) => format!("at most {max}"),
4181 (None, None) => String::new(),
4182 };
4183 if !limits.is_empty() {
4184 ui.weak(limits);
4185 }
4186 if let Some(problem) = problem {
4187 ui.colored_label(ui.visuals().warn_fg_color, problem);
4188 }
4189 });
4190 ui.end_row();
4191 }
4192 });
4193 if spin.inputs.is_empty() {
4194 ui.weak("This template marks no inputs; the copy is the template as it is.");
4195 }
4196 ui.add_space(4.0);
4197 let file = crate::store::file_name_of(&spin_out_target(&spin));
4198 ui.weak(match &spin.assembly {
4199 _ if spin.plm_template.is_some() => {
4200 "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
4201 }
4202 Some(assembly) => format!(
4203 "Saved as {file}, in the folder of {}",
4204 crate::store::file_name_of(assembly)
4205 ),
4206 None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
4207 });
4208 if let Some(problem) = &name_problem {
4209 ui.colored_label(ui.visuals().warn_fg_color, problem);
4210 }
4211 if !self.status.is_empty() {
4212 ui.weak(&self.status);
4213 }
4214 ui.add_space(6.0);
4215 ui.horizontal(|ui| {
4216 let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
4217 let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
4218 self.hit("spinout:create", &c);
4219 create = c.clicked();
4220 let x = ui.button("Cancel");
4221 self.hit("spinout:cancel", &x);
4222 cancel = x.clicked();
4223 });
4224 });
4225 let should_close = modal.should_close();
4226 if create {
4227 match self.create_spin_out(state, store, &spin) {
4228 Ok(()) => {
4229 self.open = false;
4230 return;
4231 }
4232 Err(error) => self.status = error,
4233 }
4234 } else if cancel || should_close {
4235 self.status.clear();
4236 self.open = false;
4237 return;
4238 }
4239 self.spin_out = Some(spin);
4240 }
4241
4242 fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
4244 if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
4245 let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
4248 self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
4249 let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
4250 .await
4251 .map_err(|e| e.to_string())?;
4252 let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
4253 let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
4254 format!("{} was made, but has no document yet", spun.number)
4255 })?;
4256 Ok((spun.key, text, spun.number))
4257 }))));
4258 return Err("making the part on the PLM\u{2026}".into());
4259 }
4260 if let Some(problem) = template::name_problem(&spin.name) {
4261 return Err(problem);
4262 }
4263 let target = spin_out_target(spin);
4264 if store.read(&target).is_some() {
4265 return Err(format!("'{target}' already exists: choose another name"));
4266 }
4267 let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
4268 let text = copy.to_string();
4269 store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
4270 self.save_generation += 1;
4271 self.insert_component_document(state, &target, &text);
4272 if spin.assembly.is_none() {
4273 self.status = format!(
4274 "{} — saved to the models folder as {target}, because the assembly is not saved yet",
4275 self.status
4276 );
4277 }
4278 Ok(())
4279 }
4280
4281 fn watch_member_edit(&mut self, docs: &Documents) {
4286 let doc = docs.active();
4287 if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
4288 return;
4289 }
4290 if let Some(name) = doc.name().map(str::to_string) {
4291 self.arm_member_prompt(docs, &name, false);
4292 }
4293 }
4294
4295 fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
4296 let Some(source) = family_table::engine_family_source(docs.engine()) else {
4297 return false;
4298 };
4299 self.member_prompt = Some(MemberPrompt {
4300 doc_id: docs.active().id(),
4301 family_identity: sibling_identity(name, &source.family),
4302 source,
4303 from_save,
4304 });
4305 self.status.clear();
4306 self.open_modal(Mode::MemberEdit);
4307 true
4308 }
4309
4310 fn revert_member_edit(docs: &mut Documents) {
4313 for _ in 0..64 {
4314 if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
4315 break;
4316 }
4317 docs.engine_mut().undo();
4318 }
4319 }
4320
4321 fn show_owned_save(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4326 let Some(mut prompt) = self.owned_prompt.take() else {
4327 self.open = false;
4328 return;
4329 };
4330 if docs.active_id() != prompt.plan.doc_id {
4331 self.status = "Save cancelled because the active document changed".into();
4332 self.open = false;
4333 return;
4334 }
4335 let mut choice = None;
4336 let modal = egui::Modal::new(egui::Id::new("brep-file-owned-save")).show(ctx, |ui| {
4337 ui.set_width(520.0);
4338 ui.heading("Save the parts this assembly changed");
4339 ui.add_space(4.0);
4340 ui.label("Saving writes these documents too. Untick any you do not want written; its change stays in the assembly.");
4341 ui.add_space(6.0);
4342 for (i, row) in prompt.plan.rows.iter_mut().enumerate() {
4343 if !row.asks() {
4344 if let Some(crate::document::owned_save::Issue::Blocked(_)) = row.issue {
4345 ui.weak(row.question());
4346 }
4347 continue;
4348 }
4349 let question = row.question();
4350 let tick = ui.checkbox(&mut row.proceed, question);
4351 self.hit(&format!("owned:row:{i}"), &tick);
4352 if row.issue == Some(crate::document::owned_save::Issue::Conflict) && row.proceed {
4353 ui.indent(("owned-conflict", i), |ui| {
4354 let labels: Vec<(String, String)> = row
4357 .write
4358 .variants
4359 .iter()
4360 .enumerate()
4361 .map(|(j, (names, _))| {
4362 let last = if row.edited_last == Some(j) { " (edited last)" } else { "" };
4363 (j.to_string(), format!("keep the copy in {}{last}", names.join(" > ")))
4364 })
4365 .collect();
4366 let choices: Vec<(usize, &str, &str)> = labels.iter().enumerate().map(|(j, (id, label))| (j, id.as_str(), label.as_str())).collect();
4367 crate::form::enum_dropdown(ui, &format!("owned:choice:{i}"), &choices, &mut row.choice, None, Some(&mut self.hits));
4368 });
4369 }
4370 }
4371 if prompt.plan.rows.iter().any(|row| matches!(row.issue, Some(crate::document::owned_save::Issue::CheckOut { .. }))) {
4372 ui.add_space(4.0);
4373 let remember = ui.checkbox(&mut self.checkout_remembered, "Check out without asking again in this session");
4374 self.hit("owned:remember", &remember);
4375 }
4376 for promotion in &prompt.plan.promotions {
4377 let part = promotion.names.last().map_or("", String::as_str);
4378 ui.weak(if promotion.recreates() {
4379 format!("{part}: its file is missing — re-created as {}", promotion.new_key)
4380 } else {
4381 format!("{part}: saved to a new part file {}", promotion.new_key)
4382 });
4383 }
4384 ui.add_space(8.0);
4385 ui.horizontal(|ui| {
4386 let save = ui.button("Save");
4387 self.hit("owned:save", &save);
4388 if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4389 choice = Some(true);
4390 }
4391 let cancel = ui.button("Cancel");
4392 self.hit("owned:cancel", &cancel);
4393 if cancel.clicked() {
4394 choice = Some(false);
4395 }
4396 });
4397 });
4398 let choice = choice.or_else(|| modal.should_close().then_some(false));
4399 self.owned_state = serde_json::json!({"prompt": prompt.plan.to_json()});
4400 match choice {
4401 Some(true) => {
4402 self.open = false;
4403 let OwnedPrompt { plan, name, browser } = prompt;
4404 if browser {
4405 self.commit_save_browser(docs, store, name, plan);
4406 } else {
4407 self.commit_save(docs, store, name, plan);
4408 }
4409 }
4410 Some(false) => {
4411 self.status = "Save cancelled".into();
4412 self.owned_state = serde_json::Value::Null;
4413 self.open = false;
4414 }
4415 None => self.owned_prompt = Some(prompt),
4416 }
4417 }
4418
4419 fn show_retarget(&mut self, ctx: &egui::Context, docs: &mut Documents) {
4424 let Some(retarget) = self.retarget.as_ref() else {
4425 self.open = false;
4426 return;
4427 };
4428 let mut choice = None;
4429 let new_name = model_display_name(&retarget.new_key);
4430 let old_name = model_display_name(&retarget.old);
4431 let titles: Vec<String> = retarget.tabs.iter().map(|(_, title)| title.clone()).collect();
4432 egui::Modal::new(egui::Id::new("brep-file-retarget")).show(ctx, |ui| {
4433 ui.set_width(480.0);
4434 ui.heading("Point the assemblies at the new file?");
4435 ui.label(format!(
4436 "{} {} '{old_name}'. Point {} at '{new_name}' too?",
4437 titles.join(", "),
4438 if titles.len() == 1 { "places" } else { "place" },
4439 if titles.len() == 1 { "it" } else { "them" },
4440 ));
4441 ui.add_space(8.0);
4442 ui.horizontal(|ui| {
4443 let yes = ui.button(format!("Yes, use '{new_name}'"));
4444 self.hit("retarget:yes", &yes);
4445 if yes.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4446 choice = Some(true);
4447 }
4448 let no = ui.button(format!("No, keep '{old_name}'"));
4449 self.hit("retarget:no", &no);
4450 if no.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
4451 choice = Some(false);
4452 }
4453 });
4454 });
4455 let Some(yes) = choice else { return };
4456 let Some(retarget) = self.retarget.take() else { return };
4457 self.open = false;
4458 if !yes {
4459 self.status = format!("kept the assemblies on '{old_name}'");
4460 return;
4461 }
4462 let mut moved = Vec::new();
4463 for (id, title) in &retarget.tabs {
4464 match docs.retarget_copies(*id, &retarget.old, &retarget.new_key, &retarget.text) {
4465 Ok(n) if n > 0 => moved.push(title.clone()),
4466 Ok(_) => {}
4467 Err(error) => docs.engine_mut().push_notice(format!("{title}: could not point it at '{new_name}': {error}")),
4468 }
4469 }
4470 self.status = format!("{} now {} '{new_name}' (unsaved)", moved.join(", "), if moved.len() == 1 { "uses" } else { "use" });
4471 docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, self.status.clone());
4472 }
4473
4474 fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4477 let Some(prompt) = self.member_prompt.take() else {
4478 self.open = false;
4479 return;
4480 };
4481 if docs.active().id() != prompt.doc_id {
4482 self.open = false;
4483 return;
4484 }
4485 let mut choice = None;
4486 let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
4487 let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
4488 ui.set_width(440.0);
4489 ui.heading("This part is a family member");
4490 ui.add_space(4.0);
4491 ui.label(format!(
4492 "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
4493 prompt.source.part_number, prompt.source.family
4494 ));
4495 ui.add_space(4.0);
4496 ui.weak(if prompt.from_save {
4497 "Save it as a new part, or open the family and make the change there."
4498 } else {
4499 "Keep the change in a new part, or open the family and make it there."
4500 });
4501 if !self.status.is_empty() {
4502 ui.add_space(4.0);
4503 ui.weak(&self.status);
4504 }
4505 ui.add_space(8.0);
4506 ui.horizontal(|ui| {
4507 let a = ui.button("Save as a new part\u{2026}");
4508 self.hit("member-edit:saveas", &a);
4509 if a.clicked() {
4510 choice = Some("saveas");
4511 }
4512 let offer_family = match &plm {
4514 Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
4515 None => true,
4516 };
4517 if offer_family {
4518 let b = ui.button("Open the family");
4519 self.hit("member-edit:family", &b);
4520 if b.clicked() {
4521 choice = Some("family");
4522 }
4523 }
4524 let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
4525 self.hit("member-edit:cancel", &c);
4526 if c.clicked() {
4527 choice = Some("cancel");
4528 }
4529 });
4530 });
4531 let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
4532 match choice {
4533 Some("saveas") => {
4534 self.member_ack.insert(prompt.doc_id);
4538 self.member_new_part = store.plm_client().is_some();
4539 self.status.clear();
4540 self.dispatch(FileAction::SaveAs, docs, store);
4541 }
4542 Some("family") if plm.is_some() => {
4543 let (client, _) = plm.unwrap_or_else(|| unreachable!());
4544 let number = document_class::strip_class_extension(&prompt.source.family).to_string();
4545 Self::revert_member_edit(docs);
4546 self.status = format!("opening the family {number}\u{2026}");
4547 self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
4548 let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
4549 let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
4551 Ok(crate::plm::identity::document_key(&head.id, &newest.id))
4552 }))));
4553 self.open = false;
4554 }
4555 Some("family") => {
4556 if store.read(&prompt.family_identity).is_none() {
4557 self.status = format!("the family file is not there: {}", prompt.family_identity);
4558 self.member_prompt = Some(prompt);
4559 return;
4560 }
4561 Self::revert_member_edit(docs);
4562 self.open = false;
4563 self.open_document(docs, store, &prompt.family_identity);
4564 }
4565 Some(_) => {
4566 if !prompt.from_save {
4567 Self::revert_member_edit(docs);
4568 }
4569 self.status.clear();
4570 self.open = false;
4571 }
4572 None => self.member_prompt = Some(prompt),
4573 }
4574 }
4575
4576 pub fn generate_active_family(
4586 &mut self,
4587 docs: &mut Documents,
4588 store: &dyn ModelStore,
4589 save: bool,
4590 ) -> Result<family_table::GenerateReport, String> {
4591 let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
4592 let report = match (store.plm_client(), plm_key) {
4593 (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
4594 _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
4595 };
4596 Ok(self.record_generate(report))
4597 }
4598
4599 #[cfg(not(target_arch = "wasm32"))]
4603 fn generate_on_plm(
4604 &mut self,
4605 docs: &mut Documents,
4606 store: &dyn ModelStore,
4607 client: &std::rc::Rc<crate::plm::client::PlmClient>,
4608 key: &str,
4609 save: bool,
4610 ) -> Result<family_table::GenerateReport, String> {
4611 let json = docs.tab_engine().history_request_json();
4612 if docs.active().is_dirty() {
4613 if !save {
4614 return Err(crate::plm::family::UNSAVED_FAMILY.into());
4615 }
4616 store.write(key, &json)?;
4617 docs.active_mut().mark_clean();
4618 self.save_generation += 1;
4619 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
4621 while store.pending_writes() > 0 {
4622 if std::time::Instant::now() > deadline {
4623 return Err("the family's save did not reach the server within 60 s".into());
4624 }
4625 crate::plm::native::run_pending();
4626 std::thread::sleep(std::time::Duration::from_millis(5));
4627 }
4628 if let Some(error) = store.take_persistence_errors().into_iter().next() {
4629 return Err(format!("the family was not saved, so nothing was generated: {error}"));
4630 }
4631 }
4632 crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
4633 .ok_or("the PLM server did not answer Generate within 5 minutes")?
4634 .map_err(|e| e.to_string())
4635 }
4636
4637 #[cfg(target_arch = "wasm32")]
4638 fn generate_on_plm(
4639 &mut self,
4640 _: &mut Documents,
4641 _: &dyn ModelStore,
4642 _: &std::rc::Rc<crate::plm::client::PlmClient>,
4643 _: &str,
4644 _: bool,
4645 ) -> Result<family_table::GenerateReport, String> {
4646 Err("in the browser, Generate a PLM family from the family table's Generate button".into())
4647 }
4648
4649 fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
4650 if report.rows.iter().any(|row| row.status() == "written") {
4651 self.save_generation += 1;
4652 }
4653 self.status = format!("generate: {}", report.summary());
4654 self.last_generate = Some(report.clone());
4655 report
4656 }
4657
4658 pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
4663 serde_json::json!({
4664 "name": docs.active().name(),
4665 "nameBuf": self.name_buf,
4666 "selected": self.explorer.selected(),
4667 "location": store.browser_location(),
4668 "folders": Purpose::ALL
4671 .iter()
4672 .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
4673 .collect::<serde_json::Map<_, _>>(),
4674 "dirty": docs.active().dirty_marker(),
4682 "backend": store.backend_label(),
4683 "interchange": store.supports_file_interchange(),
4684 "list": store.list(),
4685 "status": self.status,
4686 "ownedSave": self.owned_state,
4687 "ownedInFlight": self.owned_checkouts.len()
4691 + self.owned_revisions.as_ref().map_or(0, |r| r.requests.len())
4692 + usize::from(self.owned_followup.is_some())
4693 + usize::from(self.owned_top.is_some()),
4694 "retarget": self.retarget.as_ref().map(|r| serde_json::json!({
4695 "from": r.old,
4696 "to": r.new_key,
4697 "tabs": r.tabs.iter().map(|(_, title)| title.clone()).collect::<Vec<_>>(),
4698 })),
4699 "open": self.open,
4700 "mode": match self.mode {
4701 Mode::CreateComponentPart => "createcomponentpart",
4702 Mode::Open => "open",
4703 Mode::SaveAs => "saveas",
4704 Mode::ConfirmClose => "confirmclose",
4705 Mode::Export => "export",
4706 Mode::Import => "import",
4707 Mode::FlatPattern => "flatpattern",
4708 Mode::InsertComponent => "insertcomponent",
4709 Mode::StepAssembly => "stepassembly",
4710 Mode::Kicad => "kicad",
4711 Mode::PickMember => "pickmember",
4712 Mode::SpinOut => "spinout",
4713 Mode::MemberEdit => "memberedit",
4714 Mode::OwnedSave => "ownedsave",
4715 Mode::Retarget => "retarget",
4716 Mode::PickFile => "pickfile",
4717 Mode::SaveFile => "savefile",
4718 },
4719 "saveFile": self.save_file.as_ref().map(|pending| serde_json::json!({
4722 "title": pending.request.title,
4723 "name": self.save_name,
4724 "folder": store.local_files().map(|local| local.browser_location()),
4725 "bytes": pending.request.body.as_bytes().len(),
4726 })),
4727 "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
4728 "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
4729 "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
4730 })),
4731 "class": document_class::document_class(docs.engine()).slug(),
4734 "saveClass": self.save_class.slug(),
4735 "plmSaveAs": self.plm_save_as.as_ref().map(|state| state.state_json()),
4736 "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
4737 "family": source.family,
4738 "partNumber": source.part_number,
4739 "valuesHash": source.values_hash,
4740 "historyHash": source.history_hash,
4741 })),
4742 "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
4745 "family": pick.family_file,
4746 "plm": pick.plm,
4747 "loading": pick.loading,
4748 "rows": pick.rows.iter().map(|row| serde_json::json!({
4749 "partNumber": row.part_number,
4750 "identity": row.identity,
4751 "exists": row.exists,
4752 "whyNot": row.why_not,
4753 })).collect::<Vec<_>>(),
4754 })),
4755 "plmBusy": self.plm_work.is_some(),
4756 "workspaceProblem": self.workspace.problem_text(),
4758 "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
4761 "template": spin.template_file,
4762 "name": spin.name,
4763 "values": spin.values,
4764 "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
4765 "target": spin_out_target(spin),
4766 "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
4767 &spin.template,
4768 input,
4769 spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
4770 )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
4771 })),
4772 "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
4773 "partNumber": prompt.source.part_number,
4774 "family": prompt.source.family,
4775 "familyIdentity": prompt.family_identity,
4776 "fromSave": prompt.from_save,
4777 })),
4778 "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
4779 "summary": report.summary(),
4780 "rows": report.to_json(),
4781 })),
4782 "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
4783 "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
4789 "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
4790 serde_json::json!({
4791 "name": pending.name,
4792 "parts": pending.probe.parts,
4793 "instances": pending.probe.instances,
4794 "nestedDepth": pending.probe.nested_depth,
4795 "flatten": pending.flatten,
4798 })
4799 }),
4800 })
4801 .to_string()
4802 }
4803
4804 pub fn hits_json(&self) -> String {
4806 crate::automation::hit_rects::hits_json(&self.hits)
4807 }
4808
4809 fn hit(&mut self, key: &str, resp: &egui::Response) {
4812 self.hits.insert(key.to_string(), resp.rect);
4813 }
4814
4815 fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
4816 self.hits.extend(hits.iter().cloned());
4817 }
4818}
4819
4820
4821fn slug(name: &str) -> String {
4825 let cleaned: String = name
4826 .chars()
4827 .map(|c| if c.is_alphanumeric() { c } else { '-' })
4828 .collect();
4829 let trimmed = cleaned.trim_matches('-').to_string();
4830 if trimmed.is_empty() { "sheet".into() } else { trimmed }
4831}
4832pub static HIT_KEYS: &[HitKeyDoc] = &[
4834 HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
4835 HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
4836 HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
4837 HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
4838 HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
4839 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 },
4840 HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
4841 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 },
4842 HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
4843 HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
4844 HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
4845 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 },
4846 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 },
4847 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 },
4848 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 },
4849 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 },
4850 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 },
4851 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 },
4852 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") },
4853 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 },
4854];
4855
4856fn fetch_document(
4858 client: std::rc::Rc<crate::plm::client::PlmClient>,
4859 key: String,
4860 number: String,
4861) -> crate::panels::plm_family::Pending<(String, String, String)> {
4862 crate::panels::plm_family::Pending::new(Box::pin(async move {
4863 let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
4864 let text = bytes
4865 .map(|b| String::from_utf8_lossy(&b).into_owned())
4866 .ok_or_else(|| format!("{key} has no document yet"))?;
4867 Ok((key, text, number))
4868 }))
4869}
4870
4871use crate::store::RECENT_DOCUMENTS_KEY;
4873pub fn recent_documents(store: &dyn ModelStore) -> Vec<String> {
4874 store
4875 .read(RECENT_DOCUMENTS_KEY)
4876 .and_then(|s| serde_json::from_str(&s).ok())
4877 .unwrap_or_default()
4878}
4879fn remember_document(store: &dyn ModelStore, name: &str) {
4880 let mut recent = recent_documents(store);
4881 recent.retain(|item| item != name);
4882 recent.insert(0, name.into());
4883 recent.truncate(20);
4884 let _ = store.write(
4885 RECENT_DOCUMENTS_KEY,
4886 &serde_json::to_string(&recent).unwrap(),
4887 );
4888}
4889
4890#[derive(Default)]
4891pub struct FileMenuOutcome {
4892 pub action: Option<FileAction>,
4893 pub recent: Option<String>,
4894 pub plugins: bool,
4896 pub javascript: bool,
4898}
4899
4900pub const RECENT_THUMB_SIDE: f32 = crate::plm::thumbnail::RECENT_SIDE;
4902
4903const 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>"##;
4907
4908pub fn file_menu(
4913 ui: &mut egui::Ui,
4914 state: &mut EngineState,
4915 store: &dyn ModelStore,
4916 settings_open: &mut bool,
4917 properties_open: &mut bool,
4918 hits: &mut HashMap<String, egui::Rect>,
4919) -> FileMenuOutcome {
4920 use crate::panels::toolbar_button;
4921 let mut outcome = FileMenuOutcome::default();
4922 let presentation = crate::workbench::file_presentation;
4923 egui_extras::install_image_loaders(ui.ctx());
4924 let menu = ui.menu_button("File", |ui| {
4925 let plugin_glyph = presentation("workbench:manage:plugins").glyph;
4928 let dynamic = crate::workbench::owned_workbenches(state);
4929 let workbench_rows: Vec<(&str, &str)> = crate::workbench::WORKBENCHES
4930 .iter()
4931 .map(|w| (w.glyph, w.label))
4932 .chain(dynamic.iter().map(|w| (plugin_glyph, w.label.as_str())))
4933 .collect();
4934 let file_rows: Vec<_> = crate::workbench::FILE_COMMANDS
4935 .iter()
4936 .map(|p| (p.glyph, p.label()))
4937 .collect();
4938 let left_width = toolbar_button::menu_width_salted(ui, "workbench-measure", &workbench_rows);
4942 let right_width = toolbar_button::menu_width(ui, &file_rows);
4943 let gap = ui.spacing().item_spacing.x;
4944 let compact = crate::viewport::touch::compact(ui.ctx());
4945 let width = if compact { left_width.max(right_width).min((ui.ctx().content_rect().width() - 32.0).max(100.0)) }
4946 else { left_width + gap + right_width };
4947 let (left_width, right_width) = if compact { (width, width) } else { (left_width, right_width) };
4948 ui.set_width(width);
4949 let max_height = ui.ctx().content_rect().height() * 0.8;
4950 ui.set_max_height(max_height);
4951 let cap_bottom = ui.max_rect().bottom();
4956 let body = egui::ScrollArea::vertical()
4957 .id_salt("file-menu-body")
4958 .auto_shrink([false, true])
4959 .max_height(max_height)
4960 .show(ui, |ui| {
4961 let columns = |ui: &mut egui::Ui| {
4962 ui.vertical(|ui| {
4964 ui.set_width(left_width);
4965 ui.weak("Workbench");
4966 let current = crate::workbench::resolved_id(state, &state.settings.workbench);
4967 if current != state.settings.workbench {
4968 ui.add(
4969 egui::Label::new(
4970 egui::RichText::new(format!(
4971 "Unavailable workbench: {}; using Modeling",
4972 state.settings.workbench
4973 ))
4974 .weak(),
4975 )
4976 .wrap(),
4977 );
4978 }
4979 let options = crate::workbench::WORKBENCHES
4980 .iter()
4981 .map(|w| (w.id, w.label, w.glyph))
4982 .chain(dynamic.iter().map(|w| (w.id.as_str(), w.label.as_str(), plugin_glyph)));
4983 for (id, label, glyph) in options {
4984 let row = toolbar_button::menu_row(ui, glyph, label, current == id, left_width);
4985 hits.insert(format!("workbench:item:{id}"), row.rect);
4986 if row.clicked() {
4987 let _ = state
4988 .apply_settings_json(&serde_json::json!({"workbench": id}).to_string());
4989 let _ = store.write(crate::store::SETTINGS_KEY, &state.settings_json());
4990 ui.close();
4991 }
4992 }
4993 });
4994 ui.vertical(|ui| {
4996 ui.set_width(right_width);
4997 ui.weak("Document");
4998 for (class, p) in DocumentClass::ALL
4999 .into_iter()
5000 .zip(crate::workbench::NEW_DOCUMENT_COMMANDS)
5001 {
5002 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5003 hits.insert(p.id.into(), row.rect);
5004 if class == DocumentClass::Normal {
5005 hits.insert("file:new".into(), row.rect);
5006 hits.insert("file:newmenu".into(), row.rect);
5007 }
5008 if row.clicked() {
5009 outcome.action = Some(FileAction::NewOfClass(class));
5010 ui.close();
5011 }
5012 }
5013 for (id, action) in [
5014 ("file:open", FileAction::Open),
5015 ("file:save", FileAction::Save),
5016 ("file:saveas", FileAction::SaveAs),
5017 ("file:import", FileAction::Import),
5018 ("file:export", FileAction::Export),
5019 ] {
5020 let p = presentation(id);
5021 let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5022 hits.insert(id.into(), row.rect);
5023 if row.clicked() {
5024 outcome.action = Some(action);
5025 ui.close();
5026 }
5027 }
5028 let properties = toolbar_button::menu_row(
5029 ui,
5030 presentation("properties").glyph,
5031 presentation("properties").label(),
5032 *properties_open,
5033 right_width,
5034 );
5035 hits.insert("properties".into(), properties.rect);
5036 if properties.clicked() {
5037 *properties_open = !*properties_open;
5038 ui.close();
5039 }
5040 let settings = toolbar_button::menu_row(
5041 ui,
5042 presentation("settings").glyph,
5043 presentation("settings").label(),
5044 *settings_open,
5045 right_width,
5046 );
5047 hits.insert("settings".into(), settings.rect);
5048 if settings.clicked() {
5049 *settings_open = !*settings_open;
5050 ui.close();
5051 }
5052 for (id, flag) in [
5055 ("workbench:manage:plugins", &mut outcome.plugins),
5056 ("workbench:manage:javascript", &mut outcome.javascript),
5057 ] {
5058 let row = toolbar_button::menu_row(
5059 ui,
5060 presentation(id).glyph,
5061 presentation(id).label(),
5062 false,
5063 right_width,
5064 );
5065 hits.insert(id.into(), row.rect);
5066 if row.clicked() {
5067 *flag = true;
5068 ui.close();
5069 }
5070 }
5071 });
5072 };
5073 if compact { ui.vertical(columns); } else { ui.horizontal_top(columns); }
5074
5075 let recent = recent_documents(store);
5078 if !recent.is_empty() {
5079 ui.separator();
5080 let caption = ui.weak("Recent Documents");
5081 hits.insert("file:recent".into(), caption.rect);
5082 let thumbs = crate::plm::thumbnail::recent_textures(ui.ctx(), store);
5083 let side = egui::Vec2::splat(RECENT_THUMB_SIDE);
5084 let row_height = RECENT_THUMB_SIDE + 2.0 * ui.spacing().button_padding.y;
5085 let room = cap_bottom - ui.cursor().top();
5088 let list_height = room.max(2.0 * row_height + ui.spacing().item_spacing.y);
5089 egui::ScrollArea::vertical()
5090 .id_salt("file-recent")
5091 .auto_shrink([false, true])
5092 .max_height(list_height)
5093 .show(ui, |ui| {
5094 for name in &recent {
5095 let image = match thumbs.get(name) {
5096 Some((_, texture)) => egui::Image::from_texture((texture.id(), side)),
5097 None => egui::Image::new(egui::ImageSource::Bytes {
5098 uri: "bytes://brep-recent-thumb/placeholder.svg".into(),
5099 bytes: egui::load::Bytes::Static(RECENT_PLACEHOLDER_SVG.as_bytes()),
5100 }),
5101 }
5102 .fit_to_exact_size(side);
5103 let row = ui
5104 .add(
5105 egui::Button::new((image, model_display_name(name)))
5106 .wrap_mode(egui::TextWrapMode::Truncate)
5107 .min_size(egui::vec2(width, row_height)),
5108 )
5109 .on_hover_text(name);
5110 hits.insert(format!("file:recent:{name}"), row.rect);
5111 let padding = ui.spacing().button_padding;
5112 let thumb = egui::Rect::from_min_size(
5113 row.rect.min + egui::vec2(padding.x, (row.rect.height() - RECENT_THUMB_SIDE) / 2.0),
5114 side,
5115 );
5116 hits.insert(format!("file:recent:thumb:{name}"), thumb);
5117 if row.clicked() {
5118 outcome.recent = Some(name.clone());
5119 ui.close();
5120 }
5121 }
5122 });
5123 }
5124 });
5125 if compact { hits.insert("file:clip".into(), body.inner_rect); }
5126 });
5127 hits.insert("file:menu".into(), menu.response.rect);
5128 hits.insert("workbench".into(), menu.response.rect);
5130 outcome
5131}
5132
5133
5134pub fn linked_tool_ids(engine: &EngineState) -> Vec<String> {
5137 engine
5138 .instructions()
5139 .ok()
5140 .flatten()
5141 .map(|block| block.tools_list().into_iter().filter_map(|(tool, _)| tool.tool_id).collect())
5142 .unwrap_or_default()
5143}