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