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