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::document::{Document, Documents, EMPTY_DOCUMENT};
30use crate::panels::parts_library::document_signature;
31use crate::panels::file_explorer::{FileExplorer, FileExplorerOptions};
32use crate::store::{model_display_name, ModelStore};
33use brep_render::engine_state::{
34    ComponentInsert, EngineState, PartSink, StepAssemblyImport, StepAssemblyProbe,
35    StepAssemblyReport, StepProbeOutcome,
36};
37use eframe::egui;
38#[cfg(target_arch = "wasm32")]
39use serde_json::Value;
40#[cfg(target_arch = "wasm32")]
41use std::collections::HashMap;
42
43/// The file operation a toolbar button requests. The shell maps a clicked
44/// toolbar button to one of these and hands it to [`FileDialog::dispatch`].
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum FileAction {
47    New,
48    Open,
49    Save,
50    SaveAs,
51    /// Import a CAD or mesh file FROM the user's filesystem, appending it to the
52    /// model rather than replacing it. STL/OBJ use RANSAC reconstruction.
53    Import,
54    /// Export the model TO the user's filesystem in a chosen format (STEP / STL).
55    Export,
56    /// Export the sheet-metal FLAT PATTERN (unfold) as a 2D vector file
57    /// (DXF / SVG). Opens the flat-pattern export modal.
58    ExportFlatPattern,
59    /// Insert an ASSEMBLY COMPONENT: opens the component selector — existing
60    /// parts-library entries first, then the model store's Open list (+ Upload)
61    /// — and routes the chosen document through the engine's insert flow
62    /// (`add_part_to_library` → an ACOMP instance referencing the returned
63    /// effective part name). Dispatched when the palette picks `ACOMP`.
64    InsertComponent,
65}
66
67/// Which modal (if any) the dialog is currently showing.
68#[derive(Clone, Copy, PartialEq, Eq)]
69enum Mode {
70    /// Open a saved model through the common explorer.
71    Open,
72    /// Prompt for a name and save under it.
73    SaveAs,
74    /// Confirm discarding unsaved changes before CLOSING a document tab —
75    /// the ONE place unsaved work can still be lost now that New and Open
76    /// both add a tab. `pending_close` holds the tab index.
77    ConfirmClose,
78    /// Choose an export format (STEP / STL) for the current model.
79    Export,
80    /// Choose a STEP / IGES / STL / OBJ file through the common explorer.
81    Import,
82    /// Choose a 2D vector format (DXF / SVG) for the sheet-metal flat pattern.
83    FlatPattern,
84    /// Pick a part to insert as an assembly component (library entries + the
85    /// stored-model list + Upload).
86    InsertComponent,
87    /// A `.step` upload whose product structure the probe found: choose whether
88    /// to keep that structure (parts + component instances) or flatten it to
89    /// bodies. Backed by [`FileDialog::pending_step_import`].
90    StepAssembly,
91}
92
93/// A probed `.step` upload waiting on the user's choice — the state that makes
94/// the §3.9 modal work across frames (egui draws every frame; the click can
95/// land many frames after the upload).
96///
97/// The PARSED assembly itself is NOT here: it lives in the engine's stash
98/// (`EngineState::probe_step_assembly` put it there), and the import consumes
99/// that stash. This holds only what the prompt says and the `text` the flat
100/// lane needs — the two lanes that re-import from source rather than from the
101/// parse ("Import as bodies", and the structured lane's own failure fallback).
102struct PendingStepImport {
103    /// The uploaded file's name — the prompt's subject and the status line's.
104    name: String,
105    /// The file text. The engine's stash holds the PARSE, not the source, so
106    /// the flat lane's input has to be kept here.
107    text: String,
108    /// The counts the prompt shows, from the same walk the import runs.
109    probe: StepAssemblyProbe,
110    /// The §3.9 checkbox: flatten the sub-assembly tree to leaf occurrences
111    /// instead of building nested rigid sub-assembly documents. Only shown (and
112    /// only meaningful) when `probe.nested_depth > 1`; a depth-1 file imports
113    /// identically either way.
114    flatten: bool,
115}
116
117/// A `.step` upload whose structure probe is RUNNING on the document's
118/// background runner (native thread / browser worker — the parse builds every
119/// product's bodies and takes seconds on a real assembly, so it left the UI
120/// thread). Resolved by [`FileDialog::poll_step_probe`] into either the §3.9
121/// choice ([`PendingStepImport`]) or the flat lane.
122struct PendingStepProbe {
123    /// The engine's probe id, so a stale answer (a superseded upload) is ignored.
124    id: u64,
125    name: String,
126    /// The file text, kept for the flat lane the outcome may route to.
127    text: String,
128}
129
130/// What the user clicked in the §3.9 assembly-choice modal.
131#[derive(Clone, Copy, PartialEq, Eq)]
132enum StepChoice {
133    /// Keep the structure: parts-library entries + one component per occurrence.
134    Assembly,
135    /// Today's flat lane, unchanged: one IMPORT3D feature carrying the text.
136    Bodies,
137    /// Import nothing, and drop the parse (Esc / click-outside land here too).
138    Cancel,
139}
140
141/// `"s"` unless there is exactly one — the difference between "1 parts" and a
142/// sentence a user believes.
143fn plural(count: usize) -> &'static str {
144    if count == 1 {
145        ""
146    } else {
147        "s"
148    }
149}
150
151/// The document name an imported assembly's unnamed products are stemmed from:
152/// the file's base name without its STEP extension, so a nameless product reads
153/// `bracket-assy-part-7`, not `bracket-assy.step-part-7`.
154fn step_document_name(file_name: &str) -> String {
155    let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
156    let cut = base
157        .rfind('.')
158        .filter(|dot| is_step_name(&base[*dot..]))
159        .unwrap_or(base.len());
160    let stem = &base[..cut];
161    if stem.is_empty() {
162        base.to_string()
163    } else {
164        stem.to_string()
165    }
166}
167
168/// The §3.9 outcome line: `imported bracket-assy.step — 7 parts, 23 components
169/// (2 mirrored instances baked)`, plus the tail an imperfect import owes the
170/// user. Every qualifier is reported ONLY when it happened, so a clean import
171/// reads clean — but a partial one never reads as a whole one:
172///
173/// * `baked_nonrigid` — occurrences whose mirror/scale was baked into a part of
174///   their own (§3.4), which is why the part count can exceed the file's;
175/// * `failed_products` — products that did not encode, skipped and counted;
176/// * `flat_fallback` — the user asked for an assembly and got bodies. Said
177///   plainly, never silently (the dialog lane reports this through the Err
178///   branch instead, which is the only way it can reach the user from here);
179/// * `first_error` — the one thing that explains the rest.
180/// The STEP-assembly import's [`PartSink`]: writes each unique part document to
181/// the model store and hands back the identity it was stored under, so an
182/// imported part carries a REAL `sourceKey` and there is no second kind of part.
183///
184/// # The destination
185///
186/// `browser_write` at the explorer's CURRENT location, under
187/// `{assembly}-{part}` — the convention `panels::step_parts` already uses for a
188/// single STEP part (that panel lets the user pick the folder first; the
189/// assembly modal inherits wherever the explorer is pointing).
190///
191/// The kernel plan's alternative — a `{assembly}/{part}.BREP.json` SUB-FOLDER —
192/// is not reachable through this door: every `browser_write` implementation
193/// flattens the name to a single file (native takes `file_name()`, web takes
194/// `model_display_name`), so a sub-path would silently collapse. Writing the
195/// assembly name into the FILE name keeps a 50-part import grouped in one
196/// listing without a folder convention this seam cannot express. Creating and
197/// navigating into a folder as a side effect of an import is the prompt this
198/// lane would have to grow, and it is not bolted on here.
199///
200/// A failed write is per part: that part stays embedded-only (`None`) and the
201/// import continues. Losing one part's FILE is recoverable; losing the import
202/// is not.
203///
204/// # Known limit of a flat name
205///
206/// `taken` is per IMPORT, so re-importing the same file reuses the same names —
207/// which is what makes cross-import dedup work (same key, same signature, the
208/// resident entry is reused). But two DIFFERENT assemblies whose document names
209/// sanitize to the same stem (`as1-ug` and `as1_ug`) write to each other's file
210/// names. The second import wins the file; the first assembly's entry then
211/// disagrees with it, so it badges outdated and the write-through guard refuses
212/// to clobber it — visible and recoverable (the embedded document is intact),
213/// but two assemblies sharing one name. The fix is the per-assembly sub-folder
214/// `browser_write` cannot express; see the kernel plan's §3.5.
215struct StorePartSink<'a> {
216    store: &'a dyn ModelStore,
217    /// Prefixes every file name, so one import's parts sort together.
218    prefix: String,
219    /// File names already claimed this import — two STEP products can sanitize
220    /// to the same name, and the second must not overwrite the first's file
221    /// (that would leave two entries pointing at one document).
222    taken: std::collections::HashSet<String>,
223    written: usize,
224    failures: Vec<String>,
225}
226
227impl<'a> StorePartSink<'a> {
228    fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
229        Self {
230            store,
231            prefix: sanitize_file_stem(prefix),
232            taken: std::collections::HashSet::new(),
233            written: 0,
234            failures: Vec::new(),
235        }
236    }
237}
238
239impl PartSink for StorePartSink<'_> {
240    fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
241        let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
242        let mut name = base.clone();
243        let mut suffix = 2;
244        while !self.taken.insert(name.clone()) {
245            name = format!("{base}-{suffix}");
246            suffix += 1;
247        }
248        match self.store.browser_write(&name, document_json) {
249            Ok(identity) => {
250                self.written += 1;
251                Some(identity)
252            }
253            Err(error) => {
254                self.failures.push(format!("{name}: {error}"));
255                None
256            }
257        }
258    }
259}
260
261/// A STEP product name reduced to something every store backend can hold as a
262/// file name: ASCII word characters, `.`, `-` kept; everything else (spaces,
263/// slashes, the `(mirrored)` parentheses this crate appends) becomes `_`. Runs
264/// of `_` collapse and the ends are trimmed, so a name is readable rather than
265/// a row of underscores. Empty input yields `part`.
266fn sanitize_file_stem(name: &str) -> String {
267    let mut out = String::with_capacity(name.len());
268    for ch in name.chars() {
269        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
270            out.push(ch);
271        } else if !out.ends_with('_') {
272            out.push('_');
273        }
274    }
275    let trimmed = out.trim_matches('_');
276    if trimmed.is_empty() {
277        "part".to_string()
278    } else {
279        trimmed.to_string()
280    }
281}
282
283fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
284    let mut message = format!(
285        "imported {name} \u{2014} {} part{}, {} component{}",
286        report.parts,
287        plural(report.parts),
288        report.instances,
289        plural(report.instances)
290    );
291    if report.baked_nonrigid > 0 {
292        message.push_str(&format!(
293            " ({} mirrored instance{} baked)",
294            report.baked_nonrigid,
295            plural(report.baked_nonrigid)
296        ));
297    }
298    if report.failed_products > 0 {
299        message.push_str(&format!(
300            "; {} product{} could not be built",
301            report.failed_products,
302            plural(report.failed_products)
303        ));
304    }
305    if report.flat_fallback {
306        message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
307    }
308    if let Some(error) = &report.first_error {
309        message.push_str(&format!(" [{error}]"));
310    }
311    message
312}
313
314/// Whether an imported file name is a STEP file (`.step` / `.stp`, any case) —
315/// the routing key in [`FileDialog::show`]. Model documents arrive
316/// extension-stripped (web) or as a stored model name, so this
317/// never mis-routes a `.BREP.json` file.
318fn is_step_name(name: &str) -> bool {
319    let lower = name.to_ascii_lowercase();
320    lower.ends_with(".step") || lower.ends_with(".stp")
321}
322
323/// Whether an imported file name is an IGES file (`.iges` / `.igs`, any case) —
324/// the sibling routing key of [`is_step_name`].
325fn is_iges_name(name: &str) -> bool {
326    let lower = name.to_ascii_lowercase();
327    lower.ends_with(".iges") || lower.ends_with(".igs")
328}
329
330fn is_stl_name(name: &str) -> bool {
331    name.to_ascii_lowercase().ends_with(".stl")
332}
333
334fn is_obj_name(name: &str) -> bool {
335    name.to_ascii_lowercase().ends_with(".obj")
336}
337
338/// The reusable file dialog — transient UI buffers + the current document
339/// identity; the model itself lives in `EngineState`'s history.
340pub struct FileDialog {
341    /// Reusable browser body shared by Open, Save As, and ACOMP selection.
342    explorer: FileExplorer,
343    /// The name field buffer, used by the Save As modal.
344    name_buf: String,
345    /// Last-action status line, surfaced inside the modal.
346    status: String,
347    /// Whether a modal is currently open.
348    open: bool,
349    /// The open modal's mode (only meaningful while `open`).
350    mode: Mode,
351    /// Set on open so the Save As text input grabs focus on the next frame.
352    want_focus: bool,
353    /// A pending real-file pick belongs to the INSERT-COMPONENT flow (the
354    /// modal's Upload fired there): the next completed `take_import` routes to
355    /// the component insert instead of Open's load-document.
356    pending_component_import: bool,
357    /// The tab index a pending [`Mode::ConfirmClose`] will close on "Discard".
358    pending_close: Option<usize>,
359    /// A probed STEP assembly waiting on the §3.9 choice modal. `Some` means the
360    /// ENGINE is holding a parsed assembly for us (with every product's solids
361    /// resident), so every exit from that modal must end its life: import it,
362    /// discard it, or be superseded by the next upload.
363    pending_step_import: Option<PendingStepImport>,
364    /// A `.step` upload whose probe has not answered yet (see
365    /// [`PendingStepProbe`]); the status line says "reading…" meanwhile.
366    pending_step_probe: Option<PendingStepProbe>,
367    pending_stl_import: Option<(String, Vec<u8>)>,
368    /// Bumped on every SUCCESSFUL store save (plain Save / Save As / native
369    /// Save-As) — one half of the update-components staleness key (saving a
370    /// part's source must re-check the outdated badges without a reload).
371    save_generation: u64,
372    /// Per-frame widget hit-rects for the headed verifier (wasm only).
373    #[cfg(target_arch = "wasm32")]
374    hits: HashMap<String, egui::Rect>,
375}
376
377impl FileDialog {
378    /// A closed dialog with empty buffers. Document identity (name + clean
379    /// baseline) lives on [`Document`], so there is nothing to seed here.
380    pub fn new() -> Self {
381        Self {
382            explorer: FileExplorer::new(),
383            name_buf: String::new(),
384            status: String::new(),
385            open: false,
386            mode: Mode::Open,
387            want_focus: false,
388            pending_component_import: false,
389            pending_close: None,
390            pending_step_import: None,
391            pending_step_probe: None,
392            pending_stl_import: None,
393            save_generation: 0,
394            #[cfg(target_arch = "wasm32")]
395            hits: HashMap::new(),
396        }
397    }
398
399    /// The monotonic successful-save counter — the shell feeds it to the
400    /// update-components checker as half its staleness key.
401    pub fn save_generation(&self) -> u64 {
402        self.save_generation
403    }
404
405    // --- dispatch: a toolbar button was clicked -------------------------------
406
407    /// Act on a toolbar file button. New acts immediately (it adds a tab, so
408    /// there is nothing to discard); all browsing flows use the same in-app
409    /// modal on both platforms.
410    pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
411        match action {
412            FileAction::New => self.new_document(docs),
413            FileAction::Open => self.open_modal(Mode::Open),
414            FileAction::Save => match docs.active().name().map(str::to_string) {
415                // A named document saves straight to its name.
416                Some(name) => {
417                    let _ = self.save_to(docs, store, name);
418                }
419                // An unnamed document falls through to Save As.
420                None => self.dispatch(FileAction::SaveAs, docs, store),
421            },
422            FileAction::SaveAs => {
423                self.name_buf = self.effective_name(docs);
424                self.open_modal(Mode::SaveAs);
425            }
426            FileAction::Import => {
427                self.open_modal(Mode::Import);
428            }
429            FileAction::Export => {
430                self.name_buf = self.effective_name(docs);
431                self.open_modal(Mode::Export);
432            }
433            FileAction::ExportFlatPattern => {
434                self.name_buf = self.effective_name(docs);
435                self.open_modal(Mode::FlatPattern);
436            }
437            // ALWAYS the in-app modal (never the native picker directly): the
438            // "existing library entries first" list is an in-app concept; the
439            // modal's own Upload button drives the platform picker when the
440            // store supports interchange.
441            FileAction::InsertComponent => self.open_modal(Mode::InsertComponent),
442        }
443    }
444
445    /// Open the modal in `mode` (and focus its input next frame).
446    fn open_modal(&mut self, mode: Mode) {
447        self.mode = mode;
448        self.open = true;
449        self.want_focus = true;
450    }
451
452    // --- per-frame draw -------------------------------------------------------
453
454    /// Draw the modal (if open) and pick up any completed async import. Called
455    /// every frame by the shell with a ctx-level handle (the modal is ctx-level,
456    /// like the command palette).
457    pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
458        #[cfg(target_arch = "wasm32")]
459        self.hits.clear();
460
461        // A STEP probe the runner has answered since last frame resolves into
462        // the assembly choice or the flat import now, before drawing.
463        self.poll_step_probe(docs.engine_mut());
464
465        // A completed async browser upload is picked up
466        // here, before drawing, so the model is live for this frame. Routed by
467        // extension: a STEP file (`.step`/`.stp`) is APPENDED to the model as an
468        // IMPORT3D feature; anything else is a `.BREP.json` model document loaded
469        // (replacing the model). The model lanes deliver an extension-less name
470        // (web, stripped by `model_display_name`) or a stored model name, so only
471        // real STEP files route here.
472        if let Some(imported) = store.take_import() {
473            // A new delivery SUPERSEDES an armed assembly choice. The modal
474            // describes a parse this routing is about to replace (a STEP probe
475            // drops the previous stash by contract, and a document load drops it
476            // outright), so leaving the choice armed would offer the user a
477            // button whose stash is already gone.
478            self.cancel_pending_step_import(docs.engine_mut());
479            let name = imported.name;
480            let bytes = imported.bytes;
481            // CAD/mesh imports target the active document. STL waits for
482            // preview acceptance; model documents open in a separate tab.
483            if is_step_name(&name) {
484                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
485            } else if is_iges_name(&name) {
486                self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
487            } else if is_stl_name(&name) {
488                self.stage_stl_preview(name, bytes);
489            } else if is_obj_name(&name) {
490                self.import_obj(docs.engine_mut(), &name, &bytes);
491            } else if std::mem::take(&mut self.pending_component_import) {
492                // The insert-component modal's Upload fired this pick: the
493                // chosen document becomes a parts-library entry + an instance,
494                // NOT a new tab.
495                match String::from_utf8(bytes) {
496                    Ok(contents) => {
497                        self.insert_component_document(docs.engine_mut(), &name, &contents)
498                    }
499                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
500                }
501            } else {
502                match String::from_utf8(bytes) {
503                    Ok(contents) => self.load_document(docs, &name, &contents),
504                    Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
505                }
506            }
507            self.close_after_import();
508        }
509
510        if !self.open {
511            return;
512        }
513
514        match self.mode {
515            Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
516            Mode::SaveAs => self.show_save_as(ctx, docs, store),
517            Mode::Open => self.show_open(ctx, docs, store),
518            Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
519            Mode::Export => self.show_export(ctx, docs, store),
520            Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
521            Mode::InsertComponent => self.show_insert_component(ctx, docs.engine_mut(), store),
522            Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
523        }
524    }
525
526    /// Close the modal after a completed import — unless the import ARMED the
527    /// §3.9 assembly choice, in which case the modal stays open and switches to
528    /// it. Both import routing sites (the async-upload poll and the Import
529    /// modal's own pick) end here, so neither can close a dialog the probe just
530    /// raised.
531    fn close_after_import(&mut self) {
532        if self.pending_step_import.is_some() {
533            self.open_modal(Mode::StepAssembly);
534        } else {
535            self.open = false;
536        }
537    }
538
539    /// Abandon an armed assembly choice and the engine stash behind it. The ONE
540    /// place the app-side pending state and the engine-side parse are dropped
541    /// together — they are two halves of one thing, and a half-drop is either a
542    /// dialog with no stash or solids nobody will ever consume.
543    fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
544        if self.pending_step_import.take().is_some() {
545            state.discard_probed_step_assembly();
546        }
547    }
548
549    /// Common CAD/mesh browser. Desktop enumerates files in the application's
550    /// models directory; web shows the same explorer shell with an Upload action.
551    fn show_import(
552        &mut self,
553        ctx: &egui::Context,
554        state: &mut EngineState,
555        store: &dyn ModelStore,
556    ) {
557        const EXTENSIONS: &[&str] = &["step", "stp", "iges", "igs", "stl", "obj"];
558        let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
559            ui.set_width(600.0);
560            ui.heading("Import CAD file");
561            ui.add_space(4.0);
562            let options = FileExplorerOptions {
563                hit_prefix: "import",
564                empty_label: "(no STEP, IGES, STL, or OBJ files)",
565                row_icon: "\u{1F5CE}",
566                current: None,
567                allow_delete: false,
568                allow_import: store.supports_file_interchange(),
569                import_label: "Upload\u{2026}",
570                import_hit: "import:upload",
571                show_cancel: true,
572                confirm_label: Some("Import"),
573                extensions: EXTENSIONS,
574            };
575            let output = self.explorer.show_store(ui, store, options);
576            self.record_explorer_hits(&output.hits);
577            if !self.status.is_empty() {
578                ui.add_space(4.0);
579                ui.weak(&self.status);
580            }
581            output
582        });
583        let should_close = modal.should_close();
584        let output = modal.inner;
585        if let Some(name) = output.activated {
586            match store.read_external_file(&name) {
587                Some(bytes) if is_step_name(&name) => {
588                    self.import_text(state, &name, &bytes, Self::import_step)
589                }
590                Some(bytes) if is_iges_name(&name) => {
591                    self.import_text(state, &name, &bytes, Self::import_iges)
592                }
593                Some(bytes) if is_stl_name(&name) => self.stage_stl_preview(name, bytes),
594                Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
595                Some(_) => self.status = format!("unsupported import file: {name}"),
596                None => self.status = format!("import failed: '{name}' not found"),
597            }
598            self.close_after_import();
599        } else if output.import {
600            match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
601                Ok(()) => {
602                    self.status = "choose a STEP, IGES, STL, or OBJ file\u{2026}".into();
603                    self.open = false;
604                }
605                Err(e) => self.status = format!("import failed: {e}"),
606            }
607        } else if output.cancel || should_close {
608            self.open = false;
609        }
610    }
611
612    /// CLOSE tab `index` with the unsaved-changes contract: a DIRTY document
613    /// prompts to discard first (the confirm modal); a clean one closes
614    /// immediately. The tab strip's `\u{2715}` routes here — it is the only
615    /// door through which unsaved work can be dropped, so it is the only one
616    /// that asks.
617    pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
618        let dirty = docs.get(index).is_some_and(Document::is_dirty);
619        if dirty {
620            self.pending_close = Some(index);
621            self.open_modal(Mode::ConfirmClose);
622        } else {
623            docs.close(index);
624        }
625    }
626
627    /// The **Discard unsaved changes?** confirmation shown before closing a
628    /// dirty document tab. Keeps the verifier's `confirm:discard` /
629    /// `confirm:cancel` hit keys — the same two buttons, one door further on.
630    fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
631        // A tab closed / reordered under an open prompt (there is no such path
632        // today, but the index is only meaningful while it resolves).
633        let Some(title) = self
634            .pending_close
635            .and_then(|index| docs.get(index))
636            .map(Document::title)
637        else {
638            self.pending_close = None;
639            self.open = false;
640            return;
641        };
642        let mut discard = false;
643        let mut cancel = false;
644        let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
645            ui.set_width(340.0);
646            ui.heading("Discard unsaved changes?");
647            ui.add_space(4.0);
648            ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
649            ui.add_space(6.0);
650            ui.horizontal(|ui| {
651                let d = ui.button("Discard and close");
652                self.hit("confirm:discard", &d);
653                if d.clicked() {
654                    discard = true;
655                }
656                let c = ui.button("Cancel");
657                self.hit("confirm:cancel", &c);
658                if c.clicked() {
659                    cancel = true;
660                }
661            });
662        });
663        if discard {
664            if let Some(index) = self.pending_close.take() {
665                docs.close(index);
666            }
667            self.open = false;
668        } else if cancel || modal.should_close() {
669            self.pending_close = None;
670            self.open = false;
671        }
672    }
673
674    /// The §3.9 **assembly choice**, raised when a `.step` upload's probe found a
675    /// product structure:
676    ///
677    /// > **"bracket-assy.step" contains an assembly** — 7 parts, 23 instances.
678    /// > [ Import as assembly ] [ Import as bodies ] [ Cancel ]
679    ///
680    /// Default (and first) is **Import as assembly**. "Import as bodies" is
681    /// today's flat lane, unchanged. Cancel — and Esc / click-outside, which
682    /// egui folds into `should_close` — import nothing and DROP the parse: the
683    /// engine is holding every product's solids until one of these three lands.
684    ///
685    /// **Flatten sub-assemblies** (§3.9) is offered only when the file actually
686    /// HAS sub-assemblies (`nested_depth > 1`) — on a single-level file both
687    /// lanes produce the identical document, so a box there would teach the user
688    /// a distinction that does not exist. Unchecked (the default) keeps the
689    /// tree; checked flattens to leaf occurrences, which is the right answer for
690    /// a deep or pathological file and stores a part reused across two levels
691    /// once rather than once per level (build-spec §2.2).
692    fn show_step_assembly(
693        &mut self,
694        ctx: &egui::Context,
695        state: &mut EngineState,
696        store: &dyn ModelStore,
697    ) {
698        // Nothing armed means nothing to choose about (a supersession raced the
699        // draw): close rather than render an empty prompt.
700        let Some(pending) = self.pending_step_import.as_ref() else {
701            self.open = false;
702            return;
703        };
704        let name = pending.name.clone();
705        let probe = pending.probe;
706        let mut flatten = pending.flatten;
707        let mut choice: Option<StepChoice> = None;
708        let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
709            ui.set_width(360.0);
710            ui.heading(format!("\"{name}\" contains an assembly"));
711            ui.add_space(4.0);
712            ui.label(format!(
713                "{} part{}, {} instance{}{}.",
714                probe.parts,
715                plural(probe.parts),
716                probe.instances,
717                plural(probe.instances),
718                match probe.nested_depth {
719                    0 | 1 => String::new(),
720                    depth => format!(", {depth} levels deep"),
721                }
722            ));
723            if probe.nested_depth > 1 {
724                ui.add_space(4.0);
725                let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
726                self.hit("stepassembly:flatten", &check);
727                check.on_hover_text(
728                    "Off: each sub-assembly becomes one rigid component you can \
729                     expand in the structure tree.\nOn: every part is placed \
730                     directly in this document at its world position.",
731                );
732            }
733            ui.add_space(6.0);
734            ui.horizontal(|ui| {
735                let a = ui.button("Import as assembly");
736                self.hit("stepassembly:assembly", &a);
737                if a.clicked() {
738                    choice = Some(StepChoice::Assembly);
739                }
740                let b = ui.button("Import as bodies");
741                self.hit("stepassembly:bodies", &b);
742                if b.clicked() {
743                    choice = Some(StepChoice::Bodies);
744                }
745                let c = ui.button("Cancel");
746                self.hit("stepassembly:cancel", &c);
747                if c.clicked() {
748                    choice = Some(StepChoice::Cancel);
749                }
750            });
751        });
752        // The box must survive the frames between the tick and the click.
753        if let Some(pending) = self.pending_step_import.as_mut() {
754            pending.flatten = flatten;
755        }
756        // Esc / click-outside is a Cancel, not a no-op: the stash must not
757        // outlive the prompt that was going to consume it.
758        let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
759        let Some(choice) = choice else { return };
760        match choice {
761            StepChoice::Assembly => self.step_assembly_import(state, store),
762            StepChoice::Bodies => self.step_assembly_bodies(state),
763            StepChoice::Cancel => self.step_assembly_cancel(state),
764        }
765        self.open = false;
766    }
767
768    /// The **Save As** name prompt.
769    fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
770        let current = docs.active().name().map(str::to_string);
771        let mut do_save = false;
772        let mut cancel = false;
773        let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
774            ui.set_width(600.0);
775            ui.heading("Save model as");
776            ui.add_space(4.0);
777            let options = FileExplorerOptions {
778                hit_prefix: "saveas:file",
779                empty_label: "(no saved models)",
780                row_icon: "\u{1F5CE}",
781                current: current.as_deref(),
782                allow_delete: false,
783                allow_import: false,
784                import_label: "",
785                import_hit: "saveas:upload",
786                show_cancel: false,
787                confirm_label: None,
788                extensions: &["BREP.json", "json"],
789            };
790            let output = self.explorer.show_store(ui, store, options);
791            self.record_explorer_hits(&output.hits);
792            // Selecting (or double-clicking) a stored model fills the name field
793            // so it can be overwritten; `picked`/`activated` are one-shot so it
794            // never clobbers a name the user then types.
795            if let Some(name) = output.picked.or(output.activated) {
796                self.name_buf = model_display_name(&name);
797            }
798            ui.label("File name");
799            let field = ui.add(
800                egui::TextEdit::singleline(&mut self.name_buf)
801                    .hint_text("model name")
802                    .desired_width(f32::INFINITY),
803            );
804            self.hit("field:name", &field);
805            if self.want_focus {
806                field.request_focus();
807                self.want_focus = false;
808            }
809            let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
810            ui.add_space(6.0);
811            ui.horizontal(|ui| {
812                let save = ui.button("Save");
813                self.hit("save", &save);
814                if save.clicked() || enter {
815                    do_save = true;
816                }
817                let c = ui.button("Cancel");
818                self.hit("cancel", &c);
819                if c.clicked() {
820                    cancel = true;
821                }
822            });
823            if !self.status.is_empty() {
824                ui.add_space(4.0);
825                ui.weak(&self.status);
826            }
827        });
828        if do_save {
829            let name = self.name_buf.clone();
830            if self.save_to_browser(docs, store, name) {
831                self.open = false;
832            }
833        } else if cancel || modal.should_close() {
834            self.open = false;
835        }
836    }
837
838    /// The **Export** chooser: pick a format (STEP / IGES / STL) and write the
839    /// current model to the user's filesystem under `<name>.<ext>` through the
840    /// store's format-typed interchange. STEP and IGES serialize the exact NURBS
841    /// topology; STL is the ASCII display mesh. No solids → a clear status line,
842    /// nothing written. A second row offers the assembly BOM (CSV / JSON),
843    /// enabled only while the document HAS components.
844    fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
845        // BOM gating (assemblies build-spec §9): `component_ids` scans the
846        // history (cheap, no kernel session). A rolled-back/failed ACOMP can
847        // still enable the buttons — the export's own loud "no components"
848        // error covers that gap.
849        let has_components = !docs.engine().component_ids().is_empty();
850        let mut chosen: Option<&'static str> = None;
851        let mut bom_chosen: Option<&'static str> = None;
852        let mut cancel = false;
853        let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
854            ui.set_width(320.0);
855            ui.heading("Export model");
856            ui.add_space(4.0);
857            let field = ui.add(
858                egui::TextEdit::singleline(&mut self.name_buf)
859                    .hint_text("file name")
860                    .desired_width(f32::INFINITY),
861            );
862            self.hit("field:name", &field);
863            if self.want_focus {
864                field.request_focus();
865                self.want_focus = false;
866            }
867            ui.add_space(6.0);
868            ui.horizontal(|ui| {
869                let step = ui.button("STEP (.step)");
870                self.hit("export:step", &step);
871                if step.clicked() {
872                    chosen = Some("step");
873                }
874                let iges = ui.button("IGES (.igs)");
875                self.hit("export:iges", &iges);
876                if iges.clicked() {
877                    chosen = Some("iges");
878                }
879                let stl = ui.button("STL (.stl)");
880                self.hit("export:stl", &stl);
881                if stl.clicked() {
882                    chosen = Some("stl");
883                }
884                // The full model RECIPE (`.BREP.json`) — for saving a document to
885                // disk / sharing a failing model for a bug report. Re-openable via
886                // Open / Import.
887                let json = ui.button("JSON (.BREP.json)");
888                self.hit("export:json", &json);
889                if json.clicked() {
890                    chosen = Some("json");
891                }
892                let c = ui.button("Cancel");
893                self.hit("cancel", &c);
894                if c.clicked() {
895                    cancel = true;
896                }
897            });
898            ui.add_space(4.0);
899            ui.horizontal(|ui| {
900                // The assembly BOM (parts list): one row per parts-library
901                // entry with the live instance count. Disabled while the
902                // document has no components (spec §9).
903                let csv = ui.add_enabled(has_components, egui::Button::new("BOM (CSV)"));
904                self.hit("export:bomcsv", &csv);
905                if csv.clicked() {
906                    bom_chosen = Some("csv");
907                }
908                let json = ui.add_enabled(has_components, egui::Button::new("BOM (JSON)"));
909                self.hit("export:bomjson", &json);
910                if json.clicked() {
911                    bom_chosen = Some("json");
912                }
913            });
914            if !self.status.is_empty() {
915                ui.add_space(4.0);
916                ui.weak(&self.status);
917            }
918        });
919        if let Some(format) = chosen {
920            if self.export_as(docs, store, format) {
921                self.open = false;
922            }
923        } else if let Some(format) = bom_chosen {
924            if self.export_bom_as(docs, store, format) {
925                self.open = false;
926            }
927        } else if cancel || modal.should_close() {
928            self.open = false;
929        }
930    }
931
932    /// The **Flat pattern** chooser: pick a 2D vector format (DXF R12 / SVG) and
933    /// write the sheet-metal body's unfolded flat pattern to the user's filesystem
934    /// under `<name>.<ext>`. The unfold runs TRANSIENTLY in the engine (no feature,
935    /// no history change). A part with no sheet-metal body reports it in the status
936    /// line AND queues a toast (see [`Self::export_flat_pattern_as`]).
937    fn show_flat_pattern(
938        &mut self,
939        ctx: &egui::Context,
940        docs: &mut Documents,
941        store: &dyn ModelStore,
942    ) {
943        let mut chosen: Option<&'static str> = None;
944        let mut cancel = false;
945        let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
946            ui.set_width(340.0);
947            ui.heading("Export flat pattern");
948            ui.add_space(2.0);
949            ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
950            ui.add_space(4.0);
951            let field = ui.add(
952                egui::TextEdit::singleline(&mut self.name_buf)
953                    .hint_text("file name")
954                    .desired_width(f32::INFINITY),
955            );
956            self.hit("field:name", &field);
957            if self.want_focus {
958                field.request_focus();
959                self.want_focus = false;
960            }
961            ui.add_space(6.0);
962            ui.horizontal(|ui| {
963                let dxf = ui.button("DXF (.dxf)");
964                self.hit("flat:dxf", &dxf);
965                if dxf.clicked() {
966                    chosen = Some("dxf");
967                }
968                let svg = ui.button("SVG (.svg)");
969                self.hit("flat:svg", &svg);
970                if svg.clicked() {
971                    chosen = Some("svg");
972                }
973                let c = ui.button("Cancel");
974                self.hit("cancel", &c);
975                if c.clicked() {
976                    cancel = true;
977                }
978            });
979            if !self.status.is_empty() {
980                ui.add_space(4.0);
981                ui.weak(&self.status);
982            }
983        });
984        if let Some(format) = chosen {
985            if self.export_flat_pattern_as(docs, store, format) {
986                self.open = false;
987            }
988        } else if cancel || modal.should_close() {
989            self.open = false;
990        }
991    }
992
993    /// The **Open** browser: the saved-model list (+ Upload where the platform
994    /// supports real-file interchange).
995    fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
996        let current = docs.active().name().map(str::to_string);
997        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
998            ui.set_width(600.0);
999            ui.heading("Open model");
1000            ui.add_space(4.0);
1001            let mut options = FileExplorerOptions::open(current.as_deref());
1002            options.allow_import = store.supports_file_interchange();
1003            let output = self.explorer.show_store(ui, store, options);
1004            self.record_explorer_hits(&output.hits);
1005            if !self.status.is_empty() {
1006                ui.add_space(4.0);
1007                ui.weak(&self.status);
1008            }
1009            output
1010        });
1011        let should_close = modal.should_close();
1012        let output = modal.inner;
1013        if let Some(name) = output.activated {
1014            self.open_document(docs, store, &name);
1015            self.open = false;
1016        } else if let Some(name) = output.remove {
1017            let _ = store.remove(&name);
1018            self.status = format!("removed {name}");
1019            // A tab holding the removed document keeps its content but loses its
1020            // file: it becomes an untitled document, so a later Save asks where
1021            // to put it rather than silently recreating what was deleted.
1022            if let Some(index) = docs.index_of(&name) {
1023                if let Some(doc) = docs.get_mut(index) {
1024                    doc.set_name(None);
1025                }
1026            }
1027        } else if output.import {
1028            // Fire the platform picker; the file arrives via take_import() and is
1029            // loaded on a later frame (the modal closes now).
1030            match store.begin_import() {
1031                Ok(()) => {
1032                    self.status = "choose a file…".into();
1033                    self.open = false;
1034                }
1035                Err(e) => self.status = format!("import failed: {e}"),
1036            }
1037        } else if output.cancel || should_close {
1038            self.open = false;
1039        }
1040    }
1041
1042    /// The **Insert component** selector (assemblies build-spec §2.2): the
1043    /// EXISTING parts-library entries first (instant re-insert — no store read),
1044    /// then the model store's document list, + Upload where the platform
1045    /// supports real-file interchange. REUSES the Open modal's machinery (the
1046    /// same list + `take_import` poll), routed to the engine's insert flow.
1047    fn show_insert_component(
1048        &mut self,
1049        ctx: &egui::Context,
1050        state: &mut EngineState,
1051        store: &dyn ModelStore,
1052    ) {
1053        let library = state.parts_library_names();
1054        let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
1055            ui.set_width(600.0);
1056            ui.heading("Insert component");
1057            ui.add_space(4.0);
1058            let mut chosen_library = None;
1059            if !library.is_empty() {
1060                ui.weak("In this document (parts library)");
1061                for name in &library {
1062                    let row = ui.add_sized(
1063                        egui::vec2(ui.available_width(), 18.0),
1064                        crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}")).frame(false),
1065                    );
1066                    self.hit(&format!("insert:lib:{name}"), &row);
1067                    if row.clicked() {
1068                        chosen_library = Some(name.clone());
1069                    }
1070                }
1071                ui.add_space(4.0);
1072            }
1073            let options = FileExplorerOptions {
1074                hit_prefix: "insert:model",
1075                empty_label: "(no saved models)",
1076                row_icon: "\u{1F5CE}",
1077                current: None,
1078                allow_delete: false,
1079                allow_import: store.supports_file_interchange(),
1080                import_label: "Upload\u{2026}",
1081                import_hit: "insert:upload",
1082                show_cancel: true,
1083                confirm_label: Some("Insert"),
1084                extensions: &["BREP.json", "json"],
1085            };
1086            let output = self.explorer.show_store(ui, store, options);
1087            self.record_explorer_hits(&output.hits);
1088            if !self.status.is_empty() {
1089                ui.add_space(4.0);
1090                ui.weak(&self.status);
1091            }
1092            (chosen_library, output)
1093        });
1094        let should_close = modal.should_close();
1095        let (chosen_library, output) = modal.inner;
1096        if let Some(part_name) = chosen_library {
1097            // An already-inserted library part: skip the store read entirely.
1098            match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
1099                Ok(id) => {
1100                    self.status = format!("inserted {part_name} ({id})");
1101                    self.open = false;
1102                }
1103                Err(e) => self.status = format!("insert failed: {e}"),
1104            }
1105        } else if let Some(name) = output.activated {
1106            match store.read(&name) {
1107                Some(contents) => {
1108                    self.insert_component_document(state, &name, &contents);
1109                    self.open = false;
1110                }
1111                None => self.status = format!("insert failed: '{name}' not found"),
1112            }
1113        } else if output.import {
1114            match store.begin_import() {
1115                Ok(()) => {
1116                    // The picked file routes to the component insert (not Open).
1117                    self.pending_component_import = true;
1118                    self.status = "choose a part file…".into();
1119                    self.open = false;
1120                }
1121                Err(e) => self.status = format!("insert failed: {e}"),
1122            }
1123        } else if output.cancel || should_close {
1124            self.open = false;
1125        }
1126    }
1127
1128    /// Insert a part DOCUMENT as an assembly component: library-add (dedup by
1129    /// sourceKey + content signature; the RETURNED effective name is what the
1130    /// instance references) + an ACOMP feature, through the engine's one insert
1131    /// flow. The first instance of an empty assembly is written `isFixed:true`.
1132    /// `sourceSignature` is written with [`document_signature`] — the ONE
1133    /// signature fn — so the update-components comparison reads a freshly
1134    /// inserted, unchanged part as up-to-date.
1135    fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1136        let display = model_display_name(name);
1137        match state.insert_component(ComponentInsert::New {
1138            name: &display,
1139            source_key: name,
1140            source_signature: &document_signature(contents),
1141            document_json: contents,
1142        }) {
1143            Ok(id) => self.status = format!("inserted {display} ({id})"),
1144            Err(e) => self.status = format!("insert failed: {e}"),
1145        }
1146    }
1147
1148    // --- model operations -----------------------------------------------------
1149
1150    /// The name to save under: the field if non-empty, else the active
1151    /// document's name, else `"untitled"`.
1152    fn effective_name(&self, docs: &Documents) -> String {
1153        let field = self.name_buf.trim();
1154        if !field.is_empty() {
1155            field.to_string()
1156        } else {
1157            docs.active()
1158                .name()
1159                .map(str::to_string)
1160                .unwrap_or_else(|| "untitled".into())
1161        }
1162    }
1163
1164    /// **New** — an empty model in a NEW TAB. Nothing is replaced, so there is
1165    /// nothing to confirm.
1166    fn new_document(&mut self, docs: &mut Documents) {
1167        let mut engine = docs.spawn_engine();
1168        let _ = engine.set_history_json(EMPTY_DOCUMENT);
1169        docs.open_document(Document::new(engine));
1170        self.name_buf.clear();
1171        self.status = "new (empty) model".into();
1172    }
1173
1174    /// Refuse a save that would give TWO open tabs the same store identity —
1175    /// "focus the tab holding this document" has no answer then, and the second
1176    /// save would silently overwrite the first tab's file. `true` = go ahead.
1177    ///
1178    /// Compared by DISPLAY name, not raw identity: on desktop an open document
1179    /// carries the full path it was loaded from while the Save As field holds a
1180    /// bare name, so an identity compare would never match and the clobber would
1181    /// happen before anything noticed. Two same-stemmed files in different
1182    /// folders are refused too — stricter than strictly necessary, and the side
1183    /// to err on when the alternative is overwriting another tab's file.
1184    fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
1185        let display = model_display_name(name);
1186        let taken = docs.iter().enumerate().any(|(index, doc)| {
1187            index != docs.active_index()
1188                && doc
1189                    .name()
1190                    .is_some_and(|open| model_display_name(open) == display)
1191        });
1192        if taken {
1193            self.status = format!("'{display}' is already open in another tab");
1194        }
1195        !taken
1196    }
1197
1198    /// Write the active document's request JSON through the store under `name`.
1199    /// Returns `true` on success (so the caller can close the modal).
1200    fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
1201        let name = name.trim().to_string();
1202        if name.is_empty() {
1203            self.status = "enter a name to save".into();
1204            return false;
1205        }
1206        if !self.name_is_free(docs, &name) {
1207            return false;
1208        }
1209        match store.write(&name, &docs.engine().history_request_json()) {
1210            Ok(()) => {
1211                // The document keeps the raw identity (a full path when a
1212                // native dialog chose it); the name field shows the bare name.
1213                self.name_buf = model_display_name(&name);
1214                let doc = docs.active_mut();
1215                doc.set_name(Some(name.clone()));
1216                doc.mark_clean();
1217                self.save_generation += 1;
1218                self.status = format!("saved {name}");
1219                true
1220            }
1221            Err(e) => {
1222                self.status = format!("save failed: {e}");
1223                false
1224            }
1225        }
1226    }
1227
1228    /// Save As through the explorer's current directory, then retain the
1229    /// backend's returned identity (an absolute path on desktop or a virtual
1230    /// `/models/...` path in the browser) for subsequent plain Save commands.
1231    fn save_to_browser(
1232        &mut self,
1233        docs: &mut Documents,
1234        store: &dyn ModelStore,
1235        name: String,
1236    ) -> bool {
1237        let name = name.trim().to_string();
1238        if name.is_empty() {
1239            self.status = "enter a name to save".into();
1240            return false;
1241        }
1242        if !self.name_is_free(docs, &name) {
1243            return false;
1244        }
1245        match store.browser_write(&name, &docs.engine().history_request_json()) {
1246            Ok(identity) => {
1247                self.name_buf = model_display_name(&identity);
1248                let doc = docs.active_mut();
1249                doc.set_name(Some(identity.clone()));
1250                doc.mark_clean();
1251                self.save_generation += 1;
1252                self.status = format!("saved {identity}");
1253                true
1254            }
1255            Err(e) => {
1256                self.status = format!("save failed: {e}");
1257                false
1258            }
1259        }
1260    }
1261
1262    /// **Open** — read a stored document into its own tab (or focus the tab
1263    /// already holding it). The one door every open lane uses: File>Open, the
1264    /// Edit-Part flow, and the session restore's siblings.
1265    pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
1266        if docs.focus_named(name) {
1267            self.status = format!("{name} is already open");
1268            return;
1269        }
1270        match store.read(name) {
1271            Some(contents) => self.load_document(docs, name, &contents),
1272            None => self.status = format!("open failed: '{name}' not found"),
1273        }
1274    }
1275
1276    /// Serialize the current model in `format` (`"step"` | `"stl"` | `"json"`) and
1277    /// write it through the store's format-typed interchange under `<name>.<ext>`.
1278    /// `"json"` is the full model RECIPE (`.BREP.json`, `history_request_json`) —
1279    /// the exact document Open/Import consume, for sharing a failing model. Returns
1280    /// `true` on success (so the caller can close the modal); a guard message and
1281    /// `false` when there is nothing to export or the engine/store errs.
1282    fn export_as(&mut self, docs: &Documents, store: &dyn ModelStore, format: &str) -> bool {
1283        let name = self.effective_name(docs);
1284        let state = docs.engine();
1285        let (text, ext) = match format {
1286            "stl" => (state.export_stl_text(), "stl"),
1287            "iges" => (state.export_iges_text(), "igs"),
1288            "json" => (Ok(state.history_request_json()), "BREP.json"),
1289            _ => (state.export_step_text(), "step"),
1290        };
1291        match text {
1292            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1293                Ok(()) => {
1294                    self.status = format!("exported {name}.{ext}");
1295                    true
1296                }
1297                Err(e) => {
1298                    self.status = format!("export failed: {e}");
1299                    false
1300                }
1301            },
1302            Err(e) => {
1303                self.status = format!("export failed: {e}");
1304                false
1305            }
1306        }
1307    }
1308
1309    /// Serialize the sheet-metal flat pattern in `format` (`"dxf"` | `"svg"`) and
1310    /// write it through the store under `<name>.<ext>`. Returns `true` on success
1311    /// (so the caller can close the modal). On failure the message goes to the
1312    /// status line AND is queued as a toast (the existing engine notice path), so
1313    /// a "no sheet-metal body in the part" error is surfaced prominently.
1314    fn export_flat_pattern_as(
1315        &mut self,
1316        docs: &mut Documents,
1317        store: &dyn ModelStore,
1318        format: &str,
1319    ) -> bool {
1320        let name = self.effective_name(docs);
1321        let state = docs.engine_mut();
1322        let (text, ext) = match format {
1323            "svg" => (state.export_flat_pattern_svg(), "svg"),
1324            _ => (state.export_flat_pattern_dxf(), "dxf"),
1325        };
1326        match text {
1327            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1328                Ok(()) => {
1329                    self.status = format!("exported {name}.{ext}");
1330                    true
1331                }
1332                Err(e) => {
1333                    self.status = format!("flat-pattern export failed: {e}");
1334                    false
1335                }
1336            },
1337            Err(e) => {
1338                self.status = format!("flat-pattern export failed: {e}");
1339                state.push_notice(format!("Flat pattern: {e}"));
1340                false
1341            }
1342        }
1343    }
1344
1345    /// Serialize the assembly BOM in `format` (`"csv"` | `"json"`) and write it
1346    /// through the store under `<name>.bom.<ext>` (a compound extension naming
1347    /// the content, like the `.BREP.json` recipe). On failure the message goes
1348    /// to the status line AND is queued as a toast — the flat-pattern error
1349    /// pattern — so a componentless document reports loudly.
1350    fn export_bom_as(
1351        &mut self,
1352        docs: &mut Documents,
1353        store: &dyn ModelStore,
1354        format: &str,
1355    ) -> bool {
1356        let name = self.effective_name(docs);
1357        let state = docs.engine_mut();
1358        let (text, ext) = match format {
1359            "json" => (state.export_bom_json(), "bom.json"),
1360            _ => (state.export_bom_csv(), "bom.csv"),
1361        };
1362        match text {
1363            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
1364                Ok(()) => {
1365                    self.status = format!("exported {name}.{ext}");
1366                    true
1367                }
1368                Err(e) => {
1369                    self.status = format!("BOM export failed: {e}");
1370                    false
1371                }
1372            },
1373            Err(e) => {
1374                self.status = format!("BOM export failed: {e}");
1375                state.push_notice(format!("BOM export: {e}"));
1376                false
1377            }
1378        }
1379    }
1380
1381    /// Route an imported STEP file: **probe first** (kernel-plan §3.9). The probe
1382    /// IS the parse — it stashes the structure in the engine for the import to
1383    /// consume — so a structured file arms the choice modal and is imported on
1384    /// the click, never parsed a second time.
1385    ///
1386    /// Everything else goes straight to today's flat lane, unchanged:
1387    ///
1388    /// * `Ok(None)` — no product structure. A part file must NEVER see the
1389    ///   dialog, and its behaviour stays byte-for-byte what it was.
1390    /// * `Err` — text the Part 21 parser refuses. Handing it to the flat lane
1391    ///   keeps the failure wording exactly today's (the two share the same
1392    ///   `ISO-10303-21` guard), rather than inventing a second one.
1393    fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1394        // A previous file's armed choice cannot survive this one: the probe
1395        // below REPLACES the engine's stash on every outcome (including the
1396        // structureless one), so an app-side pending left standing would offer
1397        // a button whose parse is already gone. A probe still running for an
1398        // earlier upload is superseded the same way (its answer is ignored).
1399        self.pending_step_import = None;
1400        let id = state.submit_step_probe(contents);
1401        self.pending_step_probe = Some(PendingStepProbe {
1402            id,
1403            name: name.to_string(),
1404            text: contents.to_string(),
1405        });
1406        self.status = format!("reading \"{name}\"\u{2026}");
1407        // The synchronous Inline runner (tests, headless) has answered inside
1408        // the submit; a background runner answers on a later frame's poll.
1409        self.poll_step_probe(state);
1410    }
1411
1412    /// Resolve an answered STEP probe: structure arms the §3.9 choice, no
1413    /// structure (or a parse the flat lane will refuse with its own wording)
1414    /// takes the flat lane. A probe the engine no longer holds — a document
1415    /// switch or a cancelled run dropped it — is reported and forgotten.
1416    fn poll_step_probe(&mut self, state: &mut EngineState) {
1417        while let Some((id, outcome)) = state.take_step_probe() {
1418            let Some(pending) = self.pending_step_probe.as_ref() else {
1419                continue;
1420            };
1421            if pending.id != id {
1422                continue; // an earlier upload's answer; a newer probe replaced it
1423            }
1424            let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
1425            match outcome {
1426                StepProbeOutcome::Structure(probe) => {
1427                    self.status = format!(
1428                        "\"{name}\" contains an assembly — {} part{}, {} instance{}",
1429                        probe.parts,
1430                        plural(probe.parts),
1431                        probe.instances,
1432                        plural(probe.instances)
1433                    );
1434                    self.pending_step_import = Some(PendingStepImport {
1435                        name,
1436                        text,
1437                        probe,
1438                        // Default = keep the tree (§3.9). Flattening is the escape
1439                        // hatch for a deep or pathological file, not the norm.
1440                        flatten: false,
1441                    });
1442                    // The routing site's `close_after_import` ran frames ago,
1443                    // while the probe was still out, and closed the modal: the
1444                    // choice is raised HERE, when the answer lands. (Under the
1445                    // synchronous Inline runner both run in the same call, and
1446                    // the second open is idempotent.)
1447                    self.open_modal(Mode::StepAssembly);
1448                }
1449                StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
1450                    self.import_step_flat(state, &name, &text);
1451                }
1452            }
1453        }
1454        if self.pending_step_probe.is_some() && !state.step_probes_pending() {
1455            // Nothing is running and no answer came: the engine dropped the
1456            // probe (document switch / cancel). Say so rather than reading
1457            // "reading…" forever.
1458            let pending = self.pending_step_probe.take().unwrap();
1459            self.status = format!("import of \"{}\" was cancelled", pending.name);
1460        }
1461    }
1462
1463    /// Append an imported STEP file to the model (an IMPORT3D feature), then treat
1464    /// the enlarged model as dirty (an import is an edit, not an Open — the model
1465    /// keeps its current name / save baseline). THE flat lane, reached from a
1466    /// structureless file, an unparseable one, and the dialog's "Import as
1467    /// bodies".
1468    fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1469        match state.import_step_feature(contents) {
1470            // Framing is deferred to the engine (`pending_fit`): under the native
1471            // thread / wasm worker runner the body is not resident yet, so framing
1472            // here would frame the empty scene. See [`EngineState::pending_fit`].
1473            Ok(_) => self.status = format!("imported {name}"),
1474            Err(e) => self.status = format!("import failed: {e}"),
1475        }
1476    }
1477
1478    /// **Import as assembly** — consume the probe's stash into parts-library
1479    /// entries + one component per occurrence, in the engine's single batch.
1480    ///
1481    /// An `Err` here means the structured lane produced nothing, and the engine
1482    /// returns BEFORE it touches history when that happens — so the honest
1483    /// answer is the one A6's contract prescribes: re-run the flat import with
1484    /// the text this dialog is still holding, and say plainly that the assembly
1485    /// the user asked for came in as bodies. Silence there would leave them
1486    /// believing they have a structure tree that does not exist.
1487    fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
1488        let Some(pending) = self.pending_step_import.take() else {
1489            return;
1490        };
1491        // Read BEFORE the import: afterwards its own ACOMP features make the
1492        // answer unconditionally "yes" and the workbench switch never fires.
1493        let was_assembly = state.history_has_assembly();
1494        let doc_name = step_document_name(&pending.name);
1495        // The §3.9 checkbox. A depth-1 file imports identically either way, so
1496        // an un-shown box costs nothing.
1497        let opts = StepAssemblyImport {
1498            nested: !pending.flatten,
1499        };
1500        // Every unique part is written to the store as its own document, at the
1501        // browser's current location and under `{assembly}-{part}` — the same
1502        // `browser_write` door + "wherever the explorer is pointing" convention
1503        // the STEP parts-library import uses (`panels::step_parts`). So an
1504        // imported part is a part like any other: Open Part opens it, Update
1505        // Components tracks it, a part edit writes through to it.
1506        let mut sink = StorePartSink::new(store, &doc_name);
1507        let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
1508            Ok(report) => {
1509                if !was_assembly {
1510                    self.enter_assembly_workbench(state);
1511                }
1512                // The store side effect is REPORTED, never silent: a 50-part
1513                // import writes 50 files the user did not individually ask for.
1514                for failure in &sink.failures {
1515                    state.push_notice(format!("part not saved — {failure}"));
1516                }
1517                let saved = sink.written;
1518                let base = assembly_import_message(&pending.name, &report);
1519                match (saved, sink.failures.len()) {
1520                    (0, _) => base,
1521                    (saved, 0) => format!("{base}; saved {saved} part file(s)"),
1522                    (saved, failed) => {
1523                        format!("{base}; saved {saved} part file(s), {failed} could not be saved")
1524                    }
1525                }
1526            }
1527            Err(error) => match state.import_step_feature(&pending.text) {
1528                Ok(_) => format!(
1529                    "imported {} as bodies — the assembly structure could not be built ({error})",
1530                    pending.name
1531                ),
1532                Err(flat) => format!("import failed: {flat}"),
1533            },
1534        };
1535        // The status line only renders inside an OPEN modal and this one closes
1536        // on the click, so the toast is the half the user actually sees.
1537        self.status = message.clone();
1538        state.push_notice(message);
1539    }
1540
1541    /// **Import as bodies** — today's flat lane, unchanged. Drops the probe's
1542    /// stash first: the user chose the text, so the parsed assembly (and every
1543    /// product's solids it is holding resident) has no consumer left.
1544    fn step_assembly_bodies(&mut self, state: &mut EngineState) {
1545        let Some(pending) = self.pending_step_import.take() else {
1546            return;
1547        };
1548        state.discard_probed_step_assembly();
1549        self.import_step_flat(state, &pending.name, &pending.text);
1550    }
1551
1552    /// **Cancel** — import nothing and drop the parse (§3.9).
1553    fn step_assembly_cancel(&mut self, state: &mut EngineState) {
1554        let Some(pending) = self.pending_step_import.take() else {
1555            return;
1556        };
1557        state.discard_probed_step_assembly();
1558        self.status = format!("import cancelled: {}", pending.name);
1559    }
1560
1561    /// Switch to the **Assembly** workbench so a freshly imported structure is
1562    /// actually reachable: the Assembly Structure tree and the Constraints panel
1563    /// are CLAIMED by that workbench, so an assembly imported under Modeling
1564    /// would land with its structure invisible.
1565    ///
1566    /// No-op when the active workbench already shows those panels — Assembly
1567    /// itself, and "All", whose users must not be yanked out of it. Applied
1568    /// through the same settings seam the toolbar dropdown and the saved-
1569    /// workbench restore use, and like the restore NOT persisted to the settings
1570    /// blob: that blob stays the user's boot preference, and a document's
1571    /// workbench is session-scoped.
1572    fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
1573        if crate::workbench::panel_visible(
1574            &state.settings.workbench,
1575            crate::workbench::assembly::BOM_PANEL_ID,
1576        ) {
1577            return;
1578        }
1579        let _ = state.apply_settings_json(
1580            &serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
1581                .to_string(),
1582        );
1583    }
1584
1585    /// Append an imported IGES file to the model (an IMPORT3D feature) — the
1586    /// IGES sibling of [`Self::import_step`].
1587    fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
1588        match state.import_iges_feature(contents) {
1589            // Framing deferred to the engine (`pending_fit`) — see `import_step`.
1590            Ok(_) => self.status = format!("imported {name}"),
1591            Err(e) => self.status = format!("import failed: {e}"),
1592        }
1593    }
1594
1595    fn stage_stl_preview(&mut self, name: String, contents: Vec<u8>) {
1596        self.pending_stl_import = Some((name, contents));
1597        self.status.clear();
1598    }
1599
1600    /// Selecting an STL starts a preview session; only its Accept action edits
1601    /// the destination document.
1602    pub fn take_stl_import(&mut self) -> Option<(String, Vec<u8>)> {
1603        self.pending_stl_import.take()
1604    }
1605
1606    fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
1607        match state.import_obj_bytes_feature(contents) {
1608            Ok(_) => self.status = format!("reconstructing {name} in background…"),
1609            Err(e) => self.status = format!("import failed: {e}"),
1610        }
1611    }
1612
1613    fn import_text(
1614        &mut self,
1615        state: &mut EngineState,
1616        name: &str,
1617        bytes: &[u8],
1618        importer: fn(&mut Self, &mut EngineState, &str, &str),
1619    ) {
1620        match std::str::from_utf8(bytes) {
1621            Ok(contents) => importer(self, state, name, contents),
1622            Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
1623        }
1624    }
1625
1626    /// Load a model document's contents into a NEW TAB (roll to the last
1627    /// feature + zoom-to-fit), clean from the start. A document that fails to
1628    /// load leaves no tab behind — the engine it was loading into is dropped
1629    /// with its runner.
1630    fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str) {
1631        let mut engine = docs.spawn_engine();
1632        match engine.load_model_and_fit(contents) {
1633            Ok(_) => {
1634                // The document keeps the raw identity — the full path when a
1635                // native dialog picked the file, so plain Save writes back to
1636                // it; the name field shows only the bare display name.
1637                let mut doc = Document::new(engine);
1638                doc.set_name(Some(name.to_string()));
1639                docs.open_document(doc);
1640                self.name_buf = model_display_name(name);
1641                self.status = format!("opened {name}");
1642            }
1643            Err(e) => self.status = format!("open failed: {e}"),
1644        }
1645    }
1646
1647    // --- verifier hooks (wasm only) -------------------------------------------
1648
1649    /// The published state for the headed verifier: current name, dirty flag,
1650    /// backend label, the stored-document list, and the modal's open/mode.
1651    #[cfg(target_arch = "wasm32")]
1652    pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
1653        serde_json::json!({
1654            "name": docs.active().name(),
1655            "nameBuf": self.name_buf,
1656            "selected": self.explorer.selected(),
1657            "location": store.browser_location(),
1658            "dirty": docs.active().is_dirty(),
1659            "backend": store.backend_label(),
1660            "interchange": store.supports_file_interchange(),
1661            "list": store.list(),
1662            "status": self.status,
1663            "open": self.open,
1664            "mode": match self.mode {
1665                Mode::Open => "open",
1666                Mode::SaveAs => "saveas",
1667                Mode::ConfirmClose => "confirmclose",
1668                Mode::Export => "export",
1669                Mode::Import => "import",
1670                Mode::FlatPattern => "flatpattern",
1671                Mode::InsertComponent => "insertcomponent",
1672                Mode::StepAssembly => "stepassembly",
1673            },
1674            // The armed §3.9 assembly choice: the file it belongs to and the
1675            // counts the prompt is showing, so the headed verifier can confirm
1676            // the dialog appeared with the numbers the import will deliver.
1677            // A `.step` upload whose structure probe is still running on the
1678            // background runner (the "reading…" state).
1679            "probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
1680            "stepAssembly": self.pending_step_import.as_ref().map(|pending| {
1681                serde_json::json!({
1682                    "name": pending.name,
1683                    "parts": pending.probe.parts,
1684                    "instances": pending.probe.instances,
1685                    "nestedDepth": pending.probe.nested_depth,
1686                    // The §3.9 checkbox: shown only when there is a tree to
1687                    // flatten, and OFF by default (import keeps the tree).
1688                    "flatten": pending.flatten,
1689                })
1690            }),
1691        })
1692        .to_string()
1693    }
1694
1695    /// The published widget hit-rects (egui points) for the headed verifier.
1696    #[cfg(target_arch = "wasm32")]
1697    pub fn hits_json(&self) -> String {
1698        let map: serde_json::Map<String, Value> = self
1699            .hits
1700            .iter()
1701            .map(|(k, r)| {
1702                (
1703                    k.clone(),
1704                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
1705                )
1706            })
1707            .collect();
1708        Value::Object(map).to_string()
1709    }
1710
1711    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
1712    /// elsewhere so the dialog code reads the same on both targets).
1713    #[cfg(target_arch = "wasm32")]
1714    fn hit(&mut self, key: &str, resp: &egui::Response) {
1715        self.hits.insert(key.to_string(), resp.rect);
1716    }
1717    #[cfg(not(target_arch = "wasm32"))]
1718    #[inline]
1719    fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
1720
1721    #[cfg(target_arch = "wasm32")]
1722    fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
1723        self.hits.extend(hits.iter().cloned());
1724    }
1725    #[cfg(not(target_arch = "wasm32"))]
1726    #[inline]
1727    fn record_explorer_hits(&mut self, _hits: &[(String, egui::Rect)]) {}
1728}
1729
1730#[cfg(all(test, not(target_arch = "wasm32")))]
1731mod tests {
1732    use super::*;
1733    use crate::store::native_test_store;
1734
1735    /// A ONE-DOCUMENT session over the SYNCHRONOUS inline runner, seeded with
1736    /// `history` and clean — the shape every dialog flow needs now that the
1737    /// dialog acts on documents rather than a bare engine. (The real shell's
1738    /// factory installs a background runner; a test needs the load's solids
1739    /// resident by the time the call returns.)
1740    fn documents(history: &str) -> Documents {
1741        let mut docs = Documents::new(Box::new(EngineState::new));
1742        docs.engine_mut().set_history_json(history).unwrap();
1743        docs.active_mut().mark_clean();
1744        docs
1745    }
1746
1747    /// The engine's `history_request_json()` is a stable model document: writing
1748    /// it through the store and loading it back reproduces the same request —
1749    /// the native save/open round-trip the brief asks for.
1750    #[test]
1751    fn model_document_round_trips_through_store_and_engine() {
1752        // A temp-dir store (never touches the real config dir).
1753        let dir = std::env::temp_dir().join(format!("brep-app-file-{}", std::process::id()));
1754        let _ = std::fs::remove_dir_all(&dir);
1755        let store = native_test_store(dir.clone());
1756
1757        // Build a model in the engine, serialize it, save it through the store.
1758        let mut engine = EngineState::new();
1759        let seed = r#"{"expressions":"","configurator":{},"features":[
1760            {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1761             "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1762             "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1763        ]}"#;
1764        engine.set_history_json(seed).unwrap();
1765        let before = engine.history_request_json();
1766        store.write("roundtrip", &before).unwrap();
1767
1768        // A fresh engine loads the stored document + frames it, reproducing it.
1769        let mut reopened = EngineState::new();
1770        let contents = store.read("roundtrip").unwrap();
1771        reopened.load_model_and_fit(&contents).unwrap();
1772        let after = reopened.history_request_json();
1773
1774        assert_eq!(before, after, "request JSON round-trips through the store");
1775        assert_eq!(reopened.history_len(), 1);
1776        assert_eq!(reopened.scene.solids().len(), 1);
1777
1778        let _ = std::fs::remove_dir_all(&dir);
1779    }
1780
1781    /// A saved part REMEMBERS the active workbench and Open RESTORES it — the
1782    /// full app-side lane over the engine's document round-trip: Save writes the
1783    /// top-level `workbench` field through the store; opening a stored document
1784    /// that carries the field switches the app's workbench through the shared
1785    /// settings seam (the toolbar dropdown's apply path); a LEGACY document
1786    /// without the field changes nothing; a BOGUS stored id opens cleanly and
1787    /// resolves to the default workbench via the registry's `resolve()`
1788    /// tolerance (never a panic or an error status).
1789    #[test]
1790    fn saved_documents_remember_and_restore_the_workbench() {
1791        use serde_json::Value;
1792        let dir = std::env::temp_dir().join(format!("brep-app-wb-{}", std::process::id()));
1793        let _ = std::fs::remove_dir_all(&dir);
1794        let store = native_test_store(dir.clone());
1795
1796        // SAVE from the Sheet Metal workbench → the stored bytes carry the id.
1797        let mut docs = documents(BOX_SEED);
1798        docs.engine_mut()
1799            .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
1800            .unwrap();
1801        let mut dialog = FileDialog::new();
1802        assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1803        let stored: Value = serde_json::from_str(&store.read("part").unwrap()).unwrap();
1804        assert_eq!(
1805            stored.get("workbench").and_then(Value::as_str),
1806            Some("sheetMetal"),
1807            "Save embeds the active workbench id in the document"
1808        );
1809
1810        // OPEN in a fresh (default-Modeling) session → the NEW TAB comes up in
1811        // Sheet Metal, and is CLEAN (the baseline includes the field).
1812        let mut reopened = documents(EMPTY_DOCUMENT);
1813        assert_eq!(
1814            reopened.engine().settings.workbench, "modeling",
1815            "fresh session default"
1816        );
1817        let mut viewer = FileDialog::new();
1818        viewer.open_document(&mut reopened, &*store, "part");
1819        assert_eq!(reopened.len(), 2, "Open adds a tab");
1820        assert_eq!(
1821            reopened.engine().settings.workbench, "sheetMetal",
1822            "Open restores the saved workbench"
1823        );
1824        assert!(
1825            !reopened.active().is_dirty(),
1826            "a just-opened document is clean"
1827        );
1828
1829        // LEGACY: a document WITHOUT the field leaves the active workbench alone.
1830        store.write("legacy", BOX_SEED).unwrap();
1831        viewer.open_document(&mut reopened, &*store, "legacy");
1832        assert!(viewer.status.starts_with("opened"), "legacy open succeeds");
1833        assert_eq!(
1834            reopened.engine().settings.workbench, "sheetMetal",
1835            "a legacy document inherits the session's workbench, unchanged"
1836        );
1837
1838        // BOGUS: an unknown stored id opens fine — stored raw, resolved to the
1839        // default workbench at every consumption site by the registry.
1840        let mut bogus: Value = serde_json::from_str(BOX_SEED).unwrap();
1841        bogus["workbench"] = Value::String("conveyorBelts".into());
1842        store.write("bogus", &bogus.to_string()).unwrap();
1843        viewer.open_document(&mut reopened, &*store, "bogus");
1844        assert!(
1845            viewer.status.starts_with("opened"),
1846            "a bogus workbench id must not fail the open: {}",
1847            viewer.status
1848        );
1849        assert_eq!(
1850            reopened.engine().settings.workbench, "conveyorBelts",
1851            "raw id stored"
1852        );
1853        assert_eq!(
1854            crate::workbench::resolve(&reopened.engine().settings.workbench).id,
1855            "modeling",
1856            "…and it resolves to the default workbench wherever it is consumed"
1857        );
1858
1859        let _ = std::fs::remove_dir_all(&dir);
1860    }
1861
1862    /// An edit marks the DOCUMENT dirty; saving clears the flag again and
1863    /// records the name on that document.
1864    #[test]
1865    fn dirty_flag_flips_on_edit_and_clears_on_save() {
1866        let dir = std::env::temp_dir().join(format!("brep-app-dirty-{}", std::process::id()));
1867        let _ = std::fs::remove_dir_all(&dir);
1868        let store = native_test_store(dir.clone());
1869
1870        let mut docs = documents(r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#);
1871        let mut dialog = FileDialog::new();
1872        assert!(!docs.active().is_dirty(), "seeded model is clean");
1873        assert_eq!(dialog.save_generation(), 0, "no saves yet");
1874
1875        // Edit a parameter → dirty.
1876        docs.engine_mut()
1877            .update_feature_params(
1878                "Box",
1879                r#"{"id":"Box","sizeX":9.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
1880            )
1881            .unwrap();
1882        assert!(docs.active().is_dirty(), "edit marks dirty");
1883
1884        // Save under a name (the Save As path) → clean, name recorded.
1885        assert!(
1886            dialog.save_to(&mut docs, &*store, "m".into()),
1887            "save succeeds"
1888        );
1889        assert!(!docs.active().is_dirty(), "save clears dirty");
1890        assert_eq!(docs.active().name(), Some("m"));
1891        assert_eq!(
1892            dialog.save_generation(),
1893            1,
1894            "a successful save bumps the update-components staleness key"
1895        );
1896
1897        let _ = std::fs::remove_dir_all(&dir);
1898    }
1899
1900    /// New ADDS A TAB and never prompts — nothing is replaced, so a dirty
1901    /// document has nothing to lose. CLOSING is where the unsaved-changes
1902    /// contract lives now: a clean tab closes on the spot, a dirty one raises
1903    /// the confirm modal and stays open until the user decides.
1904    #[test]
1905    fn new_adds_a_tab_and_only_closing_confirms() {
1906        let dir = std::env::temp_dir().join(format!("brep-app-newcfm-{}", std::process::id()));
1907        let _ = std::fs::remove_dir_all(&dir);
1908        let store = native_test_store(dir.clone());
1909
1910        let mut docs = documents(r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#);
1911        let mut dialog = FileDialog::new();
1912
1913        // New on a DIRTY document: still no modal, and the dirty model is still
1914        // there — in its own tab, behind the new empty one.
1915        docs.engine_mut()
1916            .update_feature_params(
1917                "Box",
1918                r#"{"id":"Box","sizeX":9.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
1919            )
1920            .unwrap();
1921        assert!(docs.active().is_dirty());
1922        dialog.dispatch(FileAction::New, &mut docs, &*store);
1923        assert!(!dialog.open, "New never opens a modal");
1924        assert_eq!(docs.len(), 2, "New adds a tab");
1925        assert_eq!(docs.active_index(), 1, "…and focuses it");
1926        assert_eq!(docs.engine().history_len(), 0, "the new tab is empty");
1927        assert_eq!(
1928            docs.get(0).unwrap().engine.history_len(),
1929            1,
1930            "the dirty document is untouched in its own tab"
1931        );
1932
1933        // Closing the CLEAN new tab is immediate.
1934        dialog.request_close(&mut docs, 1);
1935        assert!(!dialog.open, "a clean close needs no confirmation");
1936        assert_eq!(docs.len(), 1);
1937
1938        // Closing the DIRTY one raises the prompt and closes nothing yet.
1939        dialog.request_close(&mut docs, 0);
1940        assert!(dialog.open && dialog.mode == Mode::ConfirmClose);
1941        assert_eq!(dialog.pending_close, Some(0));
1942        assert_eq!(docs.len(), 1, "nothing closed until the user discards");
1943
1944        let _ = std::fs::remove_dir_all(&dir);
1945    }
1946
1947    /// A plain Save on a never-named document routes through Save As: with no
1948    /// name it opens the modal (nothing written); after a name is set + saved it
1949    /// records the name and clears dirty.
1950    #[test]
1951    fn save_on_unnamed_falls_through_to_save_as_modal() {
1952        let dir = std::env::temp_dir().join(format!("brep-app-unnamed-{}", std::process::id()));
1953        let _ = std::fs::remove_dir_all(&dir);
1954        let store = native_test_store(dir.clone());
1955
1956        let mut docs = documents(r#"{"features":[{"type":"P.CU","inputParams":{"id":"Box","sizeX":4.0,"sizeY":4.0,"sizeZ":4.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}}]}"#);
1957        let mut dialog = FileDialog::new();
1958
1959        // Unnamed Save → Save As modal opens (feature-off native build).
1960        dialog.dispatch(FileAction::Save, &mut docs, &*store);
1961        assert!(dialog.open, "unnamed Save opens the Save As modal");
1962        assert!(store.list().is_empty(), "nothing written yet");
1963
1964        // Provide a name + save.
1965        assert!(dialog.save_to(&mut docs, &*store, "part".into()));
1966        assert_eq!(docs.active().name(), Some("part"));
1967        assert_eq!(store.list(), vec!["part".to_string()]);
1968
1969        let _ = std::fs::remove_dir_all(&dir);
1970    }
1971
1972    /// A single-box model document, for the import/export lane tests.
1973    const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
1974        {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
1975         "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
1976         "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
1977    ]}"#;
1978
1979    /// STEP file names route to the import lane; extension-less model names (and
1980    /// `.json` documents) do not — so the take_import poll never mis-routes.
1981    #[test]
1982    fn step_names_route_to_the_import_lane() {
1983        assert!(is_step_name("part.step"));
1984        assert!(is_step_name("PART.STP"));
1985        assert!(is_stl_name("scan.STL"));
1986        assert!(is_obj_name("scan.obj"));
1987        assert!(!is_step_name("model"), "model docs arrive extension-less");
1988        assert!(!is_step_name("model.json"));
1989        assert!(!is_stl_name("model.json"));
1990        assert!(!is_obj_name("model.json"));
1991    }
1992
1993    /// Export dispatch opens the format chooser; choosing STEP / STL on a box
1994    /// model serializes into the native store directory. An empty model reports a
1995    /// clear guard rather than exporting.
1996    #[test]
1997    fn export_dispatch_and_chooser_serialize_the_model() {
1998        let dir = std::env::temp_dir().join(format!("brep-app-export-{}", std::process::id()));
1999        let _ = std::fs::remove_dir_all(&dir);
2000        let store = native_test_store(dir.clone());
2001
2002        let mut docs = documents(BOX_SEED);
2003        let mut dialog = FileDialog::new();
2004
2005        dialog.dispatch(FileAction::Export, &mut docs, &*store);
2006        assert!(dialog.open && dialog.mode == Mode::Export, "Export opens the chooser");
2007        assert!(dialog.export_as(&docs, &*store, "step"), "STEP export ok");
2008        assert!(dialog.status.contains("exported"), "status: {}", dialog.status);
2009        assert!(dialog.export_as(&docs, &*store, "stl"), "STL export ok");
2010        assert!(dialog.export_as(&docs, &*store, "json"), "JSON recipe export ok");
2011        assert!(dialog.status.contains(".BREP.json"), "json status: {}", dialog.status);
2012        assert!(dir.join("untitled.step").is_file());
2013        assert!(dir.join("untitled.stl").is_file());
2014        assert!(dir.join("untitled.BREP.json").is_file());
2015
2016        let empty = documents(EMPTY_DOCUMENT);
2017        assert!(!dialog.export_as(&empty, &*store, "step"), "empty model does not export");
2018        assert!(dialog.status.contains("nothing to export"), "status: {}", dialog.status);
2019
2020        let _ = std::fs::remove_dir_all(&dir);
2021    }
2022
2023    #[test]
2024    fn import_dispatch_opens_the_common_explorer() {
2025        let dir = std::env::temp_dir().join(format!("brep-app-import-{}", std::process::id()));
2026        let _ = std::fs::remove_dir_all(&dir);
2027        let store = native_test_store(dir.clone());
2028        let mut docs = documents(EMPTY_DOCUMENT);
2029        let mut dialog = FileDialog::new();
2030
2031        dialog.dispatch(FileAction::Import, &mut docs, &*store);
2032        assert!(dialog.open && dialog.mode == Mode::Import);
2033
2034        let _ = std::fs::remove_dir_all(&dir);
2035    }
2036
2037    /// The flat-pattern action opens the DXF/SVG chooser (its own modal mode). On
2038    /// a model with NO sheet-metal body the export reports the exact target error
2039    /// in the status line AND queues it as a toast (the engine notice path).
2040    #[test]
2041    fn flat_pattern_dispatch_errors_and_toasts_without_sheet_metal() {
2042        let dir = std::env::temp_dir().join(format!("brep-app-flat-{}", std::process::id()));
2043        let _ = std::fs::remove_dir_all(&dir);
2044        let store = native_test_store(dir.clone());
2045
2046        // A plain box — no sheet-metal body.
2047        let mut docs = documents(BOX_SEED);
2048        let mut dialog = FileDialog::new();
2049
2050        dialog.dispatch(FileAction::ExportFlatPattern, &mut docs, &*store);
2051        assert!(
2052            dialog.open && dialog.mode == Mode::FlatPattern,
2053            "flat-pattern action opens the DXF/SVG chooser"
2054        );
2055
2056        // Choosing DXF fails loudly (no SM body): status set, notice queued, modal
2057        // stays open (the false return keeps it up).
2058        assert!(!dialog.export_flat_pattern_as(&mut docs, &*store, "dxf"));
2059        assert!(
2060            dialog.status.contains("no sheet-metal body"),
2061            "status names the missing body: {}",
2062            dialog.status
2063        );
2064        let notices = docs.engine_mut().take_notices();
2065        assert!(
2066            notices.iter().any(|n| n.contains("no sheet-metal body")),
2067            "the error is queued as a toast: {notices:?}"
2068        );
2069
2070        let _ = std::fs::remove_dir_all(&dir);
2071    }
2072
2073    /// The BOM lane (assemblies §9): on a componentless document the Export
2074    /// chooser's BOM buttons gate DISABLED (the `component_ids` predicate) and
2075    /// a direct export refuses loudly (status + toast, modal stays open) — the
2076    /// flat-pattern outcome pattern. With components resident the gate flips
2077    /// and both formats export under the `<name>.bom.<ext>` compound extension.
2078    #[test]
2079    fn bom_export_gates_on_components_and_exports_both_formats() {
2080        brep_render::brep_kernel::clear_history_cache();
2081        let dir = std::env::temp_dir().join(format!("brep-app-bom-{}", std::process::id()));
2082        let _ = std::fs::remove_dir_all(&dir);
2083        let store = native_test_store(dir.clone());
2084
2085        // A plain box — no components.
2086        let mut docs = documents(BOX_SEED);
2087        let mut dialog = FileDialog::new();
2088        assert!(
2089            docs.engine().component_ids().is_empty(),
2090            "componentless doc → the chooser draws the BOM buttons disabled"
2091        );
2092        assert!(!dialog.export_bom_as(&mut docs, &*store, "csv"));
2093        assert!(
2094            dialog.status.contains("no components"),
2095            "status names the guard: {}",
2096            dialog.status
2097        );
2098        let notices = docs.engine_mut().take_notices();
2099        assert!(
2100            notices.iter().any(|n| n.contains("no components")),
2101            "the error is queued as a toast: {notices:?}"
2102        );
2103
2104        // Insert the SAME part twice (one library entry, two instances): the
2105        // gate flips and both formats export.
2106        dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2107        dialog.insert_component_document(docs.engine_mut(), "widget", BOX_SEED);
2108        assert!(
2109            !docs.engine().component_ids().is_empty(),
2110            "components resident → the BOM buttons enable"
2111        );
2112        assert!(dialog.export_bom_as(&mut docs, &*store, "csv"), "CSV export ok");
2113        assert!(dialog.status.contains(".bom.csv"), "status: {}", dialog.status);
2114        assert!(dialog.export_bom_as(&mut docs, &*store, "json"), "JSON export ok");
2115        assert!(dialog.status.contains(".bom.json"), "status: {}", dialog.status);
2116
2117        let _ = std::fs::remove_dir_all(&dir);
2118    }
2119
2120    /// The INSERT-COMPONENT lane: a stored part document inserts as a
2121    /// parts-library entry + an ACOMP instance (the returned effective name in
2122    /// `partName`, first instance explicitly fixed), and `sourceSignature` is
2123    /// written with the ONE signature fn (`document_signature`) — the
2124    /// update-components comparison's up-to-date invariant.
2125    #[test]
2126    fn insert_component_document_adds_a_library_backed_instance() {
2127        brep_render::brep_kernel::clear_history_cache();
2128        let mut engine = EngineState::new();
2129        let mut dialog = FileDialog::new();
2130
2131        // Insert the box part document as a component.
2132        dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2133        assert!(
2134            dialog.status.contains("inserted box-part (ACOMP1)"),
2135            "status: {}",
2136            dialog.status
2137        );
2138        let params = engine
2139            .history
2140            .feature_params(engine.history.index_of("ACOMP1").unwrap())
2141            .unwrap();
2142        assert_eq!(params["partName"], "box-part", "effective library name");
2143        assert_eq!(params["isFixed"], true, "first instance explicitly fixed");
2144        assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2145        // The instance is live geometry under the namespaced member name.
2146        assert!(engine.scene.solid("ACOMP1:Box").is_some());
2147        // The entry's signature is document_signature(contents): a fresh insert
2148        // with UNCHANGED source must compare up-to-date (outdated count 0).
2149        let doc: serde_json::Value =
2150            serde_json::from_str(&engine.history_request_json()).unwrap();
2151        assert_eq!(
2152            doc["partsLibrary"]["box-part"]["sourceSignature"],
2153            document_signature(BOX_SEED),
2154            "insert writes the ONE signature fn's value"
2155        );
2156
2157        // Re-inserting the SAME document dedups to the same library entry and
2158        // the second instance is NOT fixed.
2159        dialog.insert_component_document(&mut engine, "box-part", BOX_SEED);
2160        let params2 = engine
2161            .history
2162            .feature_params(engine.history.index_of("ACOMP2").unwrap())
2163            .unwrap();
2164        assert_eq!(params2["partName"], "box-part");
2165        assert_eq!(params2["isFixed"], false);
2166        assert_eq!(engine.parts_library_names(), vec!["box-part".to_string()]);
2167    }
2168
2169    /// The Edit-Part lane (and File>Open, which is the same door):
2170    /// `open_document` ADDS A TAB for a document that is not open, and FOCUSES
2171    /// the existing tab for one that is — never a second copy of one file, and
2172    /// never a prompt, because nothing is replaced.
2173    #[test]
2174    fn open_document_adds_a_tab_or_focuses_the_open_one() {
2175        let dir = std::env::temp_dir().join(format!("brep-app-openpart-{}", std::process::id()));
2176        let _ = std::fs::remove_dir_all(&dir);
2177        let store = native_test_store(dir.clone());
2178        store.write("part", BOX_SEED).unwrap();
2179        store.write("other", BOX_SEED).unwrap();
2180
2181        // A DIRTY current document — the case that used to prompt.
2182        let mut docs = documents(BOX_SEED);
2183        docs.engine_mut()
2184            .update_feature_params(
2185                "Box",
2186                r#"{"id":"Box","sizeX":9.0,"sizeY":8.0,"sizeZ":8.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
2187            )
2188            .unwrap();
2189        assert!(docs.active().is_dirty());
2190        let mut dialog = FileDialog::new();
2191
2192        dialog.open_document(&mut docs, &*store, "part");
2193        assert!(!dialog.open, "opening never raises a modal");
2194        assert_eq!(docs.len(), 2, "a tab was added");
2195        assert_eq!(docs.active().name(), Some("part"));
2196        assert!(!docs.active().is_dirty(), "freshly opened is clean");
2197        assert!(
2198            docs.get(0).unwrap().is_dirty(),
2199            "the dirty document is still open and still dirty"
2200        );
2201
2202        // A second document opens beside them…
2203        dialog.open_document(&mut docs, &*store, "other");
2204        assert_eq!(docs.len(), 3);
2205        assert_eq!(docs.active_index(), 2);
2206
2207        // …and re-opening the first FOCUSES its tab rather than adding another.
2208        dialog.open_document(&mut docs, &*store, "part");
2209        assert_eq!(docs.len(), 3, "no second tab for one file");
2210        assert_eq!(docs.active_index(), 1);
2211        assert!(dialog.status.contains("already open"), "status: {}", dialog.status);
2212
2213        // A name with no document says so and opens nothing.
2214        dialog.open_document(&mut docs, &*store, "missing");
2215        assert_eq!(docs.len(), 3);
2216        assert!(dialog.status.contains("not found"), "status: {}", dialog.status);
2217
2218        let _ = std::fs::remove_dir_all(&dir);
2219    }
2220
2221    /// A save that would give TWO tabs one store identity is refused: "focus the
2222    /// tab holding this document" has no answer then, and the second save would
2223    /// silently overwrite the first tab's file.
2224    #[test]
2225    fn saving_onto_a_name_open_in_another_tab_is_refused() {
2226        let dir = std::env::temp_dir().join(format!("brep-app-dupname-{}", std::process::id()));
2227        let _ = std::fs::remove_dir_all(&dir);
2228        let store = native_test_store(dir.clone());
2229        store.write("part", BOX_SEED).unwrap();
2230
2231        let mut docs = documents(BOX_SEED);
2232        let mut dialog = FileDialog::new();
2233        dialog.open_document(&mut docs, &*store, "part");
2234        assert_eq!(docs.active_index(), 1);
2235
2236        // The untitled scratch tab tries to save over the open document — by the
2237        // BARE name, which on desktop is not the open document's raw identity
2238        // (that is a full path), so the guard has to compare display names.
2239        docs.activate(0);
2240        assert!(!dialog.save_to(&mut docs, &*store, "part".into()));
2241        assert!(
2242            !dialog.save_to_browser(&mut docs, &*store, "part".into()),
2243            "the Save As lane refuses BEFORE it writes, never after"
2244        );
2245        assert!(
2246            dialog.status.contains("already open in another tab"),
2247            "status: {}",
2248            dialog.status
2249        );
2250        assert_eq!(docs.active().name(), None, "nothing claimed the name");
2251
2252        // Saving over ITS OWN name is of course fine.
2253        docs.activate(1);
2254        assert!(dialog.save_to(&mut docs, &*store, "part".into()));
2255
2256        let _ = std::fs::remove_dir_all(&dir);
2257    }
2258
2259    /// A STEP file routed to import is APPENDED to the model (an edit → dirty),
2260    /// unlike Open which replaces + cleans. Uses the engine's own STEP export to
2261    /// produce a valid document without a direct kernel dependency.
2262    #[test]
2263    fn import_step_appends_and_marks_dirty() {
2264        let dir = std::env::temp_dir().join(format!("brep-app-imp-{}", std::process::id()));
2265        let _ = std::fs::remove_dir_all(&dir);
2266        let store = native_test_store(dir.clone());
2267
2268        // A box model → STEP text via the engine.
2269        let mut source = EngineState::new();
2270        source.set_history_json(BOX_SEED).unwrap();
2271        let step = source.export_step_text().unwrap();
2272
2273        // A fresh (empty, clean) document imports it: one body appended, dirty.
2274        let mut docs = documents(EMPTY_DOCUMENT);
2275        let mut dialog = FileDialog::new();
2276        assert!(!docs.active().is_dirty(), "empty seed is clean");
2277        dialog.import_step(docs.engine_mut(), "part.step", &step);
2278        assert_eq!(docs.engine().history_len(), 1, "IMPORT3D feature appended");
2279        assert_eq!(
2280            docs.engine().scene.solids().len(),
2281            1,
2282            "the imported body is in the scene"
2283        );
2284        assert!(docs.active().is_dirty(), "an import is an edit");
2285        assert!(dialog.status.contains("imported"), "status: {}", dialog.status);
2286        let _ = store; // store unused beyond construction (test stores never drive dialogs)
2287
2288        let _ = std::fs::remove_dir_all(&dir);
2289    }
2290
2291    // -----------------------------------------------------------------------
2292    // Structured STEP assembly import (kernel-plan `step-assembly-import.md`
2293    // §3.9) — the trigger: probe first, then the choice modal.
2294    // -----------------------------------------------------------------------
2295
2296    /// A STEP fixture from the KERNEL's corpus, read at RUNTIME: that corpus is
2297    /// test-only ROOT data which deliberately stays outside every crate package
2298    /// archive, so it must not be `include_str!`d into this crate. (The engine's
2299    /// own assembly tests read it exactly this way.)
2300    fn step_fixture(name: &str) -> String {
2301        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2302            .join("../BREP_kernel/tests/fixtures/step-import")
2303            .join(name);
2304        std::fs::read_to_string(&path)
2305            .unwrap_or_else(|error| panic!("read fixture {}: {error}", path.display()))
2306    }
2307
2308    /// The classic AP214 assembly: 5 geometry-bearing parts in 18 placements.
2309    const AS1: &str = "as1-ug-214.stp";
2310
2311    /// A dialog with the AS1 choice ARMED — the state every dialog-exit test
2312    /// starts from. Asserts the arming itself, so each caller can go straight to
2313    /// the click it is about.
2314    fn armed(engine: &mut EngineState) -> FileDialog {
2315        let mut dialog = FileDialog::new();
2316        dialog.import_step(engine, AS1, &step_fixture(AS1));
2317        let pending = dialog
2318            .pending_step_import
2319            .as_ref()
2320            .expect("as1-ug-214 carries a product structure, so the choice arms");
2321        assert_eq!(
2322            (pending.probe.parts, pending.probe.instances),
2323            (5, 18),
2324            "the prompt shows the counts the import will deliver"
2325        );
2326        assert_eq!(engine.history_len(), 0, "nothing is imported before the click");
2327        dialog
2328    }
2329
2330    /// Whether the engine is still holding a probed parse — observed through the
2331    /// consume, which is the only public window onto the stash: `Err` is
2332    /// "nothing probed", and it is also the double-import guard.
2333    fn stash_is_empty(engine: &mut EngineState) -> bool {
2334        engine
2335            .import_probed_step_assembly(
2336                "probe",
2337                StepAssemblyImport::default(),
2338                &mut brep_render::engine_state::EmbeddedOnly,
2339            )
2340            .is_err()
2341    }
2342
2343    /// The store-write lane's naming: a STEP product name is reduced to
2344    /// something every backend can hold as a file name, and two products that
2345    /// reduce to the SAME name get distinct files — otherwise the second
2346    /// overwrites the first and two library entries point at one document.
2347    #[test]
2348    fn imported_part_file_names_are_sanitized_and_never_collide() {
2349        assert_eq!(sanitize_file_stem("L Bracket"), "L_Bracket");
2350        assert_eq!(sanitize_file_stem("rod/assem"), "rod_assem", "no path escape");
2351        assert_eq!(
2352            sanitize_file_stem("bolt (mirrored)"),
2353            "bolt_mirrored",
2354            "the crate's own factor suffix survives readably"
2355        );
2356        assert_eq!(sanitize_file_stem("a///b"), "a_b", "runs collapse");
2357        assert_eq!(sanitize_file_stem("  "), "part", "never an empty name");
2358        assert_eq!(sanitize_file_stem("plate-1.2"), "plate-1.2", "kept as-is");
2359
2360        let store = crate::store::MemModelStore::new();
2361        let mut sink = StorePartSink::new(&store, "as1 ug");
2362        // Two DIFFERENT products that sanitize to the same stem.
2363        let first = sink.store_part("L Bracket", "{\"a\":1}").expect("written");
2364        let second = sink.store_part("L/Bracket", "{\"a\":2}").expect("written");
2365        assert_eq!(first, "as1_ug-L_Bracket");
2366        assert_eq!(second, "as1_ug-L_Bracket-2", "disambiguated, not overwritten");
2367        assert_eq!(store.read(&first).as_deref(), Some("{\"a\":1}"));
2368        assert_eq!(store.read(&second).as_deref(), Some("{\"a\":2}"));
2369        assert_eq!(sink.written, 2);
2370        assert!(sink.failures.is_empty());
2371    }
2372
2373    /// A file with NO product structure must never see the dialog: the probe
2374    /// reports nothing, the flat lane runs immediately, and the modal closes as
2375    /// it always did. This is the "unchanged for part files" half of §3.9, and
2376    /// the engine is left holding nothing.
2377    #[test]
2378    fn a_structureless_step_never_sees_the_dialog() {
2379        let mut source = EngineState::new();
2380        source.set_history_json(BOX_SEED).unwrap();
2381        let step = source.export_step_text().unwrap();
2382
2383        let mut engine = EngineState::new();
2384        let mut dialog = FileDialog::new();
2385        dialog.import_step(&mut engine, "part.step", &step);
2386
2387        assert!(
2388            dialog.pending_step_import.is_none(),
2389            "a part file must not arm the assembly choice"
2390        );
2391        dialog.close_after_import();
2392        assert!(!dialog.open, "nothing armed, so the modal closes");
2393        assert_eq!(engine.history_len(), 1, "one IMPORT3D feature, as before");
2394        assert!(
2395            engine.history_request_json().contains("stepText"),
2396            "the flat lane stores the STEP text, byte-for-byte as before"
2397        );
2398        assert!(!engine.history_has_assembly(), "no components");
2399        assert!(engine.parts_library_names().is_empty(), "no library entries");
2400        assert_eq!(dialog.status, "imported part.step");
2401        assert!(stash_is_empty(&mut engine), "a structureless probe stashes nothing");
2402    }
2403
2404    /// The whole §3.9 flow on the classic fixture: the probe arms the choice
2405    /// with the file's real counts, the modal opens in its own mode, and "Import
2406    /// as assembly" consumes THAT parse — keeping the TREE, which is the default
2407    /// now that A8 has made the box mean something. `as1-ug-214` is
2408    /// `as1-ug → { plate, lb_assem ×2, rod_assem }`, so the document gets 3
2409    /// entries and 4 components and the depth lives inside them. The §3.9 line
2410    /// goes to both the status line and the toast lane, and the import switches
2411    /// to the Assembly workbench (whose panels the structure needs).
2412    #[test]
2413    fn importing_as_an_assembly_reports_the_counts_and_enters_the_workbench() {
2414        let mut engine = EngineState::new();
2415        assert_eq!(engine.settings.workbench, "modeling", "fresh session default");
2416        let mut dialog = armed(&mut engine);
2417
2418        dialog.close_after_import();
2419        assert!(dialog.open && dialog.mode == Mode::StepAssembly, "the modal opens");
2420        assert!(
2421            !dialog.pending_step_import.as_ref().unwrap().flatten,
2422            "the §3.9 box defaults OFF — an import keeps the tree"
2423        );
2424
2425        let store = crate::store::MemModelStore::new();
2426        dialog.step_assembly_import(&mut engine, &store);
2427
2428        // CHANGED: the import now WRITES its unique parts to the store as their
2429        // own documents (the owner's "no distinction between part kinds"), so
2430        // the outcome line reports that side effect rather than hiding it — a
2431        // 50-part import writes 50 files nobody individually asked for.
2432        assert_eq!(
2433            dialog.status,
2434            "imported as1-ug-214.stp \u{2014} 3 parts, 4 components; saved 8 part file(s)",
2435            "the §3.9 outcome line, with the report's real numbers + the writes"
2436        );
2437        // 8 = the 3 root-level entries plus the 5 distinct parts inside the two
2438        // sub-assembly documents. Every one is a part in its own right now.
2439        let saved = store.list();
2440        assert_eq!(saved.len(), 8, "one file per distinct part: {saved:?}");
2441        assert!(
2442            saved.iter().all(|name| name.starts_with("as1-ug-")),
2443            "each file carries the assembly name, so one import groups in a \
2444             listing without a folder convention `browser_write` cannot express: \
2445             {saved:?}"
2446        );
2447        // ...and every entry points at one of them, with a signature over the
2448        // bytes actually written — the write-through guard depends on that.
2449        for name in engine.parts_library_names() {
2450            let (key, signature) = engine.part_source(&name).expect("a library entry");
2451            assert!(!key.is_empty(), "'{name}' carries a real sourceKey");
2452            let stored = store.read(&key).unwrap_or_else(|| panic!("'{key}' written"));
2453            assert_eq!(
2454                signature,
2455                document_signature(&stored),
2456                "'{name}': entry signature and stored file describe one thing"
2457            );
2458        }
2459        let notices = engine.take_notices();
2460        assert!(
2461            notices.iter().any(|notice| notice == &dialog.status),
2462            "the outcome is toasted too — the status line is invisible once the \
2463             modal closes: {notices:?}"
2464        );
2465        assert_eq!(
2466            engine.parts_library_names().len(),
2467            3,
2468            "one library entry per unique ROOT-level product: plate, lb_assem, \
2469             rod_assem — the deeper parts live in THEIR documents' libraries"
2470        );
2471        assert_eq!(
2472            component_part_names(&engine).len(),
2473            4,
2474            "one ACOMP per root occurrence — a sub-assembly is ONE component"
2475        );
2476        assert!(engine.history_has_assembly(), "the document is an assembly now");
2477        assert_eq!(
2478            engine.settings.workbench, "assembly",
2479            "an imported assembly switches to the workbench its panels are gated on"
2480        );
2481        assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2482        assert!(
2483            stash_is_empty(&mut engine),
2484            "the consume TAKES the parse — a second one is never a double insert"
2485        );
2486    }
2487
2488    /// **Flatten sub-assemblies** — the §3.9 checkbox, which A8 makes real. The
2489    /// same click, the same parse, but every leaf occurrence lands directly in
2490    /// this document at its world pose: 5 entries, 18 components, and no nested
2491    /// library anywhere. Still a proper assembly (dedup, BOM, per-component
2492    /// selection) — just without the tree.
2493    #[test]
2494    fn flattening_sub_assemblies_places_every_leaf_in_this_document() {
2495        let mut engine = EngineState::new();
2496        let mut dialog = armed(&mut engine);
2497        dialog.pending_step_import.as_mut().unwrap().flatten = true;
2498
2499        let store = crate::store::MemModelStore::new();
2500        dialog.step_assembly_import(&mut engine, &store);
2501
2502        assert_eq!(
2503            dialog.status,
2504            "imported as1-ug-214.stp \u{2014} 5 parts, 18 components; saved 5 part file(s)",
2505            "flattened: the leaf counts the prompt showed, plus the writes"
2506        );
2507        // FIVE files for EIGHTEEN components: the store write is per DISTINCT
2508        // part, so the six-bolt classic is one bolt file, not six.
2509        assert_eq!(store.list().len(), 5, "one file per distinct part");
2510        assert_eq!(engine.parts_library_names().len(), 5);
2511        assert_eq!(component_part_names(&engine).len(), 18);
2512        let document: serde_json::Value =
2513            serde_json::from_str(&engine.history_request_json()).expect("document JSON");
2514        for (name, entry) in document["partsLibrary"].as_object().expect("library") {
2515            assert!(
2516                entry["document"]["partsLibrary"].is_null(),
2517                "'{name}' must be a leaf part — flattening leaves no nested library"
2518            );
2519        }
2520    }
2521
2522    /// "Import as bodies" is today's lane, unchanged — one IMPORT3D carrying the
2523    /// text, no library entries, no components — and it DROPS the parse: the
2524    /// user chose the text, so nothing may keep every product's solids resident.
2525    /// The workbench is left alone (no assembly was created).
2526    #[test]
2527    fn importing_as_bodies_uses_the_flat_lane_and_drops_the_parse() {
2528        let mut engine = EngineState::new();
2529        let mut dialog = armed(&mut engine);
2530
2531        dialog.step_assembly_bodies(&mut engine);
2532
2533        assert_eq!(engine.history_len(), 1, "one IMPORT3D feature");
2534        assert!(
2535            engine.history_request_json().contains("stepText"),
2536            "the flat lane stores the STEP text"
2537        );
2538        assert!(engine.parts_library_names().is_empty(), "no library entries");
2539        assert!(!engine.history_has_assembly(), "no components");
2540        assert_eq!(engine.settings.workbench, "modeling", "no assembly, no switch");
2541        assert_eq!(dialog.status, "imported as1-ug-214.stp");
2542        assert!(dialog.pending_step_import.is_none(), "the choice is spent");
2543        assert!(stash_is_empty(&mut engine), "the parse is dropped, not left resident");
2544    }
2545
2546    /// Cancel imports NOTHING and drops the parse — the stash and its resident
2547    /// solids must not outlive the prompt that was going to consume them. Esc /
2548    /// click-outside route here too (`show_step_assembly` folds `should_close`
2549    /// into this same call).
2550    #[test]
2551    fn cancel_imports_nothing_and_discards_the_parse() {
2552        let mut engine = EngineState::new();
2553        let mut dialog = armed(&mut engine);
2554
2555        dialog.step_assembly_cancel(&mut engine);
2556
2557        assert_eq!(engine.history_len(), 0, "cancel imports nothing");
2558        assert!(engine.parts_library_names().is_empty(), "no library entries");
2559        assert_eq!(engine.settings.workbench, "modeling", "no switch");
2560        assert!(dialog.status.contains("cancelled"), "status: {}", dialog.status);
2561        assert!(dialog.pending_step_import.is_none());
2562        assert!(stash_is_empty(&mut engine), "Cancel drops the parse");
2563    }
2564
2565    /// The armed choice never outlives the file it describes. A second STEP
2566    /// upload replaces it (the probe replaces the engine stash on EVERY outcome,
2567    /// so a structureless second file must not leave the first one's prompt
2568    /// standing), and any other delivery supersedes it outright.
2569    #[test]
2570    fn a_new_upload_supersedes_an_armed_choice() {
2571        let mut source = EngineState::new();
2572        source.set_history_json(BOX_SEED).unwrap();
2573        let part = source.export_step_text().unwrap();
2574
2575        // A structureless STEP after an armed assembly: the prompt goes with it.
2576        let mut engine = EngineState::new();
2577        let mut dialog = armed(&mut engine);
2578        dialog.import_step(&mut engine, "part.step", &part);
2579        assert!(
2580            dialog.pending_step_import.is_none(),
2581            "the prompt cannot outlive the parse the new probe dropped"
2582        );
2583        assert_eq!(engine.history_len(), 1, "the part file imported flat");
2584
2585        // Any other delivery (IGES / STL / a model document) supersedes it too.
2586        let mut other = EngineState::new();
2587        let mut dialog = armed(&mut other);
2588        dialog.cancel_pending_step_import(&mut other);
2589        assert!(dialog.pending_step_import.is_none());
2590        assert!(stash_is_empty(&mut other), "the superseded parse is discarded");
2591        dialog.close_after_import();
2592        assert!(!dialog.open, "nothing armed, so the import routing closes the modal");
2593    }
2594
2595    /// The workbench switch is a no-op where the assembly panels ALREADY show:
2596    /// "All" claims nothing away, so an "All" user must not be yanked into
2597    /// Assembly by an import.
2598    #[test]
2599    fn importing_an_assembly_leaves_the_all_workbench_alone() {
2600        let mut engine = EngineState::new();
2601        engine.apply_settings_json(r#"{"workbench":"all"}"#).unwrap();
2602        let mut dialog = armed(&mut engine);
2603
2604        dialog.step_assembly_import(&mut engine, &crate::store::MemModelStore::new());
2605
2606        assert_eq!(
2607            engine.settings.workbench, "all",
2608            "'All' already shows the assembly panels — never switch away from it"
2609        );
2610        assert_eq!(component_part_names(&engine).len(), 4, "the import still ran");
2611    }
2612
2613    /// Every ACOMP feature's `partName`, in history order — the components an
2614    /// import actually appended.
2615    fn component_part_names(state: &EngineState) -> Vec<String> {
2616        serde_json::from_str::<serde_json::Value>(&state.history_request_json())
2617            .expect("history JSON")["features"]
2618            .as_array()
2619            .expect("features array")
2620            .iter()
2621            .filter(|feature| feature["type"] == "ACOMP")
2622            .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2623            .collect()
2624    }
2625
2626    /// The outcome line reports every qualifier the report carries, and ONLY the
2627    /// ones that happened: a clean import reads clean, a baked mirror says so,
2628    /// skipped products are counted, and a flat fallback is never silent.
2629    #[test]
2630    fn the_outcome_line_reports_exactly_what_happened() {
2631        let clean = StepAssemblyReport {
2632            parts: 5,
2633            instances: 18,
2634            ..StepAssemblyReport::default()
2635        };
2636        assert_eq!(
2637            assembly_import_message("as1.stp", &clean),
2638            "imported as1.stp \u{2014} 5 parts, 18 components"
2639        );
2640        assert_eq!(
2641            assembly_import_message(
2642                "one.stp",
2643                &StepAssemblyReport { parts: 1, instances: 1, ..clean.clone() }
2644            ),
2645            "imported one.stp \u{2014} 1 part, 1 component",
2646            "singulars, so the sentence is one a user believes"
2647        );
2648
2649        let messy = StepAssemblyReport {
2650            baked_nonrigid: 2,
2651            failed_products: 1,
2652            first_error: Some("body 7: unsupported surface".into()),
2653            ..clean.clone()
2654        };
2655        let line = assembly_import_message("mixed.stp", &messy);
2656        assert!(line.contains("(2 mirrored instances baked)"), "{line}");
2657        assert!(line.contains("1 product could not be built"), "{line}");
2658        assert!(line.contains("unsupported surface"), "{line}");
2659
2660        let fell_back = StepAssemblyReport { flat_fallback: true, ..clean };
2661        assert!(
2662            assembly_import_message("flat.stp", &fell_back).contains("the bodies came in flat"),
2663            "a user who asked for an assembly and got bodies must be told"
2664        );
2665    }
2666
2667    /// Unnamed products are stemmed from the file's BASE name without its STEP
2668    /// extension (`bracket-assy-part-7`, never `bracket-assy.step-part-7`).
2669    #[test]
2670    fn the_document_name_drops_the_step_extension() {
2671        assert_eq!(step_document_name("bracket-assy.step"), "bracket-assy");
2672        assert_eq!(step_document_name("BRACKET.STP"), "BRACKET");
2673        assert_eq!(step_document_name("/tmp/a/b/as1-ug-214.stp"), "as1-ug-214");
2674        assert_eq!(step_document_name("no-extension"), "no-extension");
2675        assert_eq!(step_document_name(".step"), ".step", "a bare extension is the name");
2676    }
2677}