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