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 `.BREP.json` recipe) and loads it with
10//! `set_history_json` / `load_model_and_fit`. This dialog keeps NO model state
11//! and no document IDENTITY — the name and the clean baseline live on
12//! [`Document`], and the open set on [`Documents`]. It holds only transient UI
13//! buffers (the name field, a status line, the open modal) and drives the
14//! documents + the [`ModelStore`] seam.
15//!
16//! # New and Open never discard anything now
17//!
18//! Both ADD A TAB: New pushes an empty document, Open pushes the opened one (or
19//! focuses the tab already holding it). Nothing is replaced, so neither needs
20//! the "discard unsaved changes?" prompt they used to raise — the only place
21//! unsaved work can still be lost is CLOSING a tab, which is where that
22//! confirmation now lives ([`Mode::ConfirmClose`]).
23//!
24//! Persistence crosses the unified [`ModelStore`] seam — the ONE platform
25//! exception. The same embeddable explorer renders on web and desktop. The web
26//! backend lists localStorage models and can Upload; desktop lists the app's
27//! models directory. No OS-native file dialog is used.
28
29use crate::automation::hit_keys::HitKeyDoc;
30use crate::document::{Document, Documents, EMPTY_DOCUMENT};
31use crate::panels::parts_library::document_signature;
32use crate::panels::file_explorer::{self, FileExplorer, FileExplorerOptions};
33use crate::store::{model_display_name, ModelStore};
34use brep_render::engine_state::{
35    ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
36    StepAssemblyReport, StepProbeOutcome,
37};
38use eframe::egui;
39use std::collections::HashMap;
40
41/// The file operation a toolbar button requests. The shell maps a clicked
42/// toolbar button to one of these and hands it to [`FileDialog::dispatch`].
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum FileAction {
45    New,
46    Open,
47    Save,
48    SaveAs,
49    /// Import a CAD or mesh file FROM the user's filesystem, appending it to the
50    /// model rather than replacing it. STL/OBJ use RANSAC reconstruction.
51    Import,
52    /// Export the model TO the user's filesystem in a chosen format (STEP / STL).
53    Export,
54    /// Export the sheet-metal FLAT PATTERN (unfold) as a 2D vector file
55    /// (DXF / SVG). Opens the flat-pattern export modal.
56    ExportFlatPattern,
57    /// Insert an ASSEMBLY COMPONENT: opens the component selector — existing
58    /// parts-library entries first, then the model store's Open list (+ Upload)
59    /// — and routes the chosen document through the engine's insert flow
60    /// (`add_part_to_library` → an ACOMP instance referencing the returned
61    /// effective part name). Dispatched when the palette picks `ACOMP`.
62    InsertComponent,
63}
64
65/// Which modal (if any) the dialog is currently showing.
66#[derive(Clone, Copy, PartialEq, Eq)]
67enum Mode {
68    /// Open a saved model through the common explorer.
69    Open,
70    /// Prompt for a name and save under it.
71    SaveAs,
72    /// Confirm discarding unsaved changes before CLOSING a document tab —
73    /// the ONE place unsaved work can still be lost now that New and Open
74    /// both add a tab. `pending_close` holds the tab index.
75    ConfirmClose,
76    /// Choose an export format (STEP / STL) for the current model.
77    Export,
78    /// Choose a STEP / IGES / STL / OBJ file through the common explorer.
79    Import,
80    /// Choose a 2D vector format (DXF / SVG) for the sheet-metal flat pattern.
81    FlatPattern,
82    /// Pick a part to insert as an assembly component (library entries + the
83    /// stored-model list + Upload).
84    InsertComponent,
85    /// A `.step` upload whose product structure the probe found: choose whether
86    /// to keep that structure (parts + component instances) or flatten it to
87    /// bodies. Backed by [`FileDialog::pending_step_import`].
88    StepAssembly,
89}
90
91/// A probed `.step` upload waiting on the user's choice — the state that makes
92/// the §3.9 modal work across frames (egui draws every frame; the click can
93/// land many frames after the upload).
94///
95/// The PARSED assembly itself is NOT here: it lives in the engine's stash
96/// (`EngineState::probe_step_assembly` put it there), and the import consumes
97/// that stash. This holds only what the prompt says and the `text` the flat
98/// lane needs — the two lanes that re-import from source rather than from the
99/// parse ("Import as bodies", and the structured lane's own failure fallback).
100struct PendingStepImport {
101    /// The uploaded file's name — the prompt's subject and the status line's.
102    name: String,
103    /// The file text. The engine's stash holds the PARSE, not the source, so
104    /// the flat lane's input has to be kept here.
105    text: String,
106    /// The counts the prompt shows, from the same walk the import runs.
107    probe: StepAssemblyProbe,
108    /// The §3.9 checkbox: flatten the sub-assembly tree to leaf occurrences
109    /// instead of building nested rigid sub-assembly documents. Only shown (and
110    /// only meaningful) when `probe.nested_depth > 1`; a depth-1 file imports
111    /// identically either way.
112    flatten: bool,
113}
114
115/// A `.step` upload whose structure probe is RUNNING on the document's
116/// background runner (native thread / browser worker — the parse builds every
117/// product's bodies and takes seconds on a real assembly, so it left the UI
118/// thread). Resolved by [`FileDialog::poll_step_probe`] into either the §3.9
119/// choice ([`PendingStepImport`]) or the flat lane.
120struct PendingStepProbe {
121    /// The engine's probe id, so a stale answer (a superseded upload) is ignored.
122    id: u64,
123    name: String,
124    /// The file text, kept for the flat lane the outcome may route to.
125    text: String,
126}
127
128/// What the user clicked in the §3.9 assembly-choice modal.
129#[derive(Clone, Copy, PartialEq, Eq)]
130enum StepChoice {
131    /// Keep the structure: parts-library entries + one component per occurrence.
132    Assembly,
133    /// Today's flat lane, unchanged: one IMPORT3D feature carrying the text.
134    Bodies,
135    /// Import nothing, and drop the parse (Esc / click-outside land here too).
136    Cancel,
137}
138
139/// `"s"` unless there is exactly one — the difference between "1 parts" and a
140/// sentence a user believes.
141fn plural(count: usize) -> &'static str {
142    if count == 1 {
143        ""
144    } else {
145        "s"
146    }
147}
148
149/// The document name an imported assembly's unnamed products are stemmed from:
150/// the file's base name without its STEP extension, so a nameless product reads
151/// `bracket-assy-part-7`, not `bracket-assy.step-part-7`.
152fn step_document_name(file_name: &str) -> String {
153    let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
154    let cut = base
155        .rfind('.')
156        .filter(|dot| is_step_name(&base[*dot..]))
157        .unwrap_or(base.len());
158    let stem = &base[..cut];
159    if stem.is_empty() {
160        base.to_string()
161    } else {
162        stem.to_string()
163    }
164}
165
166/// The §3.9 outcome line: `imported bracket-assy.step — 7 parts, 23 components
167/// (2 mirrored instances baked)`, plus the tail an imperfect import owes the
168/// user. Every qualifier is reported ONLY when it happened, so a clean import
169/// reads clean — but a partial one never reads as a whole one:
170///
171/// * `baked_nonrigid` — occurrences whose mirror/scale was baked into a part of
172///   their own (§3.4), which is why the part count can exceed the file's;
173/// * `failed_products` — products that did not encode, skipped and counted;
174/// * `flat_fallback` — the user asked for an assembly and got bodies. Said
175///   plainly, never silently (the dialog lane reports this through the Err
176///   branch instead, which is the only way it can reach the user from here);
177/// * `first_error` — the one thing that explains the rest.
178/// The STEP-assembly import's [`PartSink`]: writes each unique part document to
179/// the model store and hands back the identity it was stored under, so an
180/// imported part carries a REAL `sourceKey` and there is no second kind of part.
181///
182/// # The destination
183///
184/// `browser_write` at the explorer's CURRENT location, under
185/// `{assembly}-{part}` — the convention `panels::step_parts` already uses for a
186/// single STEP part (that panel lets the user pick the folder first; the
187/// assembly modal inherits wherever the explorer is pointing).
188///
189/// The kernel plan's alternative — a `{assembly}/{part}.BREP.json` SUB-FOLDER —
190/// is not reachable through this door: every `browser_write` implementation
191/// flattens the name to a single file (native takes `file_name()`, web takes
192/// `model_display_name`), so a sub-path would silently collapse. Writing the
193/// assembly name into the FILE name keeps a 50-part import grouped in one
194/// listing without a folder convention this seam cannot express. Creating and
195/// navigating into a folder as a side effect of an import is the prompt this
196/// lane would have to grow, and it is not bolted on here.
197///
198/// A failed write is per part: that part stays embedded-only (`None`) and the
199/// import continues. Losing one part's FILE is recoverable; losing the import
200/// is not.
201///
202/// # Known limit of a flat name
203///
204/// `taken` is per IMPORT, so re-importing the same file reuses the same names —
205/// which is what makes cross-import dedup work (same key, same signature, the
206/// resident entry is reused). But two DIFFERENT assemblies whose document names
207/// sanitize to the same stem (`as1-ug` and `as1_ug`) write to each other's file
208/// names. The second import wins the file; the first assembly's entry then
209/// disagrees with it, so it badges outdated and the write-through guard refuses
210/// to clobber it — visible and recoverable (the embedded document is intact),
211/// but two assemblies sharing one name. The fix is the per-assembly sub-folder
212/// `browser_write` cannot express; see the kernel plan's §3.5.
213struct StorePartSink<'a> {
214    store: &'a dyn ModelStore,
215    /// Prefixes every file name, so one import's parts sort together.
216    prefix: String,
217    /// File names already claimed this import — two STEP products can sanitize
218    /// to the same name, and the second must not overwrite the first's file
219    /// (that would leave two entries pointing at one document).
220    taken: std::collections::HashSet<String>,
221    written: usize,
222    failures: Vec<String>,
223}
224
225impl<'a> StorePartSink<'a> {
226    fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
227        Self {
228            store,
229            prefix: sanitize_file_stem(prefix),
230            taken: std::collections::HashSet::new(),
231            written: 0,
232            failures: Vec::new(),
233        }
234    }
235}
236
237impl PartSink for StorePartSink<'_> {
238    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
239        let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
240        let mut name = base.clone();
241        let mut suffix = 2;
242        while !self.taken.insert(name.clone()) {
243            name = format!("{base}-{suffix}");
244            suffix += 1;
245        }
246        match self.store.browser_write(&name, document_json) {
247            Ok(identity) => {
248                self.written += 1;
249                Some(identity)
250            }
251            Err(error) => {
252                self.failures.push(format!("{name}: {error}"));
253                None
254            }
255        }
256    }
257}
258
259/// A STEP product name reduced to something every store backend can hold as a
260/// file name: ASCII word characters, `.`, `-` kept; everything else (spaces,
261/// slashes, the `(mirrored)` parentheses this crate appends) becomes `_`. Runs
262/// of `_` collapse and the ends are trimmed, so a name is readable rather than
263/// a row of underscores. Empty input yields `part`.
264fn sanitize_file_stem(name: &str) -> String {
265    let mut out = String::with_capacity(name.len());
266    for ch in name.chars() {
267        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
268            out.push(ch);
269        } else if !out.ends_with('_') {
270            out.push('_');
271        }
272    }
273    let trimmed = out.trim_matches('_');
274    if trimmed.is_empty() {
275        "part".to_string()
276    } else {
277        trimmed.to_string()
278    }
279}
280
281fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
282    let mut message = format!(
283        "imported {name} \u{2014} {} part{}, {} component{}",
284        report.parts,
285        plural(report.parts),
286        report.instances,
287        plural(report.instances)
288    );
289    if report.baked_nonrigid > 0 {
290        message.push_str(&format!(
291            " ({} mirrored instance{} baked)",
292            report.baked_nonrigid,
293            plural(report.baked_nonrigid)
294        ));
295    }
296    if report.failed_products > 0 {
297        message.push_str(&format!(
298            "; {} product{} could not be built",
299            report.failed_products,
300            plural(report.failed_products)
301        ));
302    }
303    if report.flat_fallback {
304        message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
305    }
306    if let Some(error) = &report.first_error {
307        message.push_str(&format!(" [{error}]"));
308    }
309    message
310}
311
312/// Whether an imported file name is a STEP file (`.step` / `.stp`, any case) —
313/// the routing key in [`FileDialog::show`]. Model documents arrive
314/// extension-stripped (web) or as a stored model name, so this
315/// never mis-routes a `.BREP.json` file.
316fn is_step_name(name: &str) -> bool {
317    let lower = name.to_ascii_lowercase();
318    lower.ends_with(".step") || lower.ends_with(".stp")
319}
320
321/// Whether an imported file name is an IGES file (`.iges` / `.igs`, any case) —
322/// the sibling routing key of [`is_step_name`].
323fn is_iges_name(name: &str) -> bool {
324    let lower = name.to_ascii_lowercase();
325    lower.ends_with(".iges") || lower.ends_with(".igs")
326}
327
328fn is_stl_name(name: &str) -> bool {
329    name.to_ascii_lowercase().ends_with(".stl")
330}
331
332fn is_obj_name(name: &str) -> bool {
333    name.to_ascii_lowercase().ends_with(".obj")
334}
335
336/// The reusable file dialog — transient UI buffers + the current document
337/// identity; the model itself lives in `EngineState`'s history.
338pub struct FileDialog {
339    /// Reusable browser body shared by Open, Save As, and ACOMP selection.
340    explorer: FileExplorer,
341    /// The name field buffer, used by the Save As modal.
342    name_buf: String,
343    /// Last-action status line, surfaced inside the modal.
344    status: String,
345    /// Whether a modal is currently open.
346    open: bool,
347    /// The open modal's mode (only meaningful while `open`).
348    mode: Mode,
349    /// Set on open so the Save As text input grabs focus on the next frame.
350    want_focus: bool,
351    /// A pending real-file pick belongs to the INSERT-COMPONENT flow (the
352    /// modal's Upload fired there): the next completed `take_import` routes to
353    /// the component insert instead of Open's load-document.
354    pending_component_import: bool,
355    /// The tab index a pending [`Mode::ConfirmClose`] will close on "Discard".
356    pending_close: Option<usize>,
357    /// A probed STEP assembly waiting on the §3.9 choice modal. `Some` means the
358    /// ENGINE is holding a parsed assembly for us (with every product's solids
359    /// resident), so every exit from that modal must end its life: import it,
360    /// discard it, or be superseded by the next upload.
361    pending_step_import: Option<PendingStepImport>,
362    /// A `.step` upload whose probe has not answered yet (see
363    /// [`PendingStepProbe`]); the status line says "reading…" meanwhile.
364    pending_step_probe: Option<PendingStepProbe>,
365    pending_stl_import: Option<(String, Vec<u8>)>,
366    /// Bumped on every SUCCESSFUL store save (plain Save / Save As / native
367    /// Save-As) — one half of the update-components staleness key (saving a
368    /// part's source must re-check the outdated badges without a reload).
369    save_generation: u64,
370    /// Per-frame widget hit-rects for the headed verifier (wasm only).
371    hits: HashMap<String, egui::Rect>,
372}
373
374impl FileDialog {
375    /// A closed dialog with empty buffers. Document identity (name + clean
376    /// baseline) lives on [`Document`], so there is nothing to seed here.
377    pub fn new() -> Self {
378        Self {
379            explorer: FileExplorer::new(),
380            name_buf: String::new(),
381            status: String::new(),
382            open: false,
383            mode: Mode::Open,
384            want_focus: false,
385            pending_component_import: false,
386            pending_close: None,
387            pending_step_import: None,
388            pending_step_probe: None,
389            pending_stl_import: None,
390            save_generation: 0,
391            hits: HashMap::new(),
392        }
393    }
394
395    /// The monotonic successful-save counter — the shell feeds it to the
396    /// update-components checker as half its staleness key.
397    pub fn save_generation(&self) -> u64 {
398        self.save_generation
399    }
400
401    // --- dispatch: a toolbar button was clicked -------------------------------
402
403    /// Act on a toolbar file button. New acts immediately (it adds a tab, so
404    /// there is nothing to discard); all browsing flows use the same in-app
405    /// modal on both platforms.
406    pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
407        match action {
408            FileAction::New => self.new_document(docs),
409            FileAction::Open => self.open_modal(Mode::Open),
410            FileAction::Save => match docs.active().name().map(str::to_string) {
411                // A named document saves straight to its name.
412                Some(name) => {
413                    let _ = self.save_to(docs, store, name);
414                }
415                // An unnamed document falls through to Save As.
416                None => self.dispatch(FileAction::SaveAs, docs, store),
417            },
418            FileAction::SaveAs => {
419                self.name_buf = self.effective_name(docs);
420                self.open_modal(Mode::SaveAs);
421            }
422            FileAction::Import => {
423                self.open_modal(Mode::Import);
424            }
425            FileAction::Export => {
426                self.name_buf = self.effective_name(docs);
427                self.open_modal(Mode::Export);
428            }
429            FileAction::ExportFlatPattern => {
430                self.name_buf = self.effective_name(docs);
431                self.open_modal(Mode::FlatPattern);
432            }
433            // ALWAYS the in-app modal (never the native picker directly): the
434            // "existing library entries first" list is an in-app concept; the
435            // modal's own Upload button drives the platform picker when the
436            // store supports interchange.
437            FileAction::InsertComponent => self.open_modal(Mode::InsertComponent),
438        }
439    }
440
441    /// Open the modal in `mode` (and focus its input next frame).
442    fn open_modal(&mut self, mode: Mode) {
443        self.mode = mode;
444        self.open = true;
445        self.want_focus = true;
446    }
447
448    // --- per-frame draw -------------------------------------------------------
449
450    /// Draw the modal (if open) and pick up any completed async import. Called
451    /// every frame by the shell with a ctx-level handle (the modal is ctx-level,
452    /// like the command palette).
453    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
454        self.hits.clear();
455
456        // A STEP probe the runner has answered since last frame resolves into
457        // the assembly choice or the flat import now, before drawing.
458        self.poll_step_probe(docs.engine_mut());
459
460        // A completed async browser upload is picked up
461        // here, before drawing, so the model is live for this frame. Routed by
462        // extension: a STEP file (`.step`/`.stp`) is APPENDED to the model as an
463        // IMPORT3D feature; anything else is a `.BREP.json` model document loaded
464        // (replacing the model). The model lanes deliver an extension-less name
465        // (web, stripped by `model_display_name`) or a stored model name, so only
466        // real STEP files route here.
467        if let Some(imported) = store.take_import() {
468            // A new delivery SUPERSEDES an armed assembly choice. The modal
469            // describes a parse this routing is about to replace (a STEP probe
470            // drops the previous stash by contract, and a document load drops it
471            // outright), so leaving the choice armed would offer the user a
472            // button whose stash is already gone.
473            self.cancel_pending_step_import(docs.engine_mut());
474            let name = imported.name;
475            let bytes = imported.bytes;
476            // CAD/mesh imports target the active document. STL waits for
477            // preview acceptance; model documents open in a separate tab.
478            if is_step_name(&name) {
479                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
480            } else if is_iges_name(&name) {
481                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
482            } else if is_stl_name(&name) {
483                self.stage_stl_preview(name, bytes);
484            } else if is_obj_name(&name) {
485                self.import_obj(docs.engine_mut(), &name, &bytes);
486            } else if std::mem::take(&mut self.pending_component_import) {
487                // The insert-component modal's Upload fired this pick: the
488                // chosen document becomes a parts-library entry + an instance,
489                // NOT a new tab.
490                match String::from_utf8(bytes) {
491                    Ok(contents) => {
492                        self.insert_component_document(docs.engine_mut(), &name, &contents)
493                    }
494                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
495                }
496            } else {
497                match String::from_utf8(bytes) {
498                    Ok(contents) => self.load_document(docs, &name, &contents),
499                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
500                }
501            }
502            self.close_after_import();
503        }
504
505        if !self.open {
506            return;
507        }
508
509        match self.mode {
510            Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
511            Mode::SaveAs => self.show_save_as(ctx, docs, store),
512            Mode::Open => self.show_open(ctx, docs, store),
513            Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
514            Mode::Export => self.show_export(ctx, docs, store),
515            Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
516            Mode::InsertComponent => self.show_insert_component(ctx, docs.engine_mut(), store),
517            Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
518        }
519    }
520
521    /// Close the modal after a completed import — unless the import ARMED the
522    /// §3.9 assembly choice, in which case the modal stays open and switches to
523    /// it. Both import routing sites (the async-upload poll and the Import
524    /// modal's own pick) end here, so neither can close a dialog the probe just
525    /// raised.
526    fn close_after_import(&mut self) {
527        if self.pending_step_import.is_some() {
528            self.open_modal(Mode::StepAssembly);
529        } else {
530            self.open = false;
531        }
532    }
533
534    /// Abandon an armed assembly choice and the engine stash behind it. The ONE
535    /// place the app-side pending state and the engine-side parse are dropped
536    /// together — they are two halves of one thing, and a half-drop is either a
537    /// dialog with no stash or solids nobody will ever consume.
538    fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
539        if self.pending_step_import.take().is_some() {
540            state.discard_probed_step_assembly();
541        }
542    }
543
544    /// Common CAD/mesh browser. Desktop enumerates files in the application's
545    /// models directory; web shows the same explorer shell with an Upload action.
546    fn show_import(
547        &mut self,
548        ctx: &egui::Context,
549        state: &mut EngineState,
550        store: &dyn ModelStore,
551    ) {
552        const EXTENSIONS: &[&str] = &["step", "stp", "iges", "igs", "stl", "obj"];
553        let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
554            file_explorer::dialog_body(ui, |ui| {
555                ui.heading("Import CAD file");
556                ui.add_space(4.0);
557                file_explorer::dialog_footer(ui, "import", |ui| {
558                    if !self.status.is_empty() {
559                        ui.add_space(4.0);
560                        ui.weak(&self.status);
561                    }
562                });
563                let options = FileExplorerOptions {
564                    hit_prefix: "import",
565                    empty_label: "(no STEP, IGES, STL, or OBJ files)",
566                    row_icon: "\u{1F5CE}",
567                    current: None,
568                    allow_delete: false,
569                    allow_import: store.supports_file_interchange(),
570                    import_label: "Upload\u{2026}",
571                    import_hit: "import:upload",
572                    show_cancel: true,
573                    confirm_label: Some("Import"),
574                    extensions: EXTENSIONS,
575                };
576                let output = self.explorer.show_store(ui, store, options);
577                self.record_explorer_hits(&output.hits);
578                output
579            })
580        });
581        let should_close = modal.should_close();
582        let output = modal.inner;
583        if let Some(name) = output.activated {
584            match store.read_external_file(&name) {
585                Some(bytes) if is_step_name(&name) => {
586                    self.import_text(state, &name, &bytes, Self::import_step)
587                }
588                Some(bytes) if is_iges_name(&name) => {
589                    self.import_text(state, &name, &bytes, Self::import_iges)
590                }
591                Some(bytes) if is_stl_name(&name) => self.stage_stl_preview(name, bytes),
592                Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
593                Some(_) => self.status = format!("unsupported import file: {name}"),
594                None => self.status = format!("import failed: '{name}' not found"),
595            }
596            self.close_after_import();
597        } else if output.import {
598            match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
599                Ok(()) => {
600                    self.status = "choose a STEP, IGES, STL, or OBJ file\u{2026}".into();
601                    self.open = false;
602                }
603                Err(e) => self.status = format!("import failed: {e}"),
604            }
605        } else if output.cancel || should_close {
606            self.open = false;
607        }
608    }
609
610    /// CLOSE tab `index` with the unsaved-changes contract: a DIRTY document
611    /// prompts to discard first (the confirm modal); a clean one closes
612    /// immediately. The tab strip's `\u{2715}` routes here — it is the only
613    /// door through which unsaved work can be dropped, so it is the only one
614    /// that asks.
615    pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
616        let dirty = docs.get(index).is_some_and(Document::is_dirty);
617        if dirty {
618            self.pending_close = Some(index);
619            self.open_modal(Mode::ConfirmClose);
620        } else {
621            docs.close(index);
622        }
623    }
624
625    /// The **Discard unsaved changes?** confirmation shown before closing a
626    /// dirty document tab. Keeps the verifier's `confirm:discard` /
627    /// `confirm:cancel` hit keys — the same two buttons, one door further on.
628    fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
629        // A tab closed / reordered under an open prompt (there is no such path
630        // today, but the index is only meaningful while it resolves).
631        let Some(title) = self
632            .pending_close
633            .and_then(|index| docs.get(index))
634            .map(Document::title)
635        else {
636            self.pending_close = None;
637            self.open = false;
638            return;
639        };
640        let mut discard = false;
641        let mut cancel = false;
642        let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
643            ui.set_width(340.0);
644            ui.heading("Discard unsaved changes?");
645            ui.add_space(4.0);
646            ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
647            ui.add_space(6.0);
648            ui.horizontal(|ui| {
649                let d = ui.button("Discard and close");
650                self.hit("confirm:discard", &d);
651                if d.clicked() {
652                    discard = true;
653                }
654                let c = ui.button("Cancel");
655                self.hit("confirm:cancel", &c);
656                if c.clicked() {
657                    cancel = true;
658                }
659            });
660        });
661        if discard {
662            if let Some(index) = self.pending_close.take() {
663                docs.close(index);
664            }
665            self.open = false;
666        } else if cancel || modal.should_close() {
667            self.pending_close = None;
668            self.open = false;
669        }
670    }
671
672    /// The §3.9 **assembly choice**, raised when a `.step` upload's probe found a
673    /// product structure:
674    ///
675    /// > **"bracket-assy.step" contains an assembly** — 7 parts, 23 instances.
676    /// > [ Import as assembly ] [ Import as bodies ] [ Cancel ]
677    ///
678    /// Default (and first) is **Import as assembly**. "Import as bodies" is
679    /// today's flat lane, unchanged. Cancel — and Esc / click-outside, which
680    /// egui folds into `should_close` — import nothing and DROP the parse: the
681    /// engine is holding every product's solids until one of these three lands.
682    ///
683    /// **Flatten sub-assemblies** (§3.9) is offered only when the file actually
684    /// HAS sub-assemblies (`nested_depth > 1`) — on a single-level file both
685    /// lanes produce the identical document, so a box there would teach the user
686    /// a distinction that does not exist. Unchecked (the default) keeps the
687    /// tree; checked flattens to leaf occurrences, which is the right answer for
688    /// a deep or pathological file and stores a part reused across two levels
689    /// once rather than once per level (build-spec §2.2).
690    fn show_step_assembly(
691        &mut self,
692        ctx: &egui::Context,
693        state: &mut EngineState,
694        store: &dyn ModelStore,
695    ) {
696        // Nothing armed means nothing to choose about (a supersession raced the
697        // draw): close rather than render an empty prompt.
698        let Some(pending) = self.pending_step_import.as_ref() else {
699            self.open = false;
700            return;
701        };
702        let name = pending.name.clone();
703        let probe = pending.probe;
704        let mut flatten = pending.flatten;
705        let mut choice: Option<StepChoice> = None;
706        let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
707            ui.set_width(360.0);
708            ui.heading(format!("\"{name}\" contains an assembly"));
709            ui.add_space(4.0);
710            ui.label(format!(
711                "{} part{}, {} instance{}{}.",
712                probe.parts,
713                plural(probe.parts),
714                probe.instances,
715                plural(probe.instances),
716                match probe.nested_depth {
717                    0 | 1 => String::new(),
718                    depth => format!(", {depth} levels deep"),
719                }
720            ));
721            if probe.nested_depth > 1 {
722                ui.add_space(4.0);
723                let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
724                self.hit("stepassembly:flatten", &check);
725                check.on_hover_text(
726                    "Off: each sub-assembly becomes one rigid component you can \
727                     expand in the structure tree.\nOn: every part is placed \
728                     directly in this document at its world position.",
729                );
730            }
731            ui.add_space(6.0);
732            ui.horizontal(|ui| {
733                let a = ui.button("Import as assembly");
734                self.hit("stepassembly:assembly", &a);
735                if a.clicked() {
736                    choice = Some(StepChoice::Assembly);
737                }
738                let b = ui.button("Import as bodies");
739                self.hit("stepassembly:bodies", &b);
740                if b.clicked() {
741                    choice = Some(StepChoice::Bodies);
742                }
743                let c = ui.button("Cancel");
744                self.hit("stepassembly:cancel", &c);
745                if c.clicked() {
746                    choice = Some(StepChoice::Cancel);
747                }
748            });
749        });
750        // The box must survive the frames between the tick and the click.
751        if let Some(pending) = self.pending_step_import.as_mut() {
752            pending.flatten = flatten;
753        }
754        // Esc / click-outside is a Cancel, not a no-op: the stash must not
755        // outlive the prompt that was going to consume it.
756        let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
757        let Some(choice) = choice else { return };
758        match choice {
759            StepChoice::Assembly => self.step_assembly_import(state, store),
760            StepChoice::Bodies => self.step_assembly_bodies(state),
761            StepChoice::Cancel => self.step_assembly_cancel(state),
762        }
763        self.open = false;
764    }
765
766    /// The **Save As** name prompt.
767    fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
768        let current = docs.active().name().map(str::to_string);
769        let mut do_save = false;
770        let mut cancel = false;
771        let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
772            file_explorer::dialog_body(ui, |ui| {
773                ui.heading("Save model as");
774                ui.add_space(4.0);
775                // The name field and its buttons are the dialog's footer: pinned
776                // under the browser, which fills whatever is left.
777                file_explorer::dialog_footer(ui, "saveas", |ui| {
778                    ui.label("File name");
779                    let field = ui.add(
780                        egui::TextEdit::singleline(&mut self.name_buf)
781                            .hint_text("model name")
782                            .desired_width(f32::INFINITY),
783                    );
784                    self.hit("field:name", &field);
785                    if self.want_focus {
786                        field.request_focus();
787                        self.want_focus = false;
788                    }
789                    let enter =
790                        field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
791                    ui.add_space(6.0);
792                    ui.horizontal(|ui| {
793                        let save = ui.button("Save");
794                        self.hit("save", &save);
795                        if save.clicked() || enter {
796                            do_save = true;
797                        }
798                        let c = ui.button("Cancel");
799                        self.hit("cancel", &c);
800                        if c.clicked() {
801                            cancel = true;
802                        }
803                    });
804                    if !self.status.is_empty() {
805                        ui.add_space(4.0);
806                        ui.weak(&self.status);
807                    }
808                });
809                let options = FileExplorerOptions {
810                    hit_prefix: "saveas:file",
811                    empty_label: "(no saved models)",
812                    row_icon: "\u{1F5CE}",
813                    current: current.as_deref(),
814                    allow_delete: false,
815                    allow_import: false,
816                    import_label: "",
817                    import_hit: "saveas:upload",
818                    show_cancel: false,
819                    confirm_label: None,
820                    extensions: &["BREP.json", "json"],
821                };
822                let output = self.explorer.show_store(ui, store, options);
823                self.record_explorer_hits(&output.hits);
824                // Selecting (or double-clicking) a stored model fills the name
825                // field so it can be overwritten; `picked`/`activated` are
826                // one-shot so it never clobbers a name the user then types. The
827                // field itself was drawn above, so the explorer asks for the
828                // repaint that shows the new name.
829                if let Some(name) = output.picked.or(output.activated) {
830                    self.name_buf = model_display_name(&name);
831                }
832            })
833        });
834        if do_save {
835            let name = self.name_buf.clone();
836            if self.save_to_browser(docs, store, name) {
837                self.open = false;
838            }
839        } else if cancel || modal.should_close() {
840            self.open = false;
841        }
842    }
843
844    /// The **Export** chooser: pick a format (STEP / IGES / STL) and write the
845    /// current model to the user's filesystem under `<name>.<ext>` through the
846    /// store's format-typed interchange. STEP and IGES serialize the exact NURBS
847    /// topology; STL is the ASCII display mesh. No solids → a clear status line,
848    /// nothing written. A second row offers the assembly BOM (CSV / JSON),
849    /// enabled only while the document HAS components.
850    fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
851        // BOM gating (assemblies build-spec §9): `component_ids` scans the
852        // history (cheap, no kernel session). A rolled-back/failed ACOMP can
853        // still enable the buttons — the export's own loud "no components"
854        // error covers that gap.
855        let has_components = !docs.engine().component_ids().is_empty();
856        let mut chosen: Option<&'static str> = None;
857        let mut bom_chosen: Option<&'static str> = None;
858        let mut cancel = false;
859        let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
860            ui.set_width(320.0);
861            ui.heading("Export model");
862            ui.add_space(4.0);
863            let field = ui.add(
864                egui::TextEdit::singleline(&mut self.name_buf)
865                    .hint_text("file name")
866                    .desired_width(f32::INFINITY),
867            );
868            self.hit("field:name", &field);
869            if self.want_focus {
870                field.request_focus();
871                self.want_focus = false;
872            }
873            ui.add_space(6.0);
874            ui.horizontal(|ui| {
875                let step = ui.button("STEP (.step)");
876                self.hit("export:step", &step);
877                if step.clicked() {
878                    chosen = Some("step");
879                }
880                let iges = ui.button("IGES (.igs)");
881                self.hit("export:iges", &iges);
882                if iges.clicked() {
883                    chosen = Some("iges");
884                }
885                let stl = ui.button("STL (.stl)");
886                self.hit("export:stl", &stl);
887                if stl.clicked() {
888                    chosen = Some("stl");
889                }
890                // The full model RECIPE (`.BREP.json`) — for saving a document to
891                // disk / sharing a failing model for a bug report. Re-openable via
892                // Open / Import.
893                let json = ui.button("JSON (.BREP.json)");
894                self.hit("export:json", &json);
895                if json.clicked() {
896                    chosen = Some("json");
897                }
898                let c = ui.button("Cancel");
899                self.hit("cancel", &c);
900                if c.clicked() {
901                    cancel = true;
902                }
903            });
904            ui.add_space(4.0);
905            ui.horizontal(|ui| {
906                // The assembly BOM (parts list): one row per parts-library
907                // entry with the live instance count. Disabled while the
908                // document has no components (spec §9).
909                let csv = ui.add_enabled(has_components, egui::Button::new("BOM (CSV)"));
910                self.hit("export:bomcsv", &csv);
911                if csv.clicked() {
912                    bom_chosen = Some("csv");
913                }
914                let json = ui.add_enabled(has_components, egui::Button::new("BOM (JSON)"));
915                self.hit("export:bomjson", &json);
916                if json.clicked() {
917                    bom_chosen = Some("json");
918                }
919            });
920            if !self.status.is_empty() {
921                ui.add_space(4.0);
922                ui.weak(&self.status);
923            }
924        });
925        if let Some(format) = chosen {
926            if self.export_as(docs, store, format) {
927                self.open = false;
928            }
929        } else if let Some(format) = bom_chosen {
930            if self.export_bom_as(docs, store, format) {
931                self.open = false;
932            }
933        } else if cancel || modal.should_close() {
934            self.open = false;
935        }
936    }
937
938    /// The **Flat pattern** chooser: pick a 2D vector format (DXF R12 / SVG) and
939    /// write the sheet-metal body's unfolded flat pattern to the user's filesystem
940    /// under `<name>.<ext>`. The unfold runs TRANSIENTLY in the engine (no feature,
941    /// no history change). A part with no sheet-metal body reports it in the status
942    /// line AND queues a toast (see [`Self::export_flat_pattern_as`]).
943    fn show_flat_pattern(
944        &mut self,
945        ctx: &egui::Context,
946        docs: &mut Documents,
947        store: &dyn ModelStore,
948    ) {
949        let mut chosen: Option<&'static str> = None;
950        let mut cancel = false;
951        let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
952            ui.set_width(340.0);
953            ui.heading("Export flat pattern");
954            ui.add_space(2.0);
955            ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
956            ui.add_space(4.0);
957            let field = ui.add(
958                egui::TextEdit::singleline(&mut self.name_buf)
959                    .hint_text("file name")
960                    .desired_width(f32::INFINITY),
961            );
962            self.hit("field:name", &field);
963            if self.want_focus {
964                field.request_focus();
965                self.want_focus = false;
966            }
967            ui.add_space(6.0);
968            ui.horizontal(|ui| {
969                let dxf = ui.button("DXF (.dxf)");
970                self.hit("flat:dxf", &dxf);
971                if dxf.clicked() {
972                    chosen = Some("dxf");
973                }
974                let svg = ui.button("SVG (.svg)");
975                self.hit("flat:svg", &svg);
976                if svg.clicked() {
977                    chosen = Some("svg");
978                }
979                let c = ui.button("Cancel");
980                self.hit("cancel", &c);
981                if c.clicked() {
982                    cancel = true;
983                }
984            });
985            if !self.status.is_empty() {
986                ui.add_space(4.0);
987                ui.weak(&self.status);
988            }
989        });
990        if let Some(format) = chosen {
991            if self.export_flat_pattern_as(docs, store, format) {
992                self.open = false;
993            }
994        } else if cancel || modal.should_close() {
995            self.open = false;
996        }
997    }
998
999    /// The **Open** browser: the saved-model list (+ Upload where the platform
1000    /// supports real-file interchange).
1001    fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
1002        let current = docs.active().name().map(str::to_string);
1003        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
1004            file_explorer::dialog_body(ui, |ui| {
1005                ui.heading("Open model");
1006                ui.add_space(4.0);
1007                file_explorer::dialog_footer(ui, "open", |ui| {
1008                    if !self.status.is_empty() {
1009                        ui.add_space(4.0);
1010                        ui.weak(&self.status);
1011                    }
1012                });
1013                let mut options = FileExplorerOptions::open(current.as_deref());
1014                options.allow_import = store.supports_file_interchange();
1015                let output = self.explorer.show_store(ui, store, options);
1016                self.record_explorer_hits(&output.hits);
1017                output
1018            })
1019        });
1020        let should_close = modal.should_close();
1021        let output = modal.inner;
1022        if let Some(name) = output.activated {
1023            self.open_document(docs, store, &name);
1024            self.open = false;
1025        } else if let Some(name) = output.remove {
1026            let _ = store.remove(&name);
1027            self.status = format!("removed {name}");
1028            // A tab holding the removed document keeps its content but loses its
1029            // file: it becomes an untitled document, so a later Save asks where
1030            // to put it rather than silently recreating what was deleted.
1031            if let Some(index) = docs.index_of(&name) {
1032                if let Some(doc) = docs.get_mut(index) {
1033                    doc.set_name(None);
1034                }
1035            }
1036        } else if output.import {
1037            // Fire the platform picker; the file arrives via take_import() and is
1038            // loaded on a later frame (the modal closes now).
1039            match store.begin_import() {
1040                Ok(()) => {
1041                    self.status = "choose a file…".into();
1042                    self.open = false;
1043                }
1044                Err(e) => self.status = format!("import failed: {e}"),
1045            }
1046        } else if output.cancel || should_close {
1047            self.open = false;
1048        }
1049    }
1050
1051    /// The **Insert component** selector (assemblies build-spec §2.2): the
1052    /// EXISTING parts-library entries first (instant re-insert — no store read),
1053    /// then the model store's document list, + Upload where the platform
1054    /// supports real-file interchange. REUSES the Open modal's machinery (the
1055    /// same list + `take_import` poll), routed to the engine's insert flow.
1056    fn show_insert_component(
1057        &mut self,
1058        ctx: &egui::Context,
1059        state: &mut EngineState,
1060        store: &dyn ModelStore,
1061    ) {
1062        let library = state.parts_library_names();
1063        let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1064            file_explorer::dialog_body(ui, |ui| {
1065                ui.heading("Insert component");
1066                ui.add_space(4.0);
1067                file_explorer::dialog_footer(ui, "insert", |ui| {
1068                    if !self.status.is_empty() {
1069                        ui.add_space(4.0);
1070                        ui.weak(&self.status);
1071                    }
1072                });
1073                let mut chosen_library = None;
1074                if !library.is_empty() {
1075                    ui.weak("In this document (parts library)");
1076                    for name in &library {
1077                        let row = ui.add_sized(
1078                            egui::vec2(ui.available_width(), 18.0),
1079                            crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
1080                                .frame(false),
1081                        );
1082                        self.hit(&format!("insert:lib:{name}"), &row);
1083                        if row.clicked() {
1084                            chosen_library = Some(name.clone());
1085                        }
1086                    }
1087                    ui.add_space(4.0);
1088                }
1089                let options = FileExplorerOptions {
1090                    hit_prefix: "insert:model",
1091                    empty_label: "(no saved models)",
1092                    row_icon: "\u{1F5CE}",
1093                    current: None,
1094                    allow_delete: false,
1095                    allow_import: store.supports_file_interchange(),
1096                    import_label: "Upload\u{2026}",
1097                    import_hit: "insert:upload",
1098                    show_cancel: true,
1099                    confirm_label: Some("Insert"),
1100                    extensions: &["BREP.json", "json"],
1101                };
1102                let output = self.explorer.show_store(ui, store, options);
1103                self.record_explorer_hits(&output.hits);
1104                (chosen_library, output)
1105            })
1106        });
1107        let should_close = modal.should_close();
1108        let (chosen_library, output) = modal.inner;
1109        if let Some(part_name) = chosen_library {
1110            // An already-inserted library part: skip the store read entirely.
1111            match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1112                Ok(id) => {
1113                    self.status = format!("inserted {part_name} ({id})");
1114                    self.open = false;
1115                }
1116                Err(e) => self.status = format!("insert failed: {e}"),
1117            }
1118        } else if let Some(name) = output.activated {
1119            match store.read(&name) {
1120                Some(contents) => {
1121                    self.insert_component_document(state, &name, &contents);
1122                    self.open = false;
1123                }
1124                None => self.status = format!("insert failed: '{name}' not found"),
1125            }
1126        } else if output.import {
1127            match store.begin_import() {
1128                Ok(()) => {
1129                    // The picked file routes to the component insert (not Open).
1130                    self.pending_component_import = true;
1131                    self.status = "choose a part file…".into();
1132                    self.open = false;
1133                }
1134                Err(e) => self.status = format!("insert failed: {e}"),
1135            }
1136        } else if output.cancel || should_close {
1137            self.open = false;
1138        }
1139    }
1140
1141    /// Insert a part DOCUMENT as an assembly component: library-add (dedup by
1142    /// sourceKey + content signature; the RETURNED effective name is what the
1143    /// instance references) + an ACOMP feature, through the engine's one insert
1144    /// flow. The first instance of an empty assembly is written `isFixed:true`.
1145    /// `sourceSignature` is written with [`document_signature`] — the ONE
1146    /// signature fn — so the update-components comparison reads a freshly
1147    /// inserted, unchanged part as up-to-date.
1148    fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1149        let display = model_display_name(name);
1150        match state.insert_component(ComponentInsert::New {
1151            name: &display,
1152            source_key: name,
1153            source_signature: &document_signature(contents),
1154            document_json: contents,
1155        }) {
1156            Ok(id) => self.status = format!("inserted {display} ({id})"),
1157            Err(e) => self.status = format!("insert failed: {e}"),
1158        }
1159    }
1160
1161    // --- model operations -----------------------------------------------------
1162
1163    /// The name to save under: the field if non-empty, else the active
1164    /// document's name, else `"untitled"`.
1165    fn effective_name(&self, docs: &Documents) -> String {
1166        let field = self.name_buf.trim();
1167        if !field.is_empty() {
1168            field.to_string()
1169        } else {
1170            docs.active()
1171                .name()
1172                .map(str::to_string)
1173                .unwrap_or_else(|| "untitled".into())
1174        }
1175    }
1176
1177    /// **New** — an empty model in a NEW TAB. Nothing is replaced, so there is
1178    /// nothing to confirm.
1179    fn new_document(&mut self, docs: &mut Documents) {
1180        let mut engine = docs.spawn_engine();
1181        let _ = engine.set_history_json(EMPTY_DOCUMENT);
1182        docs.open_document(Document::new(engine));
1183        self.name_buf.clear();
1184        self.status = "new (empty) model".into();
1185    }
1186
1187    /// Refuse a save that would give TWO open tabs the same store identity —
1188    /// "focus the tab holding this document" has no answer then, and the second
1189    /// save would silently overwrite the first tab's file. `true` = go ahead.
1190    ///
1191    /// Compared by DISPLAY name, not raw identity: on desktop an open document
1192    /// carries the full path it was loaded from while the Save As field holds a
1193    /// bare name, so an identity compare would never match and the clobber would
1194    /// happen before anything noticed. Two same-stemmed files in different
1195    /// folders are refused too — stricter than strictly necessary, and the side
1196    /// to err on when the alternative is overwriting another tab's file.
1197    fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
1198        let display = model_display_name(name);
1199        let taken = docs.iter().enumerate().any(|(index, doc)| {
1200            index != docs.active_index()
1201                && doc
1202                    .name()
1203                    .is_some_and(|open| model_display_name(open) == display)
1204        });
1205        if taken {
1206            self.status = format!("'{display}' is already open in another tab");
1207        }
1208        !taken
1209    }
1210
1211    /// Write the active document's request JSON through the store under `name`.
1212    /// Returns `true` on success (so the caller can close the modal).
1213    fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
1214        let name = name.trim().to_string();
1215        if name.is_empty() {
1216            self.status = "enter a name to save".into();
1217            return false;
1218        }
1219        if !self.name_is_free(docs, &name) {
1220            return false;
1221        }
1222        match store.write(&name, &docs.engine().history_request_json()) {
1223            Ok(()) => {
1224                // The document keeps the raw identity (a full path when a
1225                // native dialog chose it); the name field shows the bare name.
1226                self.name_buf = model_display_name(&name);
1227                let doc = docs.active_mut();
1228                doc.set_name(Some(name.clone()));
1229                doc.mark_clean();
1230                self.save_generation += 1;
1231                self.status = format!("saved {name}");
1232                true
1233            }
1234            Err(e) => {
1235                self.status = format!("save failed: {e}");
1236                false
1237            }
1238        }
1239    }
1240
1241    /// Save As through the explorer's current directory, then retain the
1242    /// backend's returned identity (an absolute path on desktop or a virtual
1243    /// `/models/...` path in the browser) for subsequent plain Save commands.
1244    fn save_to_browser(
1245        &mut self,
1246        docs: &mut Documents,
1247        store: &dyn ModelStore,
1248        name: String,
1249    ) -> bool {
1250        let name = name.trim().to_string();
1251        if name.is_empty() {
1252            self.status = "enter a name to save".into();
1253            return false;
1254        }
1255        if !self.name_is_free(docs, &name) {
1256            return false;
1257        }
1258        match store.browser_write(&name, &docs.engine().history_request_json()) {
1259            Ok(identity) => {
1260                self.name_buf = model_display_name(&identity);
1261                let doc = docs.active_mut();
1262                doc.set_name(Some(identity.clone()));
1263                doc.mark_clean();
1264                self.save_generation += 1;
1265                self.status = format!("saved {identity}");
1266                true
1267            }
1268            Err(e) => {
1269                self.status = format!("save failed: {e}");
1270                false
1271            }
1272        }
1273    }
1274
1275    /// **Open** — read a stored document into its own tab (or focus the tab
1276    /// already holding it). The one door every open lane uses: File>Open, the
1277    /// Edit-Part flow, and the session restore's siblings.
1278    pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1279        if docs.focus_named(name) {
1280            self.status = format!("{name} is already open");
1281            return;
1282        }
1283        match store.read(name) {
1284            Some(contents) => self.load_document(docs, name, &contents),
1285            None => self.status = format!("open failed: '{name}' not found"),
1286        }
1287    }
1288
1289    /// Serialize the current model in `format` (`"step"` | `"stl"` | `"json"`) and
1290    /// write it through the store's format-typed interchange under `<name>.<ext>`.
1291    /// `"json"` is the full model RECIPE (`.BREP.json`, `history_request_json`) —
1292    /// the exact document Open/Import consume, for sharing a failing model. Returns
1293    /// `true` on success (so the caller can close the modal); a guard message and
1294    /// `false` when there is nothing to export or the engine/store errs.
1295    fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
1296        let name = self.effective_name(docs);
1297        let state = docs.engine_mut();
1298        let (text, ext) = match format {
1299            "stl" => (state.export_stl_text(), "stl"),
1300            "iges" => (state.export_iges_text(), "igs"),
1301            "json" => (Ok(state.history_request_json()), "BREP.json"),
1302            // The document's name becomes the root PRODUCT's — an assembly
1303            // exports as `<name>` with its parts named under it.
1304            _ => (state.export_step_text_named(&name), "step"),
1305        };
1306        match text {
1307            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1308                Ok(()) => {
1309                    self.status = format!("exported {name}.{ext}");
1310                    true
1311                }
1312                Err(e) => {
1313                    self.status = format!("export failed: {e}");
1314                    false
1315                }
1316            },
1317            Err(e) => {
1318                self.status = format!("export failed: {e}");
1319                false
1320            }
1321        }
1322    }
1323
1324    /// Serialize the sheet-metal flat pattern in `format` (`"dxf"` | `"svg"`) and
1325    /// write it through the store under `<name>.<ext>`. Returns `true` on success
1326    /// (so the caller can close the modal). On failure the message goes to the
1327    /// status line AND is queued as a toast (the existing engine notice path), so
1328    /// a "no sheet-metal body in the part" error is surfaced prominently.
1329    fn export_flat_pattern_as(
1330        &mut self,
1331        docs: &mut Documents,
1332        store: &dyn ModelStore,
1333        format: &str,
1334    ) -> bool {
1335        let name = self.effective_name(docs);
1336        let state = docs.engine_mut();
1337        let (text, ext) = match format {
1338            "svg" => (state.export_flat_pattern_svg(), "svg"),
1339            _ => (state.export_flat_pattern_dxf(), "dxf"),
1340        };
1341        match text {
1342            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1343                Ok(()) => {
1344                    self.status = format!("exported {name}.{ext}");
1345                    true
1346                }
1347                Err(e) => {
1348                    self.status = format!("flat-pattern export failed: {e}");
1349                    false
1350                }
1351            },
1352            Err(e) => {
1353                self.status = format!("flat-pattern export failed: {e}");
1354                state.push_notice(format!("Flat pattern: {e}"));
1355                false
1356            }
1357        }
1358    }
1359
1360    /// Serialize the assembly BOM in `format` (`"csv"` | `"json"`) and write it
1361    /// through the store under `<name>.bom.<ext>` (a compound extension naming
1362    /// the content, like the `.BREP.json` recipe). On failure the message goes
1363    /// to the status line AND is queued as a toast — the flat-pattern error
1364    /// pattern — so a componentless document reports loudly.
1365    fn export_bom_as(
1366        &mut self,
1367        docs: &mut Documents,
1368        store: &dyn ModelStore,
1369        format: &str,
1370    ) -> bool {
1371        let name = self.effective_name(docs);
1372        let state = docs.engine_mut();
1373        let (text, ext) = match format {
1374            "json" => (state.export_bom_json(), "bom.json"),
1375            _ => (state.export_bom_csv(), "bom.csv"),
1376        };
1377        match text {
1378            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1379                Ok(()) => {
1380                    self.status = format!("exported {name}.{ext}");
1381                    true
1382                }
1383                Err(e) => {
1384                    self.status = format!("BOM export failed: {e}");
1385                    false
1386                }
1387            },
1388            Err(e) => {
1389                self.status = format!("BOM export failed: {e}");
1390                state.push_notice(format!("BOM export: {e}"));
1391                false
1392            }
1393        }
1394    }
1395
1396    /// Route an imported STEP file: **probe first** (kernel-plan §3.9). The probe
1397    /// IS the parse — it stashes the structure in the engine for the import to
1398    /// consume — so a structured file arms the choice modal and is imported on
1399    /// the click, never parsed a second time.
1400    ///
1401    /// Everything else goes straight to today's flat lane, unchanged:
1402    ///
1403    /// * `Ok(None)` — no product structure. A part file must NEVER see the
1404    ///   dialog, and its behaviour stays byte-for-byte what it was.
1405    /// * `Err` — text the Part 21 parser refuses. Handing it to the flat lane
1406    ///   keeps the failure wording exactly today's (the two share the same
1407    ///   `ISO-10303-21` guard), rather than inventing a second one.
1408    fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1409        // A previous file's armed choice cannot survive this one: the probe
1410        // below REPLACES the engine's stash on every outcome (including the
1411        // structureless one), so an app-side pending left standing would offer
1412        // a button whose parse is already gone. A probe still running for an
1413        // earlier upload is superseded the same way (its answer is ignored).
1414        self.pending_step_import = None;
1415        let id = state.submit_step_probe(contents);
1416        self.pending_step_probe = Some(PendingStepProbe {
1417            id,
1418            name: name.to_string(),
1419            text: contents.to_string(),
1420        });
1421        self.status = format!("reading \"{name}\"\u{2026}");
1422        // The synchronous Inline runner (tests, headless) has answered inside
1423        // the submit; a background runner answers on a later frame's poll.
1424        self.poll_step_probe(state);
1425    }
1426
1427    /// Resolve an answered STEP probe: structure arms the §3.9 choice, no
1428    /// structure (or a parse the flat lane will refuse with its own wording)
1429    /// takes the flat lane. A probe the engine no longer holds — a document
1430    /// switch or a cancelled run dropped it — is reported and forgotten.
1431    fn poll_step_probe(&mut self, state: &mut EngineState) {
1432        while let Some((id, outcome)) = state.take_step_probe() {
1433            let Some(pending) = self.pending_step_probe.as_ref() else {
1434                continue;
1435            };
1436            if pending.id != id {
1437                continue; // an earlier upload's answer; a newer probe replaced it
1438            }
1439            let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
1440            match outcome {
1441                StepProbeOutcome::Structure(probe) => {
1442                    self.status = format!(
1443                        "\"{name}\" contains an assembly — {} part{}, {} instance{}",
1444                        probe.parts,
1445                        plural(probe.parts),
1446                        probe.instances,
1447                        plural(probe.instances)
1448                    );
1449                    self.pending_step_import = Some(PendingStepImport {
1450                        name,
1451                        text,
1452                        probe,
1453                        // Default = keep the tree (§3.9). Flattening is the escape
1454                        // hatch for a deep or pathological file, not the norm.
1455                        flatten: false,
1456                    });
1457                    // The routing site's `close_after_import` ran frames ago,
1458                    // while the probe was still out, and closed the modal: the
1459                    // choice is raised HERE, when the answer lands. (Under the
1460                    // synchronous Inline runner both run in the same call, and
1461                    // the second open is idempotent.)
1462                    self.open_modal(Mode::StepAssembly);
1463                }
1464                StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
1465                    self.import_step_flat(state, &name, &text);
1466                }
1467            }
1468        }
1469        if self.pending_step_probe.is_some() && !state.step_probes_pending() {
1470            // Nothing is running and no answer came: the engine dropped the
1471            // probe (document switch / cancel). Say so rather than reading
1472            // "reading…" forever.
1473            let pending = self.pending_step_probe.take().unwrap();
1474            self.status = format!("import of \"{}\" was cancelled", pending.name);
1475        }
1476    }
1477
1478    /// Append an imported STEP file to the model (an IMPORT3D feature), then treat
1479    /// the enlarged model as dirty (an import is an edit, not an Open — the model
1480    /// keeps its current name / save baseline). THE flat lane, reached from a
1481    /// structureless file, an unparseable one, and the dialog's "Import as
1482    /// bodies".
1483    fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1484        match state.import_step_feature(contents) {
1485            // Framing is deferred to the engine (`pending_fit`): under the native
1486            // thread / wasm worker runner the body is not resident yet, so framing
1487            // here would frame the empty scene. See [`EngineState::pending_fit`].
1488            Ok(_) => self.status = format!("imported {name}"),
1489            Err(e) => self.status = format!("import failed: {e}"),
1490        }
1491    }
1492
1493    /// **Import as assembly** — consume the probe's stash into parts-library
1494    /// entries + one component per occurrence, in the engine's single batch.
1495    ///
1496    /// An `Err` here means the structured lane produced nothing, and the engine
1497    /// returns BEFORE it touches history when that happens — so the honest
1498    /// answer is the one A6's contract prescribes: re-run the flat import with
1499    /// the text this dialog is still holding, and say plainly that the assembly
1500    /// the user asked for came in as bodies. Silence there would leave them
1501    /// believing they have a structure tree that does not exist.
1502    fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
1503        let Some(pending) = self.pending_step_import.take() else {
1504            return;
1505        };
1506        // Read BEFORE the import: afterwards its own ACOMP features make the
1507        // answer unconditionally "yes" and the workbench switch never fires.
1508        let was_assembly = state.history_has_assembly();
1509        let doc_name = step_document_name(&pending.name);
1510        // The §3.9 checkbox. A depth-1 file imports identically either way, so
1511        // an un-shown box costs nothing.
1512        let opts = StepAssemblyImport {
1513            nested: !pending.flatten,
1514        };
1515        // Every unique part is written to the store as its own document, at the
1516        // browser's current location and under `{assembly}-{part}` — the same
1517        // `browser_write` door + "wherever the explorer is pointing" convention
1518        // the STEP parts-library import uses (`panels::step_parts`). So an
1519        // imported part is a part like any other: Open Part opens it, Update
1520        // Components tracks it, a part edit writes through to it.
1521        let mut sink = StorePartSink::new(store, &doc_name);
1522        let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
1523            Ok(report) => {
1524                if !was_assembly {
1525                    self.enter_assembly_workbench(state);
1526                }
1527                // The store side effect is REPORTED, never silent: a 50-part
1528                // import writes 50 files the user did not individually ask for.
1529                for failure in &sink.failures {
1530                    state.push_notice(format!("part not saved — {failure}"));
1531                }
1532                let saved = sink.written;
1533                let base = assembly_import_message(&pending.name, &report);
1534                match (saved, sink.failures.len()) {
1535                    (0, _) => base,
1536                    (saved, 0) => format!("{base}; saved {saved} part file(s)"),
1537                    (saved, failed) => {
1538                        format!("{base}; saved {saved} part file(s), {failed} could not be saved")
1539                    }
1540                }
1541            }
1542            Err(error) => match state.import_step_feature(&pending.text) {
1543                Ok(_) => format!(
1544                    "imported {} as bodies — the assembly structure could not be built ({error})",
1545                    pending.name
1546                ),
1547                Err(flat) => format!("import failed: {flat}"),
1548            },
1549        };
1550        // The status line only renders inside an OPEN modal and this one closes
1551        // on the click, so the toast is the half the user actually sees.
1552        self.status = message.clone();
1553        state.push_notice(message);
1554    }
1555
1556    /// **Import as bodies** — today's flat lane, unchanged. Drops the probe's
1557    /// stash first: the user chose the text, so the parsed assembly (and every
1558    /// product's solids it is holding resident) has no consumer left.
1559    fn step_assembly_bodies(&mut self, state: &mut EngineState) {
1560        let Some(pending) = self.pending_step_import.take() else {
1561            return;
1562        };
1563        state.discard_probed_step_assembly();
1564        self.import_step_flat(state, &pending.name, &pending.text);
1565    }
1566
1567    /// **Cancel** — import nothing and drop the parse (§3.9).
1568    fn step_assembly_cancel(&mut self, state: &mut EngineState) {
1569        let Some(pending) = self.pending_step_import.take() else {
1570            return;
1571        };
1572        state.discard_probed_step_assembly();
1573        self.status = format!("import cancelled: {}", pending.name);
1574    }
1575
1576    /// Switch to the **Assembly** workbench so a freshly imported structure is
1577    /// actually reachable: the Assembly Structure tree and the Constraints panel
1578    /// are CLAIMED by that workbench, so an assembly imported under Modeling
1579    /// would land with its structure invisible.
1580    ///
1581    /// No-op when the active workbench already shows those panels — Assembly
1582    /// itself, and "All", whose users must not be yanked out of it. Applied
1583    /// through the same settings seam the toolbar dropdown and the saved-
1584    /// workbench restore use, and like the restore NOT persisted to the settings
1585    /// blob: that blob stays the user's boot preference, and a document's
1586    /// workbench is session-scoped.
1587    fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
1588        if crate::workbench::panel_visible(
1589            &state.settings.workbench,
1590            crate::workbench::assembly::BOM_PANEL_ID,
1591        ) {
1592            return;
1593        }
1594        let _ = state.apply_settings_json(
1595            &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
1596                .to_string(),
1597        );
1598    }
1599
1600    /// Append an imported IGES file to the model (an IMPORT3D feature) — the
1601    /// IGES sibling of [`Self::import_step`].
1602    fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1603        match state.import_iges_feature(contents) {
1604            // Framing deferred to the engine (`pending_fit`) — see `import_step`.
1605            Ok(_) => self.status = format!("imported {name}"),
1606            Err(e) => self.status = format!("import failed: {e}"),
1607        }
1608    }
1609
1610    fn stage_stl_preview(&mut self, name: String, contents: Vec<u8>) {
1611        self.pending_stl_import = Some((name, contents));
1612        self.status.clear();
1613    }
1614
1615    /// Selecting an STL starts a preview session; only its Accept action edits
1616    /// the destination document.
1617    pub fn take_stl_import(&mut self) -> Option<(String, Vec<u8>)> {
1618        self.pending_stl_import.take()
1619    }
1620
1621    fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
1622        match state.import_obj_bytes_feature(contents) {
1623            Ok(_) => self.status = format!("reconstructing {name} in background…"),
1624            Err(e) => self.status = format!("import failed: {e}"),
1625        }
1626    }
1627
1628    fn import_text(
1629        &mut self,
1630        state: &mut EngineState,
1631        name: &str,
1632        bytes: &[u8],
1633        importer: fn(&mut Self, &mut EngineState, &str, &str),
1634    ) {
1635        match std::str::from_utf8(bytes) {
1636            Ok(contents) => importer(self, state, name, contents),
1637            Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
1638        }
1639    }
1640
1641    /// Load a model document's contents into a NEW TAB (roll to the last
1642    /// feature + zoom-to-fit), clean from the start. A document that fails to
1643    /// load leaves no tab behind — the engine it was loading into is dropped
1644    /// with its runner.
1645    fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str) {
1646        let mut engine = docs.spawn_engine();
1647        match engine.load_model_and_fit(contents) {
1648            Ok(_) => {
1649                // The document keeps the raw identity — the full path when a
1650                // native dialog picked the file, so plain Save writes back to
1651                // it; the name field shows only the bare display name.
1652                let mut doc = Document::new(engine);
1653                doc.set_name(Some(name.to_string()));
1654                docs.open_document(doc);
1655                self.name_buf = model_display_name(name);
1656                self.status = format!("opened {name}");
1657            }
1658            Err(e) => self.status = format!("open failed: {e}"),
1659        }
1660    }
1661
1662    // --- verifier hooks (wasm only) -------------------------------------------
1663
1664    /// The published state for the headed verifier: current name, dirty flag,
1665    /// backend label, the stored-document list, and the modal's open/mode.
1666    pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
1667        serde_json::json!({
1668            "name": docs.active().name(),
1669            "nameBuf": self.name_buf,
1670            "selected": self.explorer.selected(),
1671            "location": store.browser_location(),
1672            "dirty": docs.active().is_dirty(),
1673            "backend": store.backend_label(),
1674            "interchange": store.supports_file_interchange(),
1675            "list": store.list(),
1676            "status": self.status,
1677            "open": self.open,
1678            "mode": match self.mode {
1679                Mode::Open => "open",
1680                Mode::SaveAs => "saveas",
1681                Mode::ConfirmClose => "confirmclose",
1682                Mode::Export => "export",
1683                Mode::Import => "import",
1684                Mode::FlatPattern => "flatpattern",
1685                Mode::InsertComponent => "insertcomponent",
1686                Mode::StepAssembly => "stepassembly",
1687            },
1688            // The armed §3.9 assembly choice: the file it belongs to and the
1689            // counts the prompt is showing, so the headed verifier can confirm
1690            // the dialog appeared with the numbers the import will deliver.
1691            // A `.step` upload whose structure probe is still running on the
1692            // background runner (the "reading…" state).
1693            "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
1694            "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
1695                serde_json::json!({
1696                    "name": pending.name,
1697                    "parts": pending.probe.parts,
1698                    "instances": pending.probe.instances,
1699                    "nestedDepth": pending.probe.nested_depth,
1700                    // The §3.9 checkbox: shown only when there is a tree to
1701                    // flatten, and OFF by default (import keeps the tree).
1702                    "flatten": pending.flatten,
1703                })
1704            }),
1705        })
1706        .to_string()
1707    }
1708
1709    /// The published widget hit-rects (egui points) for the headed verifier.
1710    pub fn hits_json(&self) -> String {
1711        crate::automation::hit_rects::hits_json(&self.hits)
1712    }
1713
1714    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
1715    /// elsewhere so the dialog code reads the same on both targets).
1716    fn hit(&mut self, key: &str, resp: &egui::Response) {
1717        self.hits.insert(key.to_string(), resp.rect);
1718    }
1719
1720    fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
1721        self.hits.extend(hits.iter().cloned());
1722    }
1723}
1724
1725// BREP private tests: 9ed8ebef3ff5d27f
1726
1727/// The hit keys this panel publishes (see `automation::hit_keys`).
1728pub static HIT_KEYS: &[HitKeyDoc] = &[
1729    HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
1730    HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
1731    HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
1732    HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
1733    HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
1734    HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:stl, export:iges, export:json, export:bomcsv, export:bomjson)", command: None },
1735    HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
1736    HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
1737    HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
1738    HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
1739];