Skip to main content

brep_app/panels/
file.rs

1//! File dialog — a **reusable modal** for model-document management: **New /
2//! Open / Save / Save As** of the MODEL, driven from the toolbar. Mirrors the
3//! shape of [`crate::palette::Palette`]: the caller owns ONE [`FileDialog`],
4//! calls [`FileDialog::dispatch`] when a toolbar file button is clicked (which
5//! either acts immediately or opens the modal in a mode), and calls
6//! [`FileDialog::show`] every frame to draw the (possibly open) modal.
7//!
8//! The model document IS the engine-owned history: `EngineState` serializes it
9//! with `history_request_json()` (the `.nbrep` recipe) and loads it with
10//! `set_history_json` / `load_model_and_fit`. This dialog keeps NO model state
11//! and no document IDENTITY — the name and the clean baseline live on
12//! [`Document`], and the open set on [`Documents`]. It holds only transient UI
13//! buffers (the name field, a status line, the open modal) and drives the
14//! documents + the [`ModelStore`] seam.
15//!
16//! # New and Open never discard anything now
17//!
18//! Both ADD A TAB: New pushes an empty document, Open pushes the opened one (or
19//! focuses the tab already holding it). Nothing is replaced, so neither needs
20//! the "discard unsaved changes?" prompt they used to raise — the only place
21//! unsaved work can still be lost is CLOSING a tab, which is where that
22//! confirmation now lives ([`Mode::ConfirmClose`]).
23//!
24//! Persistence crosses the unified [`ModelStore`] seam — the ONE platform
25//! exception. The same embeddable explorer renders on web and desktop. The web
26//! backend lists localStorage models and can Upload; desktop lists the app's
27//! models directory. No OS-native file dialog is used.
28
29mod 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/// The file operation a toolbar button requests. The shell maps a clicked
49/// toolbar button to one of these and hands it to [`FileDialog::dispatch`].
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FileAction {
52    New,
53    /// New, of a chosen class: an empty family seed or template in a new tab
54    /// (the toolbar's New menu). `NewOfClass(Normal)` is plain New.
55    NewOfClass(DocumentClass),
56    Open,
57    Save,
58    SaveAs,
59    /// Import a CAD or mesh file FROM the user's filesystem, appending it to the
60    /// model rather than replacing it. STL/OBJ use RANSAC reconstruction.
61    Import,
62    /// Export the model TO the user's filesystem in a chosen format (STEP /
63    /// IGES / STL / OBJ / JSON).
64    Export,
65    /// Export the sheet-metal FLAT PATTERN (unfold) as a 2D vector file
66    /// (DXF / SVG). Opens the flat-pattern export modal.
67    ExportFlatPattern,
68    /// Insert an ASSEMBLY COMPONENT: opens the component selector — existing
69    /// parts-library entries first, then the model store's Open list (+ Upload)
70    /// — and routes the chosen document through the engine's insert flow
71    /// (`add_part_to_library` → an ACOMP instance referencing the returned
72    /// effective part name). Dispatched when the palette picks `ACOMP`.
73    InsertComponent,
74    /// Import a part from KiCad into the open part: a `.kicad_sym` symbol (the
75    /// Symbol workbench) or a `.kicad_mod` footprint (the Pads workbench), with
76    /// the footprint and 3D model KiCad's chain leads to
77    /// ([`crate::panels::kicad_import`]).
78    ImportKicad(KicadKind),
79}
80
81/// Which modal (if any) the dialog is currently showing.
82#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84    /// Confirm recovery of a missing component source from its embedded part.
85    CreateComponentPart,
86    /// Open a saved model through the common explorer.
87    Open,
88    /// Prompt for a name and save under it.
89    SaveAs,
90    /// Confirm discarding unsaved changes before CLOSING a document tab —
91    /// the ONE place unsaved work can still be lost now that New and Open
92    /// both add a tab. `pending_close` holds the tab index.
93    ConfirmClose,
94    /// Choose an export format (STEP / IGES / STL / OBJ / JSON) for the current model.
95    Export,
96    /// Choose a STEP / IGES / STL / OBJ file through the common explorer.
97    Import,
98    /// Choose a 2D vector format (DXF / SVG) for the sheet-metal flat pattern.
99    FlatPattern,
100    /// Pick a part to insert as an assembly component (library entries + the
101    /// stored-model list + Upload).
102    InsertComponent,
103    /// A `.step` upload whose product structure the probe found: choose whether
104    /// to keep that structure (parts + component instances) or flatten it to
105    /// bodies. Backed by [`FileDialog::pending_step_import`].
106    StepAssembly,
107    /// A KiCad import: its file through the common explorer, then its own
108    /// stages ([`KicadImport`]).
109    Kicad,
110    /// Insert component chose a FAMILY: pick one of its members, which is
111    /// what gets placed ([`FileDialog::pick_member`]).
112    PickMember,
113    /// Insert component chose a TEMPLATE: name the new part and set its
114    /// inputs; the spun-out part is what gets placed ([`FileDialog::spin_out`]).
115    SpinOut,
116    /// The active document is a family MEMBER and the user just changed it:
117    /// save it as a new part, open the family, or undo ([`FileDialog::member_prompt`]).
118    MemberEdit,
119    /// The file chooser: ANY file on this machine, for a panel that asked
120    /// ([`FileDialog::request_pick_file`]). Browses the local files even in a
121    /// PLM session, whose own explorer shows the server's documents.
122    PickFile,
123}
124
125/// The file-chooser tag of the Open modal's workspace (a PLM session's Open).
126const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
127
128/// A file the chooser delivered ([`FileDialog::take_picked_file`]).
129#[derive(Clone, Debug, PartialEq)]
130pub struct PickedFile {
131    /// The file's own name, extension included (`photo.jpg`).
132    pub name: String,
133    /// Where it was on this machine; `None` in the browser, which never says.
134    pub path: Option<String>,
135    pub bytes: Vec<u8>,
136}
137
138/// What an explorer folder is FOR. The explorer's location lives in the
139/// store and every modal shares it, so each purpose remembers where the user
140/// last was and the modal opens there. With one shared folder, a KiCad import
141/// from `…/kicad/symbols` sent the next Save As and Insert component into the
142/// KiCad library (ecad-reaudit-2026-09-23, B8).
143#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
144enum Purpose {
145    /// The user's own documents: Open, Save As, Insert component.
146    Models,
147    /// Foreign CAD and mesh files: Import.
148    Imports,
149    /// KiCad symbol and footprint libraries.
150    Kicad,
151}
152
153impl Purpose {
154    const ALL: [Purpose; 3] = [Purpose::Models, Purpose::Imports, Purpose::Kicad];
155
156    fn slug(self) -> &'static str {
157        match self {
158            Purpose::Models => "models",
159            Purpose::Imports => "imports",
160            Purpose::Kicad => "kicad",
161        }
162    }
163}
164
165impl Mode {
166    /// The folder this modal browses, for the modals that browse one.
167    fn purpose(self) -> Option<Purpose> {
168        match self {
169            Mode::Open | Mode::SaveAs | Mode::InsertComponent | Mode::CreateComponentPart => {
170                Some(Purpose::Models)
171            }
172            Mode::Import => Some(Purpose::Imports),
173            Mode::Kicad => Some(Purpose::Kicad),
174            Mode::ConfirmClose
175            | Mode::Export
176            | Mode::FlatPattern
177            | Mode::StepAssembly
178            | Mode::PickMember
179            | Mode::SpinOut
180            | Mode::MemberEdit
181            | Mode::PickFile => None,
182        }
183    }
184}
185
186/// The store a purpose's explorer browses. Import and the KiCad import pick
187/// a file from THIS MACHINE: in a PLM session the session store is the server's documents, so
188/// Import browses the local files the session keeps beside it
189/// ([`ModelStore::local_files`]); on a file store they are the same store.
190fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
191    match purpose {
192        Purpose::Imports | Purpose::Kicad => store.local_files().unwrap_or(store),
193        Purpose::Models => store,
194    }
195}
196
197/// The store's models folder, where a purpose not yet used starts, and where
198/// the Export dialog's files go on desktop. `None` where the store has no
199/// such place (the browser), which leaves the location alone.
200fn models_folder(store: &dyn ModelStore) -> Option<String> {
201    store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
202}
203
204/// A probed `.step` upload waiting on the user's choice — the state that makes
205/// the §3.9 modal work across frames (egui draws every frame; the click can
206/// land many frames after the upload).
207///
208/// The PARSED assembly itself is NOT here: it lives in the engine's stash
209/// (`EngineState::probe_step_assembly` put it there), and the import consumes
210/// that stash. This holds only what the prompt says and the `text` the flat
211/// lane needs — the two lanes that re-import from source rather than from the
212/// parse ("Import as bodies", and the structured lane's own failure fallback).
213struct PendingStepImport {
214    /// The uploaded file's name — the prompt's subject and the status line's.
215    name: String,
216    /// The file text. The engine's stash holds the PARSE, not the source, so
217    /// the flat lane's input has to be kept here.
218    text: String,
219    /// The counts the prompt shows, from the same walk the import runs.
220    probe: StepAssemblyProbe,
221    /// The §3.9 checkbox: flatten the sub-assembly tree to leaf occurrences
222    /// instead of building nested rigid sub-assembly documents. Only shown (and
223    /// only meaningful) when `probe.nested_depth > 1`; a depth-1 file imports
224    /// identically either way.
225    flatten: bool,
226}
227
228/// A `.step` upload whose structure probe is RUNNING on the document's
229/// background runner (native thread / browser worker — the parse builds every
230/// product's bodies and takes seconds on a real assembly, so it left the UI
231/// thread). Resolved by [`FileDialog::poll_step_probe`] into either the §3.9
232/// choice ([`PendingStepImport`]) or the flat lane.
233struct PendingStepProbe {
234    /// The engine's probe id, so a stale answer (a superseded upload) is ignored.
235    id: u64,
236    name: String,
237    /// The file text, kept for the flat lane the outcome may route to.
238    text: String,
239}
240
241/// What the user clicked in the §3.9 assembly-choice modal.
242#[derive(Clone, Copy, PartialEq, Eq)]
243enum StepChoice {
244    /// Keep the structure: parts-library entries + one component per occurrence.
245    Assembly,
246    /// Today's flat lane, unchanged: one IMPORT3D feature carrying the text.
247    Bodies,
248    /// Import nothing, and drop the parse (Esc / click-outside land here too).
249    Cancel,
250}
251
252/// What Insert component lists, and so the only files it inserts: parts, and
253/// the families and templates that each lead to a part.
254const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
255
256/// One row of the member picker: a family row and where its member is.
257struct MemberChoice {
258    part_number: String,
259    description: String,
260    /// The member's store identity; `None` when the part number cannot be a
261    /// file name.
262    identity: Option<String>,
263    /// Whether that file exists (Generate has written it).
264    exists: bool,
265    /// On a PLM store: why the member cannot be placed (not generated, waiting
266    /// for its bake, failed it), as the server's family view says.
267    why_not: Option<String>,
268}
269
270/// An armed member picker: the family Insert component chose.
271struct PickMember {
272    /// The family's file name, for the heading.
273    family_file: String,
274    rows: Vec<MemberChoice>,
275    /// On a PLM store the members are parts: the list comes from the server
276    /// (`loading` until it has answered), and a chosen member's document is
277    /// read from it.
278    plm: bool,
279    loading: bool,
280}
281
282/// An armed template spin-out: the template Insert component chose, and the
283/// user's answers so far.
284struct SpinOut {
285    template_file: String,
286    template: serde_json::Value,
287    inputs: Vec<TemplateInput>,
288    /// The new part's name (no extension).
289    name: String,
290    /// Input name -> the value text the user typed or chose.
291    values: BTreeMap<String, String>,
292    /// Where the new part goes: the folder of the assembly being edited, or
293    /// the models folder when that assembly has never been saved.
294    assembly: Option<String>,
295    /// On a PLM store: the template part the SERVER spins out, and the new
296    /// part's number (empty lets a counter part type number it).
297    plm_template: Option<String>,
298    number: String,
299}
300
301/// PLM work the dialog waits on (plm-cad-integration-todo S9), polled each
302/// frame so the web build works the same.
303enum PlmWork {
304    /// The member picker's list.
305    Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
306    /// A chosen member's (or a spun-out part's) document, to place: the key,
307    /// the text, and the part number it is named by.
308    Place(crate::panels::plm_family::Pending<(String, String, String)>),
309    /// Open the family a member came from: its newest revision's key.
310    OpenFamily(crate::panels::plm_family::Pending<String>),
311}
312
313/// The hand-edit prompt's subject: which tab, and the family it came from.
314struct MemberPrompt {
315    doc_id: u64,
316    source: FamilySource,
317    /// The family file's identity, beside the member.
318    family_identity: String,
319    /// The prompt came from plain Save rather than from an edit.
320    from_save: bool,
321}
322
323/// Make the engine's document a `class` document: the class field, and none
324/// of the other classes' blocks (a template saved as a part stops asking for
325/// inputs; a family saved as a template drops its table). No undo step: this
326/// is what the new FILE is, written as it is saved.
327fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
328    document_class::set_document_class(engine, class);
329    let drop: &[&str] = match class {
330        DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
331        DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
332        DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
333    };
334    for key in drop {
335        if engine.history.document_block(key).is_some() {
336            engine.history.set_document_block_no_undo(key, None);
337        }
338    }
339}
340
341/// Where a spun-out part is written: `<name>.nbrep` in the assembly's folder,
342/// or the bare file name (the models folder) when the assembly has none.
343fn spin_out_target(spin: &SpinOut) -> String {
344    let file = DocumentClass::Normal.file_name(spin.name.trim());
345    match &spin.assembly {
346        Some(assembly) => sibling_identity(assembly, &file),
347        None => file,
348    }
349}
350
351/// `"s"` unless there is exactly one — the difference between "1 parts" and a
352/// sentence a user believes.
353fn plural(count: usize) -> &'static str {
354    if count == 1 {
355        ""
356    } else {
357        "s"
358    }
359}
360
361/// The document name an imported assembly's unnamed products are stemmed from:
362/// the file's base name without its STEP extension, so a nameless product reads
363/// `bracket-assy-part-7`, not `bracket-assy.step-part-7`.
364fn step_document_name(file_name: &str) -> String {
365    let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
366    let cut = base
367        .rfind('.')
368        .filter(|dot| is_step_name(&base[*dot..]))
369        .unwrap_or(base.len());
370    let stem = &base[..cut];
371    if stem.is_empty() {
372        base.to_string()
373    } else {
374        stem.to_string()
375    }
376}
377
378/// The §3.9 outcome line: `imported bracket-assy.step — 7 parts, 23 components
379/// (2 mirrored instances baked)`, plus the tail an imperfect import owes the
380/// user. Every qualifier is reported ONLY when it happened, so a clean import
381/// reads clean — but a partial one never reads as a whole one:
382///
383/// * `baked_nonrigid` — occurrences whose mirror/scale was baked into a part of
384///   their own (§3.4), which is why the part count can exceed the file's;
385/// * `failed_products` — products that did not encode, skipped and counted;
386/// * `flat_fallback` — the user asked for an assembly and got bodies. Said
387///   plainly, never silently (the dialog lane reports this through the Err
388///   branch instead, which is the only way it can reach the user from here);
389/// * `first_error` — the one thing that explains the rest.
390/// The STEP-assembly import's [`PartSink`]: writes each unique part document to
391/// the model store and hands back the identity it was stored under, so an
392/// imported part carries a REAL `sourceKey` and there is no second kind of part.
393///
394/// # The destination
395///
396/// `browser_write` at the explorer's CURRENT location, under
397/// `{assembly}-{part}` — the convention `panels::step_parts` already uses for a
398/// single STEP part (that panel lets the user pick the folder first; the
399/// assembly modal inherits wherever the explorer is pointing).
400///
401/// The kernel plan's alternative — a `{assembly}/{part}.nbrep` SUB-FOLDER —
402/// is not reachable through this door: every `browser_write` implementation
403/// flattens the name to a single file (native takes `file_name()`, web takes
404/// `model_display_name`), so a sub-path would silently collapse. Writing the
405/// assembly name into the FILE name keeps a 50-part import grouped in one
406/// listing without a folder convention this seam cannot express. Creating and
407/// navigating into a folder as a side effect of an import is the prompt this
408/// lane would have to grow, and it is not bolted on here.
409///
410/// A failed write is per part: that part stays embedded-only (`None`) and the
411/// import continues. Losing one part's FILE is recoverable; losing the import
412/// is not.
413///
414/// # Known limit of a flat name
415///
416/// `taken` is per IMPORT, so re-importing the same file reuses the same names —
417/// which is what makes cross-import dedup work (same key, same signature, the
418/// resident entry is reused). But two DIFFERENT assemblies whose document names
419/// sanitize to the same stem (`as1-ug` and `as1_ug`) write to each other's file
420/// names. The second import wins the file; the first assembly's entry then
421/// disagrees with it, so it badges outdated and the write-through guard refuses
422/// to clobber it — visible and recoverable (the embedded document is intact),
423/// but two assemblies sharing one name. The fix is the per-assembly sub-folder
424/// `browser_write` cannot express; see the kernel plan's §3.5.
425struct StorePartSink<'a> {
426    store: &'a dyn ModelStore,
427    /// Prefixes every file name, so one import's parts sort together.
428    prefix: String,
429    /// File names already claimed this import — two STEP products can sanitize
430    /// to the same name, and the second must not overwrite the first's file
431    /// (that would leave two entries pointing at one document).
432    taken: std::collections::HashSet<String>,
433    written: usize,
434    failures: Vec<String>,
435}
436
437impl<'a> StorePartSink<'a> {
438    fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
439        Self {
440            store,
441            prefix: sanitize_file_stem(prefix),
442            taken: std::collections::HashSet::new(),
443            written: 0,
444            failures: Vec::new(),
445        }
446    }
447}
448
449impl PartSink for StorePartSink<'_> {
450    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
451        let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
452        let mut name = base.clone();
453        let mut suffix = 2;
454        while !self.taken.insert(name.clone()) {
455            name = format!("{base}-{suffix}");
456            suffix += 1;
457        }
458        match self.store.browser_write(&name, document_json) {
459            Ok(identity) => {
460                self.written += 1;
461                Some(identity)
462            }
463            Err(error) => {
464                self.failures.push(format!("{name}: {error}"));
465                None
466            }
467        }
468    }
469}
470
471/// A STEP product name reduced to something every store backend can hold as a
472/// file name: ASCII word characters, `.`, `-` kept; everything else (spaces,
473/// slashes, the `(mirrored)` parentheses this crate appends) becomes `_`. Runs
474/// of `_` collapse and the ends are trimmed, so a name is readable rather than
475/// a row of underscores. Empty input yields `part`.
476fn sanitize_file_stem(name: &str) -> String {
477    let mut out = String::with_capacity(name.len());
478    for ch in name.chars() {
479        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
480            out.push(ch);
481        } else if !out.ends_with('_') {
482            out.push('_');
483        }
484    }
485    let trimmed = out.trim_matches('_');
486    if trimmed.is_empty() {
487        "part".to_string()
488    } else {
489        trimmed.to_string()
490    }
491}
492
493fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
494    let mut message = format!(
495        "imported {name} \u{2014} {} part{}, {} component{}",
496        report.parts,
497        plural(report.parts),
498        report.instances,
499        plural(report.instances)
500    );
501    if report.baked_nonrigid > 0 {
502        message.push_str(&format!(
503            " ({} mirrored instance{} baked)",
504            report.baked_nonrigid,
505            plural(report.baked_nonrigid)
506        ));
507    }
508    if report.failed_products > 0 {
509        message.push_str(&format!(
510            "; {} product{} could not be built",
511            report.failed_products,
512            plural(report.failed_products)
513        ));
514    }
515    if report.flat_fallback {
516        message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
517    }
518    if let Some(error) = &report.first_error {
519        message.push_str(&format!(" [{error}]"));
520    }
521    message
522}
523
524/// Whether an imported file name is a STEP file (`.step` / `.stp`, any case) —
525/// the routing key in [`FileDialog::show`]. Model documents arrive
526/// extension-stripped (web) or as a stored model name, so this
527/// never mis-routes a `.nbrep` file.
528fn is_step_name(name: &str) -> bool {
529    let lower = name.to_ascii_lowercase();
530    lower.ends_with(".step") || lower.ends_with(".stp")
531}
532
533/// Whether an imported file name is an IGES file (`.iges` / `.igs`, any case) —
534/// the sibling routing key of [`is_step_name`].
535fn is_iges_name(name: &str) -> bool {
536    let lower = name.to_ascii_lowercase();
537    lower.ends_with(".iges") || lower.ends_with(".igs")
538}
539
540fn is_stl_name(name: &str) -> bool {
541    name.to_ascii_lowercase().ends_with(".stl")
542}
543
544fn is_obj_name(name: &str) -> bool {
545    name.to_ascii_lowercase().ends_with(".obj")
546}
547
548/// Whether an imported file name is a 3MF package. It takes the STL lane: the
549/// same reconstruction preview, the same tolerances, the same accept.
550fn is_3mf_name(name: &str) -> bool {
551    name.to_ascii_lowercase().ends_with(".3mf")
552}
553
554mod component_part;
555
556/// The reusable file dialog — transient UI buffers + the current document
557/// identity; the model itself lives in `EngineState`'s history.
558pub struct FileDialog {
559    /// Demand-loaded open, scoped to the requesting tab. A newer open or
560    /// navigation cancels it so a delayed response cannot steal focus.
561    pending_open: Option<(String, u64)>,
562    /// A cold component read belongs to the assembly tab that requested it.
563    pending_insert: Option<(String, u64)>,
564    component_part: Option<component_part::Recovery>,
565    /// Reusable browser body shared by Open, Save As, and ACOMP selection.
566    explorer: FileExplorer,
567    /// In a PLM session the Open modal is the workspace browser instead (S14, D12). It
568    /// lives here so the folder the user was in is where Open starts next time.
569    workspace: crate::panels::plm_workspace::WorkspaceBrowser,
570    /// The name field buffer, used by the Save As modal.
571    name_buf: String,
572    /// Last-action status line, surfaced inside the modal.
573    pub(crate) status: String,
574    /// Whether a modal is currently open.
575    open: bool,
576    /// The open modal's mode (only meaningful while `open`).
577    mode: Mode,
578    /// Set on open so the Save As text input grabs focus on the next frame.
579    want_focus: bool,
580    /// A pending real-file pick belongs to the INSERT-COMPONENT flow (the
581    /// modal's Upload fired there): the next completed `take_import` routes to
582    /// the component insert instead of Open's load-document.
583    pending_component_import: bool,
584    /// The tab index a pending [`Mode::ConfirmClose`] will close on "Discard".
585    pending_close: Option<usize>,
586    /// A probed STEP assembly waiting on the §3.9 choice modal. `Some` means the
587    /// ENGINE is holding a parsed assembly for us (with every product's solids
588    /// resident), so every exit from that modal must end its life: import it,
589    /// discard it, or be superseded by the next upload.
590    pending_step_import: Option<PendingStepImport>,
591    /// A `.step` upload whose probe has not answered yet (see
592    /// [`PendingStepProbe`]); the status line says "reading…" meanwhile.
593    pending_step_probe: Option<PendingStepProbe>,
594    /// A mesh file waiting to open its reconstruction preview, with the reader
595    /// the picker routed it to (STL or 3MF).
596    pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
597    /// Bumped on every SUCCESSFUL store save (plain Save / Save As / native
598    /// Save-As) — one half of the update-components staleness key (saving a
599    /// part's source must re-check the outdated badges without a reload).
600    save_generation: u64,
601    plm_save_request: Option<u64>,
602    plm_save_as: Option<plm_save_as::SaveAs>,
603    /// The hand-edit prompt chose "Save as a new part" on a PLM: the Save As
604    /// it opens starts on the NEW PART destination (never on overwriting the
605    /// generated member or its revision). Consumed when that Save As opens.
606    member_new_part: bool,
607    /// Per-frame widget hit-rects for the headed verifier (wasm only).
608    hits: HashMap<String, egui::Rect>,
609    /// The KiCad import's stages after its file.
610    kicad: KicadImport,
611    /// The last folder of each [`Purpose`], taken from the store's explorer
612    /// location every frame its modal is open.
613    folders: HashMap<Purpose, String>,
614    /// The file chooser: which panel asked (its tag) and the modal's title,
615    /// the folder it was last in (on the LOCAL files), and what it delivered.
616    pick_request: Option<(String, String)>,
617    pick_folder: Option<String>,
618    picked: Option<(String, PickedFile)>,
619    /// Save As → "A new part" in a PLM session: the shell opens the PLM pane's New part
620    /// form (S7), which writes this document into the new part's first revision.
621    plm_new_part: bool,
622    /// The class Save As writes (its class chooser).
623    save_class: DocumentClass,
624    /// The armed member picker ([`Mode::PickMember`]).
625    pick_member: Option<PickMember>,
626    plm_work: Option<PlmWork>,
627    /// The armed template spin-out ([`Mode::SpinOut`]).
628    spin_out: Option<SpinOut>,
629    /// The armed hand-edit prompt ([`Mode::MemberEdit`]).
630    member_prompt: Option<MemberPrompt>,
631    /// Tabs whose user chose "Save as a new part": they may keep editing
632    /// without being asked again; plain Save still asks.
633    member_ack: HashSet<u64>,
634    /// The last Generate's report, for the automation blob.
635    last_generate: Option<family_table::GenerateReport>,
636}
637
638impl FileDialog {
639    /// A closed dialog with empty buffers. Document identity (name + clean
640    /// baseline) lives on [`Document`], so there is nothing to seed here.
641    pub fn new() -> Self {
642        Self {
643            pending_open: None,
644            pending_insert: None,
645            component_part: None,
646            explorer: FileExplorer::new(),
647            workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
648            name_buf: String::new(),
649            status: String::new(),
650            open: false,
651            mode: Mode::Open,
652            want_focus: false,
653            pending_component_import: false,
654            pending_close: None,
655            pending_step_import: None,
656            pending_step_probe: None,
657            pending_stl_import: None,
658            save_generation: 0,
659            plm_save_request: None,
660            plm_save_as: None,
661            member_new_part: false,
662            hits: HashMap::new(),
663            kicad: KicadImport::new(),
664            folders: HashMap::new(),
665            pick_request: None,
666            pick_folder: None,
667            picked: None,
668            plm_new_part: false,
669            save_class: DocumentClass::Normal,
670            pick_member: None,
671            plm_work: None,
672            spin_out: None,
673            member_prompt: None,
674            member_ack: HashSet::new(),
675            last_generate: None,
676        }
677    }
678
679    /// The dialog's own work in flight, for the status bar's working
680    /// indicator: a KiCad part's 3D model being parsed off the UI thread.
681    pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
682        let mut out = Vec::new();
683        if self.kicad.reading() {
684            out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
685        }
686        out
687    }
688
689    /// The monotonic successful-save counter — the shell feeds it to the
690    /// update-components checker as half its staleness key.
691    pub fn save_generation(&self) -> u64 {
692        self.save_generation
693    }
694
695    // --- dispatch: a toolbar button was clicked -------------------------------
696
697    /// Act on a toolbar file button. New acts immediately (it adds a tab, so
698    /// there is nothing to discard); all browsing flows use the same in-app
699    /// modal on both platforms.
700    pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
701        if matches!(action, FileAction::New | FileAction::NewOfClass(_) | FileAction::Open | FileAction::Import) {
702            self.pending_open = None;
703            self.pending_insert = None;
704        }
705        match action {
706            FileAction::New => self.new_document(docs, DocumentClass::Normal),
707            FileAction::NewOfClass(class) => self.new_document(docs, class),
708            FileAction::Open => {
709                self.use_folder(Purpose::Models, store);
710                self.open_modal(Mode::Open);
711            }
712            FileAction::Save => match docs.active().name().map(str::to_string) {
713                // A family member is not saved over by hand: the prompt says
714                // why and offers the two ways that are.
715                Some(name) if self.arm_member_prompt(docs, &name, true) => {}
716                Some(name) if store.plm_client().is_some() && crate::panels::plm_host::revision_of(&name).is_some() => {
717                    self.plm_save_request = Some(docs.active_id());
718                }
719                // A named document saves straight to its name.
720                Some(name) => {
721                    let _ = self.save_to(docs, store, name);
722                }
723                // An unnamed document falls through to Save As.
724                None => self.dispatch(FileAction::SaveAs, docs, store),
725            },
726            FileAction::SaveAs => {
727                self.name_buf = document_class::strip_class_extension(&Self::document_name(docs)).to_string();
728                self.save_class = document_class::document_class(docs.engine());
729                self.use_folder(Purpose::Models, store);
730                self.open_modal(Mode::SaveAs);
731            }
732            FileAction::Import => {
733                self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
734                self.open_modal(Mode::Import);
735            }
736            FileAction::Export => {
737                self.name_buf = Self::document_name(docs);
738                self.open_modal(Mode::Export);
739            }
740            FileAction::ExportFlatPattern => {
741                self.name_buf = Self::document_name(docs);
742                self.open_modal(Mode::FlatPattern);
743            }
744            // ALWAYS the in-app modal (never the native picker directly): the
745            // "existing library entries first" list is an in-app concept; the
746            // modal's own Upload button drives the platform picker when the
747            // store supports interchange.
748            FileAction::InsertComponent => {
749                self.use_folder(Purpose::Models, store);
750                self.open_modal(Mode::InsertComponent);
751            }
752            FileAction::ImportKicad(kind) => {
753                self.kicad.start(kind, store);
754                self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
755                self.open_modal(Mode::Kicad);
756            }
757        }
758    }
759
760    /// Ask for a file (plm-cad-integration-todo S8 / S14's "Add file…"). `tag`
761    /// names the asker, which takes the file with [`Self::take_picked_file`];
762    /// `title` heads the chooser. Natively the chooser is this dialog's own
763    /// explorer over the machine's files (one behaviour on every platform, and
764    /// drivable by scripts); in the browser it is the page's file input.
765    /// `Err` where neither exists.
766    pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
767        self.picked = None;
768        if let Some(local) = store.local_files() {
769            let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
770            if !arrived {
771                let _ = local.browser_home();
772            }
773            self.pick_request = Some((tag.to_string(), title.to_string()));
774            self.status.clear();
775            self.open_modal(Mode::PickFile);
776            return Ok(());
777        }
778        store.begin_pick_file()?;
779        self.pick_request = Some((tag.to_string(), title.to_string()));
780        Ok(())
781    }
782
783    /// The file the chooser delivered for `tag`, once.
784    pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
785        if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
786            return self.picked.take().map(|(_, file)| file);
787        }
788        None
789    }
790
791    /// Whether a pick for `tag` is still open (the chooser is up, or the
792    /// browser's is and has not answered).
793    pub fn picking(&self, tag: &str) -> bool {
794        self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
795    }
796
797    /// The chooser: the shared explorer over the LOCAL files, every file shown.
798    fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
799        let Some(local) = store.local_files() else {
800            self.open = false;
801            self.pick_request = None;
802            return;
803        };
804        let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
805        let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
806            file_explorer::dialog_body(ui, |ui| {
807                ui.heading(&title);
808                ui.add_space(4.0);
809                file_explorer::dialog_footer(ui, "pickfile", |ui| {
810                    if !self.status.is_empty() {
811                        ui.add_space(4.0);
812                        ui.weak(&self.status);
813                    }
814                });
815                let options = FileExplorerOptions {
816                    hit_prefix: "pickfile",
817                    empty_label: "(this folder is empty)",
818                    row_icon: "\u{1F5CE}",
819                    current: None,
820                    allow_delete: false,
821                    allow_import: false,
822                    import_label: "",
823                    import_hit: "pickfile:upload",
824                    show_cancel: true,
825                    confirm_label: Some("Choose"),
826                    extensions: &[],
827                };
828                let output = self.explorer.show_store(ui, local, options);
829                self.record_explorer_hits(&output.hits);
830                output
831            })
832        });
833        let should_close = modal.should_close();
834        let output = modal.inner;
835        self.pick_folder = Some(local.browser_location());
836        if let Some(path) = output.activated {
837            match local.read_external_file(&path) {
838                Some(bytes) => {
839                    let name = std::path::Path::new(&path)
840                        .file_name()
841                        .map(|n| n.to_string_lossy().into_owned())
842                        .unwrap_or_else(|| path.clone());
843                    if let Some((tag, _)) = self.pick_request.take() {
844                        self.status = format!("chose {name}");
845                        self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
846                    }
847                    self.open = false;
848                }
849                None => self.status = format!("'{path}' could not be read"),
850            }
851        } else if output.cancel || should_close {
852            self.pick_request = None;
853            self.status.clear();
854            self.open = false;
855        }
856    }
857
858    /// Move the explorer to where `purpose` was last, or to the models folder
859    /// the first time. A remembered folder that is gone falls back to the
860    /// models folder too.
861    fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
862        let remembered = self.folders.get(&purpose).cloned();
863        let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
864        if !arrived {
865            if let Some(models) = models_folder(store) {
866                let _ = store.browser_navigate(&models);
867            }
868        }
869    }
870
871    /// Where the open modal's explorer is is where its purpose was last.
872    fn remember_folder(&mut self, store: &dyn ModelStore) {
873        if let Some(purpose) = self.mode.purpose() {
874            self.folders.insert(purpose, browse_store(purpose, store).browser_location());
875        }
876    }
877
878    /// Open the modal in `mode` (and focus its input next frame).
879    fn open_modal(&mut self, mode: Mode) {
880        // Every opening is a new choice: the explorer is shared by every mode,
881        // so a highlight left by the last one (Import's `Timer.kicad_sym`) would
882        // otherwise be this one's confirm target (Insert component's).
883        self.pending_insert = None;
884        self.explorer.clear_selection();
885        self.mode = mode;
886        self.open = true;
887        self.want_focus = true;
888    }
889
890    // --- per-frame draw -------------------------------------------------------
891
892    /// Draw the modal (if open) and pick up any completed async import. Called
893    /// every frame by the shell with a ctx-level handle (the modal is ctx-level,
894    /// like the command palette).
895    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
896        self.hits.clear();
897        self.poll_pending_open(ctx, docs, store);
898        self.poll_pending_insert(ctx, docs, store);
899        self.poll_plm_save_as(ctx, docs, store);
900        self.poll_plm_work(ctx, docs, store);
901        self.poll_open_workspace_pick(store);
902        // The browser's file chooser answered a pick.
903        if let Some(file) = store.take_picked_file() {
904            if let Some((tag, _)) = self.pick_request.take() {
905                self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
906            }
907        }
908
909        // A STEP probe the runner has answered since last frame resolves into
910        // the assembly choice or the flat import now, before drawing.
911        self.poll_step_probe(docs.engine_mut());
912
913        // A completed async browser upload is picked up
914        // here, before drawing, so the model is live for this frame. Routed by
915        // extension: a STEP file (`.step`/`.stp`) is APPENDED to the model as an
916        // IMPORT3D feature; anything else is a `.nbrep` model document loaded
917        // (replacing the model). The model lanes deliver an extension-less name
918        // (web, stripped by `model_display_name`) or a stored model name, so only
919        // real STEP files route here.
920        if let Some(imported) = store.take_import() {
921            // A new delivery SUPERSEDES an armed assembly choice. The modal
922            // describes a parse this routing is about to replace (a STEP probe
923            // drops the previous stash by contract, and a document load drops it
924            // outright), so leaving the choice armed would offer the user a
925            // button whose stash is already gone.
926            self.cancel_pending_step_import(docs.engine_mut());
927            let name = imported.name;
928            let bytes = imported.bytes;
929            // CAD/mesh imports target the active document. STL waits for
930            // preview acceptance; model documents open in a separate tab.
931            if KicadKind::of_file(&name).is_some() {
932                self.take_kicad_file(&name, &bytes, store);
933                return;
934            } else if is_step_name(&name) {
935                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
936            } else if is_iges_name(&name) {
937                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
938            } else if is_stl_name(&name) {
939                self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes);
940            } else if is_3mf_name(&name) {
941                self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes);
942            } else if is_obj_name(&name) {
943                self.import_obj(docs.engine_mut(), &name, &bytes);
944            } else if std::mem::take(&mut self.pending_component_import) {
945                // The insert-component modal's Upload fired this pick: the
946                // chosen document becomes a parts-library entry + an instance,
947                // NOT a new tab — or, for a family or a template, the prompt
948                // that leads to one.
949                match String::from_utf8(bytes) {
950                    Ok(contents) => {
951                        let assembly = docs.active().name().map(str::to_string);
952                        if !self.begin_class_insert(store, &name, &contents, assembly) {
953                            self.insert_component_document(docs.engine_mut(), &name, &contents)
954                        }
955                        if self.pick_member.is_some() || self.spin_out.is_some() {
956                            return;
957                        }
958                    }
959                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
960                }
961            } else {
962                match String::from_utf8(bytes) {
963                    Ok(contents) => self.load_document(docs, &name, &contents, store),
964                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
965                }
966            }
967            self.close_after_import();
968        }
969
970        // A family member the user has just started to change raises the
971        // hand-edit prompt (unless they already chose "Save as a new part").
972        // On every store (integrator ruling 2026-10-03): on a PLM the
973        // prompt's "Save as a new part" hands off to the PLM Save As with a
974        // NEW PART preselected, so the generated member is never written
975        // over by hand.
976        if !self.open {
977            self.watch_member_edit(docs);
978        }
979
980        if !self.open {
981            return;
982        }
983        self.remember_folder(store);
984
985        match self.mode {
986            Mode::CreateComponentPart => self.show_create_component_part(ctx, docs, store),
987            Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
988            Mode::SaveAs => self.show_save_as(ctx, docs, store),
989            Mode::Open => self.show_open(ctx, docs, store),
990            Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
991            Mode::Export => self.show_export(ctx, docs, store),
992            Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
993            Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
994            Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
995            Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
996            Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
997            Mode::PickFile => self.show_pick_file(ctx, store),
998            Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
999            Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
1000        }
1001    }
1002
1003    /// Close the modal after a completed import — unless the import ARMED the
1004    /// §3.9 assembly choice, in which case the modal stays open and switches to
1005    /// it. Both import routing sites (the async-upload poll and the Import
1006    /// modal's own pick) end here, so neither can close a dialog the probe just
1007    /// raised.
1008    fn close_after_import(&mut self) {
1009        if self.pending_step_import.is_some() {
1010            self.open_modal(Mode::StepAssembly);
1011        } else {
1012            self.open = false;
1013        }
1014    }
1015
1016    /// Abandon an armed assembly choice and the engine stash behind it. The ONE
1017    /// place the app-side pending state and the engine-side parse are dropped
1018    /// together — they are two halves of one thing, and a half-drop is either a
1019    /// dialog with no stash or solids nobody will ever consume.
1020    fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
1021        if self.pending_step_import.take().is_some() {
1022            state.discard_probed_step_assembly();
1023        }
1024    }
1025
1026    /// Common CAD/mesh browser. Desktop enumerates files in the application's
1027    /// models directory; web shows the same explorer shell with an Upload action.
1028    fn show_import(
1029        &mut self,
1030        ctx: &egui::Context,
1031        state: &mut EngineState,
1032        store: &dyn ModelStore,
1033    ) {
1034        const EXTENSIONS: &[&str] =
1035            &["step", "stp", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
1036        let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
1037            file_explorer::dialog_body(ui, |ui| {
1038                ui.heading("Import CAD file");
1039                ui.add_space(4.0);
1040                file_explorer::dialog_footer(ui, "import", |ui| {
1041                    if !self.status.is_empty() {
1042                        ui.add_space(4.0);
1043                        ui.weak(&self.status);
1044                    }
1045                });
1046                let options = FileExplorerOptions {
1047                    hit_prefix: "import",
1048                    empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1049                    row_icon: "\u{1F5CE}",
1050                    current: None,
1051                    allow_delete: false,
1052                    allow_import: store.supports_file_interchange(),
1053                    import_label: "Upload\u{2026}",
1054                    import_hit: "import:upload",
1055                    show_cancel: true,
1056                    confirm_label: Some("Import"),
1057                    extensions: EXTENSIONS,
1058                };
1059                let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1060                self.record_explorer_hits(&output.hits);
1061                output
1062            })
1063        });
1064        let should_close = modal.should_close();
1065        let output = modal.inner;
1066        if let Some(name) = output.activated {
1067            match browse_store(Purpose::Imports, store).read_external_file(&name) {
1068                Some(bytes) if KicadKind::of_file(&name).is_some() => {
1069                    // Not an import yet: the KiCad dialog takes it from here.
1070                    self.take_kicad_file(&name, &bytes, store);
1071                    return;
1072                }
1073                Some(bytes) if is_step_name(&name) => {
1074                    self.import_text(state, &name, &bytes, Self::import_step)
1075                }
1076                Some(bytes) if is_iges_name(&name) => {
1077                    self.import_text(state, &name, &bytes, Self::import_iges)
1078                }
1079                Some(bytes) if is_stl_name(&name) => {
1080                    self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes)
1081                }
1082                Some(bytes) if is_3mf_name(&name) => {
1083                    self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes)
1084                }
1085                Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
1086                Some(_) => self.status = format!("unsupported import file: {name}"),
1087                None => self.status = format!("import failed: '{name}' not found"),
1088            }
1089            self.close_after_import();
1090        } else if output.import {
1091            match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1092                Ok(()) => {
1093                    self.status =
1094                        "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1095                    self.open = false;
1096                }
1097                Err(e) => self.status = format!("import failed: {e}"),
1098            }
1099        } else if output.cancel || should_close {
1100            self.open = false;
1101        }
1102    }
1103
1104    /// A `.kicad_sym` or `.kicad_mod` arrived (picked in Import, in the KiCad
1105    /// explorer, or uploaded): the KiCad dialog reads it and goes on to its
1106    /// next stage. `name` is the file's full path on desktop, which is where a
1107    /// footprint's relative model path starts from.
1108    fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1109        if !(self.open && self.mode == Mode::Kicad) {
1110            if let Some(kind) = KicadKind::of_file(name) {
1111                self.kicad.start(kind, store);
1112            }
1113        }
1114        self.kicad.take_file(name, bytes, &DiskFiles);
1115        self.open_modal(Mode::Kicad);
1116    }
1117
1118    /// The KiCad import: the common explorer for its file, then its own
1119    /// stages. Hit keys are `kicad:*`.
1120    fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1121        if !self.kicad.wants_file() {
1122            match self.kicad.show(ctx, state, store, &mut self.hits) {
1123                kicad_import::Outcome::Open => {}
1124                kicad_import::Outcome::Closed => self.open = false,
1125                kicad_import::Outcome::Imported(imported) => {
1126                    // The modal closes on the click, so the toasts are what the
1127                    // user sees: the summary, then each note.
1128                    self.status = imported.summary.clone();
1129                    state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1130                    const SHOWN: usize = 5;
1131                    let hidden = imported.notes.len().saturating_sub(SHOWN);
1132                    for note in imported.notes.into_iter().take(SHOWN) {
1133                        state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1134                    }
1135                    if hidden > 0 {
1136                        state.push_notice_as(
1137                            brep_render::engine_state::NoticeSeverity::Warning,
1138                            format!("and {hidden} more notes on this import"),
1139                        );
1140                    }
1141                    self.open = false;
1142                }
1143            }
1144            return;
1145        }
1146        let extension = self.kicad.kind().extension();
1147        let heading = match self.kicad.kind() {
1148            KicadKind::Symbol => "Import a symbol from KiCad",
1149            KicadKind::Footprint => "Import a footprint from KiCad",
1150        };
1151        let extensions = [extension];
1152        let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1153            file_explorer::dialog_body(ui, |ui| {
1154                ui.heading(heading);
1155                ui.add_space(4.0);
1156                file_explorer::dialog_footer(ui, "kicad", |ui| {
1157                    let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1158                    if !status.is_empty() {
1159                        ui.add_space(4.0);
1160                        ui.weak(status);
1161                    }
1162                });
1163                let empty_label = format!("(no .{extension} files)");
1164                let options = FileExplorerOptions {
1165                    hit_prefix: "kicad",
1166                    empty_label: &empty_label,
1167                    row_icon: "\u{1F5CE}",
1168                    current: None,
1169                    allow_delete: false,
1170                    allow_import: store.supports_file_interchange(),
1171                    import_label: "Upload\u{2026}",
1172                    import_hit: "kicad:upload",
1173                    show_cancel: true,
1174                    confirm_label: Some("Open"),
1175                    extensions: &extensions,
1176                };
1177                let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1178                self.record_explorer_hits(&output.hits);
1179                output
1180            })
1181        });
1182        let should_close = modal.should_close();
1183        let output = modal.inner;
1184        if let Some(name) = output.activated {
1185            match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1186                Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1187                None => self.kicad.status = format!("import failed: '{name}' not found"),
1188            }
1189        } else if output.import {
1190            match store.begin_import_filtered(("KiCad", &extensions)) {
1191                Ok(()) => {
1192                    self.status = format!("choose a .{extension} file\u{2026}");
1193                    self.open = false;
1194                }
1195                Err(e) => self.kicad.status = format!("import failed: {e}"),
1196            }
1197        } else if output.cancel || should_close {
1198            self.open = false;
1199        }
1200    }
1201
1202    /// CLOSE tab `index` with the unsaved-changes contract: a DIRTY document
1203    /// prompts to discard first (the confirm modal); a clean one closes
1204    /// immediately. The tab strip's `\u{2715}` routes here — it is the only
1205    /// door through which unsaved work can be dropped, so it is the only one
1206    /// that asks.
1207    pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1208        let dirty = docs.get(index).is_some_and(Document::is_dirty);
1209        if dirty {
1210            self.pending_close = Some(index);
1211            self.open_modal(Mode::ConfirmClose);
1212        } else {
1213            docs.close(index);
1214        }
1215    }
1216
1217    /// The **Discard unsaved changes?** confirmation shown before closing a
1218    /// dirty document tab. Keeps the verifier's `confirm:discard` /
1219    /// `confirm:cancel` hit keys — the same two buttons, one door further on.
1220    fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1221        // A tab closed / reordered under an open prompt (there is no such path
1222        // today, but the index is only meaningful while it resolves).
1223        let Some(title) = self
1224            .pending_close
1225            .and_then(|index| docs.get(index))
1226            .map(Document::title)
1227        else {
1228            self.pending_close = None;
1229            self.open = false;
1230            return;
1231        };
1232        let mut discard = false;
1233        let mut cancel = false;
1234        let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1235            ui.set_width(340.0);
1236            ui.heading("Discard unsaved changes?");
1237            ui.add_space(4.0);
1238            ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1239            ui.add_space(6.0);
1240            ui.horizontal(|ui| {
1241                let d = ui.button("Discard and close");
1242                self.hit("confirm:discard", &d);
1243                if d.clicked() {
1244                    discard = true;
1245                }
1246                let c = ui.button("Cancel");
1247                self.hit("confirm:cancel", &c);
1248                if c.clicked() {
1249                    cancel = true;
1250                }
1251            });
1252        });
1253        if discard {
1254            if let Some(index) = self.pending_close.take() {
1255                docs.close(index);
1256            }
1257            self.open = false;
1258        } else if cancel || modal.should_close() {
1259            self.pending_close = None;
1260            self.open = false;
1261        }
1262    }
1263
1264    /// The §3.9 **assembly choice**, raised when a `.step` upload's probe found a
1265    /// product structure:
1266    ///
1267    /// > **"bracket-assy.step" contains an assembly** — 7 parts, 23 instances.
1268    /// > [ Import as assembly ] [ Import as bodies ] [ Cancel ]
1269    ///
1270    /// Default (and first) is **Import as assembly**. "Import as bodies" is
1271    /// today's flat lane, unchanged. Cancel — and Esc / click-outside, which
1272    /// egui folds into `should_close` — import nothing and DROP the parse: the
1273    /// engine is holding every product's solids until one of these three lands.
1274    ///
1275    /// **Flatten sub-assemblies** (§3.9) is offered only when the file actually
1276    /// HAS sub-assemblies (`nested_depth > 1`) — on a single-level file both
1277    /// lanes produce the identical document, so a box there would teach the user
1278    /// a distinction that does not exist. Unchecked (the default) keeps the
1279    /// tree; checked flattens to leaf occurrences, which is the right answer for
1280    /// a deep or pathological file and stores a part reused across two levels
1281    /// once rather than once per level.
1282    fn show_step_assembly(
1283        &mut self,
1284        ctx: &egui::Context,
1285        state: &mut EngineState,
1286        store: &dyn ModelStore,
1287    ) {
1288        // Nothing armed means nothing to choose about (a supersession raced the
1289        // draw): close rather than render an empty prompt.
1290        let Some(pending) = self.pending_step_import.as_ref() else {
1291            self.open = false;
1292            return;
1293        };
1294        let name = pending.name.clone();
1295        let probe = pending.probe;
1296        let mut flatten = pending.flatten;
1297        let mut choice: Option<StepChoice> = None;
1298        let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1299            ui.set_width(360.0);
1300            ui.heading(format!("\"{name}\" contains an assembly"));
1301            ui.add_space(4.0);
1302            ui.label(format!(
1303                "{} part{}, {} instance{}{}.",
1304                probe.parts,
1305                plural(probe.parts),
1306                probe.instances,
1307                plural(probe.instances),
1308                match probe.nested_depth {
1309                    0 | 1 => String::new(),
1310                    depth => format!(", {depth} levels deep"),
1311                }
1312            ));
1313            if probe.nested_depth > 1 {
1314                ui.add_space(4.0);
1315                let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1316                self.hit("stepassembly:flatten", &check);
1317                check.on_hover_text(
1318                    "Off: each sub-assembly becomes one rigid component you can \
1319                     expand in the structure tree.\nOn: every part is placed \
1320                     directly in this document at its world position.",
1321                );
1322            }
1323            ui.add_space(6.0);
1324            ui.horizontal(|ui| {
1325                let a = ui.button("Import as assembly");
1326                self.hit("stepassembly:assembly", &a);
1327                if a.clicked() {
1328                    choice = Some(StepChoice::Assembly);
1329                }
1330                let b = ui.button("Import as bodies");
1331                self.hit("stepassembly:bodies", &b);
1332                if b.clicked() {
1333                    choice = Some(StepChoice::Bodies);
1334                }
1335                let c = ui.button("Cancel");
1336                self.hit("stepassembly:cancel", &c);
1337                if c.clicked() {
1338                    choice = Some(StepChoice::Cancel);
1339                }
1340            });
1341        });
1342        // The box must survive the frames between the tick and the click.
1343        if let Some(pending) = self.pending_step_import.as_mut() {
1344            pending.flatten = flatten;
1345        }
1346        // Esc / click-outside is a Cancel, not a no-op: the stash must not
1347        // outlive the prompt that was going to consume it.
1348        let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1349        let Some(choice) = choice else { return };
1350        match choice {
1351            StepChoice::Assembly => self.step_assembly_import(state, store),
1352            StepChoice::Bodies => self.step_assembly_bodies(state),
1353            StepChoice::Cancel => self.step_assembly_cancel(state),
1354        }
1355        self.open = false;
1356    }
1357
1358    /// Save As asked for a new PLM part: the shell opens the PLM pane's New part form.
1359    /// Once: true the frame after it was chosen.
1360    pub fn take_plm_new_part(&mut self) -> bool {
1361        std::mem::take(&mut self.plm_new_part)
1362    }
1363
1364    /// The **Save As** name prompt.
1365    fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1366        // A PLM session saves as a new part or a new revision, not under a file name.
1367        if let Some(client) = store.plm_client() {
1368            self.show_save_as_plm(ctx, docs, store, client);
1369            return;
1370        }
1371        let current = docs.active().name().map(str::to_string);
1372        let mut do_save = false;
1373        let mut cancel = false;
1374        let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1375            file_explorer::dialog_body(ui, |ui| {
1376                ui.heading("Save model as");
1377                ui.add_space(4.0);
1378                // The name field and its buttons are the dialog's footer: pinned
1379                // under the browser, which fills whatever is left.
1380                file_explorer::dialog_footer(ui, "saveas", |ui| {
1381                    ui.label("File name");
1382                    let field = ui.add(
1383                        egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1384                            .hint_text("model name")
1385                            .desired_width(f32::INFINITY),
1386                    );
1387                    self.hit("field:name", &field);
1388                    if self.want_focus {
1389                        field.request_focus();
1390                        self.want_focus = false;
1391                    }
1392                    let enter =
1393                        field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1394                    // The class the file is saved as. A part's class is fixed
1395                    // for its life, so Save As is the one place it is chosen:
1396                    // saving a part as a Family or a Template makes a new file
1397                    // of that class.
1398                    ui.horizontal(|ui| {
1399                        ui.label("Save as");
1400                        for class in DocumentClass::ALL {
1401                            let text = format!("{} ({})", class.label(), class.extension());
1402                            let chip = ui.selectable_label(self.save_class == class, text);
1403                            self.hit(&format!("saveas:class:{}", class.slug()), &chip);
1404                            if chip.clicked() {
1405                                self.save_class = class;
1406                            }
1407                        }
1408                    });
1409                    ui.add_space(6.0);
1410                    ui.horizontal(|ui| {
1411                        let save = ui.button("Save");
1412                        self.hit("save", &save);
1413                        if save.clicked() || enter {
1414                            do_save = true;
1415                        }
1416                        let c = ui.button("Cancel");
1417                        self.hit("cancel", &c);
1418                        if c.clicked() {
1419                            cancel = true;
1420                        }
1421                    });
1422                    if !self.status.is_empty() {
1423                        ui.add_space(4.0);
1424                        ui.weak(&self.status);
1425                    }
1426                });
1427                let options = FileExplorerOptions {
1428                    hit_prefix: "saveas:file",
1429                    empty_label: "(no saved models)",
1430                    row_icon: "\u{1F5CE}",
1431                    current: current.as_deref(),
1432                    allow_delete: false,
1433                    allow_import: false,
1434                    import_label: "",
1435                    import_hit: "saveas:upload",
1436                    show_cancel: false,
1437                    confirm_label: None,
1438                    extensions: MODEL_EXTENSIONS,
1439                };
1440                let output = self.explorer.show_store(ui, store, options);
1441                self.record_explorer_hits(&output.hits);
1442                // Selecting (or double-clicking) a stored model fills the name
1443                // field so it can be overwritten; `picked`/`activated` are
1444                // one-shot so it never clobbers a name the user then types. The
1445                // field itself was drawn above, so the explorer asks for the
1446                // repaint that shows the new name.
1447                if let Some(name) = output.picked.or(output.activated) {
1448                    self.name_buf =
1449                        document_class::strip_class_extension(&model_display_name(&name)).to_string();
1450                    if let Some(class) = DocumentClass::of_name(&name) {
1451                        self.save_class = class;
1452                    }
1453                }
1454            })
1455        });
1456        if do_save {
1457            let name = self.save_class.file_name(self.name_buf.trim());
1458            if self.save_to_browser(docs, store, name) {
1459                self.open = false;
1460            }
1461        } else if cancel || modal.should_close() {
1462            self.open = false;
1463        }
1464    }
1465
1466    /// The **Export** chooser: pick a format (STEP / IGES / STL / OBJ / GLB) and
1467    /// write the current model to the user's filesystem under `<name>.<ext>`
1468    /// through the store's format-typed interchange. STEP and IGES serialize the
1469    /// exact NURBS topology; STL, OBJ and GLB are the display mesh (ASCII
1470    /// facets, one indexed object, and binary glTF 2.0 with a mesh per body).
1471    /// No solids → a clear status line,
1472    /// nothing written. A second row offers the BOM (CSV / JSON), enabled only
1473    /// while the document has components or harness wires.
1474    fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1475        // BOM gating: `component_ids` scans the history (cheap, no kernel
1476        // session), and a harness wire is a BOM line of its own, so a
1477        // harness-only document exports too. A rolled-back/failed ACOMP can
1478        // still enable the buttons — the export's own loud "no components or
1479        // harness wires" error covers that gap.
1480        let has_bom = !docs.engine().component_ids().is_empty()
1481            || !docs.engine().wire_harness_state().connections.is_empty();
1482        // The drawing sheets of THIS document: the sheet button writes the open
1483        // one (else the first), so it is offered only when there is one to
1484        // write — the BOM row's gating, for the same reason.
1485        let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1486        // The board's manufacturing files: offered once the PCB has parts on
1487        // its board, the BOM row's gating for the same reason.
1488        let has_board = super::fabrication_export::has_board(docs.engine());
1489        let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1490        let mut chosen: Option<&'static str> = None;
1491        let mut bom_chosen: Option<&'static str> = None;
1492        let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1493        let mut cancel = false;
1494        let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1495            // Wide enough for the 3D formats on one line; each group wraps
1496            // rather than widening the modal (lane U audit item 7).
1497            ui.set_width(440.0);
1498            ui.heading("Export model");
1499            ui.add_space(4.0);
1500            Self::preview_export_line(ui, docs);
1501            let field = ui.add(
1502                egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1503                    .hint_text("file name")
1504                    .desired_width(f32::INFINITY),
1505            );
1506            self.hit("field:name", &field);
1507            if self.want_focus {
1508                field.request_focus();
1509                self.want_focus = false;
1510            }
1511            // One headed, wrapping row per kind of output, and Cancel on its
1512            // own row below them all — it used to sit among the 3D formats,
1513            // with "Sheets + 3D (PDF)" orphaned on a line of its own.
1514            let group = |ui: &mut egui::Ui, title: &str| {
1515                ui.add_space(6.0);
1516                ui.label(egui::RichText::new(title).weak());
1517            };
1518            group(ui, "3D model");
1519            ui.horizontal_wrapped(|ui| {
1520                let step = ui.button("STEP (.step)");
1521                self.hit("export:step", &step);
1522                if step.clicked() {
1523                    chosen = Some("step");
1524                }
1525                let iges = ui.button("IGES (.igs)");
1526                self.hit("export:iges", &iges);
1527                if iges.clicked() {
1528                    chosen = Some("iges");
1529                }
1530                let stl = ui.button("STL (.stl)");
1531                self.hit("export:stl", &stl);
1532                if stl.clicked() {
1533                    chosen = Some("stl");
1534                }
1535                let obj = ui.button("OBJ (.obj)");
1536                self.hit("export:obj", &obj);
1537                if obj.clicked() {
1538                    chosen = Some("obj");
1539                }
1540                let glb = ui.button("GLB (.glb)");
1541                self.hit("export:glb", &glb);
1542                if glb.clicked() {
1543                    chosen = Some("glb");
1544                }
1545                // The full model RECIPE (`.nbrep`) — for saving a document to
1546                // disk / sharing a failing model for a bug report. Re-openable via
1547                // Open / Import.
1548                let json = ui.button("Model (.nbrep)");
1549                self.hit("export:json", &json);
1550                if json.clicked() {
1551                    chosen = Some("json");
1552                }
1553            });
1554            group(ui, "Bill of materials");
1555            ui.horizontal_wrapped(|ui| {
1556                // The BOM: one row per parts-library entry with the live
1557                // instance count, then one per harness wire with its cut
1558                // length. Disabled while the document has neither.
1559                let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1560                self.hit("export:bomcsv", &csv);
1561                if csv.clicked() {
1562                    bom_chosen = Some("csv");
1563                }
1564                let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1565                self.hit("export:bomjson", &json);
1566                if json.clicked() {
1567                    bom_chosen = Some("json");
1568                }
1569            });
1570            group(ui, "Drawing");
1571            ui.horizontal_wrapped(|ui| {
1572                // The DRAWING SHEET: the paper as an SVG of lines and text.
1573                // Not the sheet-METAL flat pattern, which is its own dialog.
1574                let sheet = ui
1575                    .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1576                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1577                self.hit("export:sheetsvg", &sheet);
1578                if sheet.clicked() {
1579                    chosen = Some("sheetsvg");
1580                }
1581                // EVERY sheet as one PDF: a page per sheet, in sheet order, each
1582                // at its own paper size — the drawing set, not the page on
1583                // screen. The SVG beside it stays one file per sheet.
1584                let pdf = ui
1585                    .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1586                    .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1587                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1588                self.hit("export:sheetpdf", &pdf);
1589                if pdf.clicked() {
1590                    chosen = Some("sheetpdf");
1591                }
1592                // The same drawing set with the LIVE 3D model: a 3D box over
1593                // every placement marked "Live 3D in PDF", and a last page
1594                // given over to the model with a button per saved PMI view.
1595                // Its own file, so the plain drawing set is never replaced.
1596                let pdf3d = ui
1597                    .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1598                    .on_hover_text("The drawing set with the live 3D model: Acrobat and Foxit turn and zoom it and switch PMI views; other PDF readers show the drawings")
1599                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1600                self.hit("export:sheetpdf3d", &pdf3d);
1601                if pdf3d.clicked() {
1602                    chosen = Some("sheetpdf3d");
1603                }
1604            });
1605            group(ui, "Electronics");
1606            ui.horizontal_wrapped(|ui| {
1607                // The PCB's manufacturing files. The zip is what a board house
1608                // takes; the two CSVs are also inside it, and on their own for
1609                // an assembler that asks for them separately.
1610                use super::fabrication_export::Output;
1611                let no_board = "Place the schematic's parts on the PCB board first";
1612                for (key, label, hover, output) in [
1613                    ("export:fabrication", "Fabrication (zip)", "Gerber X2 layers, Excellon drills, pick and place, the parts BOM and a README of the layers, in one zip for the board house", Output::Bundle),
1614                    ("export:pickplace", "Pick & place (CSV)", "One row per placed part: Designator, Val, Package, Mid X, Mid Y, Rotation, Layer (JLCPCB's columns), in mm from the board's lower-left corner", Output::PickPlace),
1615                    ("export:ecadbom", "Parts BOM (CSV)", "The electronic parts: Comment (the value), Designator, Footprint, Quantity (JLCPCB's columns), one line per value and footprint", Output::Bom),
1616                    ("export:kicadnet", "Netlist (KiCad)", "The PCB schematic's nets as a KiCad .net file, the netlist Pcbnew and other tools read: every part with its value and footprint, every net with its pins, under the names the Connectivity panel lists", Output::Netlist),
1617                ] {
1618                    let (enabled, why) = match output {
1619                        Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
1620                        _ => (has_board, no_board),
1621                    };
1622                    let button = ui
1623                        .add_enabled(enabled, egui::Button::new(label))
1624                        .on_hover_text(hover)
1625                        .on_disabled_hover_text(why);
1626                    self.hit(key, &button);
1627                    if button.clicked() {
1628                        fab_chosen = Some(output);
1629                    }
1630                }
1631            });
1632            if !self.status.is_empty() {
1633                ui.add_space(4.0);
1634                ui.weak(&self.status);
1635            }
1636            ui.add_space(6.0);
1637            ui.separator();
1638            let c = ui.button("Cancel");
1639            self.hit("cancel", &c);
1640            if c.clicked() {
1641                cancel = true;
1642            }
1643        });
1644        if let Some(format) = chosen {
1645            if self.export_as(docs, store, format) {
1646                // The `.nbrep` recipe is the document; every other format is
1647                // of the geometry, which a family-row preview has replaced.
1648                if format != "json" {
1649                    self.say_previewed_export(docs);
1650                }
1651                self.open = false;
1652            }
1653        } else if let Some(format) = bom_chosen {
1654            if self.export_bom_as(docs, store, format) {
1655                self.open = false;
1656            }
1657        } else if let Some(output) = fab_chosen {
1658            if self.export_fabrication_as(docs, store, output) {
1659                self.open = false;
1660            }
1661        } else if cancel || modal.should_close() {
1662            self.open = false;
1663        }
1664    }
1665
1666    /// The **Flat pattern** chooser: pick a 2D vector format (DXF R12 / SVG) and
1667    /// write the sheet-metal body's unfolded flat pattern to the user's filesystem
1668    /// under `<name>.<ext>`. The unfold runs TRANSIENTLY in the engine (no feature,
1669    /// no history change). A part with no sheet-metal body reports it in the status
1670    /// line AND queues a toast (see [`Self::export_flat_pattern_as`]).
1671    fn show_flat_pattern(
1672        &mut self,
1673        ctx: &egui::Context,
1674        docs: &mut Documents,
1675        store: &dyn ModelStore,
1676    ) {
1677        let mut chosen: Option<&'static str> = None;
1678        let mut cancel = false;
1679        let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
1680            ui.set_width(340.0);
1681            ui.heading("Export flat pattern");
1682            ui.add_space(2.0);
1683            Self::preview_export_line(ui, docs);
1684            ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
1685            ui.add_space(4.0);
1686            let field = ui.add(
1687                egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1688                    .hint_text("file name")
1689                    .desired_width(f32::INFINITY),
1690            );
1691            self.hit("field:name", &field);
1692            if self.want_focus {
1693                field.request_focus();
1694                self.want_focus = false;
1695            }
1696            ui.add_space(6.0);
1697            ui.horizontal(|ui| {
1698                let dxf = ui.button("DXF (.dxf)");
1699                self.hit("flat:dxf", &dxf);
1700                if dxf.clicked() {
1701                    chosen = Some("dxf");
1702                }
1703                let svg = ui.button("SVG (.svg)");
1704                self.hit("flat:svg", &svg);
1705                if svg.clicked() {
1706                    chosen = Some("svg");
1707                }
1708                let c = ui.button("Cancel");
1709                self.hit("cancel", &c);
1710                if c.clicked() {
1711                    cancel = true;
1712                }
1713            });
1714            if !self.status.is_empty() {
1715                ui.add_space(4.0);
1716                ui.weak(&self.status);
1717            }
1718        });
1719        if let Some(format) = chosen {
1720            if self.export_flat_pattern_as(docs, store, format) {
1721                self.say_previewed_export(docs);
1722                self.open = false;
1723            }
1724        } else if cancel || modal.should_close() {
1725            self.open = false;
1726        }
1727    }
1728
1729    /// The **Open** browser: the saved-model list (+ Upload where the platform
1730    /// supports real-file interchange).
1731    fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1732        // A PLM session opens from the user's workspace, not from a list of store keys.
1733        if let Some(client) = store.plm_client() {
1734            self.show_open_workspace(ctx, docs, store, client);
1735            return;
1736        }
1737        let current = docs.active().name().map(str::to_string);
1738        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1739            file_explorer::dialog_body(ui, |ui| {
1740                ui.heading("Open model");
1741                ui.add_space(4.0);
1742                file_explorer::dialog_footer(ui, "open", |ui| {
1743                    if !self.status.is_empty() {
1744                        ui.add_space(4.0);
1745                        ui.weak(&self.status);
1746                    }
1747                });
1748                let mut options = FileExplorerOptions::open(current.as_deref());
1749                options.allow_import = store.supports_file_interchange();
1750                let output = self.explorer.show_store(ui, store, options);
1751                self.record_explorer_hits(&output.hits);
1752                output
1753            })
1754        });
1755        let should_close = modal.should_close();
1756        let output = modal.inner;
1757        if let Some(name) = output.activated {
1758            self.open_document(docs, store, &name);
1759            self.open = false;
1760        } else if let Some(name) = output.remove {
1761            let _ = store.remove(&name);
1762            self.status = format!("removed {name}");
1763            // A tab holding the removed document keeps its content but loses its
1764            // file: it becomes an untitled document, so a later Save asks where
1765            // to put it rather than silently recreating what was deleted.
1766            if let Some(index) = docs.index_of(&name) {
1767                if let Some(doc) = docs.get_mut(index) {
1768                    doc.set_name(None);
1769                }
1770            }
1771        } else if output.import {
1772            // Fire the platform picker; the file arrives via take_import() and is
1773            // loaded on a later frame (the modal closes now).
1774            match store.begin_import() {
1775                Ok(()) => {
1776                    self.status = "choose a file…".into();
1777                    self.open = false;
1778                }
1779                Err(e) => self.status = format!("import failed: {e}"),
1780            }
1781        } else if output.cancel || should_close {
1782            self.open = false;
1783        }
1784    }
1785
1786    /// The **Open** browser in a PLM session: the workspace (S14) — folders of links and
1787    /// files, other users' workspaces read-only, and Open by number. A link opens its
1788    /// part's document. Workspace changes are linked to the web app; downloads
1789    /// go out through the store's export lane.
1790    fn show_open_workspace(
1791        &mut self,
1792        ctx: &egui::Context,
1793        docs: &mut Documents,
1794        store: &dyn ModelStore,
1795        client: std::rc::Rc<crate::plm::client::PlmClient>,
1796    ) {
1797        use crate::panels::plm_workspace::{document_name, load_pins, save_pins, PlmWorkspaces};
1798        let plm = PlmWorkspaces { client };
1799        if self.workspace.pins.is_none() {
1800            self.workspace.pins = Some(load_pins(store));
1801        }
1802        self.workspace.browse_only = true;
1803        let mut cancel = false;
1804        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1805            file_explorer::dialog_body(ui, |ui| {
1806                ui.heading("Open from the PLM");
1807                if let Some(url) = crate::panels::plm_host::web_url(store, "#/workspace") {
1808                    crate::panels::plm_host::web_link(ui, "Manage workspace in PLM", url);
1809                }
1810                ui.add_space(4.0);
1811                file_explorer::dialog_footer(ui, "open", |ui| {
1812                    if !self.status.is_empty() {
1813                        ui.add_space(4.0);
1814                        ui.weak(&self.status);
1815                    }
1816                    let button = ui.button("Cancel");
1817                    self.hits.insert("open:cancel".into(), button.rect);
1818                    cancel = button.clicked();
1819                });
1820                self.workspace.show(ui, &plm, "open:")
1821            })
1822        });
1823        let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
1824        self.record_explorer_hits(&hits);
1825        let should_close = modal.should_close();
1826        let outcome = modal.inner;
1827        if let Some(pins) = &outcome.pins {
1828            if let Err(e) = save_pins(store, pins) {
1829                self.status = format!("could not save the pinned folders: {e}");
1830            }
1831        }
1832        if let Some((name, bytes)) = outcome.downloaded {
1833            self.status = match store.export_file_named_bytes(&name, &bytes) {
1834                Ok(()) => format!("saved {name}"),
1835                Err(e) => format!("could not save {name}: {e}"),
1836            };
1837        }
1838        if outcome.pick_file {
1839            // The chooser takes the modal over; the file comes back to
1840            // `poll_open_workspace_pick`, which reopens Open on the folder.
1841            if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
1842                self.status = error;
1843            }
1844            return;
1845        }
1846        if let Some(key) = outcome.open {
1847            let name = document_name(store, &key);
1848            self.open_document(docs, store, &name);
1849            self.open = false;
1850        } else if cancel || should_close {
1851            self.open = false;
1852        }
1853    }
1854
1855    /// A file picked for the Open modal's workspace: added to the folder shown,
1856    /// and the Open modal comes back.
1857    fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
1858        let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
1859        if let Some(client) = store.plm_client() {
1860            let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
1861            self.workspace.add_file(&plm, &file.name, file.bytes);
1862            self.open_modal(Mode::Open);
1863        }
1864    }
1865
1866    /// The **Insert component** selector: the EXISTING parts-library entries
1867    /// first (instant re-insert — no store read), then the model store's
1868    /// document list, + Upload where the platform supports real-file
1869    /// interchange. REUSES the Open modal's machinery (the same list +
1870    /// `take_import` poll), routed to the engine's insert flow.
1871    fn show_insert_component(
1872        &mut self,
1873        ctx: &egui::Context,
1874        docs: &mut Documents,
1875        store: &dyn ModelStore,
1876    ) {
1877        let library = docs.engine_mut().parts_library_names();
1878        let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1879            file_explorer::dialog_body(ui, |ui| {
1880                ui.heading("Insert component");
1881                ui.add_space(4.0);
1882                file_explorer::dialog_footer(ui, "insert", |ui| {
1883                    if !self.status.is_empty() {
1884                        ui.add_space(4.0);
1885                        ui.weak(&self.status);
1886                    }
1887                });
1888                let mut chosen_library = None;
1889                if !library.is_empty() {
1890                    ui.weak("In this document (parts library)");
1891                    for name in &library {
1892                        let row = ui.add_sized(
1893                            egui::vec2(ui.available_width(), 18.0),
1894                            crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
1895                                .frame(false),
1896                        );
1897                        self.hit(&format!("insert:lib:{name}"), &row);
1898                        if row.clicked() {
1899                            chosen_library = Some(name.clone());
1900                        }
1901                    }
1902                    ui.add_space(4.0);
1903                }
1904                let options = FileExplorerOptions {
1905                    hit_prefix: "insert:model",
1906                    empty_label: "(no saved models)",
1907                    row_icon: "\u{1F5CE}",
1908                    current: None,
1909                    allow_delete: false,
1910                    allow_import: store.supports_file_interchange(),
1911                    import_label: "Upload\u{2026}",
1912                    import_hit: "insert:upload",
1913                    show_cancel: true,
1914                    confirm_label: Some("Insert"),
1915                    extensions: INSERT_EXTENSIONS,
1916                };
1917                let output = self.explorer.show_store(ui, store, options);
1918                self.record_explorer_hits(&output.hits);
1919                (chosen_library, output)
1920            })
1921        });
1922        let should_close = modal.should_close();
1923        let (chosen_library, output) = modal.inner;
1924        if let Some(part_name) = chosen_library {
1925            // An already-inserted library part: skip the store read entirely.
1926            match docs.engine_mut().insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1927                Ok(id) => {
1928                    self.status = format!("inserted {part_name} ({id})");
1929                    self.open = false;
1930                }
1931                Err(e) => self.status = format!("insert failed: {e}"),
1932            }
1933        } else if let Some(name) = output.activated.filter(|name| {
1934            // Only a file the list OFFERS is a component: a part document. A
1935            // highlight left by another purpose (Import's `.kicad_sym`) is not
1936            // one, and reached the parts library as a non-document.
1937            let listed = store
1938                .browser_entries(INSERT_EXTENSIONS)
1939                .iter()
1940                .any(|entry| !entry.is_dir && entry.identity == *name);
1941            if !listed {
1942                self.status = format!(
1943                    "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
1944                );
1945            }
1946            listed
1947        }) {
1948            self.request_component_document(docs, store, &name);
1949            if self.pending_insert.is_some() {
1950                ctx.request_repaint_after(std::time::Duration::from_millis(100));
1951            }
1952        } else if output.import {
1953            match store.begin_import() {
1954                Ok(()) => {
1955                    // The picked file routes to the component insert (not Open).
1956                    self.pending_component_import = true;
1957                    self.status = "choose a part file…".into();
1958                    self.open = false;
1959                }
1960                Err(e) => self.status = format!("insert failed: {e}"),
1961            }
1962        } else if output.cancel || should_close {
1963            self.open = false;
1964        }
1965    }
1966
1967    fn request_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1968        self.pending_insert = None;
1969        match crate::store::read_now(store, name) {
1970            crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, name, &contents),
1971            crate::store::ReadNow::Loading => {
1972                self.pending_insert = Some((name.to_owned(), docs.active_id()));
1973                self.status = format!("'{name}' is still loading");
1974            }
1975            crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
1976        }
1977    }
1978
1979    fn finish_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str, contents: &str) {
1980        let assembly = docs.active().name().map(str::to_string);
1981        if !self.begin_class_insert(store, name, contents, assembly) {
1982            self.insert_component_document(docs.engine_mut(), name, contents);
1983            self.open = false;
1984        }
1985    }
1986
1987    fn poll_pending_insert(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1988        let Some((name, requester)) = self.pending_insert.take() else { return };
1989        if !self.open || self.mode != Mode::InsertComponent || docs.active_id() != requester {
1990            return;
1991        }
1992        match crate::store::read_now(store, &name) {
1993            crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, &name, &contents),
1994            crate::store::ReadNow::Loading => {
1995                self.pending_insert = Some((name, requester));
1996                ctx.request_repaint_after(std::time::Duration::from_millis(100));
1997            }
1998            crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
1999        }
2000    }
2001
2002    /// Insert a part DOCUMENT as an assembly component: library-add (dedup by
2003    /// sourceKey + content signature; the RETURNED effective name is what the
2004    /// instance references) + an ACOMP feature, through the engine's one insert
2005    /// flow. The first instance of an empty assembly is written `isFixed:true`.
2006    /// `sourceSignature` is written with [`document_signature`] — the ONE
2007    /// signature fn — so the update-components comparison reads a freshly
2008    /// inserted, unchanged part as up-to-date.
2009    fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2010        self.insert_component_named(state, name, &model_display_name(name), contents);
2011    }
2012
2013    /// [`Self::insert_component_document`] under a given name: a PLM member
2014    /// is named by its part number, which its key does not carry.
2015    fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2016        let display = display.to_string();
2017        match state.insert_component(ComponentInsert::New {
2018            name: &display,
2019            source_key: name,
2020            source_signature: &document_signature(contents),
2021            document_json: contents,
2022        }) {
2023            Ok(id) => self.status = format!("inserted {display} ({id})"),
2024            Err(e) => self.status = format!("insert failed: {e}"),
2025        }
2026    }
2027
2028    // --- model operations -----------------------------------------------------
2029
2030    /// The name to save under: the field if non-empty, else the active
2031    /// document's display name, else its untitled name (`"untitled"`, `"untitled 2"`, …).
2032    fn effective_name(&self, docs: &Documents) -> String {
2033        let field = self.name_buf.trim();
2034        if !field.is_empty() {
2035            field.to_string()
2036        } else {
2037            Self::document_name(docs)
2038        }
2039    }
2040
2041    /// The ACTIVE document's display name, what Save As and Export start
2042    /// their name field from: the file's stem, never the full path a saved
2043    /// document is named by (whose `/`s became `_`s in every exported file
2044    /// name), and never the field's last contents, which belong to whichever
2045    /// tab last used it.
2046    fn document_name(docs: &Documents) -> String {
2047        docs.active().name().map(model_display_name).unwrap_or_else(|| docs.active().untitled_title())
2048    }
2049
2050    /// **New** — a fresh document in a NEW TAB. Nothing is replaced, so there is
2051    /// nothing to confirm.
2052    fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2053        docs.open_document(Document::fresh(docs.spawn_engine(), class));
2054        self.name_buf.clear();
2055        self.status = match class {
2056            DocumentClass::Normal => "new model".into(),
2057            _ => format!("new {}", class.label().to_ascii_lowercase()),
2058        };
2059    }
2060
2061    /// Refuse a save that would give TWO open tabs the same store identity —
2062    /// "focus the tab holding this document" has no answer then, and the second
2063    /// save would silently overwrite the first tab's file. `true` = go ahead.
2064    ///
2065    /// PLM documents compare their full part/revision key in either spelling.
2066    /// Local documents compare DISPLAY names: on desktop an open document
2067    /// carries the full path it was loaded from while the Save As field holds a
2068    /// bare name, so an identity compare would never match and the clobber would
2069    /// happen before anything noticed. Two same-stemmed files in different
2070    /// folders are refused too — stricter than strictly necessary, and the side
2071    /// to err on when the alternative is overwriting another tab's file.
2072    fn name_is_free(&mut self, docs: &mut Documents, name: &str) -> bool {
2073        let display = model_display_name(name);
2074        let target_revision = crate::plm::uses::revision_key_in(name);
2075        let taken = docs.iter().enumerate().any(|(index, doc)| {
2076            index != docs.active_index()
2077                && doc
2078                    .name()
2079                    .is_some_and(|open| {
2080                        match (&target_revision, crate::plm::uses::revision_key_in(open)) {
2081                            (Some(target), Some(other)) => *target == other,
2082                            (None, None) => model_display_name(open) == display,
2083                            _ => false,
2084                        }
2085                    })
2086        });
2087        if taken {
2088            let identity = target_revision.as_deref().unwrap_or(&display);
2089            self.status = format!("Save refused: '{identity}' is already open in another tab");
2090            docs.engine_mut().push_notice(&self.status);
2091        }
2092        !taken
2093    }
2094
2095    /// Write the active document's request JSON through the store under `name`.
2096    /// Returns `true` on success (so the caller can close the modal).
2097    fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2098        let name = name.trim().to_string();
2099        if name.is_empty() {
2100            self.status = "enter a name to save".into();
2101            return false;
2102        }
2103        if !self.name_is_free(docs, &name) {
2104            return false;
2105        }
2106        let document = docs.engine().history_request_json();
2107        crate::plm::thumbnail::stage_save(store, &name, &document, docs.engine(), docs.active_id());
2108        match store.write(&name, &document) {
2109            Ok(()) => {
2110                // The document keeps the raw identity (a full path when a
2111                // native dialog chose it); the name field shows the bare name.
2112                self.name_buf = model_display_name(&name);
2113                let doc = docs.active_mut();
2114                doc.set_name(Some(name.clone()));
2115                doc.mark_clean();
2116                self.save_generation += 1;
2117                remember_document(store, &name);
2118                self.status = format!("saved {name}");
2119                true
2120            }
2121            Err(e) => {
2122                self.status = format!("save failed: {e}");
2123                docs.engine_mut().push_notice(&self.status);
2124                false
2125            }
2126        }
2127    }
2128
2129    pub(crate) fn take_plm_save_request(&mut self) -> Option<u64> {
2130        self.plm_save_request.take()
2131    }
2132
2133    pub(crate) fn finish_plm_save(&mut self, docs: &mut Documents, store: &dyn ModelStore) {
2134        if let Some(name) = docs.active().name().map(str::to_string) {
2135            self.save_to(docs, store, name);
2136        }
2137    }
2138
2139    /// Save As through the explorer's current directory, then retain the
2140    /// backend's returned identity (an absolute path on desktop or a virtual
2141    /// `/models/...` path in the browser) for subsequent plain Save commands.
2142    fn save_to_browser(
2143        &mut self,
2144        docs: &mut Documents,
2145        store: &dyn ModelStore,
2146        name: String,
2147    ) -> bool {
2148        let name = name.trim().to_string();
2149        if name.is_empty() {
2150            self.status = "enter a name to save".into();
2151            return false;
2152        }
2153        if !self.name_is_free(docs, &name) {
2154            return false;
2155        }
2156        // Save As makes a NEW file: its class is the one the name carries
2157        // (the chooser put it there), the blocks of the other classes go, and
2158        // a family member's stamp goes — the copy is a part of its own, which
2159        // is what "Save as a new part" means.
2160        let class = DocumentClass::of_name(&name).unwrap_or_default();
2161        adopt_class(docs.engine_mut(), class);
2162        if docs.engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
2163            docs.engine_mut()
2164                .history
2165                .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
2166        }
2167        match store.browser_write(&name, &docs.engine().history_request_json()) {
2168            Ok(identity) => {
2169                // The recent list's picture, under the identity the backend
2170                // chose (the name the recent row will carry).
2171                crate::plm::thumbnail::stage_recent(&identity, docs.engine(), docs.active_id());
2172                self.name_buf = model_display_name(&identity);
2173                let doc = docs.active_mut();
2174                self.member_ack.remove(&doc.id());
2175                doc.set_name(Some(identity.clone()));
2176                doc.mark_clean();
2177                self.save_generation += 1;
2178                remember_document(store, &identity);
2179                self.status = format!("saved {identity}");
2180                true
2181            }
2182            Err(e) => {
2183                self.status = format!("save failed: {e}");
2184                false
2185            }
2186        }
2187    }
2188
2189    /// **Open** — read a stored document into its own tab (or focus the tab
2190    /// already holding it). The one door every open lane uses: File>Open, the
2191    /// Edit-Part flow, and the session restore's siblings.
2192    pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2193        self.pending_open = None;
2194        if docs.focus_named(name) {
2195            remember_document(store, name);
2196            self.status = format!("{name} is already open");
2197            return;
2198        }
2199        match crate::store::read_now(store, name) {
2200            crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store),
2201            // An index-hydrated PLM store lists what it has not loaded yet;
2202            // the read above asked for it.
2203            crate::store::ReadNow::Loading => {
2204                self.pending_open = Some((name.to_owned(), docs.active_id()));
2205                self.status = format!("'{name}' is still loading");
2206            }
2207            crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
2208        }
2209    }
2210
2211    /// Complete the common File>Open / Edit-Part demand read on a later frame.
2212    /// Store reads are already deduplicated while a request is in flight.
2213    fn poll_pending_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2214        let Some((name, requester)) = self.pending_open.take() else { return };
2215        if docs.active_id() != requester {
2216            return;
2217        }
2218        if docs.focus_named(&name) {
2219            self.status = format!("{name} is already open");
2220            return;
2221        }
2222        match crate::store::read_now(store, &name) {
2223            crate::store::ReadNow::Ready(contents) => {
2224                self.load_document(docs, &name, &contents, store);
2225            }
2226            crate::store::ReadNow::Loading => {
2227                self.pending_open = Some((name, requester));
2228                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2229            }
2230            crate::store::ReadNow::Absent => {
2231                self.status = format!("open failed: '{name}' not found");
2232            }
2233        }
2234    }
2235
2236    /// Serialize the current model in `format` (`"step"` | `"iges"` | `"stl"` |
2237    /// `"obj"` | `"glb"` | `"json"`) and
2238    /// write it through the store's format-typed interchange under `<name>.<ext>`.
2239    /// `"json"` is the full model RECIPE (`.nbrep`, `history_request_json`) —
2240    /// the exact document Open/Import consume, for sharing a failing model. Returns
2241    /// `true` on success (so the caller can close the modal); a guard message and
2242    /// `false` when there is nothing to export or the engine/store errs.
2243    /// An export of the geometry taken while a family row is previewed is of
2244    /// THAT row: say so on the status line and in a toast, so a user does not
2245    /// ship the wrong size without knowing.
2246    fn say_previewed_export(&mut self, docs: &mut Documents) {
2247        let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
2248            return;
2249        };
2250        let note = format!("exported {label} (preview): the family's own values are not in this file");
2251        self.status = format!("{} — {label} (preview)", self.status);
2252        docs.engine_mut().push_notice(note);
2253    }
2254
2255    /// The line the export dialogs show while a family row is previewed.
2256    fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
2257        if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
2258            let text = format!(
2259                "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
2260                 not the family's own values. The .nbrep recipe keeps the family's own."
2261            );
2262            ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
2263            ui.add_space(4.0);
2264        }
2265    }
2266
2267    fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
2268        let name = self.effective_name(docs);
2269        // GLB is BINARY, so it takes the store's byte-shaped interchange rather
2270        // than the text one below — a `.glb` routed through a `&str` would be
2271        // mangled the moment a chunk length happened not to be valid UTF-8.
2272        if format == "glb" {
2273            // The document's name becomes the root NODE's, as it becomes the
2274            // root PRODUCT's on a STEP export.
2275            return match docs.engine().export_glb_bytes(&name) {
2276                Ok(bytes) => match store.export_file_named_bytes(&format!("{name}.glb"), &bytes) {
2277                    Ok(()) => {
2278                        self.status = format!("exported {name}.glb");
2279                        true
2280                    }
2281                    Err(e) => {
2282                        self.status = format!("export failed: {e}");
2283                        false
2284                    }
2285                },
2286                Err(e) => {
2287                    self.status = format!("export failed: {e}");
2288                    false
2289                }
2290            };
2291        }
2292        let state = docs.engine_mut();
2293        // A drawing sheet's SVG writes `<name>-<sheet>.svg`: a document can
2294        // carry several sheets, and one file per export must not overwrite the
2295        // last. The PDF is the whole drawing SET, one page per sheet, so it is
2296        // the document's own `<name>.pdf`. It takes the BYTE lane — it is a
2297        // byte format, and both lanes type the file from its extension, so the
2298        // user gets `application/pdf` either way.
2299        // The 3D drawing set is `<name>-3d.pdf`, beside the plain one rather
2300        // than over it.
2301        if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
2302            let pdf = format != "sheetsvg";
2303            let three_d = format == "sheetpdf3d";
2304            let (file, written) = if pdf {
2305                let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
2306                let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
2307                let written = bytes.and_then(|bytes| store.export_file_named_bytes(&file, &bytes).map(|()| ()));
2308                (file, written)
2309            } else {
2310                let sheet_name = state
2311                    .resolve_sheet("")
2312                    .ok()
2313                    .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
2314                    .unwrap_or_default();
2315                let file = format!("{name}-{}.svg", slug(&sheet_name));
2316                let written = state.resolve_sheet("").and_then(|id| {
2317                    state
2318                        .export_sheet_svg(&id)
2319                        .and_then(|text| store.export_file_named(&file, &text).map(|()| ()))
2320                });
2321                (file, written)
2322            };
2323            let pages = state.sheet_state().sheets.len() + usize::from(three_d);
2324            return match written {
2325                Ok(()) => {
2326                    self.status = if pdf {
2327                        format!("exported {file} ({pages} page{})", if pages == 1 { "" } else { "s" })
2328                    } else {
2329                        format!("exported {file}")
2330                    };
2331                    true
2332                }
2333                Err(e) => {
2334                    self.status = format!("export failed: {e}");
2335                    false
2336                }
2337            };
2338        }
2339        let (text, ext) = match format {
2340            "stl" => (state.export_stl_text(), "stl"),
2341            "obj" => (state.export_obj_text(), "obj"),
2342            "iges" => (state.export_iges_text(), "igs"),
2343            "json" => (Ok(state.history_request_json()), "nbrep"),
2344            // The document's name becomes the root PRODUCT's — an assembly
2345            // exports as `<name>` with its parts named under it.
2346            _ => (state.export_step_text_named(&name), "step"),
2347        };
2348        match text {
2349            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2350                Ok(()) => {
2351                    self.status = format!("exported {name}.{ext}");
2352                    true
2353                }
2354                Err(e) => {
2355                    self.status = format!("export failed: {e}");
2356                    false
2357                }
2358            },
2359            Err(e) => {
2360                self.status = format!("export failed: {e}");
2361                false
2362            }
2363        }
2364    }
2365
2366    /// Serialize the sheet-metal flat pattern in `format` (`"dxf"` | `"svg"`) and
2367    /// write it through the store under `<name>.<ext>`. Returns `true` on success
2368    /// (so the caller can close the modal). On failure the message goes to the
2369    /// status line AND is queued as a toast (the existing engine notice path), so
2370    /// a "no sheet-metal body in the part" error is surfaced prominently.
2371    fn export_flat_pattern_as(
2372        &mut self,
2373        docs: &mut Documents,
2374        store: &dyn ModelStore,
2375        format: &str,
2376    ) -> bool {
2377        let name = self.effective_name(docs);
2378        let state = docs.engine_mut();
2379        let (text, ext) = match format {
2380            "svg" => (state.export_flat_pattern_svg(), "svg"),
2381            _ => (state.export_flat_pattern_dxf(), "dxf"),
2382        };
2383        match text {
2384            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2385                Ok(()) => {
2386                    self.status = format!("exported {name}.{ext}");
2387                    true
2388                }
2389                Err(e) => {
2390                    self.status = format!("flat-pattern export failed: {e}");
2391                    false
2392                }
2393            },
2394            Err(e) => {
2395                self.status = format!("flat-pattern export failed: {e}");
2396                state.push_notice(format!("Flat pattern: {e}"));
2397                false
2398            }
2399        }
2400    }
2401
2402    /// Serialize the assembly BOM in `format` (`"csv"` | `"json"`) and write it
2403    /// through the store under `<name>.bom.<ext>` (a compound extension naming
2404    /// the content, like the `.nbrep` recipe). On failure the message goes
2405    /// to the status line AND is queued as a toast — the flat-pattern error
2406    /// pattern — so a componentless document reports loudly.
2407    fn export_bom_as(
2408        &mut self,
2409        docs: &mut Documents,
2410        store: &dyn ModelStore,
2411        format: &str,
2412    ) -> bool {
2413        let name = self.effective_name(docs);
2414        let state = docs.engine_mut();
2415        let (text, ext) = match format {
2416            "json" => (state.export_bom_json(), "bom.json"),
2417            _ => (state.export_bom_csv(), "bom.csv"),
2418        };
2419        match text {
2420            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
2421                Ok(()) => {
2422                    self.status = format!("exported {name}.{ext}");
2423                    true
2424                }
2425                Err(e) => {
2426                    self.status = format!("BOM export failed: {e}");
2427                    false
2428                }
2429            },
2430            Err(e) => {
2431                self.status = format!("BOM export failed: {e}");
2432                state.push_notice(format!("BOM export: {e}"));
2433                false
2434            }
2435        }
2436    }
2437
2438    /// Write one of the PCB's manufacturing outputs through the store under
2439    /// [`Output::file_name`](super::fabrication_export::Output::file_name).
2440    /// The bundle's warnings (design-rule findings, silkscreen characters
2441    /// the font lacks) keep the dialog OPEN with the warnings on its status
2442    /// line, and go to a toast: the files are complete, but the user should
2443    /// read why before ordering boards. Returns `true` to close the dialog.
2444    fn export_fabrication_as(
2445        &mut self,
2446        docs: &mut Documents,
2447        store: &dyn ModelStore,
2448        output: super::fabrication_export::Output,
2449    ) -> bool {
2450        use super::fabrication_export::{self as fab, Output};
2451        let name = self.effective_name(docs);
2452        let file = output.file_name(&name);
2453        let state = docs.engine_mut();
2454        let (written, warnings) = match output {
2455            Output::Bundle => match fab::bundle(state, &name) {
2456                Ok(bundle) => (
2457                    store.export_file_named_bytes(&file, &bundle.zip()).map(|()| bundle.files.len()),
2458                    bundle.warnings,
2459                ),
2460                Err(e) => (Err(e), vec![]),
2461            },
2462            _ => (
2463                fab::text(state, output, &name).and_then(|text| store.export_file_named(&file, &text).map(|()| 1)),
2464                vec![],
2465            ),
2466        };
2467        match written {
2468            Ok(count) => {
2469                // Say WHERE: the dialog has no folder of its own, and on desktop
2470                // the files go to the models folder, not beside the board.
2471                let into = models_folder(store).map(|folder| format!(" into {folder}")).unwrap_or_default();
2472                self.status = if output == Output::Bundle {
2473                    format!("exported {file} ({count} files){into}")
2474                } else {
2475                    format!("exported {file}{into}")
2476                };
2477                if warnings.is_empty() {
2478                    state.push_notice_as(
2479                        brep_render::engine_state::NoticeSeverity::Info,
2480                        format!("{}: {}", output.label(), self.status),
2481                    );
2482                    return true;
2483                }
2484                self.status = format!("{}. Before ordering: {}", self.status, warnings.join(" "));
2485                state.push_notice_as(
2486                    brep_render::engine_state::NoticeSeverity::Warning,
2487                    format!("{}: {}", output.label(), warnings.join(" ")),
2488                );
2489                false
2490            }
2491            Err(e) => {
2492                self.status = format!("{} export failed: {e}", output.label());
2493                state.push_notice(format!("{} export: {e}", output.label()));
2494                false
2495            }
2496        }
2497    }
2498
2499    /// Route an imported STEP file: **probe first**. The probe IS the parse —
2500    /// it stashes the structure in the engine for the import to consume — so a
2501    /// structured file arms the choice modal and is imported on the click,
2502    /// never parsed a second time.
2503    ///
2504    /// Everything else goes straight to today's flat lane, unchanged:
2505    ///
2506    /// * `Ok(None)` — no product structure. A part file must NEVER see the
2507    ///   dialog, and its behaviour stays byte-for-byte what it was.
2508    /// * `Err` — text the Part 21 parser refuses. Handing it to the flat lane
2509    ///   keeps the failure wording exactly today's (the two share the same
2510    ///   `ISO-10303-21` guard), rather than inventing a second one.
2511    fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2512        // A previous file's armed choice cannot survive this one: the probe
2513        // below REPLACES the engine's stash on every outcome (including the
2514        // structureless one), so an app-side pending left standing would offer
2515        // a button whose parse is already gone. A probe still running for an
2516        // earlier upload is superseded the same way (its answer is ignored).
2517        self.pending_step_import = None;
2518        let id = state.submit_step_probe(contents);
2519        self.pending_step_probe = Some(PendingStepProbe {
2520            id,
2521            name: name.to_string(),
2522            text: contents.to_string(),
2523        });
2524        self.status = format!("reading \"{name}\"\u{2026}");
2525        // The synchronous Inline runner (tests, headless) has answered inside
2526        // the submit; a background runner answers on a later frame's poll.
2527        self.poll_step_probe(state);
2528    }
2529
2530    /// Resolve an answered STEP probe: structure arms the §3.9 choice, no
2531    /// structure (or a parse the flat lane will refuse with its own wording)
2532    /// takes the flat lane. A probe the engine no longer holds — a document
2533    /// switch or a cancelled run dropped it — is reported and forgotten.
2534    fn poll_step_probe(&mut self, state: &mut EngineState) {
2535        while let Some((id, outcome)) = state.take_step_probe() {
2536            let Some(pending) = self.pending_step_probe.as_ref() else {
2537                continue;
2538            };
2539            if pending.id != id {
2540                continue; // an earlier upload's answer; a newer probe replaced it
2541            }
2542            let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
2543            match outcome {
2544                StepProbeOutcome::Structure(probe) => {
2545                    self.status = format!(
2546                        "\"{name}\" contains an assembly — {} part{}, {} instance{}",
2547                        probe.parts,
2548                        plural(probe.parts),
2549                        probe.instances,
2550                        plural(probe.instances)
2551                    );
2552                    self.pending_step_import = Some(PendingStepImport {
2553                        name,
2554                        text,
2555                        probe,
2556                        // Default = keep the tree (§3.9). Flattening is the escape
2557                        // hatch for a deep or pathological file, not the norm.
2558                        flatten: false,
2559                    });
2560                    // The routing site's `close_after_import` ran frames ago,
2561                    // while the probe was still out, and closed the modal: the
2562                    // choice is raised HERE, when the answer lands. (Under the
2563                    // synchronous Inline runner both run in the same call, and
2564                    // the second open is idempotent.)
2565                    self.open_modal(Mode::StepAssembly);
2566                }
2567                StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
2568                    self.import_step_flat(state, &name, &text);
2569                }
2570            }
2571        }
2572        if self.pending_step_probe.is_some() && !state.step_probes_pending() {
2573            // Nothing is running and no answer came: the engine dropped the
2574            // probe (document switch / cancel). Say so rather than reading
2575            // "reading…" forever.
2576            let pending = self.pending_step_probe.take().unwrap();
2577            self.status = format!("import of \"{}\" was cancelled", pending.name);
2578        }
2579    }
2580
2581    /// Append an imported STEP file to the model (an IMPORT3D feature), then treat
2582    /// the enlarged model as dirty (an import is an edit, not an Open — the model
2583    /// keeps its current name / save baseline). THE flat lane, reached from a
2584    /// structureless file, an unparseable one, and the dialog's "Import as
2585    /// bodies".
2586    fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2587        match state.import_step_feature(contents) {
2588            // Framing is deferred to the engine (`pending_fit`): under the native
2589            // thread / wasm worker runner the body is not resident yet, so framing
2590            // here would frame the empty scene. See [`EngineState::pending_fit`].
2591            Ok(_) => self.status = format!("imported {name}"),
2592            Err(e) => self.status = format!("import failed: {e}"),
2593        }
2594    }
2595
2596    /// **Import as assembly** — consume the probe's stash into parts-library
2597    /// entries + one component per occurrence, in the engine's single batch.
2598    ///
2599    /// An `Err` here means the structured lane produced nothing, and the engine
2600    /// returns BEFORE it touches history when that happens — so the honest
2601    /// answer is the one A6's contract prescribes: re-run the flat import with
2602    /// the text this dialog is still holding, and say plainly that the assembly
2603    /// the user asked for came in as bodies. Silence there would leave them
2604    /// believing they have a structure tree that does not exist.
2605    fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
2606        let Some(pending) = self.pending_step_import.take() else {
2607            return;
2608        };
2609        // Read BEFORE the import: afterwards its own ACOMP features make the
2610        // answer unconditionally "yes" and the workbench switch never fires.
2611        let was_assembly = state.history_has_assembly();
2612        let doc_name = step_document_name(&pending.name);
2613        // The §3.9 checkbox. A depth-1 file imports identically either way, so
2614        // an un-shown box costs nothing.
2615        let opts = StepAssemblyImport {
2616            nested: !pending.flatten,
2617        };
2618        // Every unique part is written to the store as its own document, at the
2619        // browser's current location and under `{assembly}-{part}` — the same
2620        // `browser_write` door + "wherever the explorer is pointing" convention
2621        // the STEP parts-library import uses (`panels::step_parts`). So an
2622        // imported part is a part like any other: Open Part opens it, Update
2623        // Components tracks it, a part edit writes through to it.
2624        let mut sink = StorePartSink::new(store, &doc_name);
2625        let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
2626            Ok(report) => {
2627                if !was_assembly {
2628                    self.enter_assembly_workbench(state);
2629                }
2630                // The store side effect is REPORTED, never silent: a 50-part
2631                // import writes 50 files the user did not individually ask for.
2632                for failure in &sink.failures {
2633                    state.push_notice(format!("part not saved — {failure}"));
2634                }
2635                let saved = sink.written;
2636                let base = assembly_import_message(&pending.name, &report);
2637                match (saved, sink.failures.len()) {
2638                    (0, _) => base,
2639                    (saved, 0) => format!("{base}; saved {saved} part file(s)"),
2640                    (saved, failed) => {
2641                        format!("{base}; saved {saved} part file(s), {failed} could not be saved")
2642                    }
2643                }
2644            }
2645            Err(error) => match state.import_step_feature(&pending.text) {
2646                Ok(_) => format!(
2647                    "imported {} as bodies — the assembly structure could not be built ({error})",
2648                    pending.name
2649                ),
2650                Err(flat) => format!("import failed: {flat}"),
2651            },
2652        };
2653        // The status line only renders inside an OPEN modal and this one closes
2654        // on the click, so the toast is the half the user actually sees.
2655        self.status = message.clone();
2656        state.push_notice(message);
2657    }
2658
2659    /// **Import as bodies** — today's flat lane, unchanged. Drops the probe's
2660    /// stash first: the user chose the text, so the parsed assembly (and every
2661    /// product's solids it is holding resident) has no consumer left.
2662    fn step_assembly_bodies(&mut self, state: &mut EngineState) {
2663        let Some(pending) = self.pending_step_import.take() else {
2664            return;
2665        };
2666        state.discard_probed_step_assembly();
2667        self.import_step_flat(state, &pending.name, &pending.text);
2668    }
2669
2670    /// **Cancel** — import nothing and drop the parse (§3.9).
2671    fn step_assembly_cancel(&mut self, state: &mut EngineState) {
2672        let Some(pending) = self.pending_step_import.take() else {
2673            return;
2674        };
2675        state.discard_probed_step_assembly();
2676        self.status = format!("import cancelled: {}", pending.name);
2677    }
2678
2679    /// Switch to the **Assembly** workbench so a freshly imported structure is
2680    /// actually reachable: the Assembly Structure tree and the Constraints panel
2681    /// are CLAIMED by that workbench, so an assembly imported under Modeling
2682    /// would land with its structure invisible.
2683    ///
2684    /// No-op when the active workbench already shows those panels: the
2685    /// predicate asks whether the BOM panel is visible, which is true for
2686    /// Assembly itself, for Wire harness (it lists the assembly panels too),
2687    /// and for "All", whose users must not be yanked out of it. Applied
2688    /// through the same settings seam the toolbar dropdown and the saved-
2689    /// workbench restore use, and like the restore NOT persisted to the settings
2690    /// blob: that blob stays the user's boot preference, and a document's
2691    /// workbench is session-scoped.
2692    fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
2693        if crate::workbench::panel_visible(
2694            &state.settings.workbench,
2695            crate::workbench::assembly::BOM_PANEL_ID,
2696            &crate::workbench::ButtonState::of(state),
2697        ) {
2698            return;
2699        }
2700        let _ = state.apply_settings_json(
2701            &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
2702                .to_string(),
2703        );
2704    }
2705
2706    /// Append an imported IGES file to the model (an IMPORT3D feature) — the
2707    /// IGES sibling of [`Self::import_step`].
2708    fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2709        match state.import_iges_feature(contents) {
2710            // Framing deferred to the engine (`pending_fit`) — see `import_step`.
2711            Ok(_) => self.status = format!("imported {name}"),
2712            Err(e) => self.status = format!("import failed: {e}"),
2713        }
2714    }
2715
2716    fn stage_mesh_preview(
2717        &mut self,
2718        format: MeshImportFormat,
2719        name: String,
2720        contents: Vec<u8>,
2721    ) {
2722        self.pending_stl_import = Some((format, name, contents));
2723        self.status.clear();
2724    }
2725
2726    /// Selecting an STL or a 3MF starts a preview session; only its Accept
2727    /// action edits the destination document.
2728    pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
2729        self.pending_stl_import.take()
2730    }
2731
2732    fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
2733        match state.import_obj_bytes_feature(contents) {
2734            Ok(_) => self.status = format!("reconstructing {name} in background…"),
2735            Err(e) => self.status = format!("import failed: {e}"),
2736        }
2737    }
2738
2739    fn import_text(
2740        &mut self,
2741        state: &mut EngineState,
2742        name: &str,
2743        bytes: &[u8],
2744        importer: fn(&mut Self, &mut EngineState, &str, &str),
2745    ) {
2746        match std::str::from_utf8(bytes) {
2747            Ok(contents) => importer(self, state, name, contents),
2748            Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
2749        }
2750    }
2751
2752    /// Load a model document's contents into a NEW TAB (roll to the last
2753    /// feature + zoom-to-fit), clean from the start. A document that fails to
2754    /// load leaves no tab behind — the engine it was loading into is dropped
2755    /// with its runner.
2756    fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, store: &dyn ModelStore) {
2757        let plm = store.plm_client().is_some();
2758        let mut engine = docs.spawn_engine();
2759        match engine.load_model_and_fit(contents) {
2760            Ok(_) => {
2761                // The extension names the class on the file system; the
2762                // document field follows it, before `Document::new` takes the
2763                // clean baseline so a hand-renamed file does not open dirty.
2764                // On a PLM store the name is a revision key the explorer spells
2765                // with `.nbrep` whatever the part is, so the document's own
2766                // field is the class (the part's fixed `document_class`).
2767                if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
2768                    document_class::set_document_class(&mut engine, class);
2769                }
2770                // The document keeps the raw identity — the full path when a
2771                // native dialog picked the file, so plain Save writes back to
2772                // it; the name field shows only the bare display name.
2773                let mut doc = Document::new(engine);
2774                doc.set_name(Some(name.to_string()));
2775                docs.open_document(doc);
2776                self.name_buf = model_display_name(name);
2777                self.status = format!("opened {name}");
2778                remember_document(store, name);
2779            }
2780            Err(e) => self.status = format!("open failed: {e}"),
2781        }
2782    }
2783
2784    // --- document classes: families, templates, members ------------------------
2785
2786    /// The session's PLM client and the revision key `identity` names, when
2787    /// the store IS the PLM and `identity` is one of its revisions.
2788    fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
2789        let client = store.plm_client()?;
2790        let key = crate::plm::family::revision_key(identity)?;
2791        Some((client, key))
2792    }
2793
2794    /// Poll the PLM work in flight (S9) and act on what finished.
2795    fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2796        let Some(work) = self.plm_work.as_mut() else { return };
2797        ctx.request_repaint_after(std::time::Duration::from_millis(30));
2798        match work {
2799            PlmWork::Members(pending) => {
2800                let Some(result) = pending.poll() else { return };
2801                self.plm_work = None;
2802                let Some(pick) = self.pick_member.as_mut() else { return };
2803                pick.loading = false;
2804                match result {
2805                    Ok(members) => {
2806                        pick.rows = members
2807                            .into_iter()
2808                            .map(|member| MemberChoice {
2809                                why_not: member.not_placeable(),
2810                                exists: member.not_placeable().is_none(),
2811                                identity: member.key(),
2812                                description: member.name.clone(),
2813                                part_number: member.number,
2814                            })
2815                            .collect();
2816                    }
2817                    Err(error) => self.status = format!("the members could not be read: {error}"),
2818                }
2819            }
2820            PlmWork::Place(pending) => {
2821                let Some(result) = pending.poll() else { return };
2822                self.plm_work = None;
2823                match result {
2824                    Ok((key, text, number)) => {
2825                        self.insert_component_named(docs.engine_mut(), &key, &number, &text);
2826                        self.pick_member = None;
2827                        self.spin_out = None;
2828                        self.open = false;
2829                    }
2830                    Err(error) => self.status = error,
2831                }
2832            }
2833            PlmWork::OpenFamily(pending) => {
2834                let Some(result) = pending.poll() else { return };
2835                self.plm_work = None;
2836                match result {
2837                    Ok(key) => self.open_document(docs, store, &key),
2838                    Err(error) => self.status = format!("the family could not be opened: {error}"),
2839                }
2840            }
2841        }
2842    }
2843
2844    /// Route an Insert-component choice by its class. A FAMILY arms the member
2845    /// picker and a TEMPLATE the spin-out prompt; both return `true` (handled,
2846    /// the modal switches). A normal part returns `false` and is inserted as
2847    /// it always was. The class is the file's extension, or the document's own
2848    /// field when the name carries none (a browser upload's bare name).
2849    fn begin_class_insert(
2850        &mut self,
2851        store: &dyn ModelStore,
2852        identity: &str,
2853        contents: &str,
2854        assembly: Option<String>,
2855    ) -> bool {
2856        let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
2857        let class = DocumentClass::of_name(identity)
2858            .filter(|class| *class != DocumentClass::Normal)
2859            .unwrap_or_else(|| DocumentClass::of_document(&document));
2860        let file = crate::store::file_name_of(identity);
2861        match class {
2862            DocumentClass::Normal => false,
2863            DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
2864                let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
2865                let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
2866                    return false;
2867                };
2868                self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
2869                    crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
2870                }))));
2871                self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
2872                self.status.clear();
2873                self.open_modal(Mode::PickMember);
2874                true
2875            }
2876            DocumentClass::Family => {
2877                let table = family_table::read_table(contents);
2878                let rows = table
2879                    .rows
2880                    .iter()
2881                    .map(|row| {
2882                        let identity = family_table::member_identity(Some(identity), &row.part_number);
2883                        let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
2884                        MemberChoice {
2885                            part_number: row.part_number.clone(),
2886                            description: row.description.clone(),
2887                            identity,
2888                            exists,
2889                            why_not: None,
2890                        }
2891                    })
2892                    .collect();
2893                self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
2894                self.status.clear();
2895                self.open_modal(Mode::PickMember);
2896                true
2897            }
2898            DocumentClass::Template => {
2899                let inputs = template::inputs_of(&document);
2900                let values = inputs
2901                    .iter()
2902                    .map(|input| (input.name.clone(), template::default_value(&document, input)))
2903                    .collect();
2904                let stem = document_class::strip_class_extension(&file).to_string();
2905                let plm_template = self
2906                    .plm_revision(store, identity)
2907                    .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
2908                self.spin_out = Some(SpinOut {
2909                    template_file: file,
2910                    template: document,
2911                    inputs,
2912                    name: format!("{stem}-1"),
2913                    values,
2914                    assembly,
2915                    plm_template,
2916                    number: String::new(),
2917                });
2918                self.status.clear();
2919                self.open_modal(Mode::SpinOut);
2920                true
2921            }
2922        }
2923    }
2924
2925    /// The **member picker**: a family is never placed itself. Each table row
2926    /// is a button; one whose member file exists inserts that `.nbrep` as a
2927    /// normal component, one that was never generated says so.
2928    fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
2929        let Some(pick) = self.pick_member.take() else {
2930            self.open = false;
2931            return;
2932        };
2933        let mut chosen = None;
2934        let mut cancel = false;
2935        let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
2936            ui.set_width(420.0);
2937            ui.heading(format!("Insert a member of {}", pick.family_file));
2938            ui.add_space(2.0);
2939            ui.weak("A family is never placed itself: choose the member to place.");
2940            ui.add_space(6.0);
2941            if pick.loading {
2942                ui.label("Reading the family's members from the PLM\u{2026}");
2943            } else if pick.rows.is_empty() {
2944                ui.label("This family's table has no rows yet.");
2945            }
2946            egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
2947                for (index, row) in pick.rows.iter().enumerate() {
2948                    ui.horizontal(|ui| {
2949                        let text = if row.description.trim().is_empty() {
2950                            row.part_number.clone()
2951                        } else {
2952                            format!("{} \u{2014} {}", row.part_number, row.description)
2953                        };
2954                        let button = ui.add_enabled(
2955                            row.exists,
2956                            egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
2957                        );
2958                        self.hit(&format!("member:{}", row.part_number), &button);
2959                        if button.clicked() {
2960                            chosen = Some(index);
2961                        }
2962                        if let Some(why) = &row.why_not {
2963                            ui.weak(format!("({why})"));
2964                        } else if row.identity.is_none() {
2965                            ui.weak("(the part number is not a file name)");
2966                        } else if !row.exists {
2967                            ui.weak("(not generated yet: open the family and Generate)");
2968                        }
2969                    });
2970                }
2971            });
2972            ui.add_space(6.0);
2973            if !self.status.is_empty() {
2974                ui.weak(&self.status);
2975                ui.add_space(4.0);
2976            }
2977            let c = ui.button("Cancel");
2978            self.hit("member:cancel", &c);
2979            cancel = c.clicked();
2980        });
2981        let should_close = modal.should_close();
2982        if let (Some(index), true) = (chosen, pick.plm) {
2983            let row = &pick.rows[index];
2984            if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
2985                self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
2986                self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
2987            }
2988        } else if let Some(index) = chosen {
2989            let row = &pick.rows[index];
2990            let identity = row.identity.clone().unwrap_or_default();
2991            match store.read(&identity) {
2992                Some(contents) => {
2993                    self.insert_component_document(state, &identity, &contents);
2994                    self.open = false;
2995                    return;
2996                }
2997                None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
2998            }
2999        } else if cancel || should_close {
3000            self.status.clear();
3001            self.open = false;
3002            return;
3003        }
3004        self.pick_member = Some(pick);
3005    }
3006
3007    /// The **template spin-out** prompt: a template is never placed itself.
3008    /// The user names the new part and sets each marked input (a fixed list
3009    /// is offered as buttons; limits are checked as they type), then Create
3010    /// writes the specialised copy as `<name>.nbrep` beside the assembly and
3011    /// places it. A name that already exists is refused, never overwritten.
3012    fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
3013        let Some(mut spin) = self.spin_out.take() else {
3014            self.open = false;
3015            return;
3016        };
3017        let mut create = false;
3018        let mut cancel = false;
3019        let problems: Vec<Option<String>> = spin
3020            .inputs
3021            .iter()
3022            .map(|input| {
3023                let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
3024                template::value_problem(&spin.template, input, text)
3025            })
3026            .collect();
3027        let name_problem = template::name_problem(&spin.name);
3028        let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
3029            ui.set_width(440.0);
3030            ui.heading(format!("New part from {}", spin.template_file));
3031            ui.add_space(2.0);
3032            ui.weak("A template is never placed itself: this makes a new part from it and places that.");
3033            ui.add_space(6.0);
3034            egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
3035                ui.label("New part name");
3036                let field = ui.add(egui::TextEdit::singleline(&mut spin.name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
3037                self.hit("spinout:name", &field);
3038                ui.end_row();
3039                if spin.plm_template.is_some() {
3040                    ui.label("Part number");
3041                    let field = ui.add(
3042                        egui::TextEdit::singleline(&mut spin.number).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
3043                            .hint_text("empty: the part type numbers it")
3044                            .desired_width(240.0),
3045                    );
3046                    self.hit("spinout:number", &field);
3047                    ui.end_row();
3048                }
3049                for (input, problem) in spin.inputs.iter().zip(&problems) {
3050                    ui.label(input.shown());
3051                    ui.vertical(|ui| {
3052                        let value = spin.values.entry(input.name.clone()).or_default();
3053                        if input.choices.is_empty() {
3054                            let field = ui.add(egui::TextEdit::singleline(value).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
3055                            self.hit(&format!("spinout:input:{}", input.name), &field);
3056                        } else {
3057                            ui.horizontal_wrapped(|ui| {
3058                                for choice in &input.choices {
3059                                    let chip = ui.selectable_label(value.trim() == choice.trim(), choice);
3060                                    self.hit(&format!("spinout:choice:{}:{}", input.name, choice.trim()), &chip);
3061                                    if chip.clicked() {
3062                                        *value = choice.trim().to_string();
3063                                    }
3064                                }
3065                            });
3066                        }
3067                        let limits = match (input.min, input.max) {
3068                            (Some(min), Some(max)) => format!("{min} to {max}"),
3069                            (Some(min), None) => format!("at least {min}"),
3070                            (None, Some(max)) => format!("at most {max}"),
3071                            (None, None) => String::new(),
3072                        };
3073                        if !limits.is_empty() {
3074                            ui.weak(limits);
3075                        }
3076                        if let Some(problem) = problem {
3077                            ui.colored_label(ui.visuals().warn_fg_color, problem);
3078                        }
3079                    });
3080                    ui.end_row();
3081                }
3082            });
3083            if spin.inputs.is_empty() {
3084                ui.weak("This template marks no inputs; the copy is the template as it is.");
3085            }
3086            ui.add_space(4.0);
3087            let file = crate::store::file_name_of(&spin_out_target(&spin));
3088            ui.weak(match &spin.assembly {
3089                _ if spin.plm_template.is_some() => {
3090                    "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
3091                }
3092                Some(assembly) => format!(
3093                    "Saved as {file}, in the folder of {}",
3094                    crate::store::file_name_of(assembly)
3095                ),
3096                None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
3097            });
3098            if let Some(problem) = &name_problem {
3099                ui.colored_label(ui.visuals().warn_fg_color, problem);
3100            }
3101            if !self.status.is_empty() {
3102                ui.weak(&self.status);
3103            }
3104            ui.add_space(6.0);
3105            ui.horizontal(|ui| {
3106                let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
3107                let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
3108                self.hit("spinout:create", &c);
3109                create = c.clicked();
3110                let x = ui.button("Cancel");
3111                self.hit("spinout:cancel", &x);
3112                cancel = x.clicked();
3113            });
3114        });
3115        let should_close = modal.should_close();
3116        if create {
3117            match self.create_spin_out(state, store, &spin) {
3118                Ok(()) => {
3119                    self.open = false;
3120                    return;
3121                }
3122                Err(error) => self.status = error,
3123            }
3124        } else if cancel || should_close {
3125            self.status.clear();
3126            self.open = false;
3127            return;
3128        }
3129        self.spin_out = Some(spin);
3130    }
3131
3132    /// Write the spun-out part and place it. `Err` is the status line.
3133    fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
3134        if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
3135            // The server makes the part; it is placed when it answers. The
3136            // dialog stays open until then, saying so.
3137            let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
3138            self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
3139                let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
3140                    .await
3141                    .map_err(|e| e.to_string())?;
3142                let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
3143                let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
3144                    format!("{} was made, but has no document yet", spun.number)
3145                })?;
3146                Ok((spun.key, text, spun.number))
3147            }))));
3148            return Err("making the part on the PLM\u{2026}".into());
3149        }
3150        if let Some(problem) = template::name_problem(&spin.name) {
3151            return Err(problem);
3152        }
3153        let target = spin_out_target(spin);
3154        if store.read(&target).is_some() {
3155            return Err(format!("'{target}' already exists: choose another name"));
3156        }
3157        let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
3158        let text = copy.to_string();
3159        store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
3160        self.save_generation += 1;
3161        self.insert_component_document(state, &target, &text);
3162        if spin.assembly.is_none() {
3163            self.status = format!(
3164                "{} — saved to the models folder as {target}, because the assembly is not saved yet",
3165                self.status
3166            );
3167        }
3168        Ok(())
3169    }
3170
3171    /// Arm the hand-edit prompt when the active tab is a family member. `true`
3172    /// when it was armed (the caller does not go on to write). Raise the
3173    /// hand-edit prompt the frame a family member first shows an unsaved
3174    /// change — the moment the user "starts to change it".
3175    fn watch_member_edit(&mut self, docs: &Documents) {
3176        let doc = docs.active();
3177        if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
3178            return;
3179        }
3180        if let Some(name) = doc.name().map(str::to_string) {
3181            self.arm_member_prompt(docs, &name, false);
3182        }
3183    }
3184
3185    fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
3186        let Some(source) = family_table::engine_family_source(docs.engine()) else {
3187            return false;
3188        };
3189        self.member_prompt = Some(MemberPrompt {
3190            doc_id: docs.active().id(),
3191            family_identity: sibling_identity(name, &source.family),
3192            source,
3193            from_save,
3194        });
3195        self.status.clear();
3196        self.open_modal(Mode::MemberEdit);
3197        true
3198    }
3199
3200    /// Undo the active tab back to its saved state (bounded), so a member the
3201    /// user declined to change is left exactly as generated.
3202    fn revert_member_edit(docs: &mut Documents) {
3203        for _ in 0..64 {
3204            if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
3205                break;
3206            }
3207            docs.engine_mut().undo();
3208        }
3209    }
3210
3211    /// The **hand-edit prompt** on a family member: a member is the family's
3212    /// output, so a change belongs either in a NEW part or in the family.
3213    fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3214        let Some(prompt) = self.member_prompt.take() else {
3215            self.open = false;
3216            return;
3217        };
3218        if docs.active().id() != prompt.doc_id {
3219            self.open = false;
3220            return;
3221        }
3222        let mut choice = None;
3223        let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
3224        let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
3225            ui.set_width(440.0);
3226            ui.heading("This part is a family member");
3227            ui.add_space(4.0);
3228            ui.label(format!(
3229                "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
3230                prompt.source.part_number, prompt.source.family
3231            ));
3232            ui.add_space(4.0);
3233            ui.weak(if prompt.from_save {
3234                "Save it as a new part, or open the family and make the change there."
3235            } else {
3236                "Keep the change in a new part, or open the family and make it there."
3237            });
3238            if !self.status.is_empty() {
3239                ui.add_space(4.0);
3240                ui.weak(&self.status);
3241            }
3242            ui.add_space(8.0);
3243            ui.horizontal(|ui| {
3244                let a = ui.button("Save as a new part\u{2026}");
3245                self.hit("member-edit:saveas", &a);
3246                if a.clicked() {
3247                    choice = Some("saveas");
3248                }
3249                // On a PLM only someone who may edit the family is offered it.
3250                let offer_family = match &plm {
3251                    Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
3252                    None => true,
3253                };
3254                if offer_family {
3255                    let b = ui.button("Open the family");
3256                    self.hit("member-edit:family", &b);
3257                    if b.clicked() {
3258                        choice = Some("family");
3259                    }
3260                }
3261                let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
3262                self.hit("member-edit:cancel", &c);
3263                if c.clicked() {
3264                    choice = Some("cancel");
3265                }
3266            });
3267        });
3268        let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
3269        match choice {
3270            Some("saveas") => {
3271                // The tab may keep changing; Save As strips the stamp from
3272                // the copy it writes. Plain Save still asks. On a PLM the
3273                // Save As opens on "A new part".
3274                self.member_ack.insert(prompt.doc_id);
3275                self.member_new_part = store.plm_client().is_some();
3276                self.status.clear();
3277                self.dispatch(FileAction::SaveAs, docs, store);
3278            }
3279            Some("family") if plm.is_some() => {
3280                let (client, _) = plm.unwrap_or_else(|| unreachable!());
3281                let number = document_class::strip_class_extension(&prompt.source.family).to_string();
3282                Self::revert_member_edit(docs);
3283                self.status = format!("opening the family {number}\u{2026}");
3284                self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
3285                    let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
3286                    // The newest revision, drafts included (D13).
3287                    let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
3288                    Ok(crate::plm::identity::document_key(&head.id, &newest.id))
3289                }))));
3290                self.open = false;
3291            }
3292            Some("family") => {
3293                if store.read(&prompt.family_identity).is_none() {
3294                    self.status = format!("the family file is not there: {}", prompt.family_identity);
3295                    self.member_prompt = Some(prompt);
3296                    return;
3297                }
3298                Self::revert_member_edit(docs);
3299                self.open = false;
3300                self.open_document(docs, store, &prompt.family_identity);
3301            }
3302            Some(_) => {
3303                if !prompt.from_save {
3304                    Self::revert_member_edit(docs);
3305                }
3306                self.status.clear();
3307                self.open = false;
3308            }
3309            None => self.member_prompt = Some(prompt),
3310        }
3311    }
3312
3313    /// Run the family's Generate on the ACTIVE document (a family seed) and
3314    /// keep the report for the automation blob. The one door the automation
3315    /// command and the table editor's button share.
3316    /// Generate the active family. On a PLM store (the document is a
3317    /// `part/…/rev/…` revision and the session has a client) the server
3318    /// writes the members from the family's SAVED revision: a family with
3319    /// unsaved edits is refused unless `save` is set, which saves it first
3320    /// (plm-cad-integration-todo S9). Everywhere else this is the file-system
3321    /// Generate, and `save` is ignored.
3322    pub fn generate_active_family(
3323        &mut self,
3324        docs: &mut Documents,
3325        store: &dyn ModelStore,
3326        save: bool,
3327    ) -> Result<family_table::GenerateReport, String> {
3328        let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
3329        let report = match (store.plm_client(), plm_key) {
3330            (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
3331            _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
3332        };
3333        Ok(self.record_generate(report))
3334    }
3335
3336    /// The PLM half of [`Self::generate_active_family`]: blocks this thread
3337    /// while the members are built and the server answers (native; the web
3338    /// build's editor polls instead).
3339    #[cfg(not(target_arch = "wasm32"))]
3340    fn generate_on_plm(
3341        &mut self,
3342        docs: &mut Documents,
3343        store: &dyn ModelStore,
3344        client: &std::rc::Rc<crate::plm::client::PlmClient>,
3345        key: &str,
3346        save: bool,
3347    ) -> Result<family_table::GenerateReport, String> {
3348        let json = docs.engine().history_request_json();
3349        if docs.active().is_dirty() {
3350            if !save {
3351                return Err(crate::plm::family::UNSAVED_FAMILY.into());
3352            }
3353            store.write(key, &json)?;
3354            docs.active_mut().mark_clean();
3355            self.save_generation += 1;
3356            // The write is behind: wait for it to land before the server reads it.
3357            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3358            while store.pending_writes() > 0 {
3359                if std::time::Instant::now() > deadline {
3360                    return Err("the family's save did not reach the server within 60 s".into());
3361                }
3362                crate::plm::native::run_pending();
3363                std::thread::sleep(std::time::Duration::from_millis(5));
3364            }
3365            if let Some(error) = store.take_persistence_errors().into_iter().next() {
3366                return Err(format!("the family was not saved, so nothing was generated: {error}"));
3367            }
3368        }
3369        crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
3370            .ok_or("the PLM server did not answer Generate within 5 minutes")?
3371            .map_err(|e| e.to_string())
3372    }
3373
3374    #[cfg(target_arch = "wasm32")]
3375    fn generate_on_plm(
3376        &mut self,
3377        _: &mut Documents,
3378        _: &dyn ModelStore,
3379        _: &std::rc::Rc<crate::plm::client::PlmClient>,
3380        _: &str,
3381        _: bool,
3382    ) -> Result<family_table::GenerateReport, String> {
3383        Err("in the browser, Generate a PLM family from the family table's Generate button".into())
3384    }
3385
3386    fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
3387        if report.rows.iter().any(|row| row.status() == "written") {
3388            self.save_generation += 1;
3389        }
3390        self.status = format!("generate: {}", report.summary());
3391        self.last_generate = Some(report.clone());
3392        report
3393    }
3394
3395    // --- verifier hooks (wasm only) -------------------------------------------
3396
3397    /// The published state for the headed verifier: current name, dirty flag,
3398    /// backend label, the stored-document list, and the modal's open/mode.
3399    pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
3400        serde_json::json!({
3401            "name": docs.active().name(),
3402            "nameBuf": self.name_buf,
3403            "selected": self.explorer.selected(),
3404            "location": store.browser_location(),
3405            // The last folder of each purpose (Open/Save As/Insert, Import,
3406            // KiCad), which is where that modal opens next.
3407            "folders": Purpose::ALL
3408                .iter()
3409                .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
3410                .collect::<serde_json::Map<_, _>>(),
3411            // The CACHED marker, not `is_dirty()`: this blob is published every
3412            // frame, and `is_dirty()` answers by serializing the whole document
3413            // — for a document whose history holds an imported model that is
3414            // hundreds of KB of JSON built and thrown away per frame, on the
3415            // thread that also has to answer the pointer. `Document::refresh_dirty_marker`
3416            // exists for exactly this and is refreshed once per frame; the tab
3417            // strip's dot already reads it, so the two cannot disagree.
3418            "dirty": docs.active().dirty_marker(),
3419            "backend": store.backend_label(),
3420            "interchange": store.supports_file_interchange(),
3421            "list": store.list(),
3422            "status": self.status,
3423            "open": self.open,
3424            "mode": match self.mode {
3425                Mode::CreateComponentPart => "createcomponentpart",
3426                Mode::Open => "open",
3427                Mode::SaveAs => "saveas",
3428                Mode::ConfirmClose => "confirmclose",
3429                Mode::Export => "export",
3430                Mode::Import => "import",
3431                Mode::FlatPattern => "flatpattern",
3432                Mode::InsertComponent => "insertcomponent",
3433                Mode::StepAssembly => "stepassembly",
3434                Mode::Kicad => "kicad",
3435                Mode::PickMember => "pickmember",
3436                Mode::SpinOut => "spinout",
3437                Mode::MemberEdit => "memberedit",
3438                Mode::PickFile => "pickfile",
3439            },
3440            "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
3441            "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
3442                "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
3443            })),
3444            // The active document's class (from its own `documentClass`
3445            // field) and, for a family member, its stamp.
3446            "class": document_class::document_class(docs.engine()).slug(),
3447            "saveClass": self.save_class.slug(),
3448            "plmSaveAs": self.plm_save_as.as_ref().map(|state| state.state_json()),
3449            "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
3450                "family": source.family,
3451                "partNumber": source.part_number,
3452                "valuesHash": source.values_hash,
3453                "historyHash": source.history_hash,
3454            })),
3455            // The armed member picker: each row, where its member is, and
3456            // whether Generate has written it.
3457            "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
3458                "family": pick.family_file,
3459                "plm": pick.plm,
3460                "loading": pick.loading,
3461                "rows": pick.rows.iter().map(|row| serde_json::json!({
3462                    "partNumber": row.part_number,
3463                    "identity": row.identity,
3464                    "exists": row.exists,
3465                    "whyNot": row.why_not,
3466                })).collect::<Vec<_>>(),
3467            })),
3468            "plmBusy": self.plm_work.is_some(),
3469            // The armed template spin-out: the answers so far, where the new
3470            // part will go, and what (if anything) blocks Create.
3471            "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
3472                "template": spin.template_file,
3473                "name": spin.name,
3474                "values": spin.values,
3475                "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
3476                "target": spin_out_target(spin),
3477                "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
3478                    &spin.template,
3479                    input,
3480                    spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
3481                )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
3482            })),
3483            "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
3484                "partNumber": prompt.source.part_number,
3485                "family": prompt.source.family,
3486                "familyIdentity": prompt.family_identity,
3487                "fromSave": prompt.from_save,
3488            })),
3489            "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
3490                "summary": report.summary(),
3491                "rows": report.to_json(),
3492            })),
3493            "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
3494            // The armed §3.9 assembly choice: the file it belongs to and the
3495            // counts the prompt is showing, so the headed verifier can confirm
3496            // the dialog appeared with the numbers the import will deliver.
3497            // A `.step` upload whose structure probe is still running on the
3498            // background runner (the "reading…" state).
3499            "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
3500            "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
3501                serde_json::json!({
3502                    "name": pending.name,
3503                    "parts": pending.probe.parts,
3504                    "instances": pending.probe.instances,
3505                    "nestedDepth": pending.probe.nested_depth,
3506                    // The §3.9 checkbox: shown only when there is a tree to
3507                    // flatten, and OFF by default (import keeps the tree).
3508                    "flatten": pending.flatten,
3509                })
3510            }),
3511        })
3512        .to_string()
3513    }
3514
3515    /// The published widget hit-rects (egui points) for the headed verifier.
3516    pub fn hits_json(&self) -> String {
3517        crate::automation::hit_rects::hits_json(&self.hits)
3518    }
3519
3520    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
3521    /// elsewhere so the dialog code reads the same on both targets).
3522    fn hit(&mut self, key: &str, resp: &egui::Response) {
3523        self.hits.insert(key.to_string(), resp.rect);
3524    }
3525
3526    fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
3527        self.hits.extend(hits.iter().cloned());
3528    }
3529}
3530
3531
3532/// A sheet's name as a file-name fragment: spaces and separators become
3533/// hyphens, so `Sheet 1` exports as `<document>-Sheet-1.svg` and cannot
3534/// reach the store as a path.
3535fn slug(name: &str) -> String {
3536    let cleaned: String = name
3537        .chars()
3538        .map(|c| if c.is_alphanumeric() { c } else { '-' })
3539        .collect();
3540    let trimmed = cleaned.trim_matches('-').to_string();
3541    if trimmed.is_empty() { "sheet".into() } else { trimmed }
3542}
3543/// The hit keys this panel publishes (see `automation::hit_keys`).
3544pub static HIT_KEYS: &[HitKeyDoc] = &[
3545    HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
3546    HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
3547    HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
3548    HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
3549    HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
3550    HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:iges, export:stl, export:obj, export:glb, export:json, export:bomcsv, export:bomjson, the open DRAWING SHEET as export:sheetsvg, every drawing sheet as ONE multi-page PDF as export:sheetpdf, and the same drawing set with the live 3D model as export:sheetpdf3d; for a document whose PCB board has parts, its manufacturing files: the Gerber/drill/pick-and-place/BOM zip as export:fabrication, the pick-and-place CSV as export:pickplace, the electronics BOM CSV as export:ecadbom)", command: None },
3551    HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
3552    HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
3553    HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
3554    HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
3555    HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "PLM Save As destinations (new-revision, new-part, existing), destination fields (part, revision, name, number), save and cancel", command: None },
3556    HitKeyDoc { panel: "file", prefix: "saveas:class:", meaning: "Save As's class chooser: saveas:class:normal (.nbrep), saveas:class:family (.fbrep), saveas:class:template (.tbrep)", command: None },
3557    HitKeyDoc { panel: "file", prefix: "member:", meaning: "the member picker Insert component raises for a family: member:<part number> places that member (disabled until Generate has written it), member:cancel", command: None },
3558    HitKeyDoc { panel: "file", prefix: "spinout:", meaning: "the template spin-out prompt: spinout:name, spinout:input:<name> (a value field), spinout:choice:<name>:<value> (a fixed-list input), spinout:create, spinout:cancel", command: None },
3559    HitKeyDoc { panel: "file", prefix: "member-edit:", meaning: "the hand-edit prompt on a family member: member-edit:saveas, member-edit:family (open the family), member-edit:cancel (undo the change)", command: None },
3560    HitKeyDoc { panel: "file", prefix: "pickfile:", meaning: "the file chooser (any file on this machine, for a panel's Add file…): the explorer's key set under the `pickfile` prefix — pickfile:<file name> (a row), pickfile:confirm, pickfile:path-edit-toggle, …", command: Some("file_pick") },
3561    HitKeyDoc { panel: "file", prefix: "kicad:", meaning: "the KiCad import: its explorer (kicad:<file name>, kicad:confirm, kicad:upload and the explorer's other keys), then kicad:symbol:<library:name>, kicad:next, kicad:footprint:<library:name>, kicad:use-footprint, kicad:no-footprint, kicad:search, kicad:field:footprints, kicad:field:models, kicad:apply-folders, kicad:import, kicad:cancel", command: None },
3562];
3563
3564/// Read `key`'s document from the PLM, for placing it.
3565fn fetch_document(
3566    client: std::rc::Rc<crate::plm::client::PlmClient>,
3567    key: String,
3568    number: String,
3569) -> crate::panels::plm_family::Pending<(String, String, String)> {
3570    crate::panels::plm_family::Pending::new(Box::pin(async move {
3571        let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
3572        let text = bytes
3573            .map(|b| String::from_utf8_lossy(&b).into_owned())
3574            .ok_or_else(|| format!("{key} has no document yet"))?;
3575        Ok((key, text, number))
3576    }))
3577}
3578
3579/// Recent documents are global store identities, never document content.
3580use crate::store::RECENT_DOCUMENTS_KEY;
3581pub fn recent_documents(store: &dyn ModelStore) -> Vec<String> {
3582    store
3583        .read(RECENT_DOCUMENTS_KEY)
3584        .and_then(|s| serde_json::from_str(&s).ok())
3585        .unwrap_or_default()
3586}
3587fn remember_document(store: &dyn ModelStore, name: &str) {
3588    let mut recent = recent_documents(store);
3589    recent.retain(|item| item != name);
3590    recent.insert(0, name.into());
3591    recent.truncate(20);
3592    let _ = store.write(
3593        RECENT_DOCUMENTS_KEY,
3594        &serde_json::to_string(&recent).unwrap(),
3595    );
3596}
3597
3598#[derive(Default)]
3599pub struct FileMenuOutcome {
3600    pub action: Option<FileAction>,
3601    pub recent: Option<String>,
3602    /// The Plugins row under Settings was clicked: open the plugin manager.
3603    pub plugins: bool,
3604    /// The JavaScript row under Settings was clicked: open the editor.
3605    pub javascript: bool,
3606}
3607
3608/// The side of a recent row's thumbnail square, in points.
3609pub const RECENT_THUMB_SIDE: f32 = crate::plm::thumbnail::RECENT_SIDE;
3610
3611/// The neutral square a recent document without a picture yet shows: the
3612/// same size as a thumbnail, never a gap. Drawn through the SVG loader the
3613/// menu's artwork already uses.
3614const RECENT_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect x="1.5" y="1.5" width="37" height="37" rx="4" fill="#8a8a8a" fill-opacity="0.16" stroke="#8a8a8a" stroke-opacity="0.45"/></svg>"##;
3615
3616/// Shared File menu for both appearances: the workbench picker on the left,
3617/// the document commands on the right, the recent documents (with their
3618/// thumbnails) in one column under both. Workbench selection uses apply +
3619/// store.
3620pub fn file_menu(
3621    ui: &mut egui::Ui,
3622    state: &mut EngineState,
3623    store: &dyn ModelStore,
3624    settings_open: &mut bool,
3625    properties_open: &mut bool,
3626    hits: &mut HashMap<String, egui::Rect>,
3627) -> FileMenuOutcome {
3628    use crate::panels::toolbar_button;
3629    let mut outcome = FileMenuOutcome::default();
3630    let presentation = crate::workbench::file_presentation;
3631    egui_extras::install_image_loaders(ui.ctx());
3632    let menu = ui.menu_button("File", |ui| {
3633        // Plugin workbenches declare no artwork of their own; they share the
3634        // plugin manager's glyph.
3635        let plugin_glyph = presentation("workbench:manage:plugins").glyph;
3636        let dynamic = crate::workbench::owned_workbenches(state);
3637        let workbench_rows: Vec<(&str, &str)> = crate::workbench::WORKBENCHES
3638            .iter()
3639            .map(|w| (w.glyph, w.label))
3640            .chain(dynamic.iter().map(|w| (plugin_glyph, w.label.as_str())))
3641            .collect();
3642        let file_rows: Vec<_> = crate::workbench::FILE_COMMANDS
3643            .iter()
3644            .map(|p| (p.glyph, p.label()))
3645            .collect();
3646        // Fixed measured widths — one per column — avoid feeding the popup's
3647        // previous width back through full-width rows while egui settles its
3648        // area. The recent rows span both columns.
3649        let left_width = toolbar_button::menu_width_salted(ui, "workbench-measure", &workbench_rows);
3650        let right_width = toolbar_button::menu_width(ui, &file_rows);
3651        let gap = ui.spacing().item_spacing.x;
3652        let compact = crate::viewport::touch::compact(ui.ctx());
3653        let width = if compact { left_width.max(right_width).min((ui.ctx().content_rect().width() - 32.0).max(100.0)) }
3654            else { left_width + gap + right_width };
3655        let (left_width, right_width) = if compact { (width, width) } else { (left_width, right_width) };
3656        ui.set_width(width);
3657        let max_height = ui.ctx().content_rect().height() * 0.8;
3658        ui.set_max_height(max_height);
3659        // The whole body sits in one scroll area so a window too short for
3660        // the columns themselves still shows a menu that fits; on any taller
3661        // window the columns never scroll and only the recent list does, up
3662        // to the room left under the cap (`cap_bottom`).
3663        let cap_bottom = ui.max_rect().bottom();
3664        let body = egui::ScrollArea::vertical()
3665            .id_salt("file-menu-body")
3666            .auto_shrink([false, true])
3667            .max_height(max_height)
3668            .show(ui, |ui| {
3669                let columns = |ui: &mut egui::Ui| {
3670                    // LEFT: the workbench picker.
3671                    ui.vertical(|ui| {
3672                        ui.set_width(left_width);
3673                        ui.weak("Workbench");
3674                        let current = crate::workbench::resolved_id(state, &state.settings.workbench);
3675                        if current != state.settings.workbench {
3676                            ui.add(
3677                                egui::Label::new(
3678                                    egui::RichText::new(format!(
3679                                        "Unavailable workbench: {}; using Modeling",
3680                                        state.settings.workbench
3681                                    ))
3682                                    .weak(),
3683                                )
3684                                .wrap(),
3685                            );
3686                        }
3687                        let options = crate::workbench::WORKBENCHES
3688                            .iter()
3689                            .map(|w| (w.id, w.label, w.glyph))
3690                            .chain(dynamic.iter().map(|w| (w.id.as_str(), w.label.as_str(), plugin_glyph)));
3691                        for (id, label, glyph) in options {
3692                            let row = toolbar_button::menu_row(ui, glyph, label, current == id, left_width);
3693                            hits.insert(format!("workbench:item:{id}"), row.rect);
3694                            if row.clicked() {
3695                                let _ = state
3696                                    .apply_settings_json(&serde_json::json!({"workbench": id}).to_string());
3697                                let _ = store.write(crate::store::SETTINGS_KEY, &state.settings_json());
3698                                ui.close();
3699                            }
3700                        }
3701                    });
3702                    // RIGHT: everything else, in the order the one-column menu had.
3703                    ui.vertical(|ui| {
3704                        ui.set_width(right_width);
3705                        ui.weak("Document");
3706                        for (class, p) in DocumentClass::ALL
3707                            .into_iter()
3708                            .zip(crate::workbench::NEW_DOCUMENT_COMMANDS)
3709                        {
3710                            let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
3711                            hits.insert(p.id.into(), row.rect);
3712                            if class == DocumentClass::Normal {
3713                                hits.insert("file:new".into(), row.rect);
3714                                hits.insert("file:newmenu".into(), row.rect);
3715                            }
3716                            if row.clicked() {
3717                                outcome.action = Some(FileAction::NewOfClass(class));
3718                                ui.close();
3719                            }
3720                        }
3721                        for (id, action) in [
3722                            ("file:open", FileAction::Open),
3723                            ("file:save", FileAction::Save),
3724                            ("file:saveas", FileAction::SaveAs),
3725                            ("file:import", FileAction::Import),
3726                            ("file:export", FileAction::Export),
3727                        ] {
3728                            let p = presentation(id);
3729                            let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
3730                            hits.insert(id.into(), row.rect);
3731                            if row.clicked() {
3732                                outcome.action = Some(action);
3733                                ui.close();
3734                            }
3735                        }
3736                        let properties = toolbar_button::menu_row(
3737                            ui,
3738                            presentation("properties").glyph,
3739                            presentation("properties").label(),
3740                            *properties_open,
3741                            right_width,
3742                        );
3743                        hits.insert("properties".into(), properties.rect);
3744                        if properties.clicked() {
3745                            *properties_open = !*properties_open;
3746                            ui.close();
3747                        }
3748                        let settings = toolbar_button::menu_row(
3749                            ui,
3750                            presentation("settings").glyph,
3751                            presentation("settings").label(),
3752                            *settings_open,
3753                            right_width,
3754                        );
3755                        hits.insert("settings".into(), settings.rect);
3756                        if settings.clicked() {
3757                            *settings_open = !*settings_open;
3758                            ui.close();
3759                        }
3760                        // Plugin management sits under Settings: the plugin manager
3761                        // and the JavaScript editor, the same rows in both styles.
3762                        for (id, flag) in [
3763                            ("workbench:manage:plugins", &mut outcome.plugins),
3764                            ("workbench:manage:javascript", &mut outcome.javascript),
3765                        ] {
3766                            let row = toolbar_button::menu_row(
3767                                ui,
3768                                presentation(id).glyph,
3769                                presentation(id).label(),
3770                                false,
3771                                right_width,
3772                            );
3773                            hits.insert(id.into(), row.rect);
3774                            if row.clicked() {
3775                                *flag = true;
3776                                ui.close();
3777                            }
3778                        }
3779                    });
3780                };
3781                if compact { ui.vertical(columns); } else { ui.horizontal_top(columns); }
3782
3783                // BELOW BOTH: the recent documents, one column, a thumbnail each. The
3784                // list scrolls under the 80% cap; the columns above it never do.
3785                let recent = recent_documents(store);
3786                if !recent.is_empty() {
3787                    ui.separator();
3788                    let caption = ui.weak("Recent Documents");
3789                    hits.insert("file:recent".into(), caption.rect);
3790                    let thumbs = crate::plm::thumbnail::recent_textures(ui.ctx(), store);
3791                    let side = egui::Vec2::splat(RECENT_THUMB_SIDE);
3792                    let row_height = RECENT_THUMB_SIDE + 2.0 * ui.spacing().button_padding.y;
3793                    // The room left under the cap; at least two rows even when the
3794                    // columns already fill it (then the outer area scrolls).
3795                    let room = cap_bottom - ui.cursor().top();
3796                    let list_height = room.max(2.0 * row_height + ui.spacing().item_spacing.y);
3797                    egui::ScrollArea::vertical()
3798                        .id_salt("file-recent")
3799                        .auto_shrink([false, true])
3800                        .max_height(list_height)
3801                        .show(ui, |ui| {
3802                            for name in &recent {
3803                                let image = match thumbs.get(name) {
3804                                    Some((_, texture)) => egui::Image::from_texture((texture.id(), side)),
3805                                    None => egui::Image::new(egui::ImageSource::Bytes {
3806                                        uri: "bytes://brep-recent-thumb/placeholder.svg".into(),
3807                                        bytes: egui::load::Bytes::Static(RECENT_PLACEHOLDER_SVG.as_bytes()),
3808                                    }),
3809                                }
3810                                .fit_to_exact_size(side);
3811                                let row = ui
3812                                    .add(
3813                                        egui::Button::new((image, model_display_name(name)))
3814                                            .wrap_mode(egui::TextWrapMode::Truncate)
3815                                            .min_size(egui::vec2(width, row_height)),
3816                                    )
3817                                    .on_hover_text(name);
3818                                hits.insert(format!("file:recent:{name}"), row.rect);
3819                                let padding = ui.spacing().button_padding;
3820                                let thumb = egui::Rect::from_min_size(
3821                                    row.rect.min + egui::vec2(padding.x, (row.rect.height() - RECENT_THUMB_SIDE) / 2.0),
3822                                    side,
3823                                );
3824                                hits.insert(format!("file:recent:thumb:{name}"), thumb);
3825                                if row.clicked() {
3826                                    outcome.recent = Some(name.clone());
3827                                    ui.close();
3828                                }
3829                            }
3830                        });
3831                }
3832            });
3833        if compact { hits.insert("file:clip".into(), body.inner_rect); }
3834    });
3835    hits.insert("file:menu".into(), menu.response.rect);
3836    // Retain the historic menu-opener key for coordinate automation.
3837    hits.insert("workbench".into(), menu.response.rect);
3838    outcome
3839}
3840