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//! it only holds transient UI buffers (the name field, a status line, the
12//! last-saved signature used to derive the dirty flag, and the current document
13//! name) and drives the engine + the [`ModelStore`] seam.
14//!
15//! Persistence crosses the storage seam's model half ([`ModelStore`]) — the ONE
16//! platform exception. On the **web** the modal itself is the file browser: the
17//! Open modal lists the saved models (localStorage) with an Upload button, and
18//! Save As is a name prompt. On the **desktop** (default-on `native-dialog`
19//! feature, rfd) Open + Save As go straight to the platform's NATIVE file
20//! dialog and plain Save writes back to the opened/chosen path; built with
21//! `--no-default-features` (or on a test store), native falls back to the same
22//! egui modal over the models directory.
23
24use crate::store::{model_display_name, ModelStore};
25use brep_render::engine_state::EngineState;
26use eframe::egui;
27#[cfg(target_arch = "wasm32")]
28use serde_json::Value;
29#[cfg(target_arch = "wasm32")]
30use std::collections::HashMap;
31
32/// The file operation a toolbar button requests. The shell maps a clicked
33/// toolbar button to one of these and hands it to [`FileDialog::dispatch`].
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum FileAction {
36    New,
37    Open,
38    Save,
39    SaveAs,
40    /// Import a foreign format (STEP) FROM the user's filesystem, appending it to
41    /// the model rather than replacing it.
42    Import,
43    /// Export the model TO the user's filesystem in a chosen format (STEP / STL).
44    Export,
45}
46
47/// Which modal (if any) the dialog is currently showing.
48#[derive(Clone, Copy, PartialEq, Eq)]
49enum Mode {
50    /// Open a saved model (list + Upload on web / native-fallback).
51    Open,
52    /// Prompt for a name and save under it.
53    SaveAs,
54    /// Confirm discarding unsaved changes before New.
55    ConfirmNew,
56    /// Choose an export format (STEP / STL) for the current model.
57    Export,
58}
59
60/// Whether Open / Save As should drive the platform's NATIVE file dialog (rfd)
61/// instead of the in-app egui modal: a desktop build compiled with the
62/// (default-on) `native-dialog` feature AND a store whose file interchange is
63/// dialog-backed (the real models store — test stores opt out, keeping the
64/// suite headless on the modal path). The web build and the lean
65/// (`--no-default-features`) native build both use the egui modal.
66fn use_native_dialog(store: &dyn ModelStore) -> bool {
67    cfg!(all(not(target_arch = "wasm32"), feature = "native-dialog"))
68        && store.supports_file_interchange()
69}
70
71/// Whether an imported file name is a STEP file (`.step` / `.stp`, any case) —
72/// the routing key in [`FileDialog::show`]. Model documents arrive
73/// extension-stripped (web) or as a full `*.json` path (native dialog), so this
74/// never mis-routes a `.BREP.json` file.
75fn is_step_name(name: &str) -> bool {
76    let lower = name.to_ascii_lowercase();
77    lower.ends_with(".step") || lower.ends_with(".stp")
78}
79
80/// The reusable file dialog — transient UI buffers + the current document
81/// identity; the model itself lives in `EngineState`'s history.
82pub struct FileDialog {
83    /// The current document's name (`None` = a never-saved "untitled" model).
84    current_name: Option<String>,
85    /// The name field buffer, used by the Save As modal.
86    name_buf: String,
87    /// The model request JSON as of the last New/Open/Save — the baseline the
88    /// dirty flag compares the live history against. `None` until first seeded.
89    saved_signature: Option<String>,
90    /// Last-action status line, surfaced inside the modal.
91    status: String,
92    /// Whether a modal is currently open.
93    open: bool,
94    /// The open modal's mode (only meaningful while `open`).
95    mode: Mode,
96    /// Set on open so the Save As text input grabs focus on the next frame.
97    want_focus: bool,
98    /// Per-frame widget hit-rects for the headed verifier (wasm only).
99    #[cfg(target_arch = "wasm32")]
100    hits: HashMap<String, egui::Rect>,
101}
102
103impl FileDialog {
104    /// Seed from the engine's current model so a freshly-booted document counts
105    /// as clean (the seed history is the baseline; the first edit marks it dirty).
106    pub fn new(state: &EngineState) -> Self {
107        Self {
108            current_name: None,
109            name_buf: String::new(),
110            saved_signature: Some(state.history_request_json()),
111            status: String::new(),
112            open: false,
113            mode: Mode::Open,
114            want_focus: false,
115            #[cfg(target_arch = "wasm32")]
116            hits: HashMap::new(),
117        }
118    }
119
120    // --- dirty tracking -------------------------------------------------------
121
122    /// Snapshot the current model as the clean baseline (after New/Open/Save).
123    fn mark_clean(&mut self, state: &EngineState) {
124        self.saved_signature = Some(state.history_request_json());
125    }
126
127    /// Dirty = the live model differs from the last saved/opened baseline.
128    /// (Rolling the history does NOT change the request document, so navigating
129    /// steps never marks dirty — only real edits/add/delete/reorder do.)
130    pub fn is_dirty(&self, state: &EngineState) -> bool {
131        match &self.saved_signature {
132            Some(saved) => *saved != state.history_request_json(),
133            None => true,
134        }
135    }
136
137    // --- dispatch: a toolbar button was clicked -------------------------------
138
139    /// Act on a toolbar file button. Some actions run immediately (New on a clean
140    /// doc; Save on a named doc; Open/Save As on native w/ rfd); the rest open the
141    /// modal in the matching [`Mode`].
142    pub fn dispatch(
143        &mut self,
144        action: FileAction,
145        state: &mut EngineState,
146        store: &dyn ModelStore,
147    ) {
148        match action {
149            FileAction::New => {
150                if self.is_dirty(state) {
151                    self.open_modal(Mode::ConfirmNew);
152                } else {
153                    self.new_document(state);
154                }
155            }
156            FileAction::Open => {
157                if use_native_dialog(store) {
158                    // Native picker; the chosen file arrives via take_import().
159                    match store.begin_import() {
160                        Ok(()) => self.status = "choose a file…".into(),
161                        Err(e) => self.status = format!("open failed: {e}"),
162                    }
163                } else {
164                    self.open_modal(Mode::Open);
165                }
166            }
167            FileAction::Save => match self.current_name.clone() {
168                // A named document saves straight to its name.
169                Some(name) => {
170                    let _ = self.save_to(state, store, name);
171                }
172                // An unnamed document falls through to Save As.
173                None => self.dispatch(FileAction::SaveAs, state, store),
174            },
175            FileAction::SaveAs => {
176                if use_native_dialog(store) {
177                    // Native save dialog writes to a user-chosen path; that path
178                    // becomes the document identity so a later plain Save writes
179                    // straight back to it.
180                    let name = self.effective_name();
181                    match store.export_file(&name, &state.history_request_json()) {
182                        Ok(Some(saved)) => {
183                            self.name_buf = model_display_name(&saved);
184                            self.current_name = Some(saved.clone());
185                            self.mark_clean(state);
186                            self.status = format!("saved {saved}");
187                        }
188                        Ok(None) => self.status = "save cancelled".into(),
189                        Err(e) => self.status = format!("save failed: {e}"),
190                    }
191                } else {
192                    // Seed the name field with the current/effective name.
193                    self.name_buf = self.effective_name();
194                    self.open_modal(Mode::SaveAs);
195                }
196            }
197            FileAction::Import => {
198                // Fire the platform STEP picker; the chosen file arrives via
199                // take_import() and is routed by extension in show() (a `.step`/
200                // `.stp` name → import_step_feature, appended to the model).
201                match store.begin_import_filtered(("STEP", &["step", "stp", "STEP", "STP"])) {
202                    Ok(()) => self.status = "choose a STEP file\u{2026}".into(),
203                    Err(e) => self.status = format!("import failed: {e}"),
204                }
205            }
206            FileAction::Export => {
207                self.name_buf = self.effective_name();
208                self.open_modal(Mode::Export);
209            }
210        }
211    }
212
213    /// Open the modal in `mode` (and focus its input next frame).
214    fn open_modal(&mut self, mode: Mode) {
215        self.mode = mode;
216        self.open = true;
217        self.want_focus = true;
218    }
219
220    // --- per-frame draw -------------------------------------------------------
221
222    /// Draw the modal (if open) and pick up any completed async import. Called
223    /// every frame by the shell with a ctx-level handle (the modal is ctx-level,
224    /// like the command palette).
225    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
226        #[cfg(target_arch = "wasm32")]
227        self.hits.clear();
228
229        // A completed async import (web upload / native rfd pick) is picked up
230        // here, before drawing, so the model is live for this frame. Routed by
231        // extension: a STEP file (`.step`/`.stp`) is APPENDED to the model as an
232        // IMPORT3D feature; anything else is a `.BREP.json` model document loaded
233        // (replacing the model). The model lanes deliver an extension-less name
234        // (web, stripped by `model_display_name`) or a full `*.json` path
235        // (native dialog), so only real STEP files route here.
236        if let Some((name, contents)) = store.take_import() {
237            if is_step_name(&name) {
238                self.import_step(state, &name, &contents);
239            } else {
240                self.load_document(state, &name, &contents);
241            }
242            self.open = false;
243        }
244
245        if !self.open {
246            return;
247        }
248
249        match self.mode {
250            Mode::ConfirmNew => self.show_confirm_new(ctx, state),
251            Mode::SaveAs => self.show_save_as(ctx, state, store),
252            Mode::Open => self.show_open(ctx, state, store),
253            Mode::Export => self.show_export(ctx, state, store),
254        }
255    }
256
257    /// The **Discard unsaved changes?** confirmation shown before New on a dirty
258    /// document.
259    fn show_confirm_new(&mut self, ctx: &egui::Context, state: &mut EngineState) {
260        let title = self.current_name.clone().unwrap_or_else(|| "untitled".into());
261        let mut discard = false;
262        let mut cancel = false;
263        let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-new")).show(ctx, |ui| {
264            ui.set_width(320.0);
265            ui.heading("Discard unsaved changes?");
266            ui.add_space(4.0);
267            ui.label(format!("\"{title}\" has unsaved changes."));
268            ui.add_space(6.0);
269            ui.horizontal(|ui| {
270                let d = ui.button("Discard");
271                self.hit("confirm:discard", &d);
272                if d.clicked() {
273                    discard = true;
274                }
275                let c = ui.button("Cancel");
276                self.hit("confirm:cancel", &c);
277                if c.clicked() {
278                    cancel = true;
279                }
280            });
281        });
282        if discard {
283            self.new_document(state);
284            self.open = false;
285        } else if cancel || modal.should_close() {
286            self.open = false;
287        }
288    }
289
290    /// The **Save As** name prompt.
291    fn show_save_as(
292        &mut self,
293        ctx: &egui::Context,
294        state: &mut EngineState,
295        store: &dyn ModelStore,
296    ) {
297        let mut do_save = false;
298        let mut cancel = false;
299        let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
300            ui.set_width(320.0);
301            ui.heading("Save model as");
302            ui.add_space(4.0);
303            let field = ui.add(
304                egui::TextEdit::singleline(&mut self.name_buf)
305                    .hint_text("model name")
306                    .desired_width(f32::INFINITY),
307            );
308            self.hit("field:name", &field);
309            if self.want_focus {
310                field.request_focus();
311                self.want_focus = false;
312            }
313            let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
314            ui.add_space(6.0);
315            ui.horizontal(|ui| {
316                let save = ui.button("Save");
317                self.hit("save", &save);
318                if save.clicked() || enter {
319                    do_save = true;
320                }
321                let c = ui.button("Cancel");
322                self.hit("cancel", &c);
323                if c.clicked() {
324                    cancel = true;
325                }
326            });
327            if !self.status.is_empty() {
328                ui.add_space(4.0);
329                ui.weak(&self.status);
330            }
331        });
332        if do_save {
333            let name = self.name_buf.clone();
334            if self.save_to(state, store, name) {
335                self.open = false;
336            }
337        } else if cancel || modal.should_close() {
338            self.open = false;
339        }
340    }
341
342    /// The **Export** chooser: pick a format (STEP / STL) and write the current
343    /// model to the user's filesystem under `<name>.<ext>` through the store's
344    /// format-typed interchange. STEP serializes the exact NURBS topology; STL is
345    /// the ASCII display mesh. No solids → a clear status line, nothing written.
346    fn show_export(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
347        let mut chosen: Option<&'static str> = None;
348        let mut cancel = false;
349        let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
350            ui.set_width(320.0);
351            ui.heading("Export model");
352            ui.add_space(4.0);
353            let field = ui.add(
354                egui::TextEdit::singleline(&mut self.name_buf)
355                    .hint_text("file name")
356                    .desired_width(f32::INFINITY),
357            );
358            self.hit("field:name", &field);
359            if self.want_focus {
360                field.request_focus();
361                self.want_focus = false;
362            }
363            ui.add_space(6.0);
364            ui.horizontal(|ui| {
365                let step = ui.button("STEP (.step)");
366                self.hit("export:step", &step);
367                if step.clicked() {
368                    chosen = Some("step");
369                }
370                let stl = ui.button("STL (.stl)");
371                self.hit("export:stl", &stl);
372                if stl.clicked() {
373                    chosen = Some("stl");
374                }
375                // The full model RECIPE (`.BREP.json`) — for saving a document to
376                // disk / sharing a failing model for a bug report. Re-openable via
377                // Open / Import.
378                let json = ui.button("JSON (.BREP.json)");
379                self.hit("export:json", &json);
380                if json.clicked() {
381                    chosen = Some("json");
382                }
383                let c = ui.button("Cancel");
384                self.hit("cancel", &c);
385                if c.clicked() {
386                    cancel = true;
387                }
388            });
389            if !self.status.is_empty() {
390                ui.add_space(4.0);
391                ui.weak(&self.status);
392            }
393        });
394        if let Some(format) = chosen {
395            if self.export_as(state, store, format) {
396                self.open = false;
397            }
398        } else if cancel || modal.should_close() {
399            self.open = false;
400        }
401    }
402
403    /// The **Open** browser: the saved-model list (+ Upload where the platform
404    /// supports real-file interchange).
405    fn show_open(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
406        let names = store.list();
407        let backend = store.backend_label();
408        let interchange = store.supports_file_interchange();
409        let mut chosen: Option<String> = None;
410        let mut to_delete: Option<String> = None;
411        let mut do_upload = false;
412        let mut cancel = false;
413        let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
414            ui.set_width(360.0);
415            ui.heading("Open model");
416            ui.weak(&backend);
417            ui.add_space(4.0);
418            ui.separator();
419            egui::ScrollArea::vertical()
420                .max_height(320.0)
421                .auto_shrink([false, false])
422                .show(ui, |ui| {
423                    if names.is_empty() {
424                        ui.weak("(no saved models)");
425                    }
426                    for name in &names {
427                        ui.horizontal(|ui| {
428                            let is_current = self.current_name.as_deref() == Some(name.as_str());
429                            let row = ui.selectable_label(is_current, format!("\u{1F5CE} {name}"));
430                            self.hit(&format!("open:{name}"), &row);
431                            if row.clicked() {
432                                chosen = Some(name.clone());
433                            }
434                            let del = ui.small_button("\u{2715}");
435                            self.hit(&format!("del:{name}"), &del);
436                            if del.clicked() {
437                                to_delete = Some(name.clone());
438                            }
439                        });
440                    }
441                });
442            ui.separator();
443            ui.horizontal(|ui| {
444                if interchange {
445                    let up = ui.button("\u{2B06} Upload\u{2026}");
446                    self.hit("upload", &up);
447                    if up.clicked() {
448                        do_upload = true;
449                    }
450                }
451                let c = ui.button("Cancel");
452                self.hit("cancel", &c);
453                if c.clicked() {
454                    cancel = true;
455                }
456            });
457            if !self.status.is_empty() {
458                ui.add_space(4.0);
459                ui.weak(&self.status);
460            }
461        });
462        if let Some(name) = chosen {
463            self.open_document(state, store, &name);
464            self.open = false;
465        } else if let Some(name) = to_delete {
466            let _ = store.remove(&name);
467            self.status = format!("removed {name}");
468            if self.current_name.as_deref() == Some(name.as_str()) {
469                self.current_name = None;
470            }
471        } else if do_upload {
472            // Fire the platform picker; the file arrives via take_import() and is
473            // loaded on a later frame (the modal closes now).
474            match store.begin_import() {
475                Ok(()) => {
476                    self.status = "choose a file…".into();
477                    self.open = false;
478                }
479                Err(e) => self.status = format!("import failed: {e}"),
480            }
481        } else if cancel || modal.should_close() {
482            self.open = false;
483        }
484    }
485
486    // --- model operations -----------------------------------------------------
487
488    /// The name to save under: the field if non-empty, else the current document
489    /// name, else `"untitled"`.
490    fn effective_name(&self) -> String {
491        let field = self.name_buf.trim();
492        if !field.is_empty() {
493            field.to_string()
494        } else {
495            self.current_name.clone().unwrap_or_else(|| "untitled".into())
496        }
497    }
498
499    /// **New** — reset the engine to an empty history; clean untitled document.
500    fn new_document(&mut self, state: &mut EngineState) {
501        let _ = state.set_history_json(r#"{"expressions":"","configurator":{},"features":[]}"#);
502        self.current_name = None;
503        self.name_buf.clear();
504        self.mark_clean(state);
505        self.status = "new (empty) model".into();
506    }
507
508    /// Write the current model request JSON through the store under `name`.
509    /// Returns `true` on success (so the caller can close the modal).
510    fn save_to(&mut self, state: &mut EngineState, store: &dyn ModelStore, name: String) -> bool {
511        let name = name.trim().to_string();
512        if name.is_empty() {
513            self.status = "enter a name to save".into();
514            return false;
515        }
516        match store.write(&name, &state.history_request_json()) {
517            Ok(()) => {
518                // `current_name` keeps the raw identity (a full path when a
519                // native dialog chose it); the name field shows the bare name.
520                self.name_buf = model_display_name(&name);
521                self.current_name = Some(name.clone());
522                self.mark_clean(state);
523                self.status = format!("saved {name}");
524                true
525            }
526            Err(e) => {
527                self.status = format!("save failed: {e}");
528                false
529            }
530        }
531    }
532
533    /// **Open** — read a stored document and load+frame it.
534    fn open_document(&mut self, state: &mut EngineState, store: &dyn ModelStore, name: &str) {
535        match store.read(name) {
536            Some(contents) => self.load_document(state, name, &contents),
537            None => self.status = format!("open failed: '{name}' not found"),
538        }
539    }
540
541    /// Serialize the current model in `format` (`"step"` | `"stl"` | `"json"`) and
542    /// write it through the store's format-typed interchange under `<name>.<ext>`.
543    /// `"json"` is the full model RECIPE (`.BREP.json`, `history_request_json`) —
544    /// the exact document Open/Import consume, for sharing a failing model. Returns
545    /// `true` on success (so the caller can close the modal); a guard message and
546    /// `false` when there is nothing to export or the engine/store errs.
547    fn export_as(&mut self, state: &EngineState, store: &dyn ModelStore, format: &str) -> bool {
548        let name = self.effective_name();
549        let (text, ext) = match format {
550            "stl" => (state.export_stl_text(), "stl"),
551            "json" => (Ok(state.history_request_json()), "BREP.json"),
552            _ => (state.export_step_text(), "step"),
553        };
554        match text {
555            Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
556                Ok(()) => {
557                    self.status = format!("exported {name}.{ext}");
558                    true
559                }
560                Err(e) => {
561                    self.status = format!("export failed: {e}");
562                    false
563                }
564            },
565            Err(e) => {
566                self.status = format!("export failed: {e}");
567                false
568            }
569        }
570    }
571
572    /// Append an imported STEP file to the model (an IMPORT3D feature), then treat
573    /// the enlarged model as dirty (an import is an edit, not an Open — the model
574    /// keeps its current name / save baseline).
575    fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
576        match state.import_step_feature(contents) {
577            Ok(_) => {
578                state.zoom_to_fit(); // frame the newly imported body
579                self.status = format!("imported {name}");
580            }
581            Err(e) => self.status = format!("import failed: {e}"),
582        }
583    }
584
585    /// Load a model document's contents into the engine (roll to the last
586    /// feature + zoom-to-fit) and record it as the clean baseline.
587    fn load_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
588        match state.load_model_and_fit(contents) {
589            Ok(_) => {
590                // `current_name` keeps the raw identity — the full path when a
591                // native dialog picked the file, so plain Save writes back to
592                // it; the name field shows only the bare display name.
593                self.current_name = Some(name.to_string());
594                self.name_buf = model_display_name(name);
595                self.mark_clean(state);
596                self.status = format!("opened {name}");
597            }
598            Err(e) => self.status = format!("open failed: {e}"),
599        }
600    }
601
602    // --- verifier hooks (wasm only) -------------------------------------------
603
604    /// The published state for the headed verifier: current name, dirty flag,
605    /// backend label, the stored-document list, and the modal's open/mode.
606    #[cfg(target_arch = "wasm32")]
607    pub fn file_state_json(&self, state: &EngineState, store: &dyn ModelStore) -> String {
608        serde_json::json!({
609            "name": self.current_name,
610            "nameBuf": self.name_buf,
611            "dirty": self.is_dirty(state),
612            "backend": store.backend_label(),
613            "interchange": store.supports_file_interchange(),
614            "list": store.list(),
615            "status": self.status,
616            "open": self.open,
617            "mode": match self.mode {
618                Mode::Open => "open",
619                Mode::SaveAs => "saveas",
620                Mode::ConfirmNew => "confirmnew",
621                Mode::Export => "export",
622            },
623        })
624        .to_string()
625    }
626
627    /// The published widget hit-rects (egui points) for the headed verifier.
628    #[cfg(target_arch = "wasm32")]
629    pub fn hits_json(&self) -> String {
630        let map: serde_json::Map<String, Value> = self
631            .hits
632            .iter()
633            .map(|(k, r)| {
634                (
635                    k.clone(),
636                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
637                )
638            })
639            .collect();
640        Value::Object(map).to_string()
641    }
642
643    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
644    /// elsewhere so the dialog code reads the same on both targets).
645    #[cfg(target_arch = "wasm32")]
646    fn hit(&mut self, key: &str, resp: &egui::Response) {
647        self.hits.insert(key.to_string(), resp.rect);
648    }
649    #[cfg(not(target_arch = "wasm32"))]
650    #[inline]
651    fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
652}
653
654#[cfg(all(test, not(target_arch = "wasm32")))]
655mod tests {
656    use super::*;
657    use crate::store::native_test_store;
658
659    /// The engine's `history_request_json()` is a stable model document: writing
660    /// it through the store and loading it back reproduces the same request —
661    /// the native save/open round-trip the brief asks for.
662    #[test]
663    fn model_document_round_trips_through_store_and_engine() {
664        // A temp-dir store (never touches the real config dir).
665        let dir = std::env::temp_dir().join(format!("brep-app-file-{}", std::process::id()));
666        let _ = std::fs::remove_dir_all(&dir);
667        let store = native_test_store(dir.clone());
668
669        // Build a model in the engine, serialize it, save it through the store.
670        let mut engine = EngineState::new();
671        let seed = r#"{"expressions":"","configurator":{},"features":[
672            {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
673             "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
674             "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
675        ]}"#;
676        engine.set_history_json(seed).unwrap();
677        let before = engine.history_request_json();
678        store.write("roundtrip", &before).unwrap();
679
680        // A fresh engine loads the stored document + frames it, reproducing it.
681        let mut reopened = EngineState::new();
682        let contents = store.read("roundtrip").unwrap();
683        reopened.load_model_and_fit(&contents).unwrap();
684        let after = reopened.history_request_json();
685
686        assert_eq!(before, after, "request JSON round-trips through the store");
687        assert_eq!(reopened.history_len(), 1);
688        assert_eq!(reopened.scene.solids().len(), 1);
689
690        let _ = std::fs::remove_dir_all(&dir);
691    }
692
693    /// New empties the model + marks it clean; an edit marks it dirty; saving
694    /// clears the dirty flag again and records the name.
695    #[test]
696    fn dirty_flag_flips_on_edit_and_clears_on_save() {
697        let dir = std::env::temp_dir().join(format!("brep-app-dirty-{}", std::process::id()));
698        let _ = std::fs::remove_dir_all(&dir);
699        let store = native_test_store(dir.clone());
700
701        let mut engine = EngineState::new();
702        engine
703            .set_history_json(
704                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"}}}]}"#,
705            )
706            .unwrap();
707        let mut dialog = FileDialog::new(&engine);
708        assert!(!dialog.is_dirty(&engine), "seeded model is clean");
709
710        // Edit a parameter → dirty.
711        engine
712            .update_feature_params(
713                "Box",
714                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"}}"#,
715            )
716            .unwrap();
717        assert!(dialog.is_dirty(&engine), "edit marks dirty");
718
719        // Save under a name (the Save As path) → clean, name recorded.
720        assert!(
721            dialog.save_to(&mut engine, &*store, "m".into()),
722            "save succeeds"
723        );
724        assert!(!dialog.is_dirty(&engine), "save clears dirty");
725        assert_eq!(dialog.current_name.as_deref(), Some("m"));
726
727        let _ = std::fs::remove_dir_all(&dir);
728    }
729
730    /// New on a DIRTY document opens the confirm modal (nothing cleared yet);
731    /// New on a CLEAN document clears immediately with no modal.
732    #[test]
733    fn new_confirms_only_when_dirty() {
734        let dir = std::env::temp_dir().join(format!("brep-app-newcfm-{}", std::process::id()));
735        let _ = std::fs::remove_dir_all(&dir);
736        let store = native_test_store(dir.clone());
737
738        let mut engine = EngineState::new();
739        engine
740            .set_history_json(
741                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"}}}]}"#,
742            )
743            .unwrap();
744        let mut dialog = FileDialog::new(&engine);
745
746        // Clean doc: New clears immediately, no modal.
747        dialog.dispatch(FileAction::New, &mut engine, &*store);
748        assert!(!dialog.open, "New on clean doc opens no modal");
749        assert_eq!(engine.history_len(), 0, "clean New cleared the history");
750
751        // Make it dirty, then New opens the confirm modal without clearing.
752        engine
753            .set_history_json(
754                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"}}}]}"#,
755            )
756            .unwrap();
757        assert!(dialog.is_dirty(&engine), "reseeded model is dirty vs empty baseline");
758        dialog.dispatch(FileAction::New, &mut engine, &*store);
759        assert!(dialog.open, "New on dirty doc opens the confirm modal");
760        assert_eq!(engine.history_len(), 1, "dirty New did NOT clear yet");
761
762        let _ = std::fs::remove_dir_all(&dir);
763    }
764
765    /// A plain Save on a never-named document routes through Save As: with no
766    /// name it opens the modal (nothing written); after a name is set + saved it
767    /// records the name and clears dirty.
768    #[test]
769    fn save_on_unnamed_falls_through_to_save_as_modal() {
770        let dir = std::env::temp_dir().join(format!("brep-app-unnamed-{}", std::process::id()));
771        let _ = std::fs::remove_dir_all(&dir);
772        let store = native_test_store(dir.clone());
773
774        let mut engine = EngineState::new();
775        engine
776            .set_history_json(
777                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"}}}]}"#,
778            )
779            .unwrap();
780        let mut dialog = FileDialog::new(&engine);
781
782        // Unnamed Save → Save As modal opens (feature-off native build).
783        dialog.dispatch(FileAction::Save, &mut engine, &*store);
784        assert!(dialog.open, "unnamed Save opens the Save As modal");
785        assert!(store.list().is_empty(), "nothing written yet");
786
787        // Provide a name + save.
788        assert!(dialog.save_to(&mut engine, &*store, "part".into()));
789        assert_eq!(dialog.current_name.as_deref(), Some("part"));
790        assert_eq!(store.list(), vec!["part".to_string()]);
791
792        let _ = std::fs::remove_dir_all(&dir);
793    }
794
795    /// A single-box model document, for the import/export lane tests.
796    const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
797        {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
798         "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
799         "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
800    ]}"#;
801
802    /// STEP file names route to the import lane; extension-less model names (and
803    /// `.json` documents) do not — so the take_import poll never mis-routes.
804    #[test]
805    fn step_names_route_to_the_import_lane() {
806        assert!(is_step_name("part.step"));
807        assert!(is_step_name("PART.STP"));
808        assert!(!is_step_name("model"), "model docs arrive extension-less");
809        assert!(!is_step_name("model.json"));
810    }
811
812    /// Export dispatch opens the format chooser; choosing STEP / STL on a box
813    /// model serializes without error (the feature-off store's no-op interchange
814    /// still exercises the engine's STEP/STL text generation + `.ext` naming). An
815    /// empty model reports a clear guard rather than exporting.
816    #[test]
817    fn export_dispatch_and_chooser_serialize_the_model() {
818        let dir = std::env::temp_dir().join(format!("brep-app-export-{}", std::process::id()));
819        let _ = std::fs::remove_dir_all(&dir);
820        let store = native_test_store(dir.clone());
821
822        let mut engine = EngineState::new();
823        engine.set_history_json(BOX_SEED).unwrap();
824        let mut dialog = FileDialog::new(&engine);
825
826        dialog.dispatch(FileAction::Export, &mut engine, &*store);
827        assert!(dialog.open && dialog.mode == Mode::Export, "Export opens the chooser");
828        assert!(dialog.export_as(&engine, &*store, "step"), "STEP export ok");
829        assert!(dialog.status.contains("exported"), "status: {}", dialog.status);
830        assert!(dialog.export_as(&engine, &*store, "stl"), "STL export ok");
831        assert!(dialog.export_as(&engine, &*store, "json"), "JSON recipe export ok");
832        assert!(dialog.status.contains(".BREP.json"), "json status: {}", dialog.status);
833
834        let empty = EngineState::new();
835        assert!(!dialog.export_as(&empty, &*store, "step"), "empty model does not export");
836        assert!(dialog.status.contains("nothing to export"), "status: {}", dialog.status);
837
838        let _ = std::fs::remove_dir_all(&dir);
839    }
840
841    /// A STEP file routed to import is APPENDED to the model (an edit → dirty),
842    /// unlike Open which replaces + cleans. Uses the engine's own STEP export to
843    /// produce a valid document without a direct kernel dependency.
844    #[test]
845    fn import_step_appends_and_marks_dirty() {
846        let dir = std::env::temp_dir().join(format!("brep-app-imp-{}", std::process::id()));
847        let _ = std::fs::remove_dir_all(&dir);
848        let store = native_test_store(dir.clone());
849
850        // A box model → STEP text via the engine.
851        let mut source = EngineState::new();
852        source.set_history_json(BOX_SEED).unwrap();
853        let step = source.export_step_text().unwrap();
854
855        // A fresh (empty, clean) dialog imports it: one body appended, dirty.
856        let mut engine = EngineState::new();
857        let mut dialog = FileDialog::new(&engine);
858        assert!(!dialog.is_dirty(&engine), "empty seed is clean");
859        dialog.import_step(&mut engine, "part.step", &step);
860        assert_eq!(engine.history_len(), 1, "IMPORT3D feature appended");
861        assert_eq!(engine.scene.solids().len(), 1, "the imported body is in the scene");
862        assert!(dialog.is_dirty(&engine), "an import is an edit");
863        assert!(dialog.status.contains("imported"), "status: {}", dialog.status);
864        let _ = store; // store unused beyond construction (test stores never drive dialogs)
865
866        let _ = std::fs::remove_dir_all(&dir);
867    }
868}