Skip to main content

brep_app/panels/
file.rs

1//! File dialog — a **reusable modal** for model-document management: **New /
2//! Open / Save / Save As** of the MODEL, driven from the toolbar. Mirrors the
3//! shape of [`crate::palette::Palette`]: the caller owns ONE [`FileDialog`],
4//! calls [`FileDialog::dispatch`] when a toolbar file button is clicked (which
5//! either acts immediately or opens the modal in a mode), and calls
6//! [`FileDialog::show`] every frame to draw the (possibly open) modal.
7//!
8//! The model document IS the engine-owned history: `EngineState` serializes it
9//! with `history_request_json()` (the `.nbrep` recipe) and loads it with
10//! `set_history_json` / `load_model_and_fit`. This dialog keeps NO model state
11//! and no document IDENTITY — the name and the clean baseline live on
12//! [`Document`], and the open set on [`Documents`]. It holds only transient UI
13//! buffers (the name field, a status line, the open modal) and drives the
14//! documents + the [`ModelStore`] seam.
15//!
16//! # New and Open never discard anything now
17//!
18//! Both ADD A TAB: New pushes an empty document, Open pushes the opened one (or
19//! focuses the tab already holding it). Nothing is replaced, so neither needs
20//! the "discard unsaved changes?" prompt they used to raise — the only place
21//! unsaved work can still be lost is CLOSING a tab, which is where that
22//! confirmation now lives (`Mode::ConfirmClose`).
23//!
24//! Persistence crosses the unified [`ModelStore`] seam — the ONE platform
25//! exception. The same embeddable explorer renders on web and desktop. The web
26//! backend lists localStorage models and can Upload; desktop lists the app's
27//! models directory. No OS-native file dialog is used.
28
29mod plm_save_as;
30
31use crate::automation::hit_keys::HitKeyDoc;
32use crate::document::{Document, Documents};
33use crate::document_class::{self, DocumentClass};
34use crate::family_table::{self, FamilySource};
35use crate::template::{self, TemplateInput};
36use crate::panels::parts_library::document_signature;
37use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
38use crate::panels::kicad_import::{self, DiskFiles, KicadImport, KicadKind};
39use crate::store::{model_display_name, sibling_identity, ModelStore, PlaceKind, MODEL_EXTENSIONS};
40use brep_render::engine_state::{
41    ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
42    StepAssemblyReport, StepProbeOutcome,
43};
44use brep_render::runner::MeshImportFormat;
45use eframe::egui;
46use std::collections::{BTreeMap, HashMap, HashSet};
47
48/// The file operation a toolbar button requests. The shell maps a clicked
49/// toolbar button to one of these and hands it to [`FileDialog::dispatch`].
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FileAction {
52    New,
53    /// New, of a chosen class: an empty family seed or template in a new tab
54    /// (the toolbar's New menu). `NewOfClass(Normal)` is plain New.
55    NewOfClass(DocumentClass),
56    Open,
57    Save,
58    SaveAs,
59    /// Import a CAD or mesh file FROM the user's filesystem, appending it to the
60    /// model rather than replacing it. STL/OBJ use RANSAC reconstruction.
61    Import,
62    /// Export the model TO the user's filesystem in a chosen format (STEP /
63    /// IGES / STL / OBJ / JSON).
64    Export,
65    /// Export the sheet-metal FLAT PATTERN (unfold) as a 2D vector file
66    /// (DXF / SVG). Opens the flat-pattern export modal.
67    ExportFlatPattern,
68    /// Insert an ASSEMBLY COMPONENT: opens the component selector — existing
69    /// parts-library entries first, then the model store's Open list (+ Upload)
70    /// — and routes the chosen document through the engine's insert flow
71    /// (`add_part_to_library` → an ACOMP instance referencing the returned
72    /// effective part name). Dispatched when the palette picks `ACOMP`.
73    InsertComponent,
74    /// Import a part from KiCad into the open part: a `.kicad_sym` symbol (the
75    /// Symbol workbench) or a `.kicad_mod` footprint (the Pads workbench), with
76    /// the footprint and 3D model KiCad's chain leads to
77    /// ([`crate::panels::kicad_import`]).
78    ImportKicad(KicadKind),
79}
80
81/// Which modal (if any) the dialog is currently showing.
82#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84    /// Confirm recovery of a missing component source from its embedded part.
85    CreateComponentPart,
86    /// Open a saved model through the common explorer.
87    Open,
88    /// Prompt for a name and save under it.
89    SaveAs,
90    /// Confirm discarding unsaved changes before CLOSING a document tab —
91    /// the ONE place unsaved work can still be lost now that New and Open
92    /// both add a tab. `pending_close` holds the document's id and the
93    /// committed revision the prompt was raised at.
94    ConfirmClose,
95    /// Choose an export format (STEP / IGES / STL / OBJ / JSON) for the current model.
96    Export,
97    /// Choose a STEP / IGES / STL / OBJ file through the common explorer.
98    Import,
99    /// Choose a 2D vector format (DXF / SVG) for the sheet-metal flat pattern.
100    FlatPattern,
101    /// Pick a part to insert as an assembly component (library entries + the
102    /// stored-model list + Upload).
103    InsertComponent,
104    /// A `.step` upload whose product structure the probe found: choose whether
105    /// to keep that structure (parts + component instances) or flatten it to
106    /// bodies. Backed by [`FileDialog::pending_step_import`].
107    StepAssembly,
108    /// A KiCad import: its file through the common explorer, then its own
109    /// stages ([`KicadImport`]).
110    Kicad,
111    /// Insert component chose a FAMILY: pick one of its members, which is
112    /// what gets placed ([`FileDialog::pick_member`]).
113    PickMember,
114    /// Insert component chose a TEMPLATE: name the new part and set its
115    /// inputs; the spun-out part is what gets placed ([`FileDialog::spin_out`]).
116    SpinOut,
117    /// The active document is a family MEMBER and the user just changed it:
118    /// save it as a new part, open the family, or undo ([`FileDialog::member_prompt`]).
119    MemberEdit,
120    /// The file chooser: ANY file on this machine, for a panel that asked
121    /// ([`FileDialog::request_pick_file`]). Browses the local files even in a
122    /// PLM session, whose own explorer shows the server's documents.
123    PickFile,
124    /// The assembly Save's questions about the documents it owns
125    /// ([`crate::document::owned_save`]): one row per document, every row
126    /// defaulting to yes ([`FileDialog::owned_prompt`]).
127    OwnedSave,
128    /// A part was saved under a new name while open assemblies place it:
129    /// point them at the new file too? Yes by default ([`FileDialog::retarget`]).
130    Retarget,
131    /// The Save-file chooser: WHERE an export goes, on desktop. A format
132    /// button (or a panel's Export) produced the file; this picks its folder
133    /// through the explorer over the local files and its name through a
134    /// field, then writes it ([`FileDialog::save_file`]). The browser never
135    /// sees it: there an export is a download, and the browser asks.
136    SaveFile,
137}
138
139/// A file a panel wants handed to the user: the one door every UI export
140/// takes ([`FileDialog::request_save_file`]). On desktop it opens the
141/// Save-file chooser (`Mode::SaveFile`); in the browser it is a download.
142#[derive(Clone, Debug, PartialEq)]
143pub struct SaveRequest {
144    /// The chooser's heading: "Export STEP", "Export the script".
145    pub title: String,
146    /// The file's full name, extension included; the chooser's name field
147    /// starts from it and the browser downloads under it.
148    pub file_name: String,
149    pub body: SaveBody,
150}
151
152/// An export's content. Text keeps the browser's string lane (its `Blob`
153/// takes a string directly, see [`ModelStore::export_file_named`]); on
154/// desktop both are bytes on disk.
155#[derive(Clone, Debug, PartialEq)]
156pub enum SaveBody {
157    Text(String),
158    Bytes(Vec<u8>),
159}
160
161impl SaveBody {
162    fn as_bytes(&self) -> &[u8] {
163        match self {
164            SaveBody::Text(text) => text.as_bytes(),
165            SaveBody::Bytes(bytes) => bytes,
166        }
167    }
168}
169
170impl SaveRequest {
171    pub fn text(title: impl Into<String>, file_name: impl Into<String>, text: String) -> Self {
172        Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Text(text) }
173    }
174
175    pub fn bytes(title: impl Into<String>, file_name: impl Into<String>, bytes: Vec<u8>) -> Self {
176        Self { title: title.into(), file_name: file_name.into(), body: SaveBody::Bytes(bytes) }
177    }
178}
179
180/// An export produced and waiting for its folder: the Save-file chooser's
181/// payload, with what the write then says. Only the file is held; the
182/// dialog that produced it has closed (a format chooser) or never had a
183/// modal (a panel's Export button).
184struct PendingSave {
185    request: SaveRequest,
186    /// What the status says after the file name: " (13 files)", " (2 pages)".
187    detail: String,
188    /// The export's name for its toast ("Netlist", "Fabrication"): the PCB's
189    /// manufacturing outputs raise one, named by their output, on every
190    /// platform. Every other export's toast on desktop is the generic one.
191    label: Option<String>,
192    /// The bundle's warnings (design-rule findings): the chooser stays open
193    /// on them after the write, as the format dialog did, and they go to a
194    /// warning toast.
195    warnings: Vec<String>,
196    /// A family row was previewed: say so after the write
197    /// ([`FileDialog::say_previewed_export`]).
198    preview: bool,
199}
200
201/// How a produced export left the dialog ([`FileDialog::deliver`]).
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203enum Delivery {
204    /// Written (desktop, no chooser) or downloaded (browser): done.
205    Done,
206    /// The Save-file chooser is up with it; the write happens on its Save.
207    Chooser,
208    /// Not delivered; the status line says why.
209    Failed,
210}
211
212/// The file-chooser tag of the Open modal's workspace (a PLM session's Open).
213const PICK_OPEN_WORKSPACE: &str = "file:open-workspace";
214
215/// A file the chooser delivered ([`FileDialog::take_picked_file`]).
216#[derive(Clone, Debug, PartialEq)]
217pub struct PickedFile {
218    /// The file's own name, extension included (`photo.jpg`).
219    pub name: String,
220    /// Where it was on this machine; `None` in the browser, which never says.
221    pub path: Option<String>,
222    pub bytes: Vec<u8>,
223}
224
225/// What an explorer folder is FOR. The explorer's location lives in the
226/// store and every modal shares it, so each purpose remembers where the user
227/// last was and the modal opens there. With one shared folder, a KiCad import
228/// from `…/kicad/symbols` sent the next Save As and Insert component into the
229/// KiCad library (ecad-reaudit-2026-09-23, B8).
230#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
231enum Purpose {
232    /// The user's own documents: Open, Save As, Insert component.
233    Models,
234    /// Foreign CAD and mesh files: Import.
235    Imports,
236    /// KiCad symbol and footprint libraries.
237    Kicad,
238    /// Where exports go: the Save-file chooser's folder, on the local files.
239    Exports,
240}
241
242impl Purpose {
243    const ALL: [Purpose; 4] = [Purpose::Models, Purpose::Imports, Purpose::Kicad, Purpose::Exports];
244
245    fn slug(self) -> &'static str {
246        match self {
247            Purpose::Models => "models",
248            Purpose::Imports => "imports",
249            Purpose::Kicad => "kicad",
250            Purpose::Exports => "exports",
251        }
252    }
253}
254
255impl Mode {
256    /// The folder this modal browses, for the modals that browse one.
257    fn purpose(self) -> Option<Purpose> {
258        match self {
259            Mode::Open | Mode::SaveAs | Mode::InsertComponent | Mode::CreateComponentPart => {
260                Some(Purpose::Models)
261            }
262            Mode::Import => Some(Purpose::Imports),
263            Mode::Kicad => Some(Purpose::Kicad),
264            Mode::SaveFile => Some(Purpose::Exports),
265            Mode::ConfirmClose
266            | Mode::Export
267            | Mode::FlatPattern
268            | Mode::StepAssembly
269            | Mode::PickMember
270            | Mode::SpinOut
271            | Mode::MemberEdit
272            | Mode::OwnedSave
273            | Mode::Retarget
274            | Mode::PickFile => None,
275        }
276    }
277}
278
279/// The store a purpose's explorer browses. Import, the KiCad import and the
280/// Save-file chooser deal in files on THIS MACHINE: in a PLM session the session
281/// store is the server's documents, so they browse the local files the session
282/// keeps beside it ([`ModelStore::local_files`]); on a file store they are the
283/// same store.
284fn browse_store(purpose: Purpose, store: &dyn ModelStore) -> &dyn ModelStore {
285    match purpose {
286        Purpose::Imports | Purpose::Kicad | Purpose::Exports => store.local_files().unwrap_or(store),
287        Purpose::Models => store,
288    }
289}
290
291/// The store's models folder, where a purpose not yet used starts (the
292/// Save-file chooser's exports included). `None` where the store has no
293/// such place (the browser), which leaves the location alone.
294fn models_folder(store: &dyn ModelStore) -> Option<String> {
295    store.browser_places().into_iter().find(|place| place.kind == PlaceKind::Models).map(|place| place.location)
296}
297
298/// A probed `.step` upload waiting on the user's choice — the state that makes
299/// the §3.9 modal work across frames (egui draws every frame; the click can
300/// land many frames after the upload).
301///
302/// The PARSED assembly itself is NOT here: it lives in the engine's stash
303/// (`EngineState::probe_step_assembly` put it there), and the import consumes
304/// that stash. This holds only what the prompt says and the `text` the flat
305/// lane needs — the two lanes that re-import from source rather than from the
306/// parse ("Import as bodies", and the structured lane's own failure fallback).
307struct PendingStepImport {
308    /// The uploaded file's name — the prompt's subject and the status line's.
309    name: String,
310    /// The file text. The engine's stash holds the PARSE, not the source, so
311    /// the flat lane's input has to be kept here.
312    text: String,
313    /// The counts the prompt shows, from the same walk the import runs.
314    probe: StepAssemblyProbe,
315    /// The §3.9 checkbox: flatten the sub-assembly tree to leaf occurrences
316    /// instead of building nested rigid sub-assembly documents. Only shown (and
317    /// only meaningful) when `probe.nested_depth > 1`; a depth-1 file imports
318    /// identically either way.
319    flatten: bool,
320}
321
322/// A `.step` upload whose structure probe is RUNNING on the document's
323/// background runner (native thread / browser worker — the parse builds every
324/// product's bodies and takes seconds on a real assembly, so it left the UI
325/// thread). Resolved by [`FileDialog::poll_step_probe`] into either the §3.9
326/// choice ([`PendingStepImport`]) or the flat lane.
327struct PendingStepProbe {
328    /// The engine's probe id, so a stale answer (a superseded upload) is ignored.
329    id: u64,
330    name: String,
331    /// The file text, kept for the flat lane the outcome may route to.
332    text: String,
333}
334
335/// What the user clicked in the §3.9 assembly-choice modal.
336#[derive(Clone, Copy, PartialEq, Eq)]
337enum StepChoice {
338    /// Keep the structure: parts-library entries + one component per occurrence.
339    Assembly,
340    /// Today's flat lane, unchanged: one IMPORT3D feature carrying the text.
341    Bodies,
342    /// Import nothing, and drop the parse (Esc / click-outside land here too).
343    Cancel,
344}
345
346/// What Insert component lists, and so the only files it inserts: parts, and
347/// the families and templates that each lead to a part.
348const INSERT_EXTENSIONS: &[&str] = MODEL_EXTENSIONS;
349
350/// One row of the member picker: a family row and where its member is.
351struct MemberChoice {
352    part_number: String,
353    description: String,
354    /// The member's store identity; `None` when the part number cannot be a
355    /// file name.
356    identity: Option<String>,
357    /// Whether that file exists (Generate has written it).
358    exists: bool,
359    /// On a PLM store: why the member cannot be placed (not generated, waiting
360    /// for its bake, failed it), as the server's family view says.
361    why_not: Option<String>,
362}
363
364/// An armed member picker: the family Insert component chose.
365struct PickMember {
366    /// The family's file name, for the heading.
367    family_file: String,
368    rows: Vec<MemberChoice>,
369    /// On a PLM store the members are parts: the list comes from the server
370    /// (`loading` until it has answered), and a chosen member's document is
371    /// read from it.
372    plm: bool,
373    loading: bool,
374}
375
376/// An armed template spin-out: the template Insert component chose, and the
377/// user's answers so far.
378struct SpinOut {
379    template_file: String,
380    template: serde_json::Value,
381    inputs: Vec<TemplateInput>,
382    /// The new part's name (no extension).
383    name: String,
384    /// Input name -> the value text the user typed or chose.
385    values: BTreeMap<String, String>,
386    /// Where the new part goes: the folder of the assembly being edited, or
387    /// the models folder when that assembly has never been saved.
388    assembly: Option<String>,
389    /// On a PLM store: the template part the SERVER spins out, and the new
390    /// part's number (empty lets a counter part type number it).
391    plm_template: Option<String>,
392    number: String,
393}
394
395/// PLM work the dialog waits on (plm-cad-integration-todo S9), polled each
396/// frame so the web build works the same.
397enum PlmWork {
398    /// The member picker's list.
399    Members(crate::panels::plm_family::Pending<Vec<crate::plm::family::Member>>),
400    /// A chosen member's (or a spun-out part's) document, to place: the key,
401    /// the text, and the part number it is named by.
402    Place(crate::panels::plm_family::Pending<(String, String, String)>),
403    /// Open the family a member came from: its newest revision's key.
404    OpenFamily(crate::panels::plm_family::Pending<String>),
405}
406
407/// "Also point these assemblies at the new file?" ([`Mode::Retarget`]).
408struct Retarget {
409    /// The part's previous name, as the assemblies spell their copies of it.
410    old: String,
411    new_key: String,
412    /// The part as it was saved under the new name.
413    text: String,
414    tabs: Vec<(u64, String)>,
415}
416
417/// A Save waiting for its answers ([`Mode::OwnedSave`]).
418struct OwnedPrompt {
419    plan: crate::document::owned_save::OwnedSavePlan,
420    /// The name the tab is being saved under.
421    name: String,
422    /// Save As through the explorer (`save_to_browser`), not a plain Save.
423    browser: bool,
424}
425
426/// The hand-edit prompt's subject: which tab, and the family it came from.
427struct MemberPrompt {
428    doc_id: u64,
429    source: FamilySource,
430    /// The family file's identity, beside the member.
431    family_identity: String,
432    /// The prompt came from plain Save rather than from an edit.
433    from_save: bool,
434}
435
436/// Make the engine's document a `class` document: the class field, and none
437/// of the other classes' blocks (a template saved as a part stops asking for
438/// inputs; a family saved as a template drops its table). No undo step: this
439/// is what the new FILE is, written as it is saved.
440/// The replace-component lineage stamp: a Save As under a NEW identity
441/// appends the identity the tab was saved under (and, for a family member,
442/// its family) to the document's `derivedFrom` chain. Silent: it is part of
443/// what the copy is, not an edit. A never-saved document records nothing.
444pub(crate) fn stamp_save_as_lineage(docs: &mut Documents, store: &dyn ModelStore, new_name: &str) {
445    let Some(old) = docs.active().name().map(str::to_string) else { return };
446    let old = store.canonical_identity(&old);
447    if old == store.canonical_identity(new_name) {
448        return;
449    }
450    let engine = docs.tab_engine_mut();
451    let mut document = serde_json::json!({});
452    for key in [brep_render::engine_state::replace_component::DERIVED_FROM_KEY, family_table::FAMILY_SOURCE_KEY] {
453        if let Some(block) = engine.history.document_block(key) {
454            document[key] = block.clone();
455        }
456    }
457    brep_render::engine_state::replace_component::stamp_derived_from(&mut document, &old);
458    engine.history.set_document_block_no_undo(
459        brep_render::engine_state::replace_component::DERIVED_FROM_KEY,
460        Some(document[brep_render::engine_state::replace_component::DERIVED_FROM_KEY].clone()),
461    );
462}
463
464fn adopt_class(engine: &mut EngineState, class: DocumentClass) {
465    document_class::set_document_class(engine, class);
466    let drop: &[&str] = match class {
467        DocumentClass::Normal => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
468        DocumentClass::Family => &[template::TEMPLATE_INPUTS_KEY],
469        DocumentClass::Template => &[family_table::FAMILY_TABLE_KEY],
470        DocumentClass::Instructions => &[family_table::FAMILY_TABLE_KEY, template::TEMPLATE_INPUTS_KEY],
471    };
472    for key in drop {
473        if engine.history.document_block(key).is_some() {
474            engine.history.set_document_block_no_undo(key, None);
475        }
476    }
477}
478
479/// Where a spun-out part is written: `<name>.nbrep` in the assembly's folder,
480/// or the bare file name (the models folder) when the assembly has none.
481fn spin_out_target(spin: &SpinOut) -> String {
482    let file = DocumentClass::Normal.file_name(spin.name.trim());
483    match &spin.assembly {
484        Some(assembly) => sibling_identity(assembly, &file),
485        None => file,
486    }
487}
488
489/// `"s"` unless there is exactly one — the difference between "1 parts" and a
490/// sentence a user believes.
491fn plural(count: usize) -> &'static str {
492    if count == 1 {
493        ""
494    } else {
495        "s"
496    }
497}
498
499/// The document name an imported assembly's unnamed products are stemmed from:
500/// the file's base name without its STEP extension, so a nameless product reads
501/// `bracket-assy-part-7`, not `bracket-assy.step-part-7`.
502/// A document title as a file stem: path separators and the characters a
503/// file name cannot hold become `-`; leading and trailing dots, spaces and
504/// dashes go.
505fn file_stem_of_title(title: &str) -> String {
506    let stem: String = title.chars().map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '-' } else { c }).collect();
507    stem.trim_matches(|c: char| c == '.' || c == ' ' || c == '-').to_string()
508}
509
510fn step_document_name(file_name: &str) -> String {
511    let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
512    let cut = base
513        .rfind('.')
514        .filter(|dot| is_step_name(&base[*dot..]) || is_iges_name(&base[*dot..]))
515        .unwrap_or(base.len());
516    let stem = &base[..cut];
517    if stem.is_empty() {
518        base.to_string()
519    } else {
520        stem.to_string()
521    }
522}
523
524/// The §3.9 outcome line: `imported bracket-assy.step — 7 parts, 23 components
525/// (2 mirrored instances baked)`, plus the tail an imperfect import owes the
526/// user. Every qualifier is reported ONLY when it happened, so a clean import
527/// reads clean — but a partial one never reads as a whole one:
528///
529/// * `baked_nonrigid` — occurrences whose mirror/scale was baked into a part of
530///   their own (§3.4), which is why the part count can exceed the file's;
531/// * `failed_products` — products that did not encode, skipped and counted;
532/// * `flat_fallback` — the user asked for an assembly and got bodies. Said
533///   plainly, never silently (the dialog lane reports this through the Err
534///   branch instead, which is the only way it can reach the user from here);
535/// * `first_error` — the one thing that explains the rest.
536/// The STEP-assembly import's [`PartSink`]: writes each unique part document to
537/// the model store and hands back the identity it was stored under, so an
538/// imported part carries a REAL `sourceKey` and there is no second kind of part.
539///
540/// # The destination
541///
542/// `browser_write` at the explorer's CURRENT location, under
543/// `{assembly}-{part}` — the convention `panels::step_parts` already uses for a
544/// single STEP part (that panel lets the user pick the folder first; the
545/// assembly modal inherits wherever the explorer is pointing).
546///
547/// The kernel plan's alternative — a `{assembly}/{part}.nbrep` SUB-FOLDER —
548/// is not reachable through this door: every `browser_write` implementation
549/// flattens the name to a single file (native takes `file_name()`, web takes
550/// `model_display_name`), so a sub-path would silently collapse. Writing the
551/// assembly name into the FILE name keeps a 50-part import grouped in one
552/// listing without a folder convention this seam cannot express. Creating and
553/// navigating into a folder as a side effect of an import is the prompt this
554/// lane would have to grow, and it is not bolted on here.
555///
556/// A failed write is per part: that part stays embedded-only (`None`) and the
557/// import continues. Losing one part's FILE is recoverable; losing the import
558/// is not.
559///
560/// # Known limit of a flat name
561///
562/// `taken` is per IMPORT, so re-importing the same file reuses the same names —
563/// which is what makes cross-import dedup work (same key, same signature, the
564/// resident entry is reused). But two DIFFERENT assemblies whose document names
565/// sanitize to the same stem (`as1-ug` and `as1_ug`) write to each other's file
566/// names. The second import wins the file; the first assembly's entry then
567/// disagrees with it, so it badges outdated and the write-through guard refuses
568/// to clobber it — visible and recoverable (the embedded document is intact),
569/// but two assemblies sharing one name. The fix is the per-assembly sub-folder
570/// `browser_write` cannot express; see the kernel plan's §3.5.
571struct StorePartSink<'a> {
572    store: &'a dyn ModelStore,
573    /// Prefixes every file name, so one import's parts sort together.
574    prefix: String,
575    /// File names already claimed this import — two STEP products can sanitize
576    /// to the same name, and the second must not overwrite the first's file
577    /// (that would leave two entries pointing at one document).
578    taken: std::collections::HashSet<String>,
579    written: usize,
580    failures: Vec<String>,
581    /// On a PLM store a part is an ITEM, not a file at a location: the
582    /// browser's `/models/<name>` key is one the PLM cannot hold. The parts
583    /// stay embedded and become items when the assembly is saved (the Save's
584    /// promotion, `document::owned_save`, at every depth, one item per
585    /// content), so nothing is written here and `deferred` counts them.
586    plm: bool,
587    deferred: usize,
588}
589
590impl<'a> StorePartSink<'a> {
591    fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
592        Self {
593            store,
594            prefix: sanitize_file_stem(prefix),
595            taken: std::collections::HashSet::new(),
596            written: 0,
597            failures: Vec::new(),
598            plm: store.plm_client().is_some(),
599            deferred: 0,
600        }
601    }
602}
603
604impl PartSink for StorePartSink<'_> {
605    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
606        if self.plm {
607            self.deferred += 1;
608            return None;
609        }
610        let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
611        let mut name = base.clone();
612        let mut suffix = 2;
613        while !self.taken.insert(name.clone()) {
614            name = format!("{base}-{suffix}");
615            suffix += 1;
616        }
617        match self.store.browser_write(&name, document_json) {
618            Ok(identity) => {
619                self.written += 1;
620                Some(identity)
621            }
622            Err(error) => {
623                self.failures.push(format!("{name}: {error}"));
624                None
625            }
626        }
627    }
628}
629
630/// A STEP product name reduced to something every store backend can hold as a
631/// file name: ASCII word characters, `.`, `-` kept; everything else (spaces,
632/// slashes, the `(mirrored)` parentheses this crate appends) becomes `_`. Runs
633/// of `_` collapse and the ends are trimmed, so a name is readable rather than
634/// a row of underscores. Empty input yields `part`.
635/// The Save dialog's row for the document being saved itself, when it is a
636/// PLM revision this user cannot save yet: check it out (default yes), or
637/// `Blocked` when nobody can say yes (checked out by someone else, released).
638/// `None` when it can be saved as it is, or is not a PLM revision.
639fn top_checkout_row(docs: &Documents, store: &dyn ModelStore, name: &str) -> Option<crate::document::owned_save::Planned> {
640    use crate::document::owned_save::{Issue, OwnedWrite, Planned};
641    let client = store.plm_client()?;
642    let (part, revision) = crate::panels::plm_host::revision_of(name)?;
643    let doc = docs.active();
644    if matches!(doc.save_access(), crate::document::Access::Editable) && doc.has_plm_save_access() {
645        return None;
646    }
647    let viewer = client.can_author() == Some(false);
648    let issue = match store.plm_revision(&crate::plm::identity::document_key(&part, &revision)) {
649        Some(entry) if entry.editable && entry.locked_by_me => return None,
650        _ if viewer => Issue::Blocked(crate::document::owned_save::VIEW_ONLY.into()),
651        Some(entry) if !entry.editable => {
652            Issue::Blocked(format!("PLM revision {} is {} and not editable", entry.revision_label, entry.lifecycle))
653        }
654        Some(entry) if entry.locked_by.is_some() => Issue::Blocked(format!("checked out by {}", entry.locked_by.unwrap_or_default())),
655        _ => Issue::CheckOut { part, revision, newer: false },
656    };
657    let write = OwnedWrite { key: name.to_string(), names: Vec::new(), depth: 0, text: String::new(), synced: String::new(), variants: Vec::new() };
658    Some(Planned { write, issue: Some(issue), proceed: true, choice: 0, edited_last: None })
659}
660
661/// A checkout or new revision the Save asked for failed AFTER the assembly
662/// `id` was written, and its copies were just marked: the stored assembly
663/// holds the change without the mark, so the next open (here or on another
664/// client) would refresh the copy and lose it. Write the mark into it.
665fn write_late_marks(docs: &mut Documents, store: &dyn ModelStore, id: u64, was_clean: bool) {
666    if let Err(error) = docs.write_unsaved_marks(store, id, was_clean) {
667        docs.engine_mut().push_notice(format!("Save the assembly again: the change it keeps could not be marked in it ({error})"));
668    }
669}
670
671/// One PLM item to make per distinct new key of `plan`'s promotions:
672/// `(new key it replaces, part name, part type)`. The type is the document's
673/// own kind, as the PLM import wizard picks it: `assembly` when it places
674/// components, else `component`.
675fn plm_promotion_requests(current: &serde_json::Value, plan: &crate::document::owned_save::OwnedSavePlan) -> Vec<(String, String, String)> {
676    let mut requests: Vec<(String, String, String)> = Vec::new();
677    for promotion in &plan.promotions {
678        if requests.iter().any(|(key, ..)| *key == promotion.new_key) {
679            continue;
680        }
681        let mut document = current;
682        for name in &promotion.names {
683            document = &document["partsLibrary"][name]["document"];
684        }
685        let part = promotion.names.last().cloned().unwrap_or_default();
686        requests.push((promotion.new_key.clone(), part, crate::plm::promote::part_type_of(document).to_string()));
687    }
688    requests
689}
690
691/// Every promotion of `plan` takes the PLM key made for its new key.
692fn assign_plm_keys(plan: &mut crate::document::owned_save::OwnedSavePlan, keys: &[String]) {
693    let mut order: Vec<String> = Vec::new();
694    for promotion in &plan.promotions {
695        if !order.contains(&promotion.new_key) {
696            order.push(promotion.new_key.clone());
697        }
698    }
699    for promotion in &mut plan.promotions {
700        if let Some(key) = order.iter().position(|k| *k == promotion.new_key).and_then(|i| keys.get(i)) {
701            promotion.new_key = key.clone();
702        }
703    }
704}
705
706pub(crate) fn sanitize_file_stem(name: &str) -> String {
707    let mut out = String::with_capacity(name.len());
708    for ch in name.chars() {
709        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
710            out.push(ch);
711        } else if !out.ends_with('_') {
712            out.push('_');
713        }
714    }
715    let trimmed = out.trim_matches('_');
716    if trimmed.is_empty() {
717        "part".to_string()
718    } else {
719        trimmed.to_string()
720    }
721}
722
723fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
724    let mut message = format!(
725        "imported {name} \u{2014} {} part{}, {} component{}",
726        report.parts,
727        plural(report.parts),
728        report.instances,
729        plural(report.instances)
730    );
731    if report.baked_nonrigid > 0 {
732        message.push_str(&format!(
733            " ({} mirrored instance{} baked)",
734            report.baked_nonrigid,
735            plural(report.baked_nonrigid)
736        ));
737    }
738    if report.failed_products > 0 {
739        message.push_str(&format!(
740            "; {} product{} could not be built",
741            report.failed_products,
742            plural(report.failed_products)
743        ));
744    }
745    if report.flat_fallback {
746        message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
747    }
748    if let Some(error) = &report.first_error {
749        message.push_str(&format!(" [{error}]"));
750    }
751    message
752}
753
754/// What an imported file is, by its extension: the ONE routing table every
755/// import door shares — File > Import's explorer, the upload poll, and a file
756/// the OS hands the app (`brep-app part.step`, a double click on a file the
757/// app is an opener for). Each kind names the importer the File menu already
758/// uses; nothing here imports by itself.
759#[derive(Clone, Copy, Debug, PartialEq, Eq)]
760pub(crate) enum ImportKind {
761    /// A `.kicad_sym` / `.kicad_mod`: the KiCad dialog takes it from there.
762    Kicad,
763    /// STEP (Part 21): probe for an assembly, else the flat IMPORT3D lane.
764    Step,
765    /// IGES: the STEP lane's probe (subfigure instances raise the same choice).
766    Iges,
767    /// STL: the mesh reconstruction preview.
768    Stl,
769    /// 3MF: the STL lane — the same preview, tolerances and accept.
770    ThreeMf,
771    /// OBJ: reconstructed in the background into an IMPORT3D feature.
772    Obj,
773}
774
775/// Every extension the app imports, lowercase without the dot, and its kind.
776/// Model documents (`.nbrep`, `.fbrep`, `.tbrep`) are not here: they open.
777pub(crate) const IMPORT_TABLE: &[(&str, ImportKind)] = &[
778    ("step", ImportKind::Step),
779    ("stp", ImportKind::Step),
780    ("p21", ImportKind::Step),
781    ("iges", ImportKind::Iges),
782    ("igs", ImportKind::Iges),
783    ("stl", ImportKind::Stl),
784    ("3mf", ImportKind::ThreeMf),
785    ("obj", ImportKind::Obj),
786    ("kicad_sym", ImportKind::Kicad),
787    ("kicad_mod", ImportKind::Kicad),
788];
789
790/// The extensions of [`IMPORT_TABLE`], for the explorer's filter and the
791/// system file chooser (a test holds the two lists equal).
792const IMPORT_EXTENSIONS: &[&str] =
793    &["step", "stp", "p21", "iges", "igs", "stl", "3mf", "obj", "kicad_sym", "kicad_mod"];
794
795/// The import kind of a file name or path, by its last extension in any case;
796/// `None` for a model document or anything the app does not import. Model
797/// documents arrive extension-stripped (web) or as a stored model name, so
798/// this never mis-routes a `.nbrep` file.
799pub(crate) fn import_kind(name: &str) -> Option<ImportKind> {
800    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
801    let (_, ext) = base.rsplit_once('.')?;
802    let ext = ext.to_ascii_lowercase();
803    IMPORT_TABLE.iter().find(|(e, _)| *e == ext).map(|(_, kind)| *kind)
804}
805
806/// Whether an imported file name is a STEP file (`.step` / `.stp` / `.p21`,
807/// any case).
808fn is_step_name(name: &str) -> bool {
809    import_kind(name) == Some(ImportKind::Step)
810}
811
812/// Whether an imported file name is an IGES file (`.iges` / `.igs`, any case).
813fn is_iges_name(name: &str) -> bool {
814    import_kind(name) == Some(ImportKind::Iges)
815}
816
817/// The name a file imported into a tab of its own is shown under: the file's
818/// base name without its import extension (`/tmp/Bracket.STEP` -> `Bracket`).
819pub(crate) fn import_document_name(file_name: &str) -> String {
820    let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
821    match base.rsplit_once('.') {
822        Some((stem, _)) if !stem.is_empty() && import_kind(base).is_some() => stem.to_string(),
823        _ => base.to_string(),
824    }
825}
826
827mod component_part;
828
829/// A Save's new PLM revisions, in flight ([`FileDialog::poll_owned_revisions`]).
830struct OwnedRevisions {
831    /// The assembly tab whose copies re-point to them.
832    doc: u64,
833    /// Deepest first, as planned.
834    requests: Vec<crate::document::owned_save::CheckoutRequest>,
835    /// Each request's new key and the text written there, or why not.
836    future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>>,
837}
838
839/// The Save that follows a Save's new revisions ([`FileDialog::poll_owned_checkouts`]).
840struct OwnedFollowup {
841    doc: u64,
842    /// The new revisions' keys: the Save waits until the index has each.
843    keys: Vec<String>,
844    /// Frames waited for the index; past the cap the Save goes ahead and
845    /// asks about what the index still lacks.
846    polls: u32,
847}
848
849struct PendingInstructionsPdf {
850    document: u64,
851    name: String,
852    preview: bool,
853    job: brep_render::engine_state::InstructionsPdfExport,
854}
855
856/// The reusable file dialog — transient UI buffers + the current document
857/// identity; the model itself lives in `EngineState`'s history.
858pub struct FileDialog {
859    /// Demand-loaded open, scoped to the requesting tab. A newer open or
860    /// navigation cancels it so a delayed response cannot steal focus.
861    pending_open: Option<(String, u64)>,
862    /// A cold component read belongs to the assembly tab that requested it.
863    pending_insert: Option<(String, u64)>,
864    component_part: Option<component_part::Recovery>,
865    /// Reusable browser body shared by Open, Save As, and ACOMP selection.
866    explorer: FileExplorer,
867    /// In a PLM session the Open modal is the workspace browser instead (S14, D12). It
868    /// lives here so the folder the user was in is where Open starts next time.
869    workspace: crate::panels::plm_workspace::WorkspaceBrowser,
870    /// The name field buffer, used by the Save As modal.
871    name_buf: String,
872    /// Last-action status line, surfaced inside the modal.
873    pub(crate) status: String,
874    /// Whether a modal is currently open.
875    open: bool,
876    /// The open modal's mode (only meaningful while `open`).
877    mode: Mode,
878    /// Set on open so the Save As text input grabs focus on the next frame.
879    want_focus: bool,
880    /// A pending real-file pick belongs to the INSERT-COMPONENT flow (the
881    /// modal's Upload fired there): the next completed `take_import` routes to
882    /// the component insert instead of Open's load-document.
883    pending_component_import: bool,
884    /// The tab index a pending [`Mode::ConfirmClose`] will close on "Discard".
885    pending_close: Option<(u64, u64)>,
886    /// A probed STEP assembly waiting on the §3.9 choice modal. `Some` means the
887    /// ENGINE is holding a parsed assembly for us (with every product's solids
888    /// resident), so every exit from that modal must end its life: import it,
889    /// discard it, or be superseded by the next upload.
890    pending_step_import: Option<PendingStepImport>,
891    /// A `.step` upload whose probe has not answered yet (see
892    /// [`PendingStepProbe`]); the status line says "reading…" meanwhile.
893    pending_step_probe: Option<PendingStepProbe>,
894    /// A step-images or offline-player export held until its geometry or tool
895    /// pictures arrive. PDFs use their own incremental job below.
896    pending_instructions_export: Option<String>,
897    /// PDF preparation is polled between frames; its document and name stay pinned.
898    instructions_pdf: Option<PendingInstructionsPdf>,
899    /// A mesh file waiting to open its reconstruction preview, with the reader
900    /// the picker routed it to (STL or 3MF).
901    pending_stl_import: Option<(MeshImportFormat, String, Vec<u8>)>,
902    /// Bumped on every SUCCESSFUL store save (plain Save / Save As / native
903    /// Save-As) — one half of the update-components staleness key (saving a
904    /// part's source must re-check the outdated badges without a reload).
905    save_generation: u64,
906    plm_save_as: Option<plm_save_as::SaveAs>,
907    /// The hand-edit prompt chose "Save as a new part" on a PLM: the Save As
908    /// it opens starts on the NEW PART destination (never on overwriting the
909    /// generated member or its revision). Consumed when that Save As opens.
910    member_new_part: bool,
911    /// Per-frame widget hit-rects for the headed verifier (wasm only).
912    hits: HashMap<String, egui::Rect>,
913    /// The KiCad import's stages after its file.
914    kicad: KicadImport,
915    /// The last folder of each [`Purpose`], taken from the store's explorer
916    /// location every frame its modal is open.
917    folders: HashMap<Purpose, String>,
918    /// The file chooser: which panel asked (its tag) and the modal's title,
919    /// the folder it was last in (on the LOCAL files), and what it delivered.
920    pick_request: Option<(String, String)>,
921    pick_folder: Option<String>,
922    picked: Option<(String, PickedFile)>,
923    /// Save As → "A new part" in a PLM session: the shell opens the PLM pane's New part
924    /// form (S7), which writes this document into the new part's first revision.
925    plm_new_part: bool,
926    /// The class Save As writes (its class chooser).
927    save_class: DocumentClass,
928    /// The armed member picker ([`Mode::PickMember`]).
929    pick_member: Option<PickMember>,
930    plm_work: Option<PlmWork>,
931    /// The armed template spin-out ([`Mode::SpinOut`]).
932    spin_out: Option<SpinOut>,
933    /// The armed hand-edit prompt ([`Mode::MemberEdit`]).
934    member_prompt: Option<MemberPrompt>,
935    /// An assembly Save waiting for the answers to its questions
936    /// ([`Mode::OwnedSave`]).
937    owned_prompt: Option<OwnedPrompt>,
938    /// The last assembly Save's owned documents, for automation: the open
939    /// question (`prompt`) or what was done (`report`).
940    owned_state: serde_json::Value,
941    /// PLM items being checked out by an assembly Save; each is written when
942    /// its checkout lands.
943    owned_checkouts: Vec<(crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
944    /// The new revisions a Save asked for, made one after another deepest
945    /// first (a holder's revision names the revisions made under it): each
946    /// request with its new key and written text, or why not.
947    owned_revisions: Option<OwnedRevisions>,
948    /// The tab a Save made new revisions for: saved again once every request
949    /// has landed and the PLM index knows the new revisions, so the documents
950    /// holding them name them (and are not taken for missing files).
951    owned_followup: Option<OwnedFollowup>,
952    /// A Save of a PLM assembly held while the PLM makes an item for each part
953    /// that has no document of its own; the answer is each item's key, in the
954    /// order of [`plm_promotion_requests`].
955    /// A Save held while its PLM items are made, with where they are linked
956    /// ("your workspace folder 'X'" or "the top of your workspace").
957    /// A Save held while PLM items are made: its prompt, and the items' keys
958    /// with the workspace folder they were linked in (`(id, name)`; `None`:
959    /// the top).
960    #[allow(clippy::type_complexity)]
961    owned_promoting: Option<(OwnedPrompt, crate::plm::PlmFuture<(Vec<String>, Option<(String, String)>)>)>,
962    /// A Save held until the stored documents it writes have loaded (a PLM
963    /// session lists every revision but reads one's bytes only when asked):
964    /// `(tab id, name, keys, frames waited)`. Then the Save is planned again.
965    owned_loading: Option<(u64, String, Vec<String>, u32)>,
966    /// The held Save is being made again: plan it with what loaded, and do
967    /// not hold it a second time.
968    owned_loaded: bool,
969    /// The Save's own document, a PLM revision that was not checked out to
970    /// this user: written once its checkout lands. `(tab id, signature it
971    /// was saved at, request, checkout)`.
972    owned_top: Option<(u64, String, crate::document::owned_save::CheckoutRequest, crate::plm::PlmFuture<()>)>,
973    /// The open assemblies that place a part just saved under a new name,
974    /// waiting for the answer to "point them at the new file too?".
975    retarget: Option<Retarget>,
976    /// "Check out without asking again in this session" (the Save dialog's
977    /// checkbox): checkout rows no longer ask.
978    checkout_remembered: bool,
979    /// Tabs whose user chose "Save as a new part": they may keep editing
980    /// without being asked again; plain Save still asks.
981    member_ack: HashSet<u64>,
982    /// The last Generate's report, for the automation blob.
983    last_generate: Option<family_table::GenerateReport>,
984    /// The export the Save-file chooser holds ([`Mode::SaveFile`]), and the
985    /// chooser's own name field — its own, so the Export dialog's stem
986    /// (`name_buf`) is what the next export starts from again.
987    save_file: Option<PendingSave>,
988    save_name: String,
989}
990
991impl FileDialog {
992    /// A closed dialog with empty buffers. Document identity (name + clean
993    /// baseline) lives on [`Document`], so there is nothing to seed here.
994    pub fn new() -> Self {
995        Self {
996            pending_open: None,
997            pending_insert: None,
998            component_part: None,
999            explorer: FileExplorer::new(),
1000            workspace: crate::panels::plm_workspace::WorkspaceBrowser::new(),
1001            name_buf: String::new(),
1002            status: String::new(),
1003            open: false,
1004            mode: Mode::Open,
1005            want_focus: false,
1006            pending_component_import: false,
1007            pending_close: None,
1008            pending_step_import: None,
1009            pending_step_probe: None,
1010            pending_instructions_export: None,
1011            instructions_pdf: None,
1012            pending_stl_import: None,
1013            save_generation: 0,
1014            plm_save_as: None,
1015            member_new_part: false,
1016            hits: HashMap::new(),
1017            kicad: KicadImport::new(),
1018            folders: HashMap::new(),
1019            pick_request: None,
1020            pick_folder: None,
1021            picked: None,
1022            plm_new_part: false,
1023            save_class: DocumentClass::Normal,
1024            pick_member: None,
1025            plm_work: None,
1026            spin_out: None,
1027            member_prompt: None,
1028            owned_prompt: None,
1029            owned_state: serde_json::Value::Null,
1030            owned_checkouts: Vec::new(),
1031            owned_revisions: None,
1032            owned_followup: None,
1033            owned_promoting: None,
1034            owned_loading: None,
1035            owned_loaded: false,
1036            owned_top: None,
1037            retarget: None,
1038            checkout_remembered: false,
1039            member_ack: HashSet::new(),
1040            last_generate: None,
1041            save_file: None,
1042            save_name: String::new(),
1043        }
1044    }
1045
1046    /// The dialog's own work in flight, for the status bar's working
1047    /// indicator: a KiCad part's 3D model being parsed off the UI thread.
1048    pub fn busy_activities(&self) -> Vec<crate::panels::busy::Activity> {
1049        let mut out = Vec::new();
1050        if let Some(export) = &self.instructions_pdf {
1051            let (done, total) = export.job.progress();
1052            out.push(crate::panels::busy::Activity::new("instructionsPdf", "instructionsPdf", format!("Preparing instructions PDF: {done}/{total} pictures")));
1053        }
1054        if self.owned_loading.is_some() {
1055            out.push(crate::panels::busy::Activity::new("saveReading", "saveReading", "Reading parts from the PLM before saving"));
1056        }
1057        if self.kicad.reading() {
1058            out.push(crate::panels::busy::Activity::new("kicadRead", "kicadRead", "Reading the KiCad part's 3D model"));
1059        }
1060        out
1061    }
1062
1063    pub fn instructions_pdf_pending(&self) -> bool {
1064        self.instructions_pdf.is_some()
1065    }
1066
1067    /// A PLM Save As is writing its copy. The dialog polls that future itself,
1068    /// outside the PLM executor, so automation waits ask here instead.
1069    pub fn plm_save_as_pending(&self) -> bool {
1070        self.plm_save_as.as_ref().is_some_and(|state| state.saving())
1071    }
1072
1073    /// The monotonic successful-save counter — the shell feeds it to the
1074    /// update-components checker as half its staleness key.
1075    pub fn save_generation(&self) -> u64 {
1076        self.save_generation
1077    }
1078
1079    // --- dispatch: a toolbar button was clicked -------------------------------
1080
1081    /// Act on a toolbar file button. New acts immediately (it adds a tab, so
1082    /// there is nothing to discard); all browsing flows use the same in-app
1083    /// modal on both platforms.
1084    pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
1085        if matches!(action, FileAction::New | FileAction::NewOfClass(_) | FileAction::Open | FileAction::Import) {
1086            self.pending_open = None;
1087            self.pending_insert = None;
1088        }
1089        match action {
1090            FileAction::New => self.new_document(docs, DocumentClass::Normal),
1091            FileAction::NewOfClass(class) => self.new_document(docs, class),
1092            FileAction::Open => {
1093                self.use_folder(Purpose::Models, store);
1094                // A PLM session's Open starts in the user's Home every time.
1095                if store.plm_client().is_some() {
1096                    self.workspace.go_home();
1097                }
1098                self.open_modal(Mode::Open);
1099            }
1100            FileAction::Save => match docs.active().name().map(str::to_string) {
1101                // A family member is not saved over by hand: the prompt says
1102                // why and offers the two ways that are.
1103                Some(name) if self.arm_member_prompt(docs, &name, true) => {}
1104                // A PLM revision saves like any document: a checkout it
1105                // needs is a row of the one Save dialog (see `top_checkout_row`).
1106                // A named document saves straight to its name.
1107                Some(name) => {
1108                    let _ = self.save_to(docs, store, name);
1109                }
1110                // An unnamed document falls through to Save As.
1111                None => self.dispatch(FileAction::SaveAs, docs, store),
1112            },
1113            FileAction::SaveAs => {
1114                self.name_buf = Self::proposed_name(docs);
1115                self.save_class = document_class::document_class(docs.engine());
1116                self.use_folder(Purpose::Models, store);
1117                self.open_modal(Mode::SaveAs);
1118            }
1119            FileAction::Import => {
1120                self.use_folder(Purpose::Imports, browse_store(Purpose::Imports, store));
1121                self.open_modal(Mode::Import);
1122            }
1123            // The stem without the class extension, like Save As: a family's
1124            // STEP is `bolt-family.step`, not `bolt-family.fbrep.step`.
1125            FileAction::Export => {
1126                self.name_buf = Self::proposed_name(docs);
1127                self.open_modal(Mode::Export);
1128            }
1129            FileAction::ExportFlatPattern => {
1130                self.name_buf = Self::proposed_name(docs);
1131                self.open_modal(Mode::FlatPattern);
1132            }
1133            // ALWAYS the in-app modal (never the native picker directly): the
1134            // "existing library entries first" list is an in-app concept; the
1135            // modal's own Upload button drives the platform picker when the
1136            // store supports interchange.
1137            FileAction::InsertComponent => {
1138                self.use_folder(Purpose::Models, store);
1139                self.open_modal(Mode::InsertComponent);
1140            }
1141            FileAction::ImportKicad(kind) => {
1142                self.kicad.start(kind, store);
1143                self.use_folder(Purpose::Kicad, browse_store(Purpose::Kicad, store));
1144                self.open_modal(Mode::Kicad);
1145            }
1146        }
1147    }
1148
1149    /// Ask for a file (plm-cad-integration-todo S8 / S14's "Add file…"). `tag`
1150    /// names the asker, which takes the file with [`Self::take_picked_file`];
1151    /// `title` heads the chooser. Natively the chooser is this dialog's own
1152    /// explorer over the machine's files (one behaviour on every platform, and
1153    /// drivable by scripts); in the browser it is the page's file input.
1154    /// `Err` where neither exists.
1155    pub fn request_pick_file(&mut self, store: &dyn ModelStore, tag: &str, title: &str) -> Result<(), String> {
1156        self.picked = None;
1157        if let Some(local) = store.local_files() {
1158            let arrived = self.pick_folder.as_deref().is_some_and(|folder| local.browser_navigate(folder).is_ok());
1159            if !arrived {
1160                let _ = local.browser_home();
1161            }
1162            self.pick_request = Some((tag.to_string(), title.to_string()));
1163            self.status.clear();
1164            self.open_modal(Mode::PickFile);
1165            return Ok(());
1166        }
1167        store.begin_pick_file()?;
1168        self.pick_request = Some((tag.to_string(), title.to_string()));
1169        Ok(())
1170    }
1171
1172    /// The file the chooser delivered for `tag`, once.
1173    pub fn take_picked_file(&mut self, tag: &str) -> Option<PickedFile> {
1174        if self.picked.as_ref().is_some_and(|(asker, _)| asker == tag) {
1175            return self.picked.take().map(|(_, file)| file);
1176        }
1177        None
1178    }
1179
1180    /// Whether a pick for `tag` is still open (the chooser is up, or the
1181    /// browser's is and has not answered).
1182    pub fn picking(&self, tag: &str) -> bool {
1183        self.pick_request.as_ref().is_some_and(|(asker, _)| asker == tag)
1184    }
1185
1186    /// The chooser: the shared explorer over the LOCAL files, every file shown.
1187    fn show_pick_file(&mut self, ctx: &egui::Context, store: &dyn ModelStore) {
1188        let Some(local) = store.local_files() else {
1189            self.open = false;
1190            self.pick_request = None;
1191            return;
1192        };
1193        let title = self.pick_request.as_ref().map(|(_, title)| title.clone()).unwrap_or_else(|| "Choose a file".into());
1194        let modal = egui::Modal::new(egui::Id::new("brep-file-pick-file")).show(ctx, |ui| {
1195            file_explorer::dialog_body(ui, |ui| {
1196                ui.heading(&title);
1197                ui.add_space(4.0);
1198                file_explorer::dialog_footer(ui, "pickfile", |ui| {
1199                    if !self.status.is_empty() {
1200                        ui.add_space(4.0);
1201                        ui.weak(&self.status);
1202                    }
1203                });
1204                let options = FileExplorerOptions {
1205                    hit_prefix: "pickfile",
1206                    empty_label: "(this folder is empty)",
1207                    row_icon: "\u{1F5CE}",
1208                    current: None,
1209                    allow_delete: false,
1210                    allow_import: false,
1211                    import_label: "",
1212                    import_hit: "pickfile:upload",
1213                    show_cancel: true,
1214                    confirm_label: Some("Choose"),
1215                    extensions: &[],
1216                };
1217                let output = self.explorer.show_store(ui, local, options);
1218                self.record_explorer_hits(&output.hits);
1219                output
1220            })
1221        });
1222        let should_close = modal.should_close();
1223        let output = modal.inner;
1224        self.pick_folder = Some(local.browser_location());
1225        if let Some(path) = output.activated {
1226            match local.read_external_file(&path) {
1227                Some(bytes) => {
1228                    let name = std::path::Path::new(&path)
1229                        .file_name()
1230                        .map(|n| n.to_string_lossy().into_owned())
1231                        .unwrap_or_else(|| path.clone());
1232                    if let Some((tag, _)) = self.pick_request.take() {
1233                        self.status = format!("chose {name}");
1234                        self.picked = Some((tag, PickedFile { name, path: Some(path), bytes }));
1235                    }
1236                    self.open = false;
1237                }
1238                None => self.status = format!("'{path}' could not be read"),
1239            }
1240        } else if output.cancel || should_close {
1241            self.pick_request = None;
1242            self.status.clear();
1243            self.open = false;
1244        }
1245    }
1246
1247    /// Move the explorer to where `purpose` was last, or to the models folder
1248    /// the first time. A remembered folder that is gone falls back to the
1249    /// models folder too.
1250    fn use_folder(&mut self, purpose: Purpose, store: &dyn ModelStore) {
1251        let remembered = self.folders.get(&purpose).cloned();
1252        let arrived = remembered.is_some_and(|folder| store.browser_navigate(&folder).is_ok());
1253        if !arrived {
1254            if let Some(models) = models_folder(store) {
1255                let _ = store.browser_navigate(&models);
1256            }
1257        }
1258    }
1259
1260    /// Where the open modal's explorer is is where its purpose was last.
1261    fn remember_folder(&mut self, store: &dyn ModelStore) {
1262        if let Some(purpose) = self.mode.purpose() {
1263            self.folders.insert(purpose, browse_store(purpose, store).browser_location());
1264        }
1265    }
1266
1267    /// Open the modal in `mode` (and focus its input next frame).
1268    fn open_modal(&mut self, mode: Mode) {
1269        // Every opening is a new choice: the explorer is shared by every mode,
1270        // so a highlight left by the last one (Import's `Timer.kicad_sym`) would
1271        // otherwise be this one's confirm target (Insert component's).
1272        self.pending_insert = None;
1273        self.explorer.clear_selection();
1274        self.mode = mode;
1275        self.open = true;
1276        self.want_focus = true;
1277    }
1278
1279    // --- per-frame draw -------------------------------------------------------
1280
1281    /// Draw the modal (if open) and pick up any completed async import. Called
1282    /// every frame by the shell with a ctx-level handle (the modal is ctx-level,
1283    /// like the command palette).
1284    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1285        self.hits.clear();
1286        self.poll_pending_open(ctx, docs, store);
1287        self.poll_pending_insert(ctx, docs, store);
1288        self.poll_plm_save_as(ctx, docs, store);
1289        self.poll_plm_work(ctx, docs, store);
1290        self.poll_open_workspace_pick(store);
1291        self.poll_owned_checkouts(ctx, docs, store);
1292        self.poll_owned_promotions(ctx, docs, store);
1293        self.poll_owned_loading(ctx, docs, store);
1294        if self.retarget.is_some() && !self.open {
1295            self.open_modal(Mode::Retarget);
1296        }
1297        // The browser's file chooser answered a pick.
1298        if let Some(file) = store.take_picked_file() {
1299            if let Some((tag, _)) = self.pick_request.take() {
1300                self.picked = Some((tag, PickedFile { name: file.name, path: None, bytes: file.bytes }));
1301            }
1302        }
1303
1304        // A STEP probe the runner has answered since last frame resolves into
1305        // the assembly choice or the flat import now, before drawing.
1306        self.poll_step_probe(docs.engine_mut());
1307        self.poll_instructions_pdf(ctx, docs, store);
1308        // An instructions export held for the exact solids runs once they
1309        // have landed (the busy line says "Loading exact geometry" meanwhile).
1310        if self.pending_instructions_export.is_some() && !docs.engine().topology_pending() && crate::plm::tool_pictures::pending() == 0 {
1311            if let Some(format) = self.pending_instructions_export.take() {
1312                self.export_as(docs, store, &format);
1313            }
1314        }
1315
1316        // A completed async browser upload is picked up
1317        // here, before drawing, so the model is live for this frame. Routed by
1318        // extension: a STEP file (`.step`/`.stp`) is APPENDED to the model as an
1319        // IMPORT3D feature; anything else is a `.nbrep` model document loaded
1320        // (replacing the model). The model lanes deliver an extension-less name
1321        // (web, stripped by `model_display_name`) or a stored model name, so only
1322        // real STEP files route here.
1323        if let Some(imported) = store.take_import() {
1324            // A new delivery SUPERSEDES an armed assembly choice. The modal
1325            // describes a parse this routing is about to replace (a STEP probe
1326            // drops the previous stash by contract, and a document load drops it
1327            // outright), so leaving the choice armed would offer the user a
1328            // button whose stash is already gone.
1329            self.cancel_pending_step_import(docs.engine_mut());
1330            let name = imported.name;
1331            let bytes = imported.bytes;
1332            // CAD/mesh imports target the active document. STL waits for
1333            // preview acceptance; model documents open in a separate tab.
1334            if let Some(kind) = import_kind(&name) {
1335                if !self.import_file(kind, docs.engine_mut(), store, name, bytes) {
1336                    return;
1337                }
1338            } else if std::mem::take(&mut self.pending_component_import) {
1339                // The insert-component modal's Upload fired this pick: the
1340                // chosen document becomes a parts-library entry + an instance,
1341                // NOT a new tab — or, for a family or a template, the prompt
1342                // that leads to one.
1343                match String::from_utf8(bytes) {
1344                    Ok(contents) => {
1345                        let assembly = docs.active().name().map(str::to_string);
1346                        if !self.begin_class_insert(store, &name, &contents, assembly) {
1347                            self.insert_component_document(docs.engine_mut(), &name, &contents)
1348                        }
1349                        if self.pick_member.is_some() || self.spin_out.is_some() {
1350                            return;
1351                        }
1352                    }
1353                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1354                }
1355            } else {
1356                match String::from_utf8(bytes) {
1357                    Ok(contents) => self.load_document(docs, &name, &contents, store),
1358                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
1359                }
1360            }
1361            self.close_after_import();
1362        }
1363
1364        // A family member the user has just started to change raises the
1365        // hand-edit prompt (unless they already chose "Save as a new part").
1366        // On every store (integrator ruling 2026-10-03): on a PLM the
1367        // prompt's "Save as a new part" hands off to the PLM Save As with a
1368        // NEW PART preselected, so the generated member is never written
1369        // over by hand.
1370        if !self.open {
1371            self.watch_member_edit(docs);
1372        }
1373
1374        if !self.open {
1375            return;
1376        }
1377        self.remember_folder(store);
1378
1379        match self.mode {
1380            Mode::CreateComponentPart => self.show_create_component_part(ctx, docs, store),
1381            Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
1382            Mode::SaveAs => self.show_save_as(ctx, docs, store),
1383            Mode::Open => self.show_open(ctx, docs, store),
1384            Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
1385            Mode::Export => self.show_export(ctx, docs, store),
1386            Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
1387            Mode::InsertComponent => self.show_insert_component(ctx, docs, store),
1388            Mode::PickMember => self.show_pick_member(ctx, docs.engine_mut(), store),
1389            Mode::SpinOut => self.show_spin_out(ctx, docs.engine_mut(), store),
1390            Mode::MemberEdit => self.show_member_edit(ctx, docs, store),
1391            Mode::OwnedSave => self.show_owned_save(ctx, docs, store),
1392            Mode::Retarget => self.show_retarget(ctx, docs),
1393            Mode::PickFile => self.show_pick_file(ctx, store),
1394            Mode::SaveFile => self.show_save_file(ctx, docs, store),
1395            Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
1396            Mode::Kicad => self.show_kicad(ctx, docs.engine_mut(), store),
1397        }
1398    }
1399
1400    /// Close the modal after a completed import — unless the import ARMED the
1401    /// §3.9 assembly choice, in which case the modal stays open and switches to
1402    /// it. Both import routing sites (the async-upload poll and the Import
1403    /// modal's own pick) end here, so neither can close a dialog the probe just
1404    /// raised.
1405    fn close_after_import(&mut self) {
1406        if self.pending_step_import.is_some() {
1407            self.open_modal(Mode::StepAssembly);
1408        } else {
1409            self.open = false;
1410        }
1411    }
1412
1413    /// Abandon an armed assembly choice and the engine stash behind it. The ONE
1414    /// place the app-side pending state and the engine-side parse are dropped
1415    /// together — they are two halves of one thing, and a half-drop is either a
1416    /// dialog with no stash or solids nobody will ever consume.
1417    fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
1418        if self.pending_step_import.take().is_some() {
1419            state.discard_probed_step_assembly();
1420        }
1421    }
1422
1423    /// Common CAD/mesh browser. Desktop enumerates files in the application's
1424    /// models directory; web shows the same explorer shell with an Upload action.
1425    fn show_import(
1426        &mut self,
1427        ctx: &egui::Context,
1428        state: &mut EngineState,
1429        store: &dyn ModelStore,
1430    ) {
1431        const EXTENSIONS: &[&str] = IMPORT_EXTENSIONS;
1432        let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
1433            file_explorer::dialog_body(ui, |ui| {
1434                ui.heading("Import CAD file");
1435                ui.add_space(4.0);
1436                file_explorer::dialog_footer(ui, "import", |ui| {
1437                    if !self.status.is_empty() {
1438                        ui.add_space(4.0);
1439                        ui.weak(&self.status);
1440                    }
1441                });
1442                let options = FileExplorerOptions {
1443                    hit_prefix: "import",
1444                    empty_label: "(no STEP, IGES, STL, 3MF, OBJ, or KiCad files)",
1445                    row_icon: "\u{1F5CE}",
1446                    current: None,
1447                    allow_delete: false,
1448                    allow_import: store.supports_file_interchange(),
1449                    import_label: "Upload\u{2026}",
1450                    import_hit: "import:upload",
1451                    show_cancel: true,
1452                    confirm_label: Some("Import"),
1453                    extensions: EXTENSIONS,
1454                };
1455                let output = self.explorer.show_store(ui, browse_store(Purpose::Imports, store), options);
1456                self.record_explorer_hits(&output.hits);
1457                output
1458            })
1459        });
1460        let should_close = modal.should_close();
1461        let output = modal.inner;
1462        if let Some(name) = output.activated {
1463            match browse_store(Purpose::Imports, store).read_external_file(&name) {
1464                Some(bytes) => match import_kind(&name) {
1465                    Some(kind) => {
1466                        if !self.import_file(kind, state, store, name, bytes) {
1467                            return;
1468                        }
1469                    }
1470                    None => self.status = format!("unsupported import file: {name}"),
1471                },
1472                None => self.status = format!("import failed: '{name}' not found"),
1473            }
1474            self.close_after_import();
1475        } else if output.import {
1476            match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
1477                Ok(()) => {
1478                    self.status =
1479                        "choose a STEP, IGES, STL, 3MF, OBJ, or KiCad file\u{2026}".into();
1480                    self.open = false;
1481                }
1482                Err(e) => self.status = format!("import failed: {e}"),
1483            }
1484        } else if output.cancel || should_close {
1485            self.open = false;
1486        }
1487    }
1488
1489    /// A `.kicad_sym` or `.kicad_mod` arrived (picked in Import, in the KiCad
1490    /// explorer, or uploaded): the KiCad dialog reads it and goes on to its
1491    /// next stage. `name` is the file's full path on desktop, which is where a
1492    /// footprint's relative model path starts from.
1493    fn take_kicad_file(&mut self, name: &str, bytes: &[u8], store: &dyn ModelStore) {
1494        if !(self.open && self.mode == Mode::Kicad) {
1495            if let Some(kind) = KicadKind::of_file(name) {
1496                self.kicad.start(kind, store);
1497            }
1498        }
1499        self.kicad.take_file(name, bytes, &DiskFiles);
1500        self.open_modal(Mode::Kicad);
1501    }
1502
1503    /// The KiCad import: the common explorer for its file, then its own
1504    /// stages. Hit keys are `kicad:*`.
1505    fn show_kicad(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
1506        if !self.kicad.wants_file() {
1507            match self.kicad.show(ctx, state, store, &mut self.hits) {
1508                kicad_import::Outcome::Open => {}
1509                kicad_import::Outcome::Closed => self.open = false,
1510                kicad_import::Outcome::Imported(imported) => {
1511                    // The modal closes on the click, so the toasts are what the
1512                    // user sees: the summary, then each note.
1513                    self.status = imported.summary.clone();
1514                    state.push_notice_as(brep_render::engine_state::NoticeSeverity::Info, imported.summary);
1515                    const SHOWN: usize = 5;
1516                    let hidden = imported.notes.len().saturating_sub(SHOWN);
1517                    for note in imported.notes.into_iter().take(SHOWN) {
1518                        state.push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, note);
1519                    }
1520                    if hidden > 0 {
1521                        state.push_notice_as(
1522                            brep_render::engine_state::NoticeSeverity::Warning,
1523                            format!("and {hidden} more notes on this import"),
1524                        );
1525                    }
1526                    self.open = false;
1527                }
1528            }
1529            return;
1530        }
1531        let extension = self.kicad.kind().extension();
1532        let heading = match self.kicad.kind() {
1533            KicadKind::Symbol => "Import a symbol from KiCad",
1534            KicadKind::Footprint => "Import a footprint from KiCad",
1535        };
1536        let extensions = [extension];
1537        let modal = egui::Modal::new(egui::Id::new("brep-file-kicad")).show(ctx, |ui| {
1538            file_explorer::dialog_body(ui, |ui| {
1539                ui.heading(heading);
1540                ui.add_space(4.0);
1541                file_explorer::dialog_footer(ui, "kicad", |ui| {
1542                    let status = if self.kicad.status.is_empty() { &self.status } else { &self.kicad.status };
1543                    if !status.is_empty() {
1544                        ui.add_space(4.0);
1545                        ui.weak(status);
1546                    }
1547                });
1548                let empty_label = format!("(no .{extension} files)");
1549                let options = FileExplorerOptions {
1550                    hit_prefix: "kicad",
1551                    empty_label: &empty_label,
1552                    row_icon: "\u{1F5CE}",
1553                    current: None,
1554                    allow_delete: false,
1555                    allow_import: store.supports_file_interchange(),
1556                    import_label: "Upload\u{2026}",
1557                    import_hit: "kicad:upload",
1558                    show_cancel: true,
1559                    confirm_label: Some("Open"),
1560                    extensions: &extensions,
1561                };
1562                let output = self.explorer.show_store(ui, browse_store(Purpose::Kicad, store), options);
1563                self.record_explorer_hits(&output.hits);
1564                output
1565            })
1566        });
1567        let should_close = modal.should_close();
1568        let output = modal.inner;
1569        if let Some(name) = output.activated {
1570            match browse_store(Purpose::Kicad, store).read_external_file(&name) {
1571                Some(bytes) => self.take_kicad_file(&name, &bytes, store),
1572                None => self.kicad.status = format!("import failed: '{name}' not found"),
1573            }
1574        } else if output.import {
1575            match store.begin_import_filtered(("KiCad", &extensions)) {
1576                Ok(()) => {
1577                    self.status = format!("choose a .{extension} file\u{2026}");
1578                    self.open = false;
1579                }
1580                Err(e) => self.kicad.status = format!("import failed: {e}"),
1581            }
1582        } else if output.cancel || should_close {
1583            self.open = false;
1584        }
1585    }
1586
1587    /// CLOSE tab `index` with the unsaved-changes contract: a DIRTY document
1588    /// prompts to discard first (the confirm modal); a clean one closes
1589    /// immediately. The tab strip's `\u{2715}` routes here — it is the only
1590    /// door through which unsaved work can be dropped, so it is the only one
1591    /// that asks.
1592    pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
1593        let dirty = docs.get(index).is_some_and(Document::is_dirty);
1594        if dirty {
1595            self.pending_close = docs.get(index).map(|doc| (doc.id(), doc.committed_revision()));
1596            self.open_modal(Mode::ConfirmClose);
1597        } else {
1598            docs.close(index);
1599        }
1600    }
1601
1602    /// The **Discard unsaved changes?** confirmation shown before closing a
1603    /// dirty document tab. Keeps the verifier's `confirm:discard` /
1604    /// `confirm:cancel` hit keys — the same two buttons, one door further on.
1605    fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
1606        // The prompt names a DOCUMENT, not a tab position: a tab closed or
1607        // moved under it (an agent's close, a reorder) cannot redirect the
1608        // discard to whatever now sits at the old index, and a document that
1609        // changed since (an agent's write) dismisses the prompt — the user
1610        // confirmed discarding what they saw, not what it has become.
1611        let Some(title) = self
1612            .pending_close
1613            .and_then(|(id, revision)| docs.index_of_id(id).and_then(|i| docs.get(i)).filter(|doc| doc.committed_revision() == revision))
1614            .map(Document::title)
1615        else {
1616            self.pending_close = None;
1617            self.open = false;
1618            return;
1619        };
1620        let mut discard = false;
1621        let mut cancel = false;
1622        let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
1623            ui.set_width(340.0);
1624            ui.heading("Discard unsaved changes?");
1625            ui.add_space(4.0);
1626            ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
1627            ui.add_space(6.0);
1628            ui.horizontal(|ui| {
1629                let d = ui.button("Discard and close");
1630                self.hit("confirm:discard", &d);
1631                if d.clicked() {
1632                    discard = true;
1633                }
1634                let c = ui.button("Cancel");
1635                self.hit("confirm:cancel", &c);
1636                if c.clicked() {
1637                    cancel = true;
1638                }
1639            });
1640        });
1641        if discard {
1642            if let Some(index) = self.pending_close.take().and_then(|(id, _)| docs.index_of_id(id)) {
1643                docs.close(index);
1644            }
1645            self.open = false;
1646        } else if cancel || modal.should_close() {
1647            self.pending_close = None;
1648            self.open = false;
1649        }
1650    }
1651
1652    /// The §3.9 **assembly choice**, raised when a `.step` upload's probe found a
1653    /// product structure:
1654    ///
1655    /// > **"bracket-assy.step" contains an assembly** — 7 parts, 23 instances.
1656    /// > [ Import as assembly ] [ Import as bodies ] [ Cancel ]
1657    ///
1658    /// Default (and first) is **Import as assembly**. "Import as bodies" is
1659    /// today's flat lane, unchanged. Cancel — and Esc / click-outside, which
1660    /// egui folds into `should_close` — import nothing and DROP the parse: the
1661    /// engine is holding every product's solids until one of these three lands.
1662    ///
1663    /// **Flatten sub-assemblies** (§3.9) is offered only when the file actually
1664    /// HAS sub-assemblies (`nested_depth > 1`) — on a single-level file both
1665    /// lanes produce the identical document, so a box there would teach the user
1666    /// a distinction that does not exist. Unchecked (the default) keeps the
1667    /// tree; checked flattens to leaf occurrences, which is the right answer for
1668    /// a deep or pathological file and stores a part reused across two levels
1669    /// once rather than once per level.
1670    fn show_step_assembly(
1671        &mut self,
1672        ctx: &egui::Context,
1673        state: &mut EngineState,
1674        store: &dyn ModelStore,
1675    ) {
1676        // Nothing armed means nothing to choose about (a supersession raced the
1677        // draw): close rather than render an empty prompt.
1678        let Some(pending) = self.pending_step_import.as_ref() else {
1679            self.open = false;
1680            return;
1681        };
1682        let name = pending.name.clone();
1683        let probe = pending.probe;
1684        let mut flatten = pending.flatten;
1685        let mut choice: Option<StepChoice> = None;
1686        let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
1687            ui.set_width(360.0);
1688            ui.heading(format!("\"{name}\" contains an assembly"));
1689            ui.add_space(4.0);
1690            ui.label(format!(
1691                "{} part{}, {} instance{}{}.",
1692                probe.parts,
1693                plural(probe.parts),
1694                probe.instances,
1695                plural(probe.instances),
1696                match probe.nested_depth {
1697                    0 | 1 => String::new(),
1698                    depth => format!(", {depth} levels deep"),
1699                }
1700            ));
1701            if probe.nested_depth > 1 {
1702                ui.add_space(4.0);
1703                let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
1704                self.hit("stepassembly:flatten", &check);
1705                check.on_hover_text(
1706                    "Off: each sub-assembly becomes one rigid component you can \
1707                     expand in the structure tree.\nOn: every part is placed \
1708                     directly in this document at its world position.",
1709                );
1710            }
1711            ui.add_space(6.0);
1712            ui.horizontal(|ui| {
1713                let a = ui.button("Import as assembly");
1714                self.hit("stepassembly:assembly", &a);
1715                if a.clicked() {
1716                    choice = Some(StepChoice::Assembly);
1717                }
1718                let b = ui.button("Import as bodies");
1719                self.hit("stepassembly:bodies", &b);
1720                if b.clicked() {
1721                    choice = Some(StepChoice::Bodies);
1722                }
1723                let c = ui.button("Cancel");
1724                self.hit("stepassembly:cancel", &c);
1725                if c.clicked() {
1726                    choice = Some(StepChoice::Cancel);
1727                }
1728            });
1729        });
1730        // The box must survive the frames between the tick and the click.
1731        if let Some(pending) = self.pending_step_import.as_mut() {
1732            pending.flatten = flatten;
1733        }
1734        // Esc / click-outside is a Cancel, not a no-op: the stash must not
1735        // outlive the prompt that was going to consume it.
1736        let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
1737        let Some(choice) = choice else { return };
1738        match choice {
1739            StepChoice::Assembly => self.step_assembly_import(state, store),
1740            StepChoice::Bodies => self.step_assembly_bodies(state),
1741            StepChoice::Cancel => self.step_assembly_cancel(state),
1742        }
1743        self.open = false;
1744    }
1745
1746    /// Save As asked for a new PLM part: the shell opens the PLM pane's New part form.
1747    /// Once: true the frame after it was chosen.
1748    pub fn take_plm_new_part(&mut self) -> bool {
1749        std::mem::take(&mut self.plm_new_part)
1750    }
1751
1752    /// The **Save As** name prompt.
1753    fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1754        // A PLM session saves as a new part or a new revision, not under a file name.
1755        if let Some(client) = store.plm_client() {
1756            self.show_save_as_plm(ctx, docs, store, client);
1757            return;
1758        }
1759        let current = docs.active().name().map(str::to_string);
1760        let mut do_save = false;
1761        let mut cancel = false;
1762        let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
1763            file_explorer::dialog_body(ui, |ui| {
1764                ui.heading("Save model as");
1765                ui.add_space(4.0);
1766                // The name field and its buttons are the dialog's footer: pinned
1767                // under the browser, which fills whatever is left.
1768                file_explorer::dialog_footer(ui, "saveas", |ui| {
1769                    ui.label("File name");
1770                    let field = ui.add(
1771                        egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1772                            .hint_text("model name")
1773                            .desired_width(f32::INFINITY),
1774                    );
1775                    self.hit("field:name", &field);
1776                    if self.want_focus {
1777                        field.request_focus();
1778                        self.want_focus = false;
1779                    }
1780                    let enter =
1781                        field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1782                    // The class the file is saved as. A part's class is fixed
1783                    // for its life, so Save As is the one place it is chosen:
1784                    // saving a part as a Family or a Template makes a new file
1785                    // of that class.
1786                    ui.horizontal(|ui| {
1787                        ui.label("Save as");
1788                        // One dropdown (the form's enum field): saveas:class, its
1789                        // open entries saveas:class#<slug>.
1790                        let texts: Vec<String> = DocumentClass::ALL.iter().map(|class| format!("{} ({})", class.label(), class.extension())).collect();
1791                        let choices: Vec<(DocumentClass, &str, &str)> =
1792                            DocumentClass::ALL.iter().zip(&texts).map(|(class, text)| (*class, class.slug(), text.as_str())).collect();
1793                        crate::form::enum_dropdown(ui, "saveas:class", &choices, &mut self.save_class, None, Some(&mut self.hits));
1794                    });
1795                    ui.add_space(6.0);
1796                    ui.horizontal(|ui| {
1797                        let save = ui.button("Save");
1798                        self.hit("save", &save);
1799                        if save.clicked() || enter {
1800                            do_save = true;
1801                        }
1802                        let c = ui.button("Cancel");
1803                        self.hit("cancel", &c);
1804                        if c.clicked() {
1805                            cancel = true;
1806                        }
1807                    });
1808                    if !self.status.is_empty() {
1809                        ui.add_space(4.0);
1810                        ui.weak(&self.status);
1811                    }
1812                });
1813                let options = FileExplorerOptions {
1814                    hit_prefix: "saveas:file",
1815                    empty_label: "(no saved models)",
1816                    row_icon: "\u{1F5CE}",
1817                    current: current.as_deref(),
1818                    allow_delete: false,
1819                    allow_import: false,
1820                    import_label: "",
1821                    import_hit: "saveas:upload",
1822                    show_cancel: false,
1823                    confirm_label: None,
1824                    extensions: MODEL_EXTENSIONS,
1825                };
1826                let output = self.explorer.show_store(ui, store, options);
1827                self.record_explorer_hits(&output.hits);
1828                // Selecting (or double-clicking) a stored model fills the name
1829                // field so it can be overwritten; `picked`/`activated` are
1830                // one-shot so it never clobbers a name the user then types. The
1831                // field itself was drawn above, so the explorer asks for the
1832                // repaint that shows the new name.
1833                if let Some(name) = output.picked.or(output.activated) {
1834                    self.name_buf =
1835                        document_class::strip_class_extension(&model_display_name(&name)).to_string();
1836                    if let Some(class) = DocumentClass::of_name(&name) {
1837                        self.save_class = class;
1838                    }
1839                }
1840            })
1841        });
1842        if do_save {
1843            let name = self.save_class.file_name(self.name_buf.trim());
1844            if self.save_to_browser(docs, store, name) {
1845                self.open = false;
1846            }
1847        } else if cancel || modal.should_close() {
1848            self.open = false;
1849        }
1850    }
1851
1852    /// The **Export** chooser: pick a format (STEP / IGES / STL / OBJ / GLB /
1853    /// 3MF) and write the current model to the user's filesystem under
1854    /// `<name>.<ext>` through the store's format-typed interchange. STEP and
1855    /// IGES serialize the exact NURBS topology; STL, OBJ, GLB and 3MF are the
1856    /// display mesh (ASCII facets, one indexed object, binary glTF 2.0 with a
1857    /// mesh per body, and a 3MF package with an object per body and the
1858    /// document's colours as base materials).
1859    /// No solids → a clear status line,
1860    /// nothing written. A second row offers the BOM (CSV / JSON), enabled only
1861    /// while the document has components or harness wires.
1862    fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1863        // BOM gating: `component_ids` scans the history (cheap, no kernel
1864        // session), and a harness wire is a BOM line of its own, so a
1865        // harness-only document exports too. A rolled-back/failed ACOMP can
1866        // still enable the buttons — the export's own loud "no components or
1867        // harness wires" error covers that gap.
1868        let has_bom = !docs.engine().component_ids().is_empty()
1869            || !docs.engine().wire_harness_state().connections.is_empty();
1870        // The drawing sheets of THIS document: the sheet button writes the open
1871        // one (else the first), so it is offered only when there is one to
1872        // write — the BOM row's gating, for the same reason.
1873        let has_sheets = !docs.engine().sheet_state().sheets.is_empty();
1874        let has_instructions = docs.engine().is_instructions();
1875        // The board's manufacturing files: offered once the PCB has parts on
1876        // its board, the BOM row's gating for the same reason.
1877        let has_board = super::fabrication_export::has_board(docs.engine());
1878        let has_schematic = super::fabrication_export::has_schematic(docs.engine());
1879        let mut chosen: Option<&'static str> = None;
1880        let mut bom_chosen: Option<&'static str> = None;
1881        let mut fab_chosen: Option<super::fabrication_export::Output> = None;
1882        let mut cancel = false;
1883        let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
1884            // Wide enough for the 3D formats on one line; each group wraps
1885            // rather than widening the modal (lane U audit item 7).
1886            ui.set_width(440.0);
1887            ui.heading("Export model");
1888            ui.add_space(4.0);
1889            Self::preview_export_line(ui, docs);
1890            let field = ui.add(
1891                egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
1892                    .hint_text("file name")
1893                    .desired_width(f32::INFINITY),
1894            );
1895            self.hit("field:name", &field);
1896            if self.want_focus {
1897                field.request_focus();
1898                self.want_focus = false;
1899            }
1900            // One headed, wrapping row per kind of output, and Cancel on its
1901            // own row below them all — it used to sit among the 3D formats,
1902            // with "Sheets + 3D (PDF)" orphaned on a line of its own.
1903            let group = |ui: &mut egui::Ui, title: &str| {
1904                ui.add_space(6.0);
1905                ui.label(egui::RichText::new(title).weak());
1906            };
1907            group(ui, "3D model");
1908            ui.horizontal_wrapped(|ui| {
1909                let step = ui.button("STEP (.step)");
1910                self.hit("export:step", &step);
1911                if step.clicked() {
1912                    chosen = Some("step");
1913                }
1914                let iges = ui.button("IGES (.igs)");
1915                self.hit("export:iges", &iges);
1916                if iges.clicked() {
1917                    chosen = Some("iges");
1918                }
1919                let stl = ui.button("STL (.stl)");
1920                self.hit("export:stl", &stl);
1921                if stl.clicked() {
1922                    chosen = Some("stl");
1923                }
1924                let obj = ui.button("OBJ (.obj)");
1925                self.hit("export:obj", &obj);
1926                if obj.clicked() {
1927                    chosen = Some("obj");
1928                }
1929                let glb = ui.button("GLB (.glb)");
1930                self.hit("export:glb", &glb);
1931                if glb.clicked() {
1932                    chosen = Some("glb");
1933                }
1934                // The slicer format: the display mesh as a 3MF package, one
1935                // object per body, with the document's colours carried as
1936                // base materials so a multi-colour print arrives painted.
1937                let three_mf = ui
1938                    .button("3MF (.3mf)")
1939                    .on_hover_text("For slicers: one object per body, with the model's colours");
1940                self.hit("export:3mf", &three_mf);
1941                if three_mf.clicked() {
1942                    chosen = Some("3mf");
1943                }
1944                // The full model RECIPE (`.nbrep`) — for saving a document to
1945                // disk / sharing a failing model for a bug report. Re-openable via
1946                // Open / Import.
1947                let json = ui.button("Model (.nbrep)");
1948                self.hit("export:json", &json);
1949                if json.clicked() {
1950                    chosen = Some("json");
1951                }
1952            });
1953            group(ui, "Bill of materials");
1954            ui.horizontal_wrapped(|ui| {
1955                // The BOM: one row per parts-library entry with the live
1956                // instance count, then one per harness wire with its cut
1957                // length. Disabled while the document has neither.
1958                let csv = ui.add_enabled(has_bom, egui::Button::new("BOM (CSV)"));
1959                self.hit("export:bomcsv", &csv);
1960                if csv.clicked() {
1961                    bom_chosen = Some("csv");
1962                }
1963                let json = ui.add_enabled(has_bom, egui::Button::new("BOM (JSON)"));
1964                self.hit("export:bomjson", &json);
1965                if json.clicked() {
1966                    bom_chosen = Some("json");
1967                }
1968            });
1969            group(ui, "Drawing");
1970            ui.horizontal_wrapped(|ui| {
1971                // The DRAWING SHEET: the paper as an SVG of lines and text.
1972                // Not the sheet-METAL flat pattern, which is its own dialog.
1973                let sheet = ui
1974                    .add_enabled(has_sheets, egui::Button::new("Sheet (SVG)"))
1975                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1976                self.hit("export:sheetsvg", &sheet);
1977                if sheet.clicked() {
1978                    chosen = Some("sheetsvg");
1979                }
1980                // EVERY sheet as one PDF: a page per sheet, in sheet order, each
1981                // at its own paper size — the drawing set, not the page on
1982                // screen. The SVG beside it stays one file per sheet.
1983                let pdf = ui
1984                    .add_enabled(has_sheets, egui::Button::new("Sheets (PDF)"))
1985                    .on_hover_text("Every drawing sheet of the document as one PDF, a page per sheet")
1986                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1987                self.hit("export:sheetpdf", &pdf);
1988                if pdf.clicked() {
1989                    chosen = Some("sheetpdf");
1990                }
1991                // The same drawing set with the LIVE 3D model: a 3D box over
1992                // every placement marked "Live 3D in PDF", and a last page
1993                // given over to the model with a button per saved PMI view.
1994                // Its own file, so the plain drawing set is never replaced.
1995                let pdf3d = ui
1996                    .add_enabled(has_sheets, egui::Button::new("Sheets + 3D (PDF)"))
1997                    .on_hover_text("The drawing set with the live 3D model: Acrobat and Foxit turn and zoom it and switch PMI views; other PDF readers show the drawings")
1998                    .on_disabled_hover_text("Add a drawing sheet in the Drawing workbench first");
1999                self.hit("export:sheetpdf3d", &pdf3d);
2000                if pdf3d.clicked() {
2001                    chosen = Some("sheetpdf3d");
2002                }
2003            });
2004            group(ui, "Electronics");
2005            ui.horizontal_wrapped(|ui| {
2006                // The PCB's manufacturing files. The zip is what a board house
2007                // takes; the two CSVs are also inside it, and on their own for
2008                // an assembler that asks for them separately.
2009                use super::fabrication_export::Output;
2010                let no_board = "Place the schematic's parts on the PCB board first";
2011                for (key, label, hover, output) in [
2012                    ("export:fabrication", "Fabrication (zip)", "Gerber X2 layers, Excellon drills, pick and place, the parts BOM and a README of the layers, in one zip for the board house", Output::Bundle),
2013                    ("export:pickplace", "Pick & place (CSV)", "One row per placed part: Designator, Val, Package, Mid X, Mid Y, Rotation, Layer (JLCPCB's columns), in mm from the board's lower-left corner", Output::PickPlace),
2014                    ("export:ecadbom", "Parts BOM (CSV)", "The electronic parts: Comment (the value), Designator, Footprint, Quantity (JLCPCB's columns), one line per value and footprint", Output::Bom),
2015                    ("export:kicadnet", "Netlist (KiCad)", "The PCB schematic's nets as a KiCad .net file, the netlist Pcbnew and other tools read: every part with its value and footprint, every net with its pins, under the names the Connectivity panel lists", Output::Netlist),
2016                ] {
2017                    let (enabled, why) = match output {
2018                        Output::Netlist => (has_schematic, "Put parts on the PCB schematic first"),
2019                        _ => (has_board, no_board),
2020                    };
2021                    let button = ui
2022                        .add_enabled(enabled, egui::Button::new(label))
2023                        .on_hover_text(hover)
2024                        .on_disabled_hover_text(why);
2025                    self.hit(key, &button);
2026                    if button.clicked() {
2027                        fab_chosen = Some(output);
2028                    }
2029                }
2030            });
2031            group(ui, "Instructions");
2032            ui.horizontal_wrapped(|ui| {
2033                // The printed instructions on the page template the Instructions
2034                // pane sets, and every step's picture as PNG and SVG in one zip.
2035                let not_instructions = "Open manufacturing instructions first (New from assembly in the Instructions workbench)";
2036                let pdf = ui
2037                    .add_enabled(has_instructions, egui::Button::new("Instructions (PDF)"))
2038                    .on_hover_text("The cover, the parts list, the tools list and the steps, on the page template set in the Instructions pane")
2039                    .on_disabled_hover_text(not_instructions);
2040                self.hit("export:instructionspdf", &pdf);
2041                if pdf.clicked() {
2042                    chosen = Some("instructionspdf");
2043                }
2044                let images = ui
2045                    .add_enabled(has_instructions, egui::Button::new("Step images"))
2046                    .on_hover_text("Every step's picture as PNG and SVG, in one zip")
2047                    .on_disabled_hover_text(not_instructions);
2048                self.hit("export:instructionsimages", &images);
2049                if images.clicked() {
2050                    chosen = Some("instructionsimages");
2051                }
2052                let html = ui
2053                    .add_enabled(has_instructions, egui::Button::new("Player (HTML)"))
2054                    .on_hover_text("The interactive player in one self-contained page: Next/Back, a live 3D view per step and a step checklist, with no server")
2055                    .on_disabled_hover_text(not_instructions);
2056                self.hit("export:instructionshtml", &html);
2057                if html.clicked() {
2058                    chosen = Some("instructionshtml");
2059                }
2060            });
2061            if !self.status.is_empty() {
2062                ui.add_space(4.0);
2063                ui.weak(&self.status);
2064            }
2065            ui.add_space(6.0);
2066            ui.separator();
2067            let c = ui.button("Cancel");
2068            self.hit("cancel", &c);
2069            if c.clicked() {
2070                cancel = true;
2071            }
2072        });
2073        // A delivered export closes this dialog; one the Save-file chooser
2074        // took over has switched the modal to the chooser, which stays up.
2075        if let Some(format) = chosen {
2076            if self.export_as(docs, store, format) && self.mode == Mode::Export {
2077                self.open = false;
2078            }
2079        } else if let Some(format) = bom_chosen {
2080            if self.export_bom_as(docs, store, format) && self.mode == Mode::Export {
2081                self.open = false;
2082            }
2083        } else if let Some(output) = fab_chosen {
2084            if self.export_fabrication_as(docs, store, output) && self.mode == Mode::Export {
2085                self.open = false;
2086            }
2087        } else if cancel || modal.should_close() {
2088            self.open = false;
2089        }
2090    }
2091
2092    /// The **Flat pattern** chooser: pick a 2D vector format (DXF R12 / SVG) and
2093    /// write the sheet-metal body's unfolded flat pattern to the user's filesystem
2094    /// under `<name>.<ext>`. The unfold runs TRANSIENTLY in the engine (no feature,
2095    /// no history change). A part with no sheet-metal body reports it in the status
2096    /// line AND queues a toast (see [`Self::export_flat_pattern_as`]).
2097    fn show_flat_pattern(
2098        &mut self,
2099        ctx: &egui::Context,
2100        docs: &mut Documents,
2101        store: &dyn ModelStore,
2102    ) {
2103        let mut chosen: Option<&'static str> = None;
2104        let mut cancel = false;
2105        let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
2106            ui.set_width(340.0);
2107            ui.heading("Export flat pattern");
2108            ui.add_space(2.0);
2109            Self::preview_export_line(ui, docs);
2110            ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
2111            ui.add_space(4.0);
2112            let field = ui.add(
2113                egui::TextEdit::singleline(&mut self.name_buf).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
2114                    .hint_text("file name")
2115                    .desired_width(f32::INFINITY),
2116            );
2117            self.hit("field:name", &field);
2118            if self.want_focus {
2119                field.request_focus();
2120                self.want_focus = false;
2121            }
2122            ui.add_space(6.0);
2123            ui.horizontal(|ui| {
2124                let dxf = ui.button("DXF (.dxf)");
2125                self.hit("flat:dxf", &dxf);
2126                if dxf.clicked() {
2127                    chosen = Some("dxf");
2128                }
2129                let svg = ui.button("SVG (.svg)");
2130                self.hit("flat:svg", &svg);
2131                if svg.clicked() {
2132                    chosen = Some("svg");
2133                }
2134                let c = ui.button("Cancel");
2135                self.hit("cancel", &c);
2136                if c.clicked() {
2137                    cancel = true;
2138                }
2139            });
2140            if !self.status.is_empty() {
2141                ui.add_space(4.0);
2142                ui.weak(&self.status);
2143            }
2144        });
2145        if let Some(format) = chosen {
2146            if self.export_flat_pattern_as(docs, store, format) && self.mode == Mode::FlatPattern {
2147                self.open = false;
2148            }
2149        } else if cancel || modal.should_close() {
2150            self.open = false;
2151        }
2152    }
2153
2154    /// The **Open** browser: the saved-model list (+ Upload where the platform
2155    /// supports real-file interchange).
2156    fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2157        // A PLM session opens from the user's workspace, not from a list of store keys.
2158        if let Some(client) = store.plm_client() {
2159            self.show_open_workspace(ctx, docs, store, client);
2160            return;
2161        }
2162        let current = docs.active().name().map(str::to_string);
2163        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2164            file_explorer::dialog_body(ui, |ui| {
2165                ui.heading("Open model");
2166                ui.add_space(4.0);
2167                file_explorer::dialog_footer(ui, "open", |ui| {
2168                    if !self.status.is_empty() {
2169                        ui.add_space(4.0);
2170                        ui.weak(&self.status);
2171                    }
2172                });
2173                let mut options = FileExplorerOptions::open(current.as_deref());
2174                options.allow_import = store.supports_file_interchange();
2175                let output = self.explorer.show_store(ui, store, options);
2176                self.record_explorer_hits(&output.hits);
2177                output
2178            })
2179        });
2180        let should_close = modal.should_close();
2181        let output = modal.inner;
2182        if let Some(name) = output.activated {
2183            self.open_document(docs, store, &name);
2184            self.open = false;
2185        } else if let Some(name) = output.remove {
2186            let _ = store.remove(&name);
2187            self.status = format!("removed {name}");
2188            // A tab holding the removed document keeps its content but loses its
2189            // file: it becomes an untitled document, so a later Save asks where
2190            // to put it rather than silently recreating what was deleted.
2191            if let Some(index) = docs.index_of(&name) {
2192                if let Some(doc) = docs.get_mut(index) {
2193                    doc.set_name(None);
2194                }
2195            }
2196        } else if output.import {
2197            // Fire the platform picker; the file arrives via take_import() and is
2198            // loaded on a later frame (the modal closes now).
2199            match store.begin_import() {
2200                Ok(()) => {
2201                    self.status = "choose a file…".into();
2202                    self.open = false;
2203                }
2204                Err(e) => self.status = format!("import failed: {e}"),
2205            }
2206        } else if output.cancel || should_close {
2207            self.open = false;
2208        }
2209    }
2210
2211    /// The **Open** browser in a PLM session: the workspace (S14) — folders of links and
2212    /// files, other users' workspaces read-only, and Open by number. A link opens its
2213    /// part's document. Workspace changes are linked to the web app; downloads
2214    /// go out through the store's export lane.
2215    fn show_open_workspace(
2216        &mut self,
2217        ctx: &egui::Context,
2218        docs: &mut Documents,
2219        store: &dyn ModelStore,
2220        client: std::rc::Rc<crate::plm::client::PlmClient>,
2221    ) {
2222        use crate::panels::plm_workspace::{document_name, load_pins, save_pins, PlmWorkspaces};
2223        let plm = PlmWorkspaces { client };
2224        if self.workspace.pins.is_none() {
2225            self.workspace.pins = Some(load_pins(store));
2226        }
2227        self.workspace.browse_only = true;
2228        let mut cancel = false;
2229        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
2230            file_explorer::dialog_body(ui, |ui| {
2231                ui.heading("Open from the PLM");
2232                if let Some(url) = crate::panels::plm_host::web_url(store, "#/workspace") {
2233                    crate::panels::plm_host::web_link(ui, "Manage workspace in PLM", url);
2234                }
2235                ui.add_space(4.0);
2236                file_explorer::dialog_footer(ui, "open", |ui| {
2237                    if !self.status.is_empty() {
2238                        ui.add_space(4.0);
2239                        ui.weak(&self.status);
2240                    }
2241                    let button = ui.button("Cancel");
2242                    self.hits.insert("open:cancel".into(), button.rect);
2243                    cancel = button.clicked();
2244                });
2245                self.workspace.show(ui, &plm, "open:")
2246            })
2247        });
2248        let hits: Vec<(String, egui::Rect)> = self.workspace.hits.iter().map(|(k, r)| (k.clone(), *r)).collect();
2249        self.record_explorer_hits(&hits);
2250        let should_close = modal.should_close();
2251        let outcome = modal.inner;
2252        if let Some(pins) = &outcome.pins {
2253            if let Err(e) = save_pins(store, pins) {
2254                self.status = format!("could not save the pinned folders: {e}");
2255            }
2256        }
2257        if let Some((name, bytes)) = outcome.downloaded {
2258            self.status = match store.export_file_named_bytes(&name, &bytes) {
2259                Ok(()) => format!("saved {name}"),
2260                Err(e) => format!("could not save {name}: {e}"),
2261            };
2262        }
2263        if outcome.pick_file {
2264            // The chooser takes the modal over; the file comes back to
2265            // `poll_open_workspace_pick`, which reopens Open on the folder.
2266            if let Err(error) = self.request_pick_file(store, PICK_OPEN_WORKSPACE, "Add a file to this folder") {
2267                self.status = error;
2268            }
2269            return;
2270        }
2271        if let Some(key) = outcome.open {
2272            let name = document_name(store, &key);
2273            self.open_document(docs, store, &name);
2274            self.open = false;
2275        } else if cancel || should_close {
2276            self.open = false;
2277        }
2278    }
2279
2280    /// A file picked for the Open modal's workspace: added to the folder shown,
2281    /// and the Open modal comes back.
2282    fn poll_open_workspace_pick(&mut self, store: &dyn ModelStore) {
2283        let Some(file) = self.take_picked_file(PICK_OPEN_WORKSPACE) else { return };
2284        if let Some(client) = store.plm_client() {
2285            let plm = crate::panels::plm_workspace::PlmWorkspaces { client };
2286            self.workspace.add_file(&plm, &file.name, file.bytes);
2287            self.open_modal(Mode::Open);
2288        }
2289    }
2290
2291    /// The **Insert component** selector: the EXISTING parts-library entries
2292    /// first (instant re-insert — no store read), then the model store's
2293    /// document list, + Upload where the platform supports real-file
2294    /// interchange. REUSES the Open modal's machinery (the same list +
2295    /// `take_import` poll), routed to the engine's insert flow.
2296    fn show_insert_component(
2297        &mut self,
2298        ctx: &egui::Context,
2299        docs: &mut Documents,
2300        store: &dyn ModelStore,
2301    ) {
2302        let library = docs.engine_mut().parts_library_names();
2303        let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
2304            file_explorer::dialog_body(ui, |ui| {
2305                ui.heading("Insert component");
2306                ui.add_space(4.0);
2307                file_explorer::dialog_footer(ui, "insert", |ui| {
2308                    if !self.status.is_empty() {
2309                        ui.add_space(4.0);
2310                        ui.weak(&self.status);
2311                    }
2312                });
2313                let mut chosen_library = None;
2314                if !library.is_empty() {
2315                    ui.weak("In this document (parts library)");
2316                    for name in &library {
2317                        let row = ui.add_sized(
2318                            egui::vec2(ui.available_width(), 18.0),
2319                            crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
2320                                .frame(false),
2321                        );
2322                        self.hit(&format!("insert:lib:{name}"), &row);
2323                        if row.clicked() {
2324                            chosen_library = Some(name.clone());
2325                        }
2326                    }
2327                    ui.add_space(4.0);
2328                }
2329                let options = FileExplorerOptions {
2330                    hit_prefix: "insert:model",
2331                    empty_label: "(no saved models)",
2332                    row_icon: "\u{1F5CE}",
2333                    current: None,
2334                    allow_delete: false,
2335                    allow_import: store.supports_file_interchange(),
2336                    import_label: "Upload\u{2026}",
2337                    import_hit: "insert:upload",
2338                    show_cancel: true,
2339                    confirm_label: Some("Insert"),
2340                    extensions: INSERT_EXTENSIONS,
2341                };
2342                let output = self.explorer.show_store(ui, store, options);
2343                self.record_explorer_hits(&output.hits);
2344                (chosen_library, output)
2345            })
2346        });
2347        let should_close = modal.should_close();
2348        let (chosen_library, output) = modal.inner;
2349        if let Some(part_name) = chosen_library {
2350            // An already-inserted library part: skip the store read entirely.
2351            match docs.engine_mut().insert_component(ComponentInsert::Existing { part_name: &part_name }) {
2352                Ok(id) => {
2353                    self.status = format!("inserted {part_name} ({id})");
2354                    self.open = false;
2355                }
2356                Err(e) => self.status = format!("insert failed: {e}"),
2357            }
2358        } else if let Some(name) = output.activated.filter(|name| {
2359            // Only a file the list OFFERS is a component: a part document. A
2360            // highlight left by another purpose (Import's `.kicad_sym`) is not
2361            // one, and reached the parts library as a non-document.
2362            let listed = store
2363                .browser_entries(INSERT_EXTENSIONS)
2364                .iter()
2365                .any(|entry| !entry.is_dir && entry.identity == *name);
2366            if !listed {
2367                self.status = format!(
2368                    "insert refused: {name} is not a part, family or template document (.nbrep / .fbrep / .tbrep)"
2369                );
2370            }
2371            listed
2372        }) {
2373            self.request_component_document(docs, store, &name);
2374            if self.pending_insert.is_some() {
2375                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2376            }
2377        } else if output.import {
2378            match store.begin_import() {
2379                Ok(()) => {
2380                    // The picked file routes to the component insert (not Open).
2381                    self.pending_component_import = true;
2382                    self.status = "choose a part file…".into();
2383                    self.open = false;
2384                }
2385                Err(e) => self.status = format!("insert failed: {e}"),
2386            }
2387        } else if output.cancel || should_close {
2388            self.open = false;
2389        }
2390    }
2391
2392    fn request_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
2393        self.pending_insert = None;
2394        match crate::store::read_now(store, name) {
2395            crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, name, &contents),
2396            crate::store::ReadNow::Loading => {
2397                self.pending_insert = Some((name.to_owned(), docs.active_id()));
2398                self.status = format!("'{name}' is still loading");
2399            }
2400            crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2401        }
2402    }
2403
2404    fn finish_component_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str, contents: &str) {
2405        let assembly = docs.active().name().map(str::to_string);
2406        if !self.begin_class_insert(store, name, contents, assembly) {
2407            self.insert_component_document(docs.engine_mut(), name, contents);
2408            self.open = false;
2409        }
2410    }
2411
2412    fn poll_pending_insert(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2413        let Some((name, requester)) = self.pending_insert.take() else { return };
2414        if !self.open || self.mode != Mode::InsertComponent || docs.active_id() != requester {
2415            return;
2416        }
2417        match crate::store::read_now(store, &name) {
2418            crate::store::ReadNow::Ready(contents) => self.finish_component_document(docs, store, &name, &contents),
2419            crate::store::ReadNow::Loading => {
2420                self.pending_insert = Some((name, requester));
2421                ctx.request_repaint_after(std::time::Duration::from_millis(100));
2422            }
2423            crate::store::ReadNow::Absent => self.status = format!("insert failed: '{name}' not found"),
2424        }
2425    }
2426
2427    /// Insert a part DOCUMENT as an assembly component: library-add (dedup by
2428    /// sourceKey + content signature; the RETURNED effective name is what the
2429    /// instance references) + an ACOMP feature, through the engine's one insert
2430    /// flow. The first instance of an empty assembly is written `isFixed:true`.
2431    /// `sourceSignature` is written with [`document_signature`] — the ONE
2432    /// signature fn — so the update-components comparison reads a freshly
2433    /// inserted, unchanged part as up-to-date.
2434    fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
2435        self.insert_component_named(state, name, &model_display_name(name), contents);
2436    }
2437
2438    /// [`Self::insert_component_document`] under a given name: a PLM member
2439    /// is named by its part number, which its key does not carry.
2440    fn insert_component_named(&mut self, state: &mut EngineState, name: &str, display: &str, contents: &str) {
2441        let display = display.to_string();
2442        match state.insert_component(ComponentInsert::New {
2443            name: &display,
2444            source_key: name,
2445            source_signature: &document_signature(contents),
2446            document_json: contents,
2447        }) {
2448            Ok(id) => self.status = format!("inserted {display} ({id})"),
2449            Err(e) => self.status = format!("insert failed: {e}"),
2450        }
2451    }
2452
2453    // --- model operations -----------------------------------------------------
2454
2455    /// The name to save under: the field if non-empty, else the active
2456    /// document's display name, else its untitled name (`"untitled"`, `"untitled 2"`, …).
2457    fn effective_name(&self, docs: &Documents) -> String {
2458        let field = self.name_buf.trim();
2459        if !field.is_empty() {
2460            field.to_string()
2461        } else {
2462            Self::proposed_name(docs)
2463        }
2464    }
2465
2466    /// The name Save As and every export start from: the active document's
2467    /// stem, or, for an instructions tab not saved yet, the instructions'
2468    /// own title (`valve`), so its player, PDF and pictures are
2469    /// `valve.html`, `valve.pdf`, … rather than `untitled 2.html`.
2470    fn proposed_name(docs: &Documents) -> String {
2471        if docs.active().name().is_none() {
2472            if let Ok(Some(block)) = docs.engine().instructions() {
2473                let title = file_stem_of_title(&block.front.title);
2474                if !title.is_empty() {
2475                    return title;
2476                }
2477            }
2478        }
2479        document_class::strip_class_extension(&Self::document_name(docs)).to_string()
2480    }
2481
2482    /// The ACTIVE document's display name, what Save As and Export start
2483    /// their name field from: the file's stem, never the full path a saved
2484    /// document is named by (whose `/`s became `_`s in every exported file
2485    /// name), and never the field's last contents, which belong to whichever
2486    /// tab last used it. An untitled tab with a label (a file imported into
2487    /// a tab of its own, an agent's titled document) offers its label.
2488    fn document_name(docs: &Documents) -> String {
2489        docs.active().name().map(model_display_name).unwrap_or_else(|| file_stem_of_title(&docs.active().title()))
2490    }
2491
2492    /// **New** — a fresh document in a NEW TAB. Nothing is replaced, so there is
2493    /// nothing to confirm.
2494    fn new_document(&mut self, docs: &mut Documents, class: DocumentClass) {
2495        docs.open_document(Document::fresh(docs.spawn_engine(), class));
2496        self.name_buf.clear();
2497        self.status = match class {
2498            DocumentClass::Normal => "new model".into(),
2499            _ => format!("new {}", class.label().to_ascii_lowercase()),
2500        };
2501    }
2502
2503    /// Refuse a save that would give TWO open tabs the same store identity —
2504    /// "focus the tab holding this document" has no answer then, and the second
2505    /// save would silently overwrite the first tab's file. `true` = go ahead.
2506    ///
2507    /// PLM documents compare their full part/revision key in either spelling.
2508    /// Local documents compare DISPLAY names: on desktop an open document
2509    /// carries the full path it was loaded from while the Save As field holds a
2510    /// bare name, so an identity compare would never match and the clobber would
2511    /// happen before anything noticed. Two same-stemmed files in different
2512    /// folders are refused too — stricter than strictly necessary, and the side
2513    /// to err on when the alternative is overwriting another tab's file.
2514    fn name_is_free(&mut self, docs: &mut Documents, name: &str) -> bool {
2515        let display = model_display_name(name);
2516        let target_revision = crate::plm::uses::revision_key_in(name);
2517        let taken = docs.iter().enumerate().any(|(index, doc)| {
2518            index != docs.active_index()
2519                && doc
2520                    .name()
2521                    .is_some_and(|open| {
2522                        match (&target_revision, crate::plm::uses::revision_key_in(open)) {
2523                            (Some(target), Some(other)) => *target == other,
2524                            (None, None) => model_display_name(open) == display,
2525                            _ => false,
2526                        }
2527                    })
2528        });
2529        if taken {
2530            let identity = target_revision.as_deref().unwrap_or(&display);
2531            self.status = format!("Save refused: '{identity}' is already open in another tab");
2532            docs.engine_mut().push_notice(&self.status);
2533        }
2534        !taken
2535    }
2536
2537    /// Write the active document's request JSON through the store under `name`.
2538    /// Returns `true` on success (so the caller can close the modal).
2539    fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
2540        let name = name.trim().to_string();
2541        if name.is_empty() {
2542            self.status = "enter a name to save".into();
2543            return false;
2544        }
2545        if !self.name_is_free(docs, &name) {
2546            return false;
2547        }
2548        // A Work part edited in place: Save writes the ASSEMBLY (its tab),
2549        // with the part's latest state pushed into it first.
2550        docs.flush_work_part();
2551        let mut plan = docs.plan_owned_save(store, &name);
2552        // A document the Save writes whose bytes have not been read yet (the
2553        // plan's read asked for them): wait for them rather than refuse it
2554        // "still loading", which left a held part unsaved on the first Save.
2555        if !std::mem::take(&mut self.owned_loaded) {
2556            let loading: Vec<String> = plan
2557                .rows
2558                .iter()
2559                .map(|row| row.write.key.clone())
2560                .filter(|key| store.residency(key) == crate::store::Residency::OnDisk)
2561                .collect();
2562            for key in &loading {
2563                // A row the plan settled without its bytes (a checkout) asked
2564                // for none: ask now, so the wait is only as long as the load.
2565                let _ = store.read(key);
2566            }
2567            if !loading.is_empty() {
2568                self.status = format!("Reading {} part(s) from the PLM before saving…", loading.len());
2569                self.owned_loading = Some((docs.active_id(), name, loading, 0));
2570                return false;
2571            }
2572        }
2573        match top_checkout_row(docs, store, &name) {
2574            Some(row) if matches!(row.issue, Some(crate::document::owned_save::Issue::Blocked(_))) => {
2575                // The revision this tab is cannot take the Save (another user
2576                // holds it, or it is released): the changes exist only in the
2577                // tab, so offer the way to keep them instead of a dead end (owner
2578                // rule: prompts that default to proceed). Held by someone else,
2579                // the Save As starts on a new part, as a new revision would sit
2580                // beside their draft; not editable, on its next revision.
2581                let Some(crate::document::owned_save::Issue::Blocked(why)) = &row.issue else { unreachable!() };
2582                // A viewer has nowhere on the PLM to save to: Save As would
2583                // offer destinations that are all refused.
2584                if why == crate::document::owned_save::VIEW_ONLY {
2585                    self.status = format!(
2586                        "{} is not saved: {why}. Your changes are only in this tab: Export keeps a copy as a file.",
2587                        docs.active().title()
2588                    );
2589                    docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2590                    return false;
2591                }
2592                let held = why.starts_with("checked out by");
2593                self.status = format!(
2594                    "{} is not saved: {why}. Your changes are only in this tab: Save As keeps them as {}, or Save again once it can be written.",
2595                    docs.active().title(),
2596                    if held { "a new part" } else { "a new revision" }
2597                );
2598                docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, self.status.clone());
2599                let status = self.status.clone();
2600                self.member_new_part = held;
2601                self.dispatch(FileAction::SaveAs, docs, store);
2602                self.status = status;
2603                return false;
2604            }
2605            Some(row) => plan.rows.insert(0, row),
2606            None => {}
2607        }
2608        if self.hold_for_plm_items(docs, store, plan.clone(), &name, false) {
2609            return false;
2610        }
2611        if self.asks(&plan) {
2612            self.ask_owned_save(plan, name, false);
2613            return false;
2614        }
2615        self.commit_save(docs, store, name, plan)
2616    }
2617
2618    /// Whether `plan` needs the Save dialog: a question, unless the only
2619    /// questions are checkouts the user said not to ask about again.
2620    fn asks(&self, plan: &crate::document::owned_save::OwnedSavePlan) -> bool {
2621        use crate::document::owned_save::Issue;
2622        plan.rows
2623            .iter()
2624            .any(|row| row.asks() && !(self.checkout_remembered && matches!(row.issue, Some(Issue::CheckOut { newer: false, .. }))))
2625    }
2626
2627    /// A PLM assembly's parts with no document get PLM items, not files: start
2628    /// making them and hold the Save until they exist (`true`).
2629    fn hold_for_plm_items(&mut self, docs: &Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan, name: &str, browser: bool) -> bool {
2630        let Some(client) = store.plm_client() else { return false };
2631        if plan.promotions.is_empty() || crate::panels::plm_host::revision_of(name).is_none() {
2632            return false;
2633        }
2634        let current: serde_json::Value = serde_json::from_str(&docs.tab_engine().history_request_json()).unwrap_or_default();
2635        let requests = plm_promotion_requests(&current, &plan);
2636        let count = requests.len();
2637        // Linked into the workspace folder the assembly was opened from;
2638        // opened any other way (by number, from a search), the one folder of
2639        // the user's workspace that links it; else the top of the workspace.
2640        let opened = docs.active().plm_folder().cloned();
2641        let part = crate::panels::plm_host::revision_of(name).map(|(part, _)| part);
2642        let future = async move {
2643            let folder = match (opened, part) {
2644                (Some(folder), _) => Some(folder),
2645                (None, Some(part)) => crate::plm::promote::linking_folder(&client, &part).await.filter(|(id, _)| !id.is_empty()),
2646                (None, None) => None,
2647            };
2648            let keys = crate::plm::promote::make_items(client, requests, folder.as_ref().map_or(String::new(), |(id, _)| id.clone())).await?;
2649            Ok((keys, folder))
2650        };
2651        self.owned_promoting = Some((OwnedPrompt { plan, name: name.to_string(), browser }, Box::pin(future)));
2652        self.status = format!("Making PLM items for {count} part(s) with no document of their own…");
2653        true
2654    }
2655
2656    /// Continue a Save held until its documents loaded: plan it again once
2657    /// none is waiting, or after ~20 s (the Save then says which part is still
2658    /// loading, as before). A load that failed is asked again every ~2 s.
2659    fn poll_owned_loading(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2660        const LOAD_WAIT_FRAMES: u32 = 400;
2661        let Some((_, _, keys, frames)) = self.owned_loading.as_mut() else { return };
2662        if *frames < LOAD_WAIT_FRAMES && keys.iter().any(|key| store.residency(key) == crate::store::Residency::OnDisk) {
2663            *frames += 1;
2664            if *frames % 40 == 0 {
2665                for key in keys.iter() {
2666                    let _ = store.read(key);
2667                }
2668            }
2669            ctx.request_repaint_after(std::time::Duration::from_millis(50));
2670            return;
2671        }
2672        let Some((doc, name, _, _)) = self.owned_loading.take() else { return };
2673        if doc != docs.active_id() {
2674            self.status = "Save cancelled because the active document changed".into();
2675            return;
2676        }
2677        self.owned_loaded = true;
2678        self.save_to(docs, store, name);
2679    }
2680
2681    /// Continue a Save held by [`Self::hold_for_plm_items`] once its items
2682    /// exist; a failure saves nothing and says why (never into the assembly).
2683    fn poll_owned_promotions(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2684        let Some((_, future)) = self.owned_promoting.as_mut() else { return };
2685        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2686        let std::task::Poll::Ready(result) = std::future::Future::poll(future.as_mut(), &mut cx) else {
2687            ctx.request_repaint_after(std::time::Duration::from_millis(50));
2688            return;
2689        };
2690        let Some((mut prompt, _)) = self.owned_promoting.take() else { return };
2691        if prompt.plan.doc_id != docs.active_id() {
2692            self.status = "Save cancelled because the active document changed".into();
2693            return;
2694        }
2695        match result {
2696            Ok((keys, folder)) => {
2697                let place = folder.map_or_else(|| "the top of your workspace".to_string(), |(_, name)| format!("your workspace folder '{name}'"));
2698                docs.engine_mut().push_notice_as(
2699                    brep_render::engine_state::NoticeSeverity::Info,
2700                    format!("Made {} PLM item(s) for parts with no document, linked in {place}", keys.len()),
2701                );
2702                assign_plm_keys(&mut prompt.plan, &keys);
2703                if self.asks(&prompt.plan) {
2704                    self.ask_owned_save(prompt.plan, prompt.name, prompt.browser);
2705                } else if prompt.browser {
2706                    self.commit_save_browser(docs, store, prompt.name, prompt.plan);
2707                } else {
2708                    self.commit_save(docs, store, prompt.name, prompt.plan);
2709                }
2710            }
2711            Err(error) => {
2712                self.status = format!("Save stopped: the PLM could not make an item for a part with no document: {error}");
2713                docs.engine_mut().push_notice(&self.status);
2714            }
2715        }
2716    }
2717
2718    /// Hold a Save until the user answers its questions about the documents
2719    /// the assembly owns.
2720    fn ask_owned_save(&mut self, plan: crate::document::owned_save::OwnedSavePlan, name: String, browser: bool) {
2721        self.owned_state = serde_json::json!({"prompt": plan.to_json()});
2722        self.owned_prompt = Some(OwnedPrompt { plan, name, browser });
2723        self.open_modal(Mode::OwnedSave);
2724    }
2725
2726    /// Write the documents the assembly owns (with the user's answers), then
2727    /// return the Save summary to fold into the status line.
2728    fn save_owned(&mut self, docs: &mut Documents, store: &dyn ModelStore, plan: crate::document::owned_save::OwnedSavePlan) -> String {
2729        let (report, checkouts) = docs.execute_owned_save(store, plan);
2730        if let Some(client) = store.plm_client() {
2731            use crate::panels::plm::PlmLifecycle;
2732            let (revisions, checkouts): (Vec<_>, Vec<_>) = checkouts.into_iter().partition(|r| r.new_revision);
2733            for request in checkouts {
2734                let future = client.checkout(&request.part, &request.revision);
2735                self.owned_checkouts.push((request, future));
2736            }
2737            if !revisions.is_empty() {
2738                // Deepest first, as the Save planned them: each text names the
2739                // revisions made before it.
2740                let client = client.clone();
2741                let requests = revisions.clone();
2742                let future: crate::plm::PlmFuture<Vec<Result<(String, String), String>>> = Box::pin(async move {
2743                    let mut done: Vec<(String, String, String)> = Vec::new();
2744                    let mut answers = Vec::new();
2745                    for request in &requests {
2746                        let text = crate::document::owned_save::retarget_text(&request.text, &done);
2747                        match crate::panels::plm_parts::save_as_new_revision(&client, &request.part, "", &text).await {
2748                            Ok(new_key) => {
2749                                done.push((request.key.clone(), new_key.clone(), text.clone()));
2750                                answers.push(Ok((new_key, text)));
2751                            }
2752                            Err(why) => answers.push(Err(why)),
2753                        }
2754                    }
2755                    Ok(answers)
2756                });
2757                self.owned_revisions = Some(OwnedRevisions { doc: docs.active_id(), requests: revisions, future });
2758            }
2759        }
2760        self.owned_state = serde_json::json!({"report": report.to_json()});
2761        let summary = report.summary();
2762        if !summary.is_empty() {
2763            docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, format!("Save: {summary}"));
2764        }
2765        summary
2766    }
2767
2768    /// The new revisions a Save asked for, once their chain has answered:
2769    /// each one's copies re-point to it (in order, so a holder's re-point
2770    /// keeps the ones beneath it), a failed one stays marked.
2771    fn poll_owned_revisions(&mut self, docs: &mut Documents, store: &dyn ModelStore) {
2772        let Some(revisions) = self.owned_revisions.as_mut() else { return };
2773        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2774        let std::task::Poll::Ready(result) = std::future::Future::poll(revisions.future.as_mut(), &mut cx) else { return };
2775        let OwnedRevisions { doc, requests, .. } = self.owned_revisions.take().unwrap();
2776        let answers = result.unwrap_or_else(|why| requests.iter().map(|_| Err(why.clone())).collect());
2777        for (request, answer) in requests.iter().zip(answers) {
2778            match answer {
2779                Ok((new_key, text)) => match docs.retarget_revised_copies(doc, &request.key, &new_key, &text) {
2780                    Ok(()) => {
2781                        // As Save As → new revision: the mirror holds it now and
2782                        // the index learns it, or the next Save reads it absent.
2783                        store.remember_saved_document(&store.canonical_identity(&new_key), &text);
2784                        store.refresh_plm_index(std::slice::from_ref(&new_key), false);
2785                        docs.engine_mut().push_notice_as(
2786                            brep_render::engine_state::NoticeSeverity::Info,
2787                            format!(
2788                                "Made a new revision of {} and saved the change into it: {}",
2789                                request.shown,
2790                                crate::document::owned_save::shown(&new_key, &[])
2791                            ),
2792                        );
2793                        let followup = self.owned_followup.get_or_insert(OwnedFollowup { doc, keys: Vec::new(), polls: 0 });
2794                        followup.keys.push(new_key);
2795                    }
2796                    Err(why) => docs.engine_mut().push_notice(format!("'{new_key}' holds the change, but the assembly could not name it: {why}")),
2797                },
2798                Err(why) => {
2799                    docs.engine_mut().push_notice(format!("{} NOT saved: {why} — the change is kept in the assembly", request.shown));
2800                    let was_clean = docs.iter().find(|d| d.id() == doc).is_some_and(|d| !d.is_dirty());
2801                    if docs.mark_unsaved_copies(doc, &request.key, &format!("a new revision could not be made: {why}")) {
2802                        write_late_marks(docs, store, doc, was_clean);
2803                    }
2804                }
2805            }
2806        }
2807    }
2808
2809    /// Write the PLM items whose checkout landed since last frame.
2810    fn poll_owned_checkouts(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
2811        if let Some((_, _, _, future)) = self.owned_top.as_mut() {
2812            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2813            match std::future::Future::poll(future.as_mut(), &mut cx) {
2814                std::task::Poll::Ready(result) => {
2815                    if let Some((id, signature, request, _)) = self.owned_top.take() {
2816                        let saved = crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result);
2817                        if saved {
2818                            if let Some(doc) = docs.iter_mut().find(|d| d.id() == id) {
2819                                doc.mark_saved_signature(signature);
2820                                doc.set_plm_save_access(crate::document::Access::Editable);
2821                            }
2822                            self.save_generation += 1;
2823                            remember_document(store, &request.key);
2824                            self.status = format!("saved {}", request.key);
2825                        } else {
2826                            self.status = format!("{} NOT saved: the checkout or the write failed; your edits are kept", request.key);
2827                        }
2828                    }
2829                }
2830                std::task::Poll::Pending => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
2831            }
2832        }
2833        self.poll_owned_revisions(docs, store);
2834        if self.owned_checkouts.is_empty() && self.owned_revisions.is_none() {
2835            // The follow-up Save also waits for the assembly's own checkout.
2836            if self.owned_top.is_some() {
2837                return;
2838            }
2839            if let Some(followup) = self.owned_followup.as_mut() {
2840                // ~10 s at the 50 ms repaint below.
2841                const INDEX_WAIT_POLLS: u32 = 200;
2842                if followup.polls < INDEX_WAIT_POLLS && followup.keys.iter().any(|key| store.plm_revision(key).is_none()) {
2843                    followup.polls += 1;
2844                    ctx.request_repaint_after(std::time::Duration::from_millis(50));
2845                    return;
2846                }
2847            }
2848            if let Some(OwnedFollowup { doc: id, .. }) = self.owned_followup.take() {
2849                // Every new revision landed: save the assembly again so the
2850                // documents holding them (and the assembly itself) name them.
2851                if docs.active_id() == id {
2852                    if let Some(name) = docs.active().name().map(str::to_string) {
2853                        self.save_to(docs, store, name);
2854                    }
2855                } else {
2856                    docs.engine_mut().push_notice("Save the assembly again: it names the new revisions now");
2857                }
2858            }
2859            return;
2860        }
2861        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
2862        let mut i = 0;
2863        while i < self.owned_checkouts.len() {
2864            match std::future::Future::poll(self.owned_checkouts[i].1.as_mut(), &mut cx) {
2865                std::task::Poll::Ready(result) => {
2866                    let (request, _) = self.owned_checkouts.remove(i);
2867                    let failure = result.as_ref().err().cloned();
2868                    let id = docs.active_id();
2869                    if crate::document::owned_save::finish_checkout(docs.engine_mut(), store, &request, result) {
2870                        if docs.clear_unsaved_copies(id, &request.key) {
2871                            // A change kept by an earlier Save is written now; the
2872                            // assembly was written before it landed and still marks
2873                            // it: save the assembly again, as for new revisions.
2874                            self.owned_followup.get_or_insert(OwnedFollowup { doc: id, keys: Vec::new(), polls: 0 });
2875                        }
2876                    } else {
2877                        // Kept in the assembly: marked, so it reads unsaved, the
2878                        // next Save asks again and no refresh replaces it.
2879                        let why = failure.unwrap_or_else(|| "the write after the checkout failed".into());
2880                        let was_clean = docs.iter().find(|d| d.id() == id).is_some_and(|d| !d.is_dirty());
2881                        if docs.mark_unsaved_copies(id, &request.key, &format!("checkout failed: {why}")) {
2882                            write_late_marks(docs, store, id, was_clean);
2883                        }
2884                    }
2885                }
2886                std::task::Poll::Pending => i += 1,
2887            }
2888        }
2889        if !self.owned_checkouts.is_empty() || self.owned_revisions.is_some() {
2890            ctx.request_repaint_after(std::time::Duration::from_millis(50));
2891        }
2892    }
2893
2894    /// After the owned documents: a part that should have been given a file
2895    /// of its own and could not be stops the Save BEFORE the assembly is
2896    /// written — written now, it would keep that part only inside the
2897    /// assembly. Everything else already saved stays saved, the tab stays
2898    /// unsaved, and Save can simply be pressed again.
2899    fn promotion_failed(&mut self, docs: &mut Documents) -> bool {
2900        let failed: Vec<String> = self.owned_state["report"]["failedPromotions"]
2901            .as_array()
2902            .map(|keys| keys.iter().filter_map(|k| k.as_str().map(str::to_string)).collect())
2903            .unwrap_or_default();
2904        if failed.is_empty() {
2905            return false;
2906        }
2907        self.status = format!(
2908            "Save stopped: could not create {} — the assembly was not written, so no part is kept only inside it. Fix the problem and Save again.",
2909            failed.join(", ")
2910        );
2911        docs.engine_mut().push_notice(&self.status);
2912        true
2913    }
2914
2915    /// The Save itself, once nothing is left to ask: the owned documents
2916    /// first (deepest first), then the active tab.
2917    fn commit_save(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
2918        use crate::document::owned_save::{CheckoutRequest, Issue};
2919        let top = plan.rows.first().filter(|row| row.write.key == name).cloned();
2920        if let Some(row) = &top {
2921            if !row.proceed {
2922                self.status = format!("Save cancelled: {name} was not checked out");
2923                return false;
2924            }
2925        }
2926        let summary = self.save_owned(docs, store, plan);
2927        if self.promotion_failed(docs) {
2928            return false;
2929        }
2930        let document = docs.tab_engine().history_request_json();
2931        if let (Some(row), Some(client)) = (top, store.plm_client()) {
2932            if let Some(Issue::CheckOut { part, revision, .. }) = row.issue {
2933                use crate::panels::plm::PlmLifecycle;
2934                let signature = docs.active().save_signature();
2935                let future = client.checkout(&part, &revision);
2936                let shown = crate::document::owned_save::shown(&name, &[]);
2937                let request = CheckoutRequest { key: name.clone(), part, revision, text: document, shown: shown.clone(), new_revision: false };
2938                self.owned_top = Some((docs.active_id(), signature, request, future));
2939                self.status = if summary.is_empty() {
2940                    format!("checking out and saving {shown}")
2941                } else {
2942                    format!("checking out and saving {shown}; {summary}")
2943                };
2944                return true;
2945            }
2946        }
2947        crate::plm::thumbnail::stage_save(store, &name, &document, docs.tab_engine(), docs.active_id());
2948        match store.write(&name, &document) {
2949            Ok(()) => {
2950                // The document keeps the raw identity (a full path when a
2951                // native dialog chose it); the name field shows the bare name.
2952                self.name_buf = model_display_name(&name);
2953                let doc = docs.active_mut();
2954                doc.set_name(Some(name.clone()));
2955                doc.mark_clean();
2956                self.save_generation += 1;
2957                remember_document(store, &name);
2958                self.status = if summary.is_empty() { format!("saved {name}") } else { format!("saved {name}; {summary}") };
2959                true
2960            }
2961            Err(e) => {
2962                self.status = format!("save failed: {e}");
2963                docs.engine_mut().push_notice(&self.status);
2964                false
2965            }
2966        }
2967    }
2968
2969    /// Save As through the explorer's current directory, then retain the
2970    /// backend's returned identity (an absolute path on desktop or a virtual
2971    /// `/models/...` path in the browser) for subsequent plain Save commands.
2972    fn save_to_browser(
2973        &mut self,
2974        docs: &mut Documents,
2975        store: &dyn ModelStore,
2976        name: String,
2977    ) -> bool {
2978        let name = name.trim().to_string();
2979        if name.is_empty() {
2980            self.status = "enter a name to save".into();
2981            return false;
2982        }
2983        if !self.name_is_free(docs, &name) {
2984            return false;
2985        }
2986        // Save As makes a NEW file: its class is the one the name carries
2987        // (the chooser put it there), the blocks of the other classes go, and
2988        // a family member's stamp goes — the copy is a part of its own, which
2989        // is what "Save as a new part" means.
2990        docs.flush_work_part();
2991        let plan = docs.plan_owned_save(store, &name);
2992        if self.hold_for_plm_items(docs, store, plan.clone(), &name, true) {
2993            return false;
2994        }
2995        if plan.needs_asking() {
2996            self.ask_owned_save(plan, name, true);
2997            return false;
2998        }
2999        self.commit_save_browser(docs, store, name, plan)
3000    }
3001
3002    /// [`Self::save_to_browser`] once nothing is left to ask.
3003    fn commit_save_browser(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String, plan: crate::document::owned_save::OwnedSavePlan) -> bool {
3004        let summary = self.save_owned(docs, store, plan);
3005        if self.promotion_failed(docs) {
3006            return false;
3007        }
3008        let class = DocumentClass::of_name(&name).unwrap_or_default();
3009        adopt_class(docs.tab_engine_mut(), class);
3010        // The copy remembers what it was saved from (before the family stamp
3011        // goes: the chain keeps the family too), so Replace component knows
3012        // an edited variation is still that part's relative.
3013        stamp_save_as_lineage(docs, store, &name);
3014        if docs.tab_engine().history.document_block(family_table::FAMILY_SOURCE_KEY).is_some() {
3015            docs.tab_engine_mut()
3016                .history
3017                .set_document_block_no_undo(family_table::FAMILY_SOURCE_KEY, None);
3018        }
3019        let previous = docs.active().name().map(str::to_string);
3020        let saved_text = docs.tab_engine().history_request_json();
3021        match store.browser_write(&name, &saved_text) {
3022            Ok(identity) => {
3023                // Open assemblies that place this part under its OLD name:
3024                // ask once whether they follow it to the new file.
3025                if let Some(old) = previous.filter(|old| store.canonical_identity(old) != store.canonical_identity(&identity)) {
3026                    let tabs = docs.tabs_holding(&old, docs.active_id());
3027                    if !tabs.is_empty() {
3028                        self.retarget = Some(Retarget { old, new_key: identity.clone(), text: saved_text.clone(), tabs });
3029                    }
3030                }
3031                // The recent list's picture, under the identity the backend
3032                // chose (the name the recent row will carry).
3033                crate::plm::thumbnail::stage_recent(&identity, docs.tab_engine(), docs.active_id());
3034                self.name_buf = model_display_name(&identity);
3035                let doc = docs.active_mut();
3036                self.member_ack.remove(&doc.id());
3037                doc.set_name(Some(identity.clone()));
3038                doc.mark_clean();
3039                self.save_generation += 1;
3040                remember_document(store, &identity);
3041                self.status = if summary.is_empty() { format!("saved {identity}") } else { format!("saved {identity}; {summary}") };
3042                true
3043            }
3044            Err(e) => {
3045                self.status = format!("save failed: {e}");
3046                false
3047            }
3048        }
3049    }
3050
3051    /// **Open** — read a stored document into its own tab (or focus the tab
3052    /// already holding it). The one door every open lane uses: File>Open, the
3053    /// Edit-Part flow, and the session restore's siblings.
3054    pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
3055        self.pending_open = None;
3056        if docs.focus_named(name) {
3057            remember_document(store, name);
3058            self.status = format!("{name} is already open");
3059            return;
3060        }
3061        match crate::store::read_now(store, name) {
3062            crate::store::ReadNow::Ready(contents) => self.load_document(docs, name, &contents, store),
3063            // An index-hydrated PLM store lists what it has not loaded yet;
3064            // the read above asked for it.
3065            crate::store::ReadNow::Loading => {
3066                self.pending_open = Some((name.to_owned(), docs.active_id()));
3067                self.status = format!("'{name}' is still loading");
3068            }
3069            crate::store::ReadNow::Absent => self.status = format!("open failed: '{name}' not found"),
3070        }
3071    }
3072
3073    /// Complete the common File>Open / Edit-Part demand read on a later frame.
3074    /// Store reads are already deduplicated while a request is in flight.
3075    fn poll_pending_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3076        let Some((name, requester)) = self.pending_open.take() else { return };
3077        if docs.active_id() != requester {
3078            return;
3079        }
3080        if docs.focus_named(&name) {
3081            self.status = format!("{name} is already open");
3082            return;
3083        }
3084        match crate::store::read_now(store, &name) {
3085            crate::store::ReadNow::Ready(contents) => {
3086                self.load_document(docs, &name, &contents, store);
3087            }
3088            crate::store::ReadNow::Loading => {
3089                self.pending_open = Some((name, requester));
3090                ctx.request_repaint_after(std::time::Duration::from_millis(100));
3091            }
3092            crate::store::ReadNow::Absent => {
3093                self.status = format!("open failed: '{name}' not found");
3094            }
3095        }
3096    }
3097
3098    /// Serialize the current model in `format` (`"step"` | `"iges"` | `"stl"` |
3099    /// `"obj"` | `"glb"` | `"3mf"` | `"json"`) and
3100    /// write it through the store's format-typed interchange under `<name>.<ext>`.
3101    /// `"json"` is the full model RECIPE (`.nbrep`, `history_request_json`) —
3102    /// the exact document Open/Import consume, for sharing a failing model. Returns
3103    /// `true` on success (so the caller can close the modal); a guard message and
3104    /// `false` when there is nothing to export or the engine/store errs.
3105    /// An export of the geometry taken while a family row is previewed is of
3106    /// THAT row: say so on the status line and in a toast, so a user does not
3107    /// ship the wrong size without knowing.
3108    fn say_previewed_export(&mut self, docs: &mut Documents) {
3109        let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) else {
3110            return;
3111        };
3112        let note = format!("exported {label} (preview): the family's own values are not in this file");
3113        self.status = format!("{} — {label} (preview)", self.status);
3114        docs.engine_mut().push_notice(note);
3115    }
3116
3117    /// The line the export dialogs show while a family row is previewed.
3118    fn preview_export_line(ui: &mut egui::Ui, docs: &Documents) {
3119        if let Some(label) = docs.engine().expression_preview().map(|p| p.label.clone()) {
3120            let text = format!(
3121                "Previewing {label}: STEP, IGES, meshes, drawings and flat patterns export {label}, \
3122                 not the family's own values. The .nbrep recipe keeps the family's own."
3123            );
3124            ui.add(egui::Label::new(egui::RichText::new(text).color(ui.visuals().warn_fg_color)).wrap());
3125            ui.add_space(4.0);
3126        }
3127    }
3128
3129    fn poll_instructions_pdf(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3130        let Some(mut export) = self.instructions_pdf.take() else { return };
3131        if export.document != docs.view_id() {
3132            self.status = "PDF export cancelled: the active document changed".into();
3133            docs.engine_mut().push_notice(self.status.clone());
3134            return;
3135        }
3136        match docs.engine_mut().poll_instructions_pdf(&mut export.job) {
3137            Ok(None) => {
3138                self.instructions_pdf = Some(export);
3139                ctx.request_repaint_after(std::time::Duration::from_millis(16));
3140            }
3141            Ok(Some((bytes, pages))) => {
3142                let pending = PendingSave {
3143                    request: SaveRequest::bytes("Export the instructions", format!("{}.pdf", export.name), bytes),
3144                    detail: format!(" ({pages} page{})", if pages == 1 { "" } else { "s" }),
3145                    label: None, warnings: vec![], preview: export.preview,
3146                };
3147                self.deliver(docs, store, pending);
3148            }
3149            Err(error) => {
3150                self.status = format!("PDF export failed: {error}");
3151                docs.engine_mut().push_notice(self.status.clone());
3152            }
3153        }
3154    }
3155
3156    fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
3157        let name = self.effective_name(docs);
3158        // The `.nbrep` recipe is the document; every other format is of the
3159        // geometry, which a family-row preview has replaced — said once the
3160        // file is delivered.
3161        let preview = format != "json";
3162        let export = |request: SaveRequest| PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview };
3163        // GLB and 3MF are BINARY, so they take the store's byte-shaped
3164        // interchange rather than the text one below — a `.glb` routed through
3165        // a `&str` would be mangled the moment a chunk length happened not to
3166        // be valid UTF-8, and a 3MF is a ZIP.
3167        // The offline player: one page, named for the document, the tools'
3168        // pictures fetched from the library and inlined (the door is held
3169        // while they load; without a session the page carries none).
3170        if format == "instructionshtml" {
3171            let ids = linked_tool_ids(docs.engine());
3172            if let Some(client) = store.plm_client().filter(|_| !ids.is_empty()) {
3173                crate::plm::tool_pictures::request(&client, &ids);
3174                if crate::plm::tool_pictures::pending_of(&ids) > 0 {
3175                    self.pending_instructions_export = Some(format.to_string());
3176                    self.status = "loading the tool pictures for the export…".into();
3177                    return true;
3178                }
3179            }
3180            let made = docs.engine().instructions_player_data().map(|mut data| {
3181                data.inline_tool_pictures(&crate::plm::tool_pictures::fetched(&ids));
3182                brep_instructions::player::offline_html(&data, brep_instructions::player::Mode::Shop)
3183            });
3184            return match made {
3185                Ok(html) => {
3186                    let pending = export(SaveRequest::text("Export the offline player", format!("{name}.html"), html));
3187                    self.deliver(docs, store, pending) != Delivery::Failed
3188                }
3189                Err(e) => {
3190                    self.status = format!("export failed: {e}");
3191                    false
3192                }
3193            };
3194        }
3195        // The instructions' PDF and their step pictures: byte formats, named
3196        // for the document (`<name>.pdf`, `<name>-steps.zip`).
3197        if format == "instructionspdf" {
3198            if self.instructions_pdf.is_some() {
3199                self.status = "an instructions PDF is already being prepared".into();
3200                return false;
3201            }
3202            return match docs.engine_mut().begin_instructions_pdf() {
3203                Ok(job) => {
3204                    self.instructions_pdf = Some(PendingInstructionsPdf { document: docs.view_id(), name, preview, job });
3205                    self.status = "preparing the instructions PDF…".into();
3206                    true
3207                }
3208                Err(error) => { self.status = format!("export failed: {error}"); false }
3209            };
3210        }
3211        if format == "instructionsimages" {
3212            // The pictures are drawn from the exact solids: asked of the
3213            // runner first, and on a runner that cannot be waited on the
3214            // export is held until they land.
3215            if !docs.engine_mut().instructions_topology_ready() {
3216                self.pending_instructions_export = Some(format.to_string());
3217                self.status = "loading the exact geometry for the export…".into();
3218                return true;
3219            }
3220            let made = super::instructions::images_zip(docs.engine_mut()).map(|bytes| (bytes, String::new()));
3221            return match made {
3222                Ok((bytes, detail)) => {
3223                    let (title, file) = ("Export the step images", format!("{name}-steps.zip"));
3224                    let pending = PendingSave { detail, ..export(SaveRequest::bytes(title, file, bytes)) };
3225                    self.deliver(docs, store, pending) != Delivery::Failed
3226                }
3227                Err(e) => {
3228                    self.status = format!("export failed: {e}");
3229                    false
3230                }
3231            };
3232        }
3233        if format == "glb" || format == "3mf" {
3234            // The document's name becomes the root NODE's (GLB) or the model's
3235            // Title (3MF), as it becomes the root PRODUCT's on a STEP export.
3236            let written = if format == "glb" {
3237                docs.engine().export_glb_bytes(&name)
3238            } else {
3239                docs.engine().export_3mf_bytes(&name)
3240            };
3241            return match written {
3242                Ok(bytes) => {
3243                    let title = if format == "glb" { "Export GLB" } else { "Export 3MF" };
3244                    let request = SaveRequest::bytes(title, format!("{name}.{format}"), bytes);
3245                    self.deliver(docs, store, export(request)) != Delivery::Failed
3246                }
3247                Err(e) => {
3248                    self.status = format!("export failed: {e}");
3249                    false
3250                }
3251            };
3252        }
3253        let state = docs.engine_mut();
3254        // A drawing sheet's SVG writes `<name>-<sheet>.svg`: a document can
3255        // carry several sheets, and one file per export must not overwrite the
3256        // last. The PDF is the whole drawing SET, one page per sheet, so it is
3257        // the document's own `<name>.pdf`. It takes the BYTE lane — it is a
3258        // byte format, and both lanes type the file from its extension, so the
3259        // user gets `application/pdf` either way.
3260        // The 3D drawing set is `<name>-3d.pdf`, beside the plain one rather
3261        // than over it.
3262        if format == "sheetsvg" || format == "sheetpdf" || format == "sheetpdf3d" {
3263            let pdf = format != "sheetsvg";
3264            let three_d = format == "sheetpdf3d";
3265            let (file, made) = if pdf {
3266                let file = if three_d { format!("{name}-3d.pdf") } else { format!("{name}.pdf") };
3267                let bytes = if three_d { state.export_document_pdf_3d() } else { state.export_document_pdf() };
3268                (file, bytes.map(SaveBody::Bytes))
3269            } else {
3270                let sheet_name = state
3271                    .resolve_sheet("")
3272                    .ok()
3273                    .and_then(|id| state.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()))
3274                    .unwrap_or_default();
3275                let file = format!("{name}-{}.svg", slug(&sheet_name));
3276                let text = state.resolve_sheet("").and_then(|id| state.export_sheet_svg(&id));
3277                (file, text.map(SaveBody::Text))
3278            };
3279            let pages = state.sheet_state().sheets.len() + usize::from(three_d);
3280            return match made {
3281                Ok(body) => {
3282                    let detail = if pdf {
3283                        format!(" ({pages} page{})", if pages == 1 { "" } else { "s" })
3284                    } else {
3285                        String::new()
3286                    };
3287                    let title = if pdf { "Export the drawing set" } else { "Export the drawing sheet" };
3288                    let request = SaveRequest { title: title.into(), file_name: file, body };
3289                    let pending = PendingSave { detail, ..export(request) };
3290                    self.deliver(docs, store, pending) != Delivery::Failed
3291                }
3292                Err(e) => {
3293                    self.status = format!("export failed: {e}");
3294                    false
3295                }
3296            };
3297        }
3298        let (text, ext) = match format {
3299            "stl" => (state.export_stl_text(), "stl"),
3300            "obj" => (state.export_obj_text(), "obj"),
3301            "iges" => (state.export_iges_text_selected(&name, None), "igs"),
3302            "json" => (Ok(state.history_request_json()), "nbrep"),
3303            // The document's name becomes the root PRODUCT's — an assembly
3304            // exports as `<name>` with its parts named under it.
3305            _ => (state.export_step_text_named(&name), "step"),
3306        };
3307        match text {
3308            Ok(contents) => {
3309                let title = if format == "json" { "Export the model".to_string() } else { format!("Export {}", ext.to_ascii_uppercase()) };
3310                let request = SaveRequest::text(title, format!("{name}.{ext}"), contents);
3311                self.deliver(docs, store, export(request)) != Delivery::Failed
3312            }
3313            Err(e) => {
3314                self.status = format!("export failed: {e}");
3315                false
3316            }
3317        }
3318    }
3319
3320    /// Hand a produced export to the user — the one door every export takes.
3321    /// Desktop (a store with local files): the Save-file chooser, which the
3322    /// modal switches to; the write happens on its Save. Browser: the
3323    /// download, now. What a written export says (status, toasts, the
3324    /// preview note) is [`Self::say_written`], on both roads.
3325    fn deliver(&mut self, docs: &mut Documents, store: &dyn ModelStore, pending: PendingSave) -> Delivery {
3326        if let Some(local) = store.local_files() {
3327            self.save_name = pending.request.file_name.clone();
3328            self.save_file = Some(pending);
3329            self.use_folder(Purpose::Exports, local);
3330            self.status.clear();
3331            self.open_modal(Mode::SaveFile);
3332            return Delivery::Chooser;
3333        }
3334        let file = pending.request.file_name.clone();
3335        let written = match &pending.request.body {
3336            SaveBody::Text(text) => store.export_file_named(&file, text),
3337            SaveBody::Bytes(bytes) => store.export_file_named_bytes(&file, bytes),
3338        };
3339        match written {
3340            Ok(()) => {
3341                self.say_written(docs, &pending, &file, None);
3342                Delivery::Done
3343            }
3344            Err(e) => {
3345                self.status = match &pending.label {
3346                    Some(label) => format!("{label} export failed: {e}"),
3347                    None => format!("export failed: {e}"),
3348                };
3349                Delivery::Failed
3350            }
3351        }
3352    }
3353
3354    /// What a written export says: the status line ("exported <file>
3355    /// [detail] [into <folder>]"), the family-preview note, and the toast —
3356    /// a labelled export's own (the PCB outputs name themselves: "Netlist:
3357    /// …"), a warning one carrying the bundle's warnings, or on desktop,
3358    /// where the chooser closes on the write and the status with it, the
3359    /// generic "exported … into …". Returns whether the dialog should STAY
3360    /// open: it does on warnings, so they are read before boards are ordered.
3361    fn say_written(&mut self, docs: &mut Documents, pending: &PendingSave, file: &str, folder: Option<&str>) -> bool {
3362        let into = folder.map(|folder| format!(" into {folder}")).unwrap_or_default();
3363        self.status = format!("exported {file}{}{into}", pending.detail);
3364        if pending.preview {
3365            self.say_previewed_export(docs);
3366        }
3367        use brep_render::engine_state::NoticeSeverity;
3368        let state = docs.engine_mut();
3369        match &pending.label {
3370            Some(label) if !pending.warnings.is_empty() => {
3371                let warnings = pending.warnings.join(" ");
3372                self.status = format!("{}. Before ordering: {warnings}", self.status);
3373                state.push_notice_as(NoticeSeverity::Warning, format!("{label}: {warnings}"));
3374                true
3375            }
3376            Some(label) => {
3377                state.push_notice_as(NoticeSeverity::Info, format!("{label}: {}", self.status));
3378                false
3379            }
3380            None => {
3381                if folder.is_some() {
3382                    state.push_notice_as(NoticeSeverity::Info, self.status.clone());
3383                }
3384                false
3385            }
3386        }
3387    }
3388
3389    /// A panel's export (the JavaScript editor's script, a plugin bundle, a
3390    /// PLM download): hand `request` to the user through the export door.
3391    /// Desktop: the Save-file chooser opens. Browser: the download, now.
3392    /// `Err` only when the download could not start.
3393    pub fn request_save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore, request: SaveRequest) -> Result<(), String> {
3394        let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3395        match self.deliver(docs, store, pending) {
3396            Delivery::Failed => Err(std::mem::take(&mut self.status)),
3397            Delivery::Done | Delivery::Chooser => Ok(()),
3398        }
3399    }
3400
3401    /// The **Save-file** chooser ([`Mode::SaveFile`]): the explorer over the
3402    /// local files picks the FOLDER, the field the NAME, and Save writes the
3403    /// held export there. Shaped like Save As (the name field and buttons
3404    /// pinned under the browser), over the exports' own remembered folder,
3405    /// which starts in the models folder.
3406    fn show_save_file(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3407        let Some(local) = store.local_files() else {
3408            self.save_file = None;
3409            self.open = false;
3410            return;
3411        };
3412        let title = self.save_file.as_ref().map(|pending| pending.request.title.clone()).unwrap_or_else(|| "Save file".into());
3413        let pending = self.save_file.is_some();
3414        // The explorer lists the files of the export's own kind beside the
3415        // folders, so a name to overwrite can be picked; a name without an
3416        // extension lists every file.
3417        let extension = std::path::Path::new(self.save_name.trim())
3418            .extension()
3419            .map(|extension| extension.to_string_lossy().into_owned());
3420        let extensions: Vec<&str> = extension.iter().map(String::as_str).collect();
3421        let mut do_save = false;
3422        let mut cancel = false;
3423        let modal = egui::Modal::new(egui::Id::new("brep-file-savefile")).show(ctx, |ui| {
3424            file_explorer::dialog_body(ui, |ui| {
3425                ui.heading(&title);
3426                ui.add_space(4.0);
3427                file_explorer::dialog_footer(ui, "savefile", |ui| {
3428                    ui.label("File name");
3429                    let field = ui.add(
3430                        egui::TextEdit::singleline(&mut self.save_name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
3431                            .hint_text("file name")
3432                            .desired_width(f32::INFINITY),
3433                    );
3434                    self.hit("savefile:name", &field);
3435                    if self.want_focus {
3436                        field.request_focus();
3437                        self.want_focus = false;
3438                    }
3439                    let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
3440                    ui.add_space(6.0);
3441                    ui.horizontal(|ui| {
3442                        // Written already (the chooser stays up on a warning):
3443                        // nothing is left to save, only to read and close.
3444                        let save = ui.add_enabled(pending, egui::Button::new("Save"));
3445                        self.hit("save", &save);
3446                        if save.clicked() || (enter && pending) {
3447                            do_save = true;
3448                        }
3449                        let c = ui.button(if pending { "Cancel" } else { "Close" });
3450                        self.hit("cancel", &c);
3451                        if c.clicked() {
3452                            cancel = true;
3453                        }
3454                    });
3455                    if !self.status.is_empty() {
3456                        ui.add_space(4.0);
3457                        ui.weak(&self.status);
3458                    }
3459                });
3460                let options = FileExplorerOptions {
3461                    hit_prefix: "savefile",
3462                    empty_label: "(no files of this kind here)",
3463                    row_icon: "\u{1F5CE}",
3464                    current: None,
3465                    allow_delete: false,
3466                    allow_import: false,
3467                    import_label: "",
3468                    import_hit: "savefile:upload",
3469                    show_cancel: false,
3470                    confirm_label: None,
3471                    extensions: &extensions,
3472                };
3473                let output = self.explorer.show_store(ui, local, options);
3474                self.record_explorer_hits(&output.hits);
3475                // A row fills the name field (to overwrite that file); the
3476                // row's identity is its full path on desktop, so just its
3477                // name. One-shot, so it never clobbers a name typed after.
3478                if let Some(identity) = output.picked.or(output.activated) {
3479                    self.save_name = std::path::Path::new(&identity)
3480                        .file_name()
3481                        .map(|name| name.to_string_lossy().into_owned())
3482                        .unwrap_or(identity);
3483                    ui.ctx().request_repaint();
3484                }
3485            })
3486        });
3487        if do_save {
3488            if self.save_file(docs, store) {
3489                self.open = false;
3490            }
3491        } else if cancel || modal.should_close() {
3492            if self.save_file.take().is_some() {
3493                self.status = "export cancelled: nothing written".into();
3494            }
3495            self.open = false;
3496        }
3497    }
3498
3499    /// The Save-file chooser's Save: write the held export under the name
3500    /// field, where the explorer is, and remember that folder for the next
3501    /// export. `true` once written, so the chooser closes — unless the export
3502    /// carried warnings, which keep it open to be read (what the format
3503    /// dialog did before the chooser existed).
3504    pub(crate) fn save_file(&mut self, docs: &mut Documents, store: &dyn ModelStore) -> bool {
3505        let Some(local) = store.local_files() else {
3506            return false;
3507        };
3508        let name = self.save_name.trim().to_string();
3509        if name.is_empty() {
3510            self.status = "enter a file name".into();
3511            return false;
3512        }
3513        let Some(pending) = self.save_file.take() else {
3514            return false;
3515        };
3516        match local.browser_write_bytes(&name, pending.request.body.as_bytes()) {
3517            Ok(path) => {
3518                self.folders.insert(Purpose::Exports, local.browser_location());
3519                let path = std::path::Path::new(&path);
3520                let folder = path.parent().map(|folder| folder.display().to_string()).unwrap_or_else(|| local.browser_location());
3521                let file = path.file_name().map(|file| file.to_string_lossy().into_owned()).unwrap_or(name);
3522                !self.say_written(docs, &pending, &file, Some(&folder))
3523            }
3524            Err(e) => {
3525                self.status = format!("export failed: {e}");
3526                self.save_file = Some(pending);
3527                false
3528            }
3529        }
3530    }
3531
3532    /// Serialize the sheet-metal flat pattern in `format` (`"dxf"` | `"svg"`) and
3533    /// write it through the store under `<name>.<ext>`. Returns `true` on success
3534    /// (so the caller can close the modal). On failure the message goes to the
3535    /// status line AND is queued as a toast (the existing engine notice path), so
3536    /// a "no sheet-metal body in the part" error is surfaced prominently.
3537    fn export_flat_pattern_as(
3538        &mut self,
3539        docs: &mut Documents,
3540        store: &dyn ModelStore,
3541        format: &str,
3542    ) -> bool {
3543        let name = self.effective_name(docs);
3544        let state = docs.engine_mut();
3545        let (text, ext) = match format {
3546            "svg" => (state.export_flat_pattern_svg(), "svg"),
3547            _ => (state.export_flat_pattern_dxf(), "dxf"),
3548        };
3549        match text {
3550            Ok(contents) => {
3551                let request = SaveRequest::text("Export the flat pattern", format!("{name}.{ext}"), contents);
3552                let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: true };
3553                self.deliver(docs, store, pending) != Delivery::Failed
3554            }
3555            Err(e) => {
3556                self.status = format!("flat-pattern export failed: {e}");
3557                state.push_notice(format!("Flat pattern: {e}"));
3558                false
3559            }
3560        }
3561    }
3562
3563    /// Serialize the assembly BOM in `format` (`"csv"` | `"json"`) and write it
3564    /// through the store under `<name>.bom.<ext>` (a compound extension naming
3565    /// the content, like the `.nbrep` recipe). On failure the message goes
3566    /// to the status line AND is queued as a toast — the flat-pattern error
3567    /// pattern — so a componentless document reports loudly.
3568    fn export_bom_as(
3569        &mut self,
3570        docs: &mut Documents,
3571        store: &dyn ModelStore,
3572        format: &str,
3573    ) -> bool {
3574        let name = self.effective_name(docs);
3575        let state = docs.engine_mut();
3576        let (text, ext) = match format {
3577            "json" => (state.export_bom_json(), "bom.json"),
3578            _ => (state.export_bom_csv(), "bom.csv"),
3579        };
3580        match text {
3581            Ok(contents) => {
3582                let request = SaveRequest::text("Export the bill of materials", format!("{name}.{ext}"), contents);
3583                let pending = PendingSave { request, detail: String::new(), label: None, warnings: vec![], preview: false };
3584                self.deliver(docs, store, pending) != Delivery::Failed
3585            }
3586            Err(e) => {
3587                self.status = format!("BOM export failed: {e}");
3588                state.push_notice(format!("BOM export: {e}"));
3589                false
3590            }
3591        }
3592    }
3593
3594    /// Write one of the PCB's manufacturing outputs through the store under
3595    /// [`Output::file_name`](super::fabrication_export::Output::file_name).
3596    /// The bundle's warnings (design-rule findings, silkscreen characters
3597    /// the font lacks) keep the dialog OPEN with the warnings on its status
3598    /// line, and go to a toast: the files are complete, but the user should
3599    /// read why before ordering boards. Returns `true` to close the dialog.
3600    fn export_fabrication_as(
3601        &mut self,
3602        docs: &mut Documents,
3603        store: &dyn ModelStore,
3604        output: super::fabrication_export::Output,
3605    ) -> bool {
3606        use super::fabrication_export::{self as fab, Output};
3607        let name = self.effective_name(docs);
3608        let file = output.file_name(&name);
3609        let state = docs.engine_mut();
3610        let made = match output {
3611            Output::Bundle => fab::bundle(state, &name).map(|bundle| {
3612                let detail = format!(" ({} files)", bundle.files.len());
3613                (SaveBody::Bytes(bundle.zip()), detail, bundle.warnings)
3614            }),
3615            _ => fab::text(state, output, &name).map(|text| (SaveBody::Text(text), String::new(), vec![])),
3616        };
3617        match made {
3618            Ok((body, detail, warnings)) => {
3619                // The output names itself in its toast (Netlist, Pick & place,
3620                // BOM, Fabrication), and the chooser's status says WHERE the
3621                // files went: on desktop that is the folder the user chose.
3622                let request = SaveRequest { title: format!("Export: {}", output.label()), file_name: file, body };
3623                let pending = PendingSave { request, detail, label: Some(output.label().to_string()), warnings, preview: false };
3624                match self.deliver(docs, store, pending) {
3625                    Delivery::Done => !self.status.contains("Before ordering"),
3626                    Delivery::Chooser => true,
3627                    Delivery::Failed => {
3628                        docs.engine_mut().push_notice(format!("{} export: {}", output.label(), self.status));
3629                        false
3630                    }
3631                }
3632            }
3633            Err(e) => {
3634                self.status = format!("{} export failed: {e}", output.label());
3635                state.push_notice(format!("{} export: {e}", output.label()));
3636                false
3637            }
3638        }
3639    }
3640
3641    /// Route an imported STEP file: **probe first**. The probe IS the parse —
3642    /// it stashes the structure in the engine for the import to consume — so a
3643    /// structured file arms the choice modal and is imported on the click,
3644    /// never parsed a second time.
3645    ///
3646    /// Everything else goes straight to today's flat lane, unchanged:
3647    ///
3648    /// * `Ok(None)` — no product structure. A part file must NEVER see the
3649    ///   dialog, and its behaviour stays byte-for-byte what it was.
3650    /// * `Err` — text the Part 21 parser refuses. Handing it to the flat lane
3651    ///   keeps the failure wording exactly today's (the two share the same
3652    ///   `ISO-10303-21` guard), rather than inventing a second one.
3653    fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3654        // A previous file's armed choice cannot survive this one: the probe
3655        // below REPLACES the engine's stash on every outcome (including the
3656        // structureless one), so an app-side pending left standing would offer
3657        // a button whose parse is already gone. A probe still running for an
3658        // earlier upload is superseded the same way (its answer is ignored).
3659        self.pending_step_import = None;
3660        let id = state.submit_step_probe(contents);
3661        self.pending_step_probe = Some(PendingStepProbe {
3662            id,
3663            name: name.to_string(),
3664            text: contents.to_string(),
3665        });
3666        self.status = format!("reading \"{name}\"\u{2026}");
3667        // The synchronous Inline runner (tests, headless) has answered inside
3668        // the submit; a background runner answers on a later frame's poll.
3669        self.poll_step_probe(state);
3670    }
3671
3672    /// Resolve an answered STEP probe: structure arms the §3.9 choice, no
3673    /// structure (or a parse the flat lane will refuse with its own wording)
3674    /// takes the flat lane. A probe the engine no longer holds — a document
3675    /// switch or a cancelled run dropped it — is reported and forgotten.
3676    fn poll_step_probe(&mut self, state: &mut EngineState) {
3677        while let Some((id, outcome)) = state.take_step_probe() {
3678            let Some(pending) = self.pending_step_probe.as_ref() else {
3679                continue;
3680            };
3681            if pending.id != id {
3682                continue; // an earlier upload's answer; a newer probe replaced it
3683            }
3684            let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
3685            match outcome {
3686                StepProbeOutcome::Structure(probe) => {
3687                    self.status = format!(
3688                        "\"{name}\" contains an assembly — {} part{}, {} instance{}",
3689                        probe.parts,
3690                        plural(probe.parts),
3691                        probe.instances,
3692                        plural(probe.instances)
3693                    );
3694                    self.pending_step_import = Some(PendingStepImport {
3695                        name,
3696                        text,
3697                        probe,
3698                        // Default = keep the tree (§3.9). Flattening is the escape
3699                        // hatch for a deep or pathological file, not the norm.
3700                        flatten: false,
3701                    });
3702                    // The routing site's `close_after_import` ran frames ago,
3703                    // while the probe was still out, and closed the modal: the
3704                    // choice is raised HERE, when the answer lands. (Under the
3705                    // synchronous Inline runner both run in the same call, and
3706                    // the second open is idempotent.)
3707                    self.open_modal(Mode::StepAssembly);
3708                }
3709                StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
3710                    self.import_step_flat(state, &name, &text);
3711                }
3712            }
3713        }
3714        if self.pending_step_probe.is_some() && !state.step_probes_pending() {
3715            // Nothing is running and no answer came: the engine dropped the
3716            // probe (document switch / cancel). Say so rather than reading
3717            // "reading…" forever.
3718            let pending = self.pending_step_probe.take().unwrap();
3719            self.status = format!("import of \"{}\" was cancelled", pending.name);
3720        }
3721    }
3722
3723    /// Append an imported STEP file to the model (an IMPORT3D feature), then treat
3724    /// the enlarged model as dirty (an import is an edit, not an Open — the model
3725    /// keeps its current name / save baseline). THE flat lane, reached from a
3726    /// structureless file, an unparseable one, and the dialog's "Import as
3727    /// bodies".
3728    fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
3729        // A STEP or an IGES text alike: the probe routes both.
3730        match state.import_model_text_feature(contents) {
3731            // Framing is deferred to the engine (`pending_fit`): under the native
3732            // thread / wasm worker runner the body is not resident yet, so framing
3733            // here would frame the empty scene. See [`EngineState::pending_fit`].
3734            Ok(_) => self.status = format!("imported {name}"),
3735            Err(e) => self.status = format!("import failed: {e}"),
3736        }
3737    }
3738
3739    /// **Import as assembly** — consume the probe's stash into parts-library
3740    /// entries + one component per occurrence, in the engine's single batch.
3741    ///
3742    /// An `Err` here means the structured lane produced nothing, and the engine
3743    /// returns BEFORE it touches history when that happens — so the honest
3744    /// answer is the one A6's contract prescribes: re-run the flat import with
3745    /// the text this dialog is still holding, and say plainly that the assembly
3746    /// the user asked for came in as bodies. Silence there would leave them
3747    /// believing they have a structure tree that does not exist.
3748    fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
3749        let Some(pending) = self.pending_step_import.take() else {
3750            return;
3751        };
3752        // Read BEFORE the import: afterwards its own ACOMP features make the
3753        // answer unconditionally "yes" and the workbench switch never fires.
3754        let was_assembly = state.history_has_assembly();
3755        let doc_name = step_document_name(&pending.name);
3756        // The §3.9 checkbox. A depth-1 file imports identically either way, so
3757        // an un-shown box costs nothing.
3758        let opts = StepAssemblyImport {
3759            nested: !pending.flatten,
3760        };
3761        // Every unique part is written to the store as its own document, at the
3762        // browser's current location and under `{assembly}-{part}` — the same
3763        // `browser_write` door + "wherever the explorer is pointing" convention
3764        // the STEP parts-library import uses (`panels::step_parts`). So an
3765        // imported part is a part like any other: Open Part opens it, Update
3766        // Components tracks it, a part edit writes through to it.
3767        let mut sink = StorePartSink::new(store, &doc_name);
3768        let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
3769            Ok(report) => {
3770                if !was_assembly {
3771                    self.enter_assembly_workbench(state);
3772                }
3773                // The store side effect is REPORTED, never silent: a 50-part
3774                // import writes 50 files the user did not individually ask for.
3775                for failure in &sink.failures {
3776                    state.push_notice(format!("part not saved — {failure}"));
3777                }
3778                let saved = sink.written;
3779                let base = assembly_import_message(&pending.name, &report);
3780                match (saved, sink.failures.len()) {
3781                    (0, _) if sink.deferred > 0 => format!(
3782                        "{base}; its {} part documents become PLM items when you save the assembly",
3783                        sink.deferred
3784                    ),
3785                    (0, _) => base,
3786                    (saved, 0) => format!("{base}; saved {saved} part file(s)"),
3787                    (saved, failed) => {
3788                        format!("{base}; saved {saved} part file(s), {failed} could not be saved")
3789                    }
3790                }
3791            }
3792            Err(error) => match state.import_step_feature(&pending.text) {
3793                Ok(_) => format!(
3794                    "imported {} as bodies — the assembly structure could not be built ({error})",
3795                    pending.name
3796                ),
3797                Err(flat) => format!("import failed: {flat}"),
3798            },
3799        };
3800        // The status line only renders inside an OPEN modal and this one closes
3801        // on the click, so the toast is the half the user actually sees.
3802        self.status = message.clone();
3803        state.push_notice(message);
3804    }
3805
3806    /// **Import as bodies** — today's flat lane, unchanged. Drops the probe's
3807    /// stash first: the user chose the text, so the parsed assembly (and every
3808    /// product's solids it is holding resident) has no consumer left.
3809    fn step_assembly_bodies(&mut self, state: &mut EngineState) {
3810        let Some(pending) = self.pending_step_import.take() else {
3811            return;
3812        };
3813        state.discard_probed_step_assembly();
3814        self.import_step_flat(state, &pending.name, &pending.text);
3815    }
3816
3817    /// **Cancel** — import nothing and drop the parse (§3.9).
3818    fn step_assembly_cancel(&mut self, state: &mut EngineState) {
3819        let Some(pending) = self.pending_step_import.take() else {
3820            return;
3821        };
3822        state.discard_probed_step_assembly();
3823        self.status = format!("import cancelled: {}", pending.name);
3824    }
3825
3826    /// Switch to the **Assembly** workbench so a freshly imported structure is
3827    /// actually reachable: the Assembly Structure tree and the Constraints panel
3828    /// are CLAIMED by that workbench, so an assembly imported under Modeling
3829    /// would land with its structure invisible.
3830    ///
3831    /// No-op when the active workbench already shows those panels: the
3832    /// predicate asks whether the BOM panel is visible, which is true for
3833    /// Assembly itself, for Wire harness (it lists the assembly panels too),
3834    /// and for "All", whose users must not be yanked out of it. Applied
3835    /// through the same settings seam the toolbar dropdown and the saved-
3836    /// workbench restore use, and like the restore NOT persisted to the settings
3837    /// blob: that blob stays the user's boot preference, and a document's
3838    /// workbench is session-scoped.
3839    fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
3840        if crate::workbench::panel_visible(
3841            &state.settings.workbench,
3842            crate::workbench::assembly::BOM_PANEL_ID,
3843            &crate::workbench::ButtonState::of(state),
3844        ) {
3845            return;
3846        }
3847        let _ = state.apply_settings_json(
3848            &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
3849                .to_string(),
3850        );
3851    }
3852
3853    /// Append an imported IGES file to the model (an IMPORT3D feature) — the
3854    /// IGES sibling of [`Self::import_step`].
3855    fn stage_mesh_preview(
3856        &mut self,
3857        format: MeshImportFormat,
3858        name: String,
3859        contents: Vec<u8>,
3860    ) {
3861        self.pending_stl_import = Some((format, name, contents));
3862        self.status.clear();
3863    }
3864
3865    /// Selecting an STL or a 3MF starts a preview session; only its Accept
3866    /// action edits the destination document.
3867    pub fn take_stl_import(&mut self) -> Option<(MeshImportFormat, String, Vec<u8>)> {
3868        self.pending_stl_import.take()
3869    }
3870
3871    fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
3872        match state.import_obj_bytes_feature(contents) {
3873            Ok(_) => self.status = format!("reconstructing {name} in background…"),
3874            Err(e) => self.status = format!("import failed: {e}"),
3875        }
3876    }
3877
3878    /// Hand a file to the importer File > Import uses for its kind. `false`
3879    /// when the file is not imported yet but has moved to a dialog of its own
3880    /// (KiCad): the caller then leaves the dialog state alone.
3881    fn import_file(
3882        &mut self,
3883        kind: ImportKind,
3884        state: &mut EngineState,
3885        store: &dyn ModelStore,
3886        name: String,
3887        bytes: Vec<u8>,
3888    ) -> bool {
3889        match kind {
3890            ImportKind::Kicad => {
3891                self.take_kicad_file(&name, &bytes, store);
3892                return false;
3893            }
3894            ImportKind::Step | ImportKind::Iges => self.import_text(state, &name, &bytes, Self::import_step),
3895            ImportKind::Stl => self.stage_mesh_preview(MeshImportFormat::Stl, name, bytes),
3896            ImportKind::ThreeMf => self.stage_mesh_preview(MeshImportFormat::ThreeMf, name, bytes),
3897            ImportKind::Obj => self.import_obj(state, &name, &bytes),
3898        }
3899        true
3900    }
3901
3902    /// A file the OS handed the app that is not a model document — `brep-app
3903    /// part.step`, or a double click on a file the app is an opener for —
3904    /// imported exactly as File > Import would, into a NEW untitled tab shown
3905    /// under the file's stem. The tab has no store identity, so a plain Save
3906    /// asks for a name and never writes over the imported file. The tab is
3907    /// opened BEFORE the import starts: the STEP probe and the mesh preview
3908    /// bind to the active document. `file_name` is the path or the bare name;
3909    /// the status line and the importer's notices name it.
3910    ///
3911    /// A name the table does not know is refused without a new tab.
3912    pub fn import_into_new_tab(
3913        &mut self,
3914        docs: &mut Documents,
3915        store: &dyn ModelStore,
3916        file_name: &str,
3917        bytes: Vec<u8>,
3918    ) -> Result<(), String> {
3919        let Some(kind) = import_kind(file_name) else {
3920            return Err(format!("{file_name} is not a BREP document or a file BREP imports"));
3921        };
3922        self.cancel_pending_step_import(docs.engine_mut());
3923        self.new_document(docs, DocumentClass::Normal);
3924        let title = import_document_name(file_name);
3925        docs.active_mut().set_label(Some(title.clone()));
3926        let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name).to_string();
3927        self.status.clear();
3928        if self.import_file(kind, docs.engine_mut(), store, base, bytes) {
3929            self.close_after_import();
3930        }
3931        if self.status.starts_with("import failed") {
3932            docs.engine_mut().push_notice(self.status.clone());
3933            return Err(self.status.clone());
3934        }
3935        Ok(())
3936    }
3937
3938    fn import_text(
3939        &mut self,
3940        state: &mut EngineState,
3941        name: &str,
3942        bytes: &[u8],
3943        importer: fn(&mut Self, &mut EngineState, &str, &str),
3944    ) {
3945        match std::str::from_utf8(bytes) {
3946            Ok(contents) => importer(self, state, name, contents),
3947            Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
3948        }
3949    }
3950
3951    /// Load a model document's contents into a NEW TAB (roll to the last
3952    /// feature + zoom-to-fit), clean from the start. A document that fails to
3953    /// load leaves no tab behind — the engine it was loading into is dropped
3954    /// with its runner.
3955    fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str, store: &dyn ModelStore) {
3956        let plm = store.plm_client().is_some();
3957        let mut engine = docs.spawn_engine();
3958        match engine.load_model_and_fit(contents) {
3959            Ok(_) => {
3960                // The extension names the class on the file system; the
3961                // document field follows it, before `Document::new` takes the
3962                // clean baseline so a hand-renamed file does not open dirty.
3963                // On a PLM store the name is a revision key the explorer spells
3964                // with `.nbrep` whatever the part is, so the document's own
3965                // field is the class (the part's fixed `document_class`).
3966                if let Some(class) = DocumentClass::of_name(name).filter(|_| !plm) {
3967                    document_class::set_document_class(&mut engine, class);
3968                }
3969                // The document keeps the raw identity — the full path when a
3970                // native dialog picked the file, so plain Save writes back to
3971                // it; the name field shows only the bare display name.
3972                let mut doc = Document::new(engine);
3973                doc.set_name(Some(name.to_string()));
3974                doc.note_opened_from(contents);
3975                docs.open_document(doc);
3976                self.name_buf = model_display_name(name);
3977                self.status = format!("opened {name}");
3978                remember_document(store, name);
3979            }
3980            Err(e) => self.status = format!("open failed: {e}"),
3981        }
3982    }
3983
3984    // --- document classes: families, templates, members ------------------------
3985
3986    /// The session's PLM client and the revision key `identity` names, when
3987    /// the store IS the PLM and `identity` is one of its revisions.
3988    fn plm_revision(&self, store: &dyn ModelStore, identity: &str) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String)> {
3989        let client = store.plm_client()?;
3990        let key = crate::plm::family::revision_key(identity)?;
3991        Some((client, key))
3992    }
3993
3994    /// Poll the PLM work in flight (S9) and act on what finished.
3995    fn poll_plm_work(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
3996        let Some(work) = self.plm_work.as_mut() else { return };
3997        ctx.request_repaint_after(std::time::Duration::from_millis(30));
3998        match work {
3999            PlmWork::Members(pending) => {
4000                let Some(result) = pending.poll() else { return };
4001                self.plm_work = None;
4002                let Some(pick) = self.pick_member.as_mut() else { return };
4003                pick.loading = false;
4004                match result {
4005                    Ok(members) => {
4006                        pick.rows = members
4007                            .into_iter()
4008                            .map(|member| MemberChoice {
4009                                why_not: member.not_placeable(),
4010                                exists: member.not_placeable().is_none(),
4011                                identity: member.key(),
4012                                description: member.name.clone(),
4013                                part_number: member.number,
4014                            })
4015                            .collect();
4016                    }
4017                    Err(error) => self.status = format!("the members could not be read: {error}"),
4018                }
4019            }
4020            PlmWork::Place(pending) => {
4021                let Some(result) = pending.poll() else { return };
4022                self.plm_work = None;
4023                match result {
4024                    Ok((key, text, number)) => {
4025                        self.insert_component_named(docs.engine_mut(), &key, &number, &text);
4026                        self.pick_member = None;
4027                        self.spin_out = None;
4028                        self.open = false;
4029                    }
4030                    Err(error) => self.status = error,
4031                }
4032            }
4033            PlmWork::OpenFamily(pending) => {
4034                let Some(result) = pending.poll() else { return };
4035                self.plm_work = None;
4036                match result {
4037                    Ok(key) => self.open_document(docs, store, &key),
4038                    Err(error) => self.status = format!("the family could not be opened: {error}"),
4039                }
4040            }
4041        }
4042    }
4043
4044    /// Route an Insert-component choice by its class. A FAMILY arms the member
4045    /// picker and a TEMPLATE the spin-out prompt; both return `true` (handled,
4046    /// the modal switches). A normal part returns `false` and is inserted as
4047    /// it always was. The class is the file's extension, or the document's own
4048    /// field when the name carries none (a browser upload's bare name).
4049    fn begin_class_insert(
4050        &mut self,
4051        store: &dyn ModelStore,
4052        identity: &str,
4053        contents: &str,
4054        assembly: Option<String>,
4055    ) -> bool {
4056        let document: serde_json::Value = serde_json::from_str(contents).unwrap_or_default();
4057        let class = DocumentClass::of_name(identity)
4058            .filter(|class| *class != DocumentClass::Normal)
4059            .unwrap_or_else(|| DocumentClass::of_document(&document));
4060        let file = crate::store::file_name_of(identity);
4061        match class {
4062            DocumentClass::Normal | DocumentClass::Instructions => false,
4063            DocumentClass::Family if self.plm_revision(store, identity).is_some() => {
4064                let (client, key) = self.plm_revision(store, identity).unwrap_or_else(|| unreachable!());
4065                let Some((part, _)) = crate::plm::family::key_ids(&key).map(|(p, r)| (p.to_string(), r.to_string())) else {
4066                    return false;
4067                };
4068                self.plm_work = Some(PlmWork::Members(crate::panels::plm_family::Pending::new(Box::pin(async move {
4069                    crate::plm::family::family_view(&client, &part).await.map(|view| view.members()).map_err(|e| e.to_string())
4070                }))));
4071                self.pick_member = Some(PickMember { family_file: file, rows: Vec::new(), plm: true, loading: true });
4072                self.status.clear();
4073                self.open_modal(Mode::PickMember);
4074                true
4075            }
4076            DocumentClass::Family => {
4077                let table = family_table::read_table(contents);
4078                let rows = table
4079                    .rows
4080                    .iter()
4081                    .map(|row| {
4082                        let identity = family_table::member_identity(Some(identity), &row.part_number);
4083                        let exists = identity.as_deref().is_some_and(|id| store.read(id).is_some());
4084                        MemberChoice {
4085                            part_number: row.part_number.clone(),
4086                            description: row.description.clone(),
4087                            identity,
4088                            exists,
4089                            why_not: None,
4090                        }
4091                    })
4092                    .collect();
4093                self.pick_member = Some(PickMember { family_file: file, rows, plm: false, loading: false });
4094                self.status.clear();
4095                self.open_modal(Mode::PickMember);
4096                true
4097            }
4098            DocumentClass::Template => {
4099                let inputs = template::inputs_of(&document);
4100                let values = inputs
4101                    .iter()
4102                    .map(|input| (input.name.clone(), template::default_value(&document, input)))
4103                    .collect();
4104                let stem = document_class::strip_class_extension(&file).to_string();
4105                let plm_template = self
4106                    .plm_revision(store, identity)
4107                    .and_then(|(_, key)| crate::plm::family::key_ids(&key).map(|(part, _)| part.to_string()));
4108                self.spin_out = Some(SpinOut {
4109                    template_file: file,
4110                    template: document,
4111                    inputs,
4112                    name: format!("{stem}-1"),
4113                    values,
4114                    assembly,
4115                    plm_template,
4116                    number: String::new(),
4117                });
4118                self.status.clear();
4119                self.open_modal(Mode::SpinOut);
4120                true
4121            }
4122        }
4123    }
4124
4125    /// The **member picker**: a family is never placed itself. Each table row
4126    /// is a button; one whose member file exists inserts that `.nbrep` as a
4127    /// normal component, one that was never generated says so.
4128    fn show_pick_member(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4129        let Some(pick) = self.pick_member.take() else {
4130            self.open = false;
4131            return;
4132        };
4133        let mut chosen = None;
4134        let mut cancel = false;
4135        let modal = egui::Modal::new(egui::Id::new("brep-file-pick-member")).show(ctx, |ui| {
4136            ui.set_width(420.0);
4137            ui.heading(format!("Insert a member of {}", pick.family_file));
4138            ui.add_space(2.0);
4139            ui.weak("A family is never placed itself: choose the member to place.");
4140            ui.add_space(6.0);
4141            if pick.loading {
4142                ui.label("Reading the family's members from the PLM\u{2026}");
4143            } else if pick.rows.is_empty() {
4144                ui.label("This family's table has no rows yet.");
4145            }
4146            egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
4147                for (index, row) in pick.rows.iter().enumerate() {
4148                    ui.horizontal(|ui| {
4149                        let text = if row.description.trim().is_empty() {
4150                            row.part_number.clone()
4151                        } else {
4152                            format!("{} \u{2014} {}", row.part_number, row.description)
4153                        };
4154                        let button = ui.add_enabled(
4155                            row.exists,
4156                            egui::Button::new(text).min_size(egui::vec2(220.0, 0.0)),
4157                        );
4158                        self.hit(&format!("member:{}", row.part_number), &button);
4159                        if button.clicked() {
4160                            chosen = Some(index);
4161                        }
4162                        if let Some(why) = &row.why_not {
4163                            ui.weak(format!("({why})"));
4164                        } else if row.identity.is_none() {
4165                            ui.weak("(the part number is not a file name)");
4166                        } else if !row.exists {
4167                            ui.weak("(not generated yet: open the family and Generate)");
4168                        }
4169                    });
4170                }
4171            });
4172            ui.add_space(6.0);
4173            if !self.status.is_empty() {
4174                ui.weak(&self.status);
4175                ui.add_space(4.0);
4176            }
4177            let c = ui.button("Cancel");
4178            self.hit("member:cancel", &c);
4179            cancel = c.clicked();
4180        });
4181        let should_close = modal.should_close();
4182        if let (Some(index), true) = (chosen, pick.plm) {
4183            let row = &pick.rows[index];
4184            if let (Some(client), Some(key)) = (store.plm_client(), row.identity.clone()) {
4185                self.status = format!("reading {} from the PLM\u{2026}", row.part_number);
4186                self.plm_work = Some(PlmWork::Place(fetch_document(client, key, row.part_number.clone())));
4187            }
4188        } else if let Some(index) = chosen {
4189            let row = &pick.rows[index];
4190            let identity = row.identity.clone().unwrap_or_default();
4191            match store.read(&identity) {
4192                Some(contents) => {
4193                    self.insert_component_document(state, &identity, &contents);
4194                    self.open = false;
4195                    return;
4196                }
4197                None => self.status = format!("'{}' has not been generated yet ({identity})", row.part_number),
4198            }
4199        } else if cancel || should_close {
4200            self.status.clear();
4201            self.open = false;
4202            return;
4203        }
4204        self.pick_member = Some(pick);
4205    }
4206
4207    /// The **template spin-out** prompt: a template is never placed itself.
4208    /// The user names the new part and sets each marked input (a fixed list
4209    /// is offered as buttons; limits are checked as they type), then Create
4210    /// writes the specialised copy as `<name>.nbrep` beside the assembly and
4211    /// places it. A name that already exists is refused, never overwritten.
4212    fn show_spin_out(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
4213        let Some(mut spin) = self.spin_out.take() else {
4214            self.open = false;
4215            return;
4216        };
4217        let mut create = false;
4218        let mut cancel = false;
4219        let problems: Vec<Option<String>> = spin
4220            .inputs
4221            .iter()
4222            .map(|input| {
4223                let text = spin.values.get(&input.name).map(String::as_str).unwrap_or("");
4224                template::value_problem(&spin.template, input, text)
4225            })
4226            .collect();
4227        let name_problem = template::name_problem(&spin.name);
4228        let modal = egui::Modal::new(egui::Id::new("brep-file-spin-out")).show(ctx, |ui| {
4229            ui.set_width(440.0);
4230            ui.heading(format!("New part from {}", spin.template_file));
4231            ui.add_space(2.0);
4232            ui.weak("A template is never placed itself: this makes a new part from it and places that.");
4233            ui.add_space(6.0);
4234            egui::Grid::new("brep-spin-out-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
4235                ui.label("New part name");
4236                let field = ui.add(egui::TextEdit::singleline(&mut spin.name).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
4237                self.hit("spinout:name", &field);
4238                ui.end_row();
4239                if spin.plm_template.is_some() {
4240                    ui.label("Part number");
4241                    let field = ui.add(
4242                        egui::TextEdit::singleline(&mut spin.number).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))
4243                            .hint_text("empty: the part type numbers it")
4244                            .desired_width(240.0),
4245                    );
4246                    self.hit("spinout:number", &field);
4247                    ui.end_row();
4248                }
4249                for (input, problem) in spin.inputs.iter().zip(&problems) {
4250                    ui.label(input.shown());
4251                    ui.vertical(|ui| {
4252                        let value = spin.values.entry(input.name.clone()).or_default();
4253                        if input.choices.is_empty() {
4254                            let field = ui.add(egui::TextEdit::singleline(value).margin(crate::viewport::touch::text_edit_margin(ui)).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)).desired_width(240.0));
4255                            self.hit(&format!("spinout:input:{}", input.name), &field);
4256                        } else {
4257                            // The input's choices as one dropdown: spinout:choice:<name>,
4258                            // its open entries spinout:choice:<name>#<choice>.
4259                            let trimmed: Vec<String> = input.choices.iter().map(|choice| choice.trim().to_string()).collect();
4260                            let choices: Vec<(String, &str, &str)> = trimmed.iter().map(|choice| (choice.clone(), choice.as_str(), choice.as_str())).collect();
4261                            let mut chosen = value.trim().to_string();
4262                            let (changed, _) = crate::form::enum_dropdown(ui, &format!("spinout:choice:{}", input.name), &choices, &mut chosen, None, Some(&mut self.hits));
4263                            if changed {
4264                                *value = chosen;
4265                            }
4266                        }
4267                        let limits = match (input.min, input.max) {
4268                            (Some(min), Some(max)) => format!("{min} to {max}"),
4269                            (Some(min), None) => format!("at least {min}"),
4270                            (None, Some(max)) => format!("at most {max}"),
4271                            (None, None) => String::new(),
4272                        };
4273                        if !limits.is_empty() {
4274                            ui.weak(limits);
4275                        }
4276                        if let Some(problem) = problem {
4277                            ui.colored_label(ui.visuals().warn_fg_color, problem);
4278                        }
4279                    });
4280                    ui.end_row();
4281                }
4282            });
4283            if spin.inputs.is_empty() {
4284                ui.weak("This template marks no inputs; the copy is the template as it is.");
4285            }
4286            ui.add_space(4.0);
4287            let file = crate::store::file_name_of(&spin_out_target(&spin));
4288            ui.weak(match &spin.assembly {
4289                _ if spin.plm_template.is_some() => {
4290                    "The PLM makes the new part from the template and bakes it; it is placed as its first revision.".to_string()
4291                }
4292                Some(assembly) => format!(
4293                    "Saved as {file}, in the folder of {}",
4294                    crate::store::file_name_of(assembly)
4295                ),
4296                None => format!("The assembly is not saved yet: the part goes to the models folder as {file}"),
4297            });
4298            if let Some(problem) = &name_problem {
4299                ui.colored_label(ui.visuals().warn_fg_color, problem);
4300            }
4301            if !self.status.is_empty() {
4302                ui.weak(&self.status);
4303            }
4304            ui.add_space(6.0);
4305            ui.horizontal(|ui| {
4306                let ok = name_problem.is_none() && problems.iter().all(Option::is_none) && self.plm_work.is_none();
4307                let c = ui.add_enabled(ok, egui::Button::new("Create and insert"));
4308                self.hit("spinout:create", &c);
4309                create = c.clicked();
4310                let x = ui.button("Cancel");
4311                self.hit("spinout:cancel", &x);
4312                cancel = x.clicked();
4313            });
4314        });
4315        let should_close = modal.should_close();
4316        if create {
4317            match self.create_spin_out(state, store, &spin) {
4318                Ok(()) => {
4319                    self.open = false;
4320                    return;
4321                }
4322                Err(error) => self.status = error,
4323            }
4324        } else if cancel || should_close {
4325            self.status.clear();
4326            self.open = false;
4327            return;
4328        }
4329        self.spin_out = Some(spin);
4330    }
4331
4332    /// Write the spun-out part and place it. `Err` is the status line.
4333    fn create_spin_out(&mut self, state: &mut EngineState, store: &dyn ModelStore, spin: &SpinOut) -> Result<(), String> {
4334        if let (Some(template_part), Some(client)) = (spin.plm_template.clone(), store.plm_client()) {
4335            // The server makes the part; it is placed when it answers. The
4336            // dialog stays open until then, saying so.
4337            let (name, number, values) = (spin.name.trim().to_string(), spin.number.trim().to_string(), spin.values.clone());
4338            self.plm_work = Some(PlmWork::Place(crate::panels::plm_family::Pending::new(Box::pin(async move {
4339                let spun = crate::plm::family::spin_out(&client, &template_part, &name, &number, &values)
4340                    .await
4341                    .map_err(|e| e.to_string())?;
4342                let bytes = client.get_document(&spun.key).await.map_err(|e| e.to_string())?;
4343                let text = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()).ok_or_else(|| {
4344                    format!("{} was made, but has no document yet", spun.number)
4345                })?;
4346                Ok((spun.key, text, spun.number))
4347            }))));
4348            return Err("making the part on the PLM\u{2026}".into());
4349        }
4350        if let Some(problem) = template::name_problem(&spin.name) {
4351            return Err(problem);
4352        }
4353        let target = spin_out_target(spin);
4354        if store.read(&target).is_some() {
4355            return Err(format!("'{target}' already exists: choose another name"));
4356        }
4357        let copy = template::spin_out(&spin.template, &spin.template_file, &spin.values)?;
4358        let text = copy.to_string();
4359        store.write(&target, &text).map_err(|error| format!("could not save {target}: {error}"))?;
4360        self.save_generation += 1;
4361        self.insert_component_document(state, &target, &text);
4362        if spin.assembly.is_none() {
4363            self.status = format!(
4364                "{} — saved to the models folder as {target}, because the assembly is not saved yet",
4365                self.status
4366            );
4367        }
4368        Ok(())
4369    }
4370
4371    /// Arm the hand-edit prompt when the active tab is a family member. `true`
4372    /// when it was armed (the caller does not go on to write). Raise the
4373    /// hand-edit prompt the frame a family member first shows an unsaved
4374    /// change — the moment the user "starts to change it".
4375    fn watch_member_edit(&mut self, docs: &Documents) {
4376        let doc = docs.active();
4377        if !doc.dirty_marker() || self.member_ack.contains(&doc.id()) {
4378            return;
4379        }
4380        if let Some(name) = doc.name().map(str::to_string) {
4381            self.arm_member_prompt(docs, &name, false);
4382        }
4383    }
4384
4385    fn arm_member_prompt(&mut self, docs: &Documents, name: &str, from_save: bool) -> bool {
4386        let Some(source) = family_table::engine_family_source(docs.engine()) else {
4387            return false;
4388        };
4389        self.member_prompt = Some(MemberPrompt {
4390            doc_id: docs.active().id(),
4391            family_identity: sibling_identity(name, &source.family),
4392            source,
4393            from_save,
4394        });
4395        self.status.clear();
4396        self.open_modal(Mode::MemberEdit);
4397        true
4398    }
4399
4400    /// Undo the active tab back to its saved state (bounded), so a member the
4401    /// user declined to change is left exactly as generated.
4402    fn revert_member_edit(docs: &mut Documents) {
4403        for _ in 0..64 {
4404            if !docs.active().is_dirty() || !docs.engine().history.can_undo() {
4405                break;
4406            }
4407            docs.engine_mut().undo();
4408        }
4409    }
4410
4411    /// The assembly Save's ONE dialog about the documents it owns: a row per
4412    /// document that needs a decision, each ticked (yes) by default, so Enter
4413    /// saves everything. An unticked row is not written and its change stays
4414    /// in the assembly. Cancel saves nothing.
4415    fn show_owned_save(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4416        let Some(mut prompt) = self.owned_prompt.take() else {
4417            self.open = false;
4418            return;
4419        };
4420        if docs.active_id() != prompt.plan.doc_id {
4421            self.status = "Save cancelled because the active document changed".into();
4422            self.open = false;
4423            return;
4424        }
4425        let mut choice = None;
4426        let modal = egui::Modal::new(egui::Id::new("brep-file-owned-save")).show(ctx, |ui| {
4427            ui.set_width(520.0);
4428            ui.heading("Save the parts this assembly changed");
4429            ui.add_space(4.0);
4430            ui.label("Saving writes these documents too. Untick any you do not want written; its change stays in the assembly.");
4431            ui.add_space(6.0);
4432            for (i, row) in prompt.plan.rows.iter_mut().enumerate() {
4433                if !row.asks() {
4434                    if let Some(crate::document::owned_save::Issue::Blocked(_)) = row.issue {
4435                        ui.weak(row.question());
4436                    }
4437                    continue;
4438                }
4439                let question = row.question();
4440                let tick = ui.checkbox(&mut row.proceed, question);
4441                self.hit(&format!("owned:row:{i}"), &tick);
4442                if row.issue == Some(crate::document::owned_save::Issue::Conflict) && row.proceed {
4443                    ui.indent(("owned-conflict", i), |ui| {
4444                        // Which copy to keep, as one dropdown: owned:choice:<row>, its
4445                        // open entries owned:choice:<row>#<variant>.
4446                        let labels: Vec<(String, String)> = row
4447                            .write
4448                            .variants
4449                            .iter()
4450                            .enumerate()
4451                            .map(|(j, (names, _))| {
4452                                let last = if row.edited_last == Some(j) { " (edited last)" } else { "" };
4453                                (j.to_string(), format!("keep the copy in {}{last}", names.join(" > ")))
4454                            })
4455                            .collect();
4456                        let choices: Vec<(usize, &str, &str)> = labels.iter().enumerate().map(|(j, (id, label))| (j, id.as_str(), label.as_str())).collect();
4457                        crate::form::enum_dropdown(ui, &format!("owned:choice:{i}"), &choices, &mut row.choice, None, Some(&mut self.hits));
4458                    });
4459                }
4460            }
4461            if prompt.plan.rows.iter().any(|row| matches!(row.issue, Some(crate::document::owned_save::Issue::CheckOut { .. }))) {
4462                ui.add_space(4.0);
4463                let remember = ui.checkbox(&mut self.checkout_remembered, "Check out without asking again in this session");
4464                self.hit("owned:remember", &remember);
4465            }
4466            for promotion in &prompt.plan.promotions {
4467                let part = promotion.names.last().map_or("", String::as_str);
4468                ui.weak(if promotion.recreates() {
4469                    format!("{part}: its file is missing — re-created as {}", promotion.new_key)
4470                } else {
4471                    format!("{part}: saved to a new part file {}", promotion.new_key)
4472                });
4473            }
4474            ui.add_space(8.0);
4475            ui.horizontal(|ui| {
4476                let save = ui.button("Save");
4477                self.hit("owned:save", &save);
4478                if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4479                    choice = Some(true);
4480                }
4481                let cancel = ui.button("Cancel");
4482                self.hit("owned:cancel", &cancel);
4483                if cancel.clicked() {
4484                    choice = Some(false);
4485                }
4486            });
4487        });
4488        let choice = choice.or_else(|| modal.should_close().then_some(false));
4489        self.owned_state = serde_json::json!({"prompt": prompt.plan.to_json()});
4490        match choice {
4491            Some(true) => {
4492                self.open = false;
4493                let OwnedPrompt { plan, name, browser } = prompt;
4494                if browser {
4495                    self.commit_save_browser(docs, store, name, plan);
4496                } else {
4497                    self.commit_save(docs, store, name, plan);
4498                }
4499            }
4500            Some(false) => {
4501                self.status = "Save cancelled".into();
4502                self.owned_state = serde_json::Value::Null;
4503                self.open = false;
4504            }
4505            None => self.owned_prompt = Some(prompt),
4506        }
4507    }
4508
4509    /// "Also point these assemblies at the new file?" — one question for every
4510    /// open assembly that places the part just saved under a new name. Yes
4511    /// (the default, Enter) moves each of their copies to the new file as one
4512    /// undoable edit per assembly; No leaves them on the old file.
4513    fn show_retarget(&mut self, ctx: &egui::Context, docs: &mut Documents) {
4514        let Some(retarget) = self.retarget.as_ref() else {
4515            self.open = false;
4516            return;
4517        };
4518        let mut choice = None;
4519        let new_name = model_display_name(&retarget.new_key);
4520        let old_name = model_display_name(&retarget.old);
4521        let titles: Vec<String> = retarget.tabs.iter().map(|(_, title)| title.clone()).collect();
4522        egui::Modal::new(egui::Id::new("brep-file-retarget")).show(ctx, |ui| {
4523            ui.set_width(480.0);
4524            ui.heading("Point the assemblies at the new file?");
4525            ui.label(format!(
4526                "{} {} '{old_name}'. Point {} at '{new_name}' too?",
4527                titles.join(", "),
4528                if titles.len() == 1 { "places" } else { "place" },
4529                if titles.len() == 1 { "it" } else { "them" },
4530            ));
4531            ui.add_space(8.0);
4532            ui.horizontal(|ui| {
4533                let yes = ui.button(format!("Yes, use '{new_name}'"));
4534                self.hit("retarget:yes", &yes);
4535                if yes.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
4536                    choice = Some(true);
4537                }
4538                let no = ui.button(format!("No, keep '{old_name}'"));
4539                self.hit("retarget:no", &no);
4540                if no.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
4541                    choice = Some(false);
4542                }
4543            });
4544        });
4545        let Some(yes) = choice else { return };
4546        let Some(retarget) = self.retarget.take() else { return };
4547        self.open = false;
4548        if !yes {
4549            self.status = format!("kept the assemblies on '{old_name}'");
4550            return;
4551        }
4552        let mut moved = Vec::new();
4553        for (id, title) in &retarget.tabs {
4554            match docs.retarget_copies(*id, &retarget.old, &retarget.new_key, &retarget.text) {
4555                Ok(n) if n > 0 => moved.push(title.clone()),
4556                Ok(_) => {}
4557                Err(error) => docs.engine_mut().push_notice(format!("{title}: could not point it at '{new_name}': {error}")),
4558            }
4559        }
4560        self.status = format!("{} now {} '{new_name}' (unsaved)", moved.join(", "), if moved.len() == 1 { "uses" } else { "use" });
4561        docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Info, self.status.clone());
4562    }
4563
4564    /// The **hand-edit prompt** on a family member: a member is the family's
4565    /// output, so a change belongs either in a NEW part or in the family.
4566    fn show_member_edit(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
4567        let Some(prompt) = self.member_prompt.take() else {
4568            self.open = false;
4569            return;
4570        };
4571        if docs.active().id() != prompt.doc_id {
4572            self.open = false;
4573            return;
4574        }
4575        let mut choice = None;
4576        let plm = docs.active().name().and_then(|name| self.plm_revision(store, name));
4577        let modal = egui::Modal::new(egui::Id::new("brep-file-member-edit")).show(ctx, |ui| {
4578            ui.set_width(440.0);
4579            ui.heading("This part is a family member");
4580            ui.add_space(4.0);
4581            ui.label(format!(
4582                "'{}' was generated from the family '{}'. A member is written by its family, not edited by hand.",
4583                prompt.source.part_number, prompt.source.family
4584            ));
4585            ui.add_space(4.0);
4586            ui.weak(if prompt.from_save {
4587                "Save it as a new part, or open the family and make the change there."
4588            } else {
4589                "Keep the change in a new part, or open the family and make it there."
4590            });
4591            if !self.status.is_empty() {
4592                ui.add_space(4.0);
4593                ui.weak(&self.status);
4594            }
4595            ui.add_space(8.0);
4596            ui.horizontal(|ui| {
4597                let a = ui.button("Save as a new part\u{2026}");
4598                self.hit("member-edit:saveas", &a);
4599                if a.clicked() {
4600                    choice = Some("saveas");
4601                }
4602                // On a PLM only someone who may edit the family is offered it.
4603                let offer_family = match &plm {
4604                    Some((client, _)) => client.me().is_some_and(|me| me.groups.iter().any(|g| g == "author" || g == "admin")),
4605                    None => true,
4606                };
4607                if offer_family {
4608                    let b = ui.button("Open the family");
4609                    self.hit("member-edit:family", &b);
4610                    if b.clicked() {
4611                        choice = Some("family");
4612                    }
4613                }
4614                let c = ui.button(if prompt.from_save { "Cancel" } else { "Cancel (undo the change)" });
4615                self.hit("member-edit:cancel", &c);
4616                if c.clicked() {
4617                    choice = Some("cancel");
4618                }
4619            });
4620        });
4621        let choice = choice.or_else(|| modal.should_close().then_some("cancel"));
4622        match choice {
4623            Some("saveas") => {
4624                // The tab may keep changing; Save As strips the stamp from
4625                // the copy it writes. Plain Save still asks. On a PLM the
4626                // Save As opens on "A new part".
4627                self.member_ack.insert(prompt.doc_id);
4628                self.member_new_part = store.plm_client().is_some();
4629                self.status.clear();
4630                self.dispatch(FileAction::SaveAs, docs, store);
4631            }
4632            Some("family") if plm.is_some() => {
4633                let (client, _) = plm.unwrap_or_else(|| unreachable!());
4634                let number = document_class::strip_class_extension(&prompt.source.family).to_string();
4635                Self::revert_member_edit(docs);
4636                self.status = format!("opening the family {number}\u{2026}");
4637                self.plm_work = Some(PlmWork::OpenFamily(crate::panels::plm_family::Pending::new(Box::pin(async move {
4638                    let head = crate::plm::family::part_head(&client, &number).await.map_err(|e| e.to_string())?;
4639                    // The newest revision, drafts included (D13).
4640                    let newest = head.revisions.last().ok_or_else(|| format!("the family {number} has no revision"))?;
4641                    Ok(crate::plm::identity::document_key(&head.id, &newest.id))
4642                }))));
4643                self.open = false;
4644            }
4645            Some("family") => {
4646                if store.read(&prompt.family_identity).is_none() {
4647                    self.status = format!("the family file is not there: {}", prompt.family_identity);
4648                    self.member_prompt = Some(prompt);
4649                    return;
4650                }
4651                Self::revert_member_edit(docs);
4652                self.open = false;
4653                self.open_document(docs, store, &prompt.family_identity);
4654            }
4655            Some(_) => {
4656                if !prompt.from_save {
4657                    Self::revert_member_edit(docs);
4658                }
4659                self.status.clear();
4660                self.open = false;
4661            }
4662            None => self.member_prompt = Some(prompt),
4663        }
4664    }
4665
4666    /// Run the family's Generate on the ACTIVE document (a family seed) and
4667    /// keep the report for the automation blob. The one door the automation
4668    /// command and the table editor's button share.
4669    /// Generate the active family. On a PLM store (the document is a
4670    /// `part/…/rev/…` revision and the session has a client) the server
4671    /// writes the members from the family's SAVED revision: a family with
4672    /// unsaved edits is refused unless `save` is set, which saves it first
4673    /// (plm-cad-integration-todo S9). Everywhere else this is the file-system
4674    /// Generate, and `save` is ignored.
4675    pub fn generate_active_family(
4676        &mut self,
4677        docs: &mut Documents,
4678        store: &dyn ModelStore,
4679        save: bool,
4680    ) -> Result<family_table::GenerateReport, String> {
4681        let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
4682        let report = match (store.plm_client(), plm_key) {
4683            (Some(client), Some(key)) => self.generate_on_plm(docs, store, &client, &key, save)?,
4684            _ => family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json()),
4685        };
4686        Ok(self.record_generate(report))
4687    }
4688
4689    /// The PLM half of [`Self::generate_active_family`]: blocks this thread
4690    /// while the members are built and the server answers (native; the web
4691    /// build's editor polls instead).
4692    #[cfg(not(target_arch = "wasm32"))]
4693    fn generate_on_plm(
4694        &mut self,
4695        docs: &mut Documents,
4696        store: &dyn ModelStore,
4697        client: &std::rc::Rc<crate::plm::client::PlmClient>,
4698        key: &str,
4699        save: bool,
4700    ) -> Result<family_table::GenerateReport, String> {
4701        let json = docs.tab_engine().history_request_json();
4702        if docs.active().is_dirty() {
4703            if !save {
4704                return Err(crate::plm::family::UNSAVED_FAMILY.into());
4705            }
4706            store.write(key, &json)?;
4707            docs.active_mut().mark_clean();
4708            self.save_generation += 1;
4709            // The write is behind: wait for it to land before the server reads it.
4710            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
4711            while store.pending_writes() > 0 {
4712                if std::time::Instant::now() > deadline {
4713                    return Err("the family's save did not reach the server within 60 s".into());
4714                }
4715                crate::plm::native::run_pending();
4716                std::thread::sleep(std::time::Duration::from_millis(5));
4717            }
4718            if let Some(error) = store.take_persistence_errors().into_iter().next() {
4719                return Err(format!("the family was not saved, so nothing was generated: {error}"));
4720            }
4721        }
4722        crate::plm::native::block_on_for(crate::plm::family::generate(client, key, &json), std::time::Duration::from_secs(300))
4723            .ok_or("the PLM server did not answer Generate within 5 minutes")?
4724            .map_err(|e| e.to_string())
4725    }
4726
4727    #[cfg(target_arch = "wasm32")]
4728    fn generate_on_plm(
4729        &mut self,
4730        _: &mut Documents,
4731        _: &dyn ModelStore,
4732        _: &std::rc::Rc<crate::plm::client::PlmClient>,
4733        _: &str,
4734        _: bool,
4735    ) -> Result<family_table::GenerateReport, String> {
4736        Err("in the browser, Generate a PLM family from the family table's Generate button".into())
4737    }
4738
4739    /// The non-blocking door automation takes (`family_generate`): a local
4740    /// Generate finishes here (`Ok(Some(report))`); a PLM Generate saves the
4741    /// family first if asked (the write is queued, not waited for) and
4742    /// answers `Ok(None)` with its revision key — the caller then waits for
4743    /// the store's writes and runs [`crate::plm::family::generate`] as a task
4744    /// on later frames, and hands the report to [`Self::record_generate_report`].
4745    pub(crate) fn begin_generate_active_family(
4746        &mut self,
4747        docs: &mut Documents,
4748        store: &dyn ModelStore,
4749        save: bool,
4750    ) -> Result<Result<family_table::GenerateReport, String>, String> {
4751        let plm_key = docs.active().name().and_then(crate::plm::family::revision_key);
4752        match (store.plm_client(), plm_key) {
4753            (Some(_), Some(key)) => {
4754                if docs.active().is_dirty() {
4755                    if !save {
4756                        return Err(crate::plm::family::UNSAVED_FAMILY.into());
4757                    }
4758                    store.write(&key, &docs.tab_engine().history_request_json())?;
4759                    docs.active_mut().mark_clean();
4760                    self.save_generation += 1;
4761                }
4762                Ok(Err(key))
4763            }
4764            _ => Ok(Ok(self.record_generate(family_table::generate_family(store, docs.active().name(), &docs.engine().history_request_json())))),
4765        }
4766    }
4767
4768    pub(crate) fn record_generate_report(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
4769        self.record_generate(report)
4770    }
4771
4772    fn record_generate(&mut self, report: family_table::GenerateReport) -> family_table::GenerateReport {
4773        if report.rows.iter().any(|row| row.status() == "written") {
4774            self.save_generation += 1;
4775        }
4776        self.status = format!("generate: {}", report.summary());
4777        self.last_generate = Some(report.clone());
4778        report
4779    }
4780
4781    // --- verifier hooks (wasm only) -------------------------------------------
4782
4783    /// The published state for the headed verifier: current name, dirty flag,
4784    /// backend label, the stored-document list, and the modal's open/mode.
4785    pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
4786        serde_json::json!({
4787            "name": docs.active().name(),
4788            "nameBuf": self.name_buf,
4789            "selected": self.explorer.selected(),
4790            "location": store.browser_location(),
4791            // The last folder of each purpose (Open/Save As/Insert, Import,
4792            // KiCad), which is where that modal opens next.
4793            "folders": Purpose::ALL
4794                .iter()
4795                .map(|purpose| (purpose.slug().to_string(), serde_json::json!(self.folders.get(purpose))))
4796                .collect::<serde_json::Map<_, _>>(),
4797            // The CACHED marker, not `is_dirty()`: this blob is published every
4798            // frame, and `is_dirty()` answers by serializing the whole document
4799            // — for a document whose history holds an imported model that is
4800            // hundreds of KB of JSON built and thrown away per frame, on the
4801            // thread that also has to answer the pointer. `Document::refresh_dirty_marker`
4802            // exists for exactly this and is refreshed once per frame; the tab
4803            // strip's dot already reads it, so the two cannot disagree.
4804            "dirty": docs.active().dirty_marker(),
4805            "backend": store.backend_label(),
4806            "interchange": store.supports_file_interchange(),
4807            "list": store.list(),
4808            "status": self.status,
4809            "ownedSave": self.owned_state,
4810            // What an assembly Save still has out after its status says
4811            // "saved": checkouts (the assembly's own included), new revisions, and the Save that names them.
4812            // 0 = the PLM holds everything this Save will write.
4813            "ownedInFlight": self.owned_checkouts.len()
4814                + self.owned_revisions.as_ref().map_or(0, |r| r.requests.len())
4815                + usize::from(self.owned_followup.is_some())
4816                + usize::from(self.owned_top.is_some()),
4817            "retarget": self.retarget.as_ref().map(|r| serde_json::json!({
4818                "from": r.old,
4819                "to": r.new_key,
4820                "tabs": r.tabs.iter().map(|(_, title)| title.clone()).collect::<Vec<_>>(),
4821            })),
4822            "open": self.open,
4823            "mode": match self.mode {
4824                Mode::CreateComponentPart => "createcomponentpart",
4825                Mode::Open => "open",
4826                Mode::SaveAs => "saveas",
4827                Mode::ConfirmClose => "confirmclose",
4828                Mode::Export => "export",
4829                Mode::Import => "import",
4830                Mode::FlatPattern => "flatpattern",
4831                Mode::InsertComponent => "insertcomponent",
4832                Mode::StepAssembly => "stepassembly",
4833                Mode::Kicad => "kicad",
4834                Mode::PickMember => "pickmember",
4835                Mode::SpinOut => "spinout",
4836                Mode::MemberEdit => "memberedit",
4837                Mode::OwnedSave => "ownedsave",
4838                Mode::Retarget => "retarget",
4839                Mode::PickFile => "pickfile",
4840                Mode::SaveFile => "savefile",
4841            },
4842            // The Save-file chooser's export: its title, the name field, the
4843            // folder the explorer is in (where Save writes) and the size.
4844            "saveFile": self.save_file.as_ref().map(|pending| serde_json::json!({
4845                "title": pending.request.title,
4846                "name": self.save_name,
4847                "folder": store.local_files().map(|local| local.browser_location()),
4848                "bytes": pending.request.body.as_bytes().len(),
4849            })),
4850            "pickFile": self.pick_request.as_ref().map(|(tag, title)| serde_json::json!({ "tag": tag, "title": title })),
4851            "picked": self.picked.as_ref().map(|(tag, file)| serde_json::json!({
4852                "tag": tag, "name": file.name, "path": file.path, "size": file.bytes.len(),
4853            })),
4854            // The active document's class (from its own `documentClass`
4855            // field) and, for a family member, its stamp.
4856            "class": document_class::document_class(docs.engine()).slug(),
4857            "saveClass": self.save_class.slug(),
4858            "plmSaveAs": self.plm_save_as.as_ref().map(|state| state.state_json()),
4859            "familySource": family_table::engine_family_source(docs.engine()).map(|source| serde_json::json!({
4860                "family": source.family,
4861                "partNumber": source.part_number,
4862                "valuesHash": source.values_hash,
4863                "historyHash": source.history_hash,
4864            })),
4865            // The armed member picker: each row, where its member is, and
4866            // whether Generate has written it.
4867            "pickMember": self.pick_member.as_ref().map(|pick| serde_json::json!({
4868                "family": pick.family_file,
4869                "plm": pick.plm,
4870                "loading": pick.loading,
4871                "rows": pick.rows.iter().map(|row| serde_json::json!({
4872                    "partNumber": row.part_number,
4873                    "identity": row.identity,
4874                    "exists": row.exists,
4875                    "whyNot": row.why_not,
4876                })).collect::<Vec<_>>(),
4877            })),
4878            "plmBusy": self.plm_work.is_some(),
4879            // What the PLM workspace browser (the Open dialog) says went wrong.
4880            "workspaceProblem": self.workspace.problem_text(),
4881            // The armed template spin-out: the answers so far, where the new
4882            // part will go, and what (if anything) blocks Create.
4883            "spinOut": self.spin_out.as_ref().map(|spin| serde_json::json!({
4884                "template": spin.template_file,
4885                "name": spin.name,
4886                "values": spin.values,
4887                "inputs": spin.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>(),
4888                "target": spin_out_target(spin),
4889                "problems": spin.inputs.iter().filter_map(|input| template::value_problem(
4890                    &spin.template,
4891                    input,
4892                    spin.values.get(&input.name).map(String::as_str).unwrap_or(""),
4893                )).chain(template::name_problem(&spin.name)).collect::<Vec<_>>(),
4894            })),
4895            "memberPrompt": self.member_prompt.as_ref().map(|prompt| serde_json::json!({
4896                "partNumber": prompt.source.part_number,
4897                "family": prompt.source.family,
4898                "familyIdentity": prompt.family_identity,
4899                "fromSave": prompt.from_save,
4900            })),
4901            "generate": self.last_generate.as_ref().map(|report| serde_json::json!({
4902                "summary": report.summary(),
4903                "rows": report.to_json(),
4904            })),
4905            "kicad": (self.open && self.mode == Mode::Kicad).then(|| self.kicad.state_json()),
4906            // The armed §3.9 assembly choice: the file it belongs to and the
4907            // counts the prompt is showing, so the headed verifier can confirm
4908            // the dialog appeared with the numbers the import will deliver.
4909            // A `.step` upload whose structure probe is still running on the
4910            // background runner (the "reading…" state).
4911            "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
4912            "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
4913                serde_json::json!({
4914                    "name": pending.name,
4915                    "parts": pending.probe.parts,
4916                    "instances": pending.probe.instances,
4917                    "nestedDepth": pending.probe.nested_depth,
4918                    // The §3.9 checkbox: shown only when there is a tree to
4919                    // flatten, and OFF by default (import keeps the tree).
4920                    "flatten": pending.flatten,
4921                })
4922            }),
4923        })
4924        .to_string()
4925    }
4926
4927    /// The published widget hit-rects (egui points) for the headed verifier.
4928    pub fn hits_json(&self) -> String {
4929        crate::automation::hit_rects::hits_json(&self.hits)
4930    }
4931
4932    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
4933    /// elsewhere so the dialog code reads the same on both targets).
4934    fn hit(&mut self, key: &str, resp: &egui::Response) {
4935        self.hits.insert(key.to_string(), resp.rect);
4936    }
4937
4938    fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
4939        self.hits.extend(hits.iter().cloned());
4940    }
4941}
4942
4943
4944/// A sheet's name as a file-name fragment: spaces and separators become
4945/// hyphens, so `Sheet 1` exports as `<document>-Sheet-1.svg` and cannot
4946/// reach the store as a path.
4947fn slug(name: &str) -> String {
4948    let cleaned: String = name
4949        .chars()
4950        .map(|c| if c.is_alphanumeric() { c } else { '-' })
4951        .collect();
4952    let trimmed = cleaned.trim_matches('-').to_string();
4953    if trimmed.is_empty() { "sheet".into() } else { trimmed }
4954}
4955/// The hit keys this panel publishes (see `automation::hit_keys`).
4956pub static HIT_KEYS: &[HitKeyDoc] = &[
4957    HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
4958    HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
4959    HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
4960    HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
4961    HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
4962    HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:iges, export:stl, export:obj, export:glb, export:3mf, export:json, export:bomcsv, export:bomjson, the open DRAWING SHEET as export:sheetsvg, every drawing sheet as ONE multi-page PDF as export:sheetpdf, and the same drawing set with the live 3D model as export:sheetpdf3d; for a document whose PCB board has parts, its manufacturing files: the Gerber/drill/pick-and-place/BOM zip as export:fabrication, the pick-and-place CSV as export:pickplace, the electronics BOM CSV as export:ecadbom; for manufacturing instructions, the printed PDF as export:instructionspdf, every step's PNG and SVG as one zip as export:instructionsimages, and the self-contained offline player as export:instructionshtml)", command: None },
4963    HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
4964    HitKeyDoc { panel: "file", prefix: "savefile:", meaning: "the Save-file chooser a desktop export opens after its format click, for WHERE the file goes: savefile:name is the file name field, and its explorer over this machine's files publishes the explorer's key set under the `savefile` prefix (savefile:<file name> a row to overwrite, savefile:crumb:<i>, savefile:place:<label>, savefile:up, savefile:path-edit, …); `save` writes the file where the explorer is, `cancel` drops it. In the browser an export is a download and the chooser never opens", command: None },
4965    HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
4966    HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
4967    HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
4968    HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "PLM Save As: saveas:destination (one dropdown; its open entries saveas:destination#new-revision, #new-part, #existing), destination fields (part, revision, name, number), save and cancel", command: None },
4969    HitKeyDoc { panel: "file", prefix: "saveas:class", meaning: "Save As's class dropdown: saveas:class, its open entries saveas:class#normal (.nbrep), saveas:class#family (.fbrep), saveas:class#template (.tbrep)", command: None },
4970    HitKeyDoc { panel: "file", prefix: "member:", meaning: "the member picker Insert component raises for a family: member:<part number> places that member (disabled until Generate has written it), member:cancel", command: None },
4971    HitKeyDoc { panel: "file", prefix: "spinout:", meaning: "the template spin-out prompt: spinout:name, spinout:input:<name> (a value field), spinout:choice:<name> (a fixed-list input's dropdown; its open entries spinout:choice:<name>#<value>), spinout:create, spinout:cancel", command: None },
4972    HitKeyDoc { panel: "file", prefix: "retarget:", meaning: "after a part is saved under a new name, point the open assemblies that place it at the new file (retarget:yes, the default) or keep them on the old one (retarget:no)", command: None },
4973    HitKeyDoc { panel: "file", prefix: "owned:", meaning: "the assembly Save's questions about the documents it owns: owned:save (save, every ticked row), owned:cancel (save nothing), owned:row:<i> (tick/untick row i), owned:choice:<i> (which copy of a document changed differently to keep, a dropdown; its open entries owned:choice:<i>#<j>), owned:remember (check out without asking again in this session)", command: None },
4974    HitKeyDoc { panel: "file", prefix: "member-edit:", meaning: "the hand-edit prompt on a family member: member-edit:saveas, member-edit:family (open the family), member-edit:cancel (undo the change)", command: None },
4975    HitKeyDoc { panel: "file", prefix: "pickfile:", meaning: "the file chooser (any file on this machine, for a panel's Add file…): the explorer's key set under the `pickfile` prefix — pickfile:<file name> (a row), pickfile:confirm, pickfile:path-edit-toggle, …", command: Some("file_pick") },
4976    HitKeyDoc { panel: "file", prefix: "kicad:", meaning: "the KiCad import: its explorer (kicad:<file name>, kicad:confirm, kicad:upload and the explorer's other keys), then kicad:symbol:<library:name>, kicad:next, kicad:footprint:<library:name>, kicad:use-footprint, kicad:no-footprint, kicad:search, kicad:field:footprints, kicad:field:models, kicad:apply-folders, kicad:import, kicad:cancel", command: None },
4977];
4978
4979/// Read `key`'s document from the PLM, for placing it.
4980fn fetch_document(
4981    client: std::rc::Rc<crate::plm::client::PlmClient>,
4982    key: String,
4983    number: String,
4984) -> crate::panels::plm_family::Pending<(String, String, String)> {
4985    crate::panels::plm_family::Pending::new(Box::pin(async move {
4986        let bytes = client.get_document(&key).await.map_err(|e| e.to_string())?;
4987        let text = bytes
4988            .map(|b| String::from_utf8_lossy(&b).into_owned())
4989            .ok_or_else(|| format!("{key} has no document yet"))?;
4990        Ok((key, text, number))
4991    }))
4992}
4993
4994/// Recent documents are global store identities, never document content.
4995use crate::store::RECENT_DOCUMENTS_KEY;
4996pub fn recent_documents(store: &dyn ModelStore) -> Vec<String> {
4997    store
4998        .read(RECENT_DOCUMENTS_KEY)
4999        .and_then(|s| serde_json::from_str(&s).ok())
5000        .unwrap_or_default()
5001}
5002fn remember_document(store: &dyn ModelStore, name: &str) {
5003    let mut recent = recent_documents(store);
5004    recent.retain(|item| item != name);
5005    recent.insert(0, name.into());
5006    recent.truncate(20);
5007    let _ = store.write(
5008        RECENT_DOCUMENTS_KEY,
5009        &serde_json::to_string(&recent).unwrap(),
5010    );
5011}
5012
5013#[derive(Default)]
5014pub struct FileMenuOutcome {
5015    pub action: Option<FileAction>,
5016    pub recent: Option<String>,
5017    /// The Plugins row under Settings was clicked: open the plugin manager.
5018    pub plugins: bool,
5019    /// The JavaScript row under Settings was clicked: open the editor.
5020    pub javascript: bool,
5021}
5022
5023/// The side of a recent row's thumbnail square, in points.
5024pub const RECENT_THUMB_SIDE: f32 = crate::plm::thumbnail::RECENT_SIDE;
5025
5026/// The neutral square a recent document without a picture yet shows: the
5027/// same size as a thumbnail, never a gap. Drawn through the SVG loader the
5028/// menu's artwork already uses.
5029const RECENT_PLACEHOLDER_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect x="1.5" y="1.5" width="37" height="37" rx="4" fill="#8a8a8a" fill-opacity="0.16" stroke="#8a8a8a" stroke-opacity="0.45"/></svg>"##;
5030
5031/// File actions and recent documents, shared by Ribbon and Classic.
5032pub fn file_menu(
5033    ui: &mut egui::Ui,
5034    _state: &mut EngineState,
5035    store: &dyn ModelStore,
5036    settings_open: &mut bool,
5037    properties_open: &mut bool,
5038    hits: &mut HashMap<String, egui::Rect>,
5039) -> FileMenuOutcome {
5040    use crate::panels::toolbar_button;
5041    let mut outcome = FileMenuOutcome::default();
5042    let presentation = crate::workbench::file_presentation;
5043    egui_extras::install_image_loaders(ui.ctx());
5044    let menu = ui.menu_button("File", |ui| {
5045        let file_rows: Vec<_> = crate::workbench::FILE_COMMANDS
5046            .iter()
5047            .map(|p| (p.glyph, p.label()))
5048            .collect();
5049        let compact = crate::viewport::touch::compact(ui.ctx());
5050        let width = toolbar_button::menu_width(ui, &file_rows)
5051            .min((ui.ctx().content_rect().width() - 32.0).max(100.0));
5052        let right_width = width;
5053        ui.set_width(width);
5054        let max_height = ui.ctx().content_rect().height() * 0.8;
5055        ui.set_max_height(max_height);
5056        // The whole body sits in one scroll area so a window too short for
5057        // the columns themselves still shows a menu that fits; on any taller
5058        // window the columns never scroll and only the recent list does, up
5059        // to the room left under the cap (`cap_bottom`).
5060        let cap_bottom = ui.max_rect().bottom();
5061        let body = egui::ScrollArea::vertical()
5062            .id_salt("file-menu-body")
5063            .auto_shrink([false, true])
5064            .max_height(max_height)
5065            .show(ui, |ui| {
5066                let columns = |ui: &mut egui::Ui| {
5067                    ui.vertical(|ui| {
5068                        ui.set_width(right_width);
5069                        ui.weak("Document");
5070                        for (class, p) in DocumentClass::ALL
5071                            .into_iter()
5072                            .zip(crate::workbench::NEW_DOCUMENT_COMMANDS)
5073                        {
5074                            let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5075                            hits.insert(p.id.into(), row.rect);
5076                            if class == DocumentClass::Normal {
5077                                hits.insert("file:new".into(), row.rect);
5078                                hits.insert("file:newmenu".into(), row.rect);
5079                            }
5080                            if row.clicked() {
5081                                outcome.action = Some(FileAction::NewOfClass(class));
5082                                ui.close();
5083                            }
5084                        }
5085                        for (id, action) in [
5086                            ("file:open", FileAction::Open),
5087                            ("file:save", FileAction::Save),
5088                            ("file:saveas", FileAction::SaveAs),
5089                            ("file:import", FileAction::Import),
5090                            ("file:export", FileAction::Export),
5091                        ] {
5092                            let p = presentation(id);
5093                            let row = toolbar_button::menu_row(ui, p.glyph, p.label(), false, right_width);
5094                            hits.insert(id.into(), row.rect);
5095                            if row.clicked() {
5096                                outcome.action = Some(action);
5097                                ui.close();
5098                            }
5099                        }
5100                        let properties = toolbar_button::menu_row(
5101                            ui,
5102                            presentation("properties").glyph,
5103                            presentation("properties").label(),
5104                            *properties_open,
5105                            right_width,
5106                        );
5107                        hits.insert("properties".into(), properties.rect);
5108                        if properties.clicked() {
5109                            *properties_open = !*properties_open;
5110                            ui.close();
5111                        }
5112                        let settings = toolbar_button::menu_row(
5113                            ui,
5114                            presentation("settings").glyph,
5115                            presentation("settings").label(),
5116                            *settings_open,
5117                            right_width,
5118                        );
5119                        hits.insert("settings".into(), settings.rect);
5120                        if settings.clicked() {
5121                            *settings_open = !*settings_open;
5122                            ui.close();
5123                        }
5124                        // Plugin management sits under Settings: the plugin manager
5125                        // and the JavaScript editor, the same rows in both styles.
5126                        for (id, flag) in [
5127                            ("workbench:manage:plugins", &mut outcome.plugins),
5128                            ("workbench:manage:javascript", &mut outcome.javascript),
5129                        ] {
5130                            let row = toolbar_button::menu_row(
5131                                ui,
5132                                presentation(id).glyph,
5133                                presentation(id).label(),
5134                                false,
5135                                right_width,
5136                            );
5137                            hits.insert(id.into(), row.rect);
5138                            if row.clicked() {
5139                                *flag = true;
5140                                ui.close();
5141                            }
5142                        }
5143                    });
5144                };
5145                if compact { ui.vertical(columns); } else { ui.horizontal_top(columns); }
5146
5147                // BELOW BOTH: the recent documents, one column, a thumbnail each. The
5148                // list scrolls under the 80% cap; the columns above it never do.
5149                let recent = recent_documents(store);
5150                if !recent.is_empty() {
5151                    ui.separator();
5152                    let caption = ui.weak("Recent Documents");
5153                    hits.insert("file:recent".into(), caption.rect);
5154                    let thumbs = crate::plm::thumbnail::recent_textures(ui.ctx(), store);
5155                    let side = egui::Vec2::splat(RECENT_THUMB_SIDE);
5156                    let row_height = RECENT_THUMB_SIDE + 2.0 * ui.spacing().button_padding.y;
5157                    // The room left under the cap; at least two rows even when the
5158                    // columns already fill it (then the outer area scrolls).
5159                    let room = cap_bottom - ui.cursor().top();
5160                    let list_height = room.max(2.0 * row_height + ui.spacing().item_spacing.y);
5161                    egui::ScrollArea::vertical()
5162                        .id_salt("file-recent")
5163                        .auto_shrink([false, true])
5164                        .max_height(list_height)
5165                        .show(ui, |ui| {
5166                            for name in &recent {
5167                                let image = match thumbs.get(name) {
5168                                    Some((_, texture)) => egui::Image::from_texture((texture.id(), side)),
5169                                    None => egui::Image::new(egui::ImageSource::Bytes {
5170                                        uri: "bytes://brep-recent-thumb/placeholder.svg".into(),
5171                                        bytes: egui::load::Bytes::Static(RECENT_PLACEHOLDER_SVG.as_bytes()),
5172                                    }),
5173                                }
5174                                .fit_to_exact_size(side);
5175                                let row = ui
5176                                    .add(
5177                                        egui::Button::new((image, model_display_name(name)))
5178                                            .wrap_mode(egui::TextWrapMode::Truncate)
5179                                            .min_size(egui::vec2(width, row_height)),
5180                                    )
5181                                    .on_hover_text(name);
5182                                hits.insert(format!("file:recent:{name}"), row.rect);
5183                                let padding = ui.spacing().button_padding;
5184                                let thumb = egui::Rect::from_min_size(
5185                                    row.rect.min + egui::vec2(padding.x, (row.rect.height() - RECENT_THUMB_SIDE) / 2.0),
5186                                    side,
5187                                );
5188                                hits.insert(format!("file:recent:thumb:{name}"), thumb);
5189                                if row.clicked() {
5190                                    outcome.recent = Some(name.clone());
5191                                    ui.close();
5192                                }
5193                            }
5194                        });
5195                }
5196            });
5197        if compact { hits.insert("file:clip".into(), body.inner_rect); }
5198    });
5199    hits.insert("file:menu".into(), menu.response.rect);
5200    outcome
5201}
5202
5203
5204/// The library ids of the tools the open instructions use (the tools whose
5205/// pictures an export fetches).
5206pub fn linked_tool_ids(engine: &EngineState) -> Vec<String> {
5207    engine
5208        .instructions()
5209        .ok()
5210        .flatten()
5211        .map(|block| block.tools_list().into_iter().filter_map(|(tool, _)| tool.tool_id).collect())
5212        .unwrap_or_default()
5213}