Skip to main content

brep_app/
app.rs

1//! `BrepApp` — the THIN shell of the engine-native UI.
2//!
3//! One eframe [`App`] that hosts the EXISTING `brep-render` engine and lays out
4//! the panels. The heavy lifting lives in focused modules; this file only owns
5//! the shell:
6//!
7//! * [`Documents`] (`crate::document`) holds the OPEN MODELS — one
8//!   [`EngineState`] per document plus its identity, exactly one active. Panels
9//!   borrow the active engine through `self.docs.engine_mut()`, which borrows
10//!   only that FIELD and so still composes with the disjoint panel borrows
11//!   beside it. [`EngineState`] (`brep-render`) is still the single
12//!   windowing-agnostic BRAIN per document (scene / camera / controls / settings
13//!   / widgets + pointer/wheel/viewcube/pick); we do NOT fork it.
14//! * [`crate::viewport::Viewport`] draws + drives the central 3D viewport (the
15//!   offscreen texture, the `egui_wgpu` blit callback, input routing).
16//! * [`crate::panels`] — one module per left-panel section, each a small state
17//!   struct + a `show(&mut self, ui, state, …)` method. Adding a panel = add
18//!   `panels/<name>.rs`, one field here, one `self.<name>.show(…)` call in
19//!   [`eframe::App::ui`] below (see `README.md` → "Adding a panel").
20//!
21//! Native (`run_native`) and wasm (`WebRunner`) run this SAME code.
22
23use crate::document::{Documents, EngineFactory};
24use crate::panels::assembly_constraints::AssemblyConstraintsPanel;
25use crate::panels::bug_report::BugReportPanel;
26use crate::panels::wire_harness::WireHarnessPanel;
27use crate::panels::component_actions::ComponentActionRequest;
28use crate::panels::context_bar::ContextBarPanel;
29use crate::panels::mode_bar::ModeBar;
30use crate::panels::expressions::ExpressionsPanel;
31use crate::panels::file::{FileAction, FileDialog};
32use crate::panels::history::HistoryPanel;
33use crate::panels::info_windows::InfoWindows;
34use crate::panels::scene::ScenePanel;
35use crate::panels::selection::SelectionPanel;
36use crate::panels::sketch::SketchPanel;
37use crate::panels::part_properties::PartPropertiesPanel;
38use crate::panels::settings::SettingsPanel;
39use crate::panels::toasts::Toasts;
40use crate::panels::toolbar::ToolbarPanel;
41use crate::panels::workbench_toolbar::WorkbenchToolbarPanel;
42use crate::panels::update_components::UpdateComponents;
43use crate::panels::bom::BomPanel;
44use crate::panels::dock::{DockContext, DockState, PaneKind};
45use crate::panels::document_tabs;
46use crate::store::{default_model_store, ModelStore, SETTINGS_KEY};
47use crate::viewport::Viewport;
48use brep_render::engine_state::EngineState;
49use brep_render::style::ThemeMode;
50use eframe::egui;
51
52/// A measured camera spin: an orbit drag the app steps ITSELF, one pointer move
53/// per frame, from `perf_spin`. See [`BrepApp::spin`] for why it is not driven
54/// from outside.
55///
56/// The steps run through the same `EngineState::pointer_down/move/up` the
57/// viewport's real drag uses (see `viewport::interaction`), so a spin costs
58/// exactly what dragging the mouse costs — no shortcut past the controls.
59#[cfg(feature = "automation")]
60pub struct Spin {
61    /// Pointer steps still to make; the drag ends (pointer_up) on the last one.
62    pub left: u32,
63    /// Where the drag started, in viewport-local logical px.
64    pub origin: (f64, f64),
65    /// Pointer step, in viewport-local logical px per frame.
66    pub step: (f64, f64),
67    /// Steps already made — the drag is a straight sweep from `origin`, and
68    /// reversing at the halfway mark keeps it inside the viewport however many
69    /// frames are asked for.
70    pub made: u32,
71    /// False until the first frame has pressed the button.
72    pub pressed: bool,
73}
74
75pub struct BrepApp {
76    /// Every OPEN MODEL and which one is active. Each document owns a full
77    /// `EngineState` (the shared viewer brain `desktop.rs` / the wasm shell
78    /// wrap) plus its file identity; panels borrow the active one.
79    pub(crate) docs: Documents,
80    /// The central 3D viewport: engine render core + offscreen texture + blit +
81    /// input routing.
82    pub(crate) viewport: Viewport,
83    /// The single persistence seam for settings, layout, and model documents
84    /// (native filesystem / wasm IndexedDB + download/upload).
85    model_store: Box<dyn ModelStore>,
86
87    // --- one small state value per panel --------------------------------------
88    /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
89    /// and the File-actions seam (owned by the concurrent file panel).
90    pub(crate) toolbar: ToolbarPanel,
91    /// New / Open / Save / Save As of the model document (the `.nbrep`
92    /// recipe) — a reusable modal file dialog opened from the toolbar.
93    file: FileDialog,
94    stl_import: Option<crate::panels::stl_import::StlImportPreview>,
95    /// The "Submit Bug" flow: on the toolbar bug button it screenshots the app
96    /// (UI + 3D model) BEFORE its own dialog opens, then collects a description
97    /// (+ optional email) and POSTs the model + screenshot to the public reports
98    /// endpoint. Native + wasm, one path.
99    bug_report: BugReportPanel,
100    /// The Info window: the licences and this session's diagnostics, toggled
101    /// from the toolbar's info button.
102    info: crate::panels::info::InfoPanel,
103    /// What this session is running on — captured ONCE, from the adapter eframe
104    /// actually gave us (never re-probed), and read by BOTH the Info window and
105    /// the problem report so the two cannot disagree. See
106    /// [`crate::diagnostics`].
107    diagnostics: crate::diagnostics::Diagnostics,
108    /// Workbench actions toolbar: the second top strip under the primary
109    /// toolbar — one button per feature the active workbench offers, plus the
110    /// constraint types where the Constraints panel is shown. Gated by the
111    /// `showWorkbenchToolbar` setting and hidden in the special modes. A click
112    /// flows out as the type to add; the shell adds it through the SAME paths
113    /// the palette / context bar use.
114    workbench_toolbar: WorkbenchToolbarPanel,
115    /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
116    /// (movable + resizable, toggled from the toolbar gear button), no longer a
117    /// left-panel section.
118    settings: SettingsPanel,
119    /// The active document's OWN BOM attributes (Part Number, Material, Mass,
120    /// …) as a floating window, toggled from the toolbar's Properties button.
121    /// Document-level, not selection-level: entity inspection is the context
122    /// bar's `info_windows`.
123    part_properties: PartPropertiesPanel,
124    /// History feature-tree + schema-driven feature dialog panel (Phase 2).
125    /// NOTE: the editable history is NOT owned here — it lives in the engine core
126    /// (`EngineState.history`), the single source of truth; this panel only reads
127    /// it back to draw and calls the engine's `history_*` methods to mutate.
128    history: HistoryPanel,
129    /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
130    /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
131    /// engine scene/emphasis; owns only transient expand + hit state.
132    scene: ScenePanel,
133    /// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
134    /// the scene's component records — per-instance fixed/visibility/status
135    /// adornments, actions routed to the owning ACOMP feature.
136    /// The BOM panel (claimed by the Assembly workbench): the parts list on
137    /// the shared column-tree widget, with the editable part/occurrence
138    /// attribute columns the Settings "Assemblies" section configures.
139    bom: BomPanel,
140    /// Assembly Constraints panel (claimed by the Assembly workbench): the
141    /// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
142    assembly_constraints: AssemblyConstraintsPanel,
143    /// The wire-harness connection list (a Wire harness workbench pane): add /
144    /// edit / remove wires, read their routed length + status, hover to
145    /// highlight. Document data lives in the engine; this holds the widget's
146    /// transient state.
147    wire_harness: WireHarnessPanel,
148    /// The PMI view tree + annotation forms (the PMI workbench's pane).
149    pmi: crate::panels::pmi::PmiPanel,
150    /// The drawing sheets + their forms (the Drawing workbench's pane).
151    sheets: crate::panels::sheets::SheetsPanel,
152    /// Update-components checker: compares each parts-library entry's
153    /// `sourceSignature` against the model store's current content. Kept
154    /// current once per frame (cheap generation key: applied run + store
155    /// save); the constraints header reads the count + runs the batch refresh,
156    /// the structure tree reads per-part badges.
157    update_components: UpdateComponents,
158    /// Expressions / parameters panel: the variable sheet (engine-owned history
159    /// `expressions`) feature params reference. Owns only its editor buffer.
160    expressions: ExpressionsPanel,
161    /// Qualify: the part's declared CONNECTION POINTS, on whichever surface is
162    /// on screen (symbol, pads or 3D). Holds only the selected address; the
163    /// block and both consistency reports come from the engine.
164    qualify: crate::panels::qualify::QualifyPanel,
165    /// The family table editor: a `.fbrep` family seed's member table. Holds
166    /// the selection, the cell being typed and the last Generate report; the
167    /// table itself is the document's `familyTable` block.
168    family_table: crate::panels::family_table_editor::FamilyTableEditor,
169    /// Every PLM panel (`panels::plm_host`): empty in a file-based session.
170    pub(crate) plm: crate::panels::plm_host::PlmHost,
171    /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
172    /// selection-driven context bar's Info action. Each floating (movable +
173    /// resizable) window is PINNED to one object name at open time — a Metadata
174    /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
175    /// and keeps showing that entity regardless of later selection changes. Replaces
176    /// the old single Properties window.
177    info_windows: InfoWindows,
178    /// Interference results window: opened by the Assembly workbench's `∩`
179    /// toolbar button, which runs the engine's pairwise-intersect check; a
180    /// floating window like the Info windows with a row per interfering pair
181    /// (click = select both components), a green all-clear pass line, and a
182    /// Re-run button.
183    interference: crate::panels::interference::InterferenceWindow,
184    /// Auto Constraints window (Assembly workbench): opened by the `⚿` toolbar
185    /// button, it lists the constraint types the kernel's inference lane can
186    /// read out of the components' current placement, with what a scan found
187    /// for each, and creates the whole accepted set as one undo step.
188    auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow,
189    /// step.parts online model library browser (Assembly workbench): a ctx-level
190    /// window (opened by the library toolbar button) that searches the public
191    /// step.parts v1 API, shows results with thumbnails, and imports a chosen
192    /// STEP model as a new part document + adds it to the assembly as an ACOMP.
193    step_parts: crate::panels::step_parts::StepPartsPanel,
194    /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
195    /// click may select — honored by the engine's `select_top_at`). The filter +
196    /// selection live in `EngineState`; this panel only reads/writes them.
197    selection: SelectionPanel,
198    /// Context action toolbar: the selection-driven action bar (Clear / Hide /
199    /// Edit-owning-feature + the feature-from-selection actions whose primary
200    /// reference accepts the selected kind). Shown only while something is
201    /// selected; drives the engine directly and returns a feature id for the shell
202    /// to expand in the history tree.
203    context_bar: ContextBarPanel,
204    /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
205    /// + circle to the `set_overlay` channel colored by solver mobility, and shows
206    /// the DOF status readout. The engine-native sketcher's foundation surface.
207    sketch: SketchPanel,
208    /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
209    /// corner — reference-selection, sketch mode, and any future special mode.
210    mode_bar: ModeBar,
211    /// Transient toast overlay: drains the engine's queued notices each frame
212    /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
213    pub(crate) toasts: Toasts,
214    /// The status bar's app-wide working indicator (`panels::busy`): what is
215    /// in flight, collected from every source each frame.
216    busy: crate::panels::busy::BusyIndicator,
217    /// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
218    /// tree that hosts every side-panel section AND the 3D viewport as tiles the
219    /// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
220    /// each panel + the engine per frame through [`DockContext`]. Drawn in normal
221    /// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
222    dock: DockState,
223
224    /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
225    /// under a background runner (native thread / wasm worker), so the boot
226    /// `zoom_to_fit` can run before the first solids exist → an unframed first
227    /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
228    /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
229    /// (tests) the scene is already populated, so this fires on the very first frame.
230    first_run_framed: bool,
231
232    /// A model fetch kicked off at boot from a `?loadModel=<url>` query param
233    /// (wasm only). When the fetch lands it REPLACES the seed model. `None` on
234    /// native and once applied.
235    pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
236
237    /// "Open in CAD" from the PLM's page: the `?open=part/…/rev/…` this page
238    /// was started with (wasm only), driven once a frame until it settles.
239    plm_launch: Option<crate::panels::plm_launch::Launch>,
240
241    /// The document handle the shared panels were last reset for. Compared to
242    /// `docs.active_id()` at the top of every frame: ONE check catches a switch
243    /// from any source (a tab click, a close, New, Open, Edit Part) instead of a
244    /// hook per call site, and it runs BEFORE any panel draws this frame.
245    active_document: u64,
246
247    /// Whether the active workbench showed the Sheets pane LAST frame — the
248    /// edge the paper is closed on. Closing is a transition, not a condition:
249    /// a sheet opened while another workbench is active (a command, a script)
250    /// stays open until the Drawing workbench is LEFT, exactly as PMI's view
251    /// deactivates on leaving rather than on every frame outside it.
252    sheets_pane_shown: bool,
253
254    /// The DOCUMENT TAB STRIP's per-tab hit-rects from the last dock frame,
255    /// published for the headed verifier. The strip is drawn inside the dock's
256    /// viewport pane, so its rects have to ride back out through the outcome.
257    document_tab_hits: Vec<(String, egui::Rect)>,
258
259    /// The `__brepParams` blob and the document state it was built from:
260    /// `(document, rolled-to step, history revision, json)`.
261    ///
262    /// The blob is the rolled-to feature's whole `inputParams`, and for an
263    /// `IMPORT3D` those params carry the embedded model text — 681 KB on the
264    /// heaviest corpus model. It was rebuilt EVERY frame, which on the browser
265    /// build (where the state registry is always on) also transcoded it into a
266    /// fresh JS string every frame, on the one thread that also has to answer
267    /// the pointer. Nothing reads it per frame; it only has to be correct when
268    /// someone asks. The revision is a mutation counter the document hands out
269    /// (`EngineState::history_revision`), so this cannot go stale without the
270    /// borrow checker having let a mutation past the door that bumps it.
271    params_blob: (u64, usize, u64, String),
272
273    /// A measured camera spin in flight (`perf_spin`): the orbit drag the app
274    /// drives ITSELF, one pointer step per frame, so the frames run back to back.
275    /// Driving it from a host instead would put a round trip between every pair
276    /// of frames, and `dt` — the frame period, the number a user feels as a
277    /// freeze — would measure the host's latency rather than the app's.
278    #[cfg(feature = "automation")]
279    spin: Option<Spin>,
280
281    /// The debounced autosave of every dirty document (`crate::recovery`), and
282    /// the boot-time **Recover unsaved work?** prompt its blob feeds. The
283    /// autosave is held while the prompt is open: the clean seed tab would
284    /// otherwise remove the very blob being offered.
285    autosave: crate::recovery::Autosave,
286    recovery: crate::recovery::RecoveryPanel,
287
288    /// The UI zoom scale CURRENTLY applied to the egui context. Tracks
289    /// `settings.ui_scale` but is only synced to it while the pointer is up, so
290    /// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
291    /// the cursor mid-drag — the settled value is committed on release. See the
292    /// zoom-apply block in `ui`.
293    applied_ui_scale: f32,
294    /// The automation channel (hosts submit commands; the frame drains them at
295    /// three fixed points — see `automation::queue`).
296    #[cfg(feature = "automation")]
297    pub(crate) automation: std::sync::Arc<crate::automation::queue::AutomationQueue>,
298}
299
300impl BrepApp {
301    /// The automation queue a host submits commands to.
302    #[cfg(feature = "automation")]
303    pub fn automation(&self) -> &std::sync::Arc<crate::automation::queue::AutomationQueue> {
304        &self.automation
305    }
306
307    /// Open a `.nbrep` file named on the command line — `brep-app model.nbrep`,
308    /// which is also what a desktop file association hands us (`Exec=… %f`).
309    ///
310    /// The path goes through the same `FileDialog::open_document` door as
311    /// File>Open, so the document keeps the full path as its identity and a
312    /// later plain Save writes back to the file the user double-clicked, rather
313    /// than to a copy in the app's own models directory. A path that does not
314    /// parse leaves the status line saying so and the seed document in place —
315    /// a bad argument must not cost the user a window.
316    ///
317    /// Native only: called once, before the first frame.
318    #[cfg(not(target_arch = "wasm32"))]
319    pub fn open_path_from_command_line(&mut self, path: &std::path::Path) {
320        // Absolute, so the store resolves it as a real file rather than as a
321        // name inside the models directory (`store::resolve` routes anything
322        // with a separator straight to the filesystem).
323        let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
324        let Some(name) = path.to_str() else {
325            log::warn!("brep-app: {} is not valid UTF-8; not opening it", path.display());
326            return;
327        };
328        // Field-by-field, because `open_document` borrows the documents and the
329        // store while `self.file` is borrowed mutably.
330        let Self { file, docs, model_store, .. } = self;
331        file.open_document(docs, model_store.as_ref(), name);
332    }
333
334    /// The 3D viewport rect of the last frame, in egui points.
335    pub fn view_rect(&self) -> Option<egui::Rect> {
336        self.viewport.last_rect()
337    }
338
339    /// The session's store, read-only (the automation layer's `frame_info`
340    /// counts its writes still on their way).
341    pub(crate) fn model_store(&self) -> &dyn ModelStore {
342        self.model_store.as_ref()
343    }
344
345    /// Why this session cannot switch stores live, or `None` when it can: every
346    /// open document untitled and clean, the state a fresh start has. A named
347    /// document's identity belongs to the store it came from, and a dirty one's
348    /// work has nowhere to go in a new store.
349    pub(crate) fn reconnect_blocker(&self) -> Option<String> {
350        let named = self.docs.iter().filter(|d| d.name().is_some()).count();
351        let dirty = self.docs.iter().filter(|d| d.is_dirty()).count();
352        match (named, dirty) {
353            (0, 0) => None,
354            (_, 0) => Some(format!("{named} document(s) from this store are open: close them, then Connect now (or restart)")),
355            _ => Some(format!("{dirty} document(s) have unsaved work: save or close them, then Connect now (or restart)")),
356        }
357    }
358
359    /// Switch this session to the PLM configured at `root`, live (S1's named
360    /// limit): the native boot S2 runs at start, then everything the app read
361    /// from the store when it was built is read again from the new one. Only
362    /// while [`Self::reconnect_blocker`] is `None`. A refused boot changes
363    /// nothing and says why.
364    #[cfg(not(target_arch = "wasm32"))]
365    pub(crate) fn reconnect_plm(&mut self, root: std::path::PathBuf) -> Result<String, String> {
366        use crate::plm::connection::Status;
367        if let Some(why) = self.reconnect_blocker() {
368            return Err(why);
369        }
370        let config = crate::plm::config::resolve(&root, &|_| None, &Default::default())?
371            .ok_or_else(|| format!("no PLM is configured in {}", root.display()))?;
372        let store = crate::store::boot_native_store(root, config);
373        let Some(Status::Connected { url, username }) = store.plm_session().map(|s| s.status) else {
374            let why = store.take_persistence_errors().join("; ");
375            return Err(if why.is_empty() { "the PLM did not take this session".into() } else { why });
376        };
377        self.model_store = store;
378        // Settings: every open engine, and every later tab, starts from the
379        // defaults with the new store's settings over them, as a fresh boot
380        // would; nothing of the old store's carries over.
381        let saved = self.model_store.read(SETTINGS_KEY);
382        let fresh = move |mut state: EngineState| {
383            state.settings = brep_render::style::RenderSettings::default();
384            let defaults = state.settings.to_json();
385            let _ = state.apply_settings_json(&defaults);
386            if let Some(saved) = &saved {
387                let _ = state.apply_settings_json(saved);
388            }
389            state
390        };
391        for doc in self.docs.iter_mut() {
392            let engine = std::mem::replace(&mut doc.engine, EngineState::new());
393            doc.engine = fresh(engine);
394        }
395        self.docs.wrap_engine_factory(fresh);
396        self.dock = DockState::new(self.model_store.as_ref());
397        self.recovery.arm(crate::recovery::read_entries(self.model_store.as_ref()));
398        self.file = FileDialog::new();
399        self.update_components = UpdateComponents::new();
400        Ok(format!("connected to {url} as {username}"))
401    }
402
403    /// The file dialog, the documents and the store together — for the
404    /// automation commands that drive a document-class flow (Generate, a
405    /// store read-back) exactly as the panels do.
406    pub(crate) fn file_docs_store(&mut self) -> (&mut FileDialog, &mut Documents, &dyn ModelStore) {
407        (&mut self.file, &mut self.docs, self.model_store.as_ref())
408    }
409
410    /// The active document's engine.
411    pub fn docs_engine(&self) -> &EngineState {
412        self.docs.engine()
413    }
414
415    /// Open the mesh import preview for the file `name` (`.stl` or `.3mf`)
416    /// against the active document — what activating it in the Import explorer
417    /// does. The preview replaces the shell until it is accepted or cancelled.
418    pub fn open_mesh_preview(&mut self, name: String, bytes: Vec<u8>) -> Result<(), String> {
419        use brep_render::runner::MeshImportFormat;
420        let format = match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
421            Some("stl") => MeshImportFormat::Stl,
422            Some("3mf") => MeshImportFormat::ThreeMf,
423            _ => return Err(format!("{name} is not an STL or 3MF file")),
424        };
425        self.start_mesh_preview(format, name, bytes);
426        Ok(())
427    }
428
429    fn start_mesh_preview(&mut self, format: brep_render::runner::MeshImportFormat, name: String, bytes: Vec<u8>) {
430        self.stl_import = Some(crate::panels::stl_import::StlImportPreview::new(
431            self.docs.active_id(), format, name, bytes, self.docs.spawn_engine(),
432        ));
433        self.viewport.forget_document();
434    }
435
436    /// Whether the mesh import preview is open.
437    pub fn mesh_preview_open(&self) -> bool {
438        self.stl_import.is_some()
439    }
440
441    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
442        Self::new_with(cc, crate::automation::AppOptions::default())
443    }
444
445    /// Build the app with host-supplied options: an isolated store (a host
446    /// MUST pass one) and whether to start on the seed model.
447    pub fn new_with(cc: &eframe::CreationContext<'_>, opts: crate::automation::AppOptions) -> Result<Self, String> {
448        let crate::automation::AppOptions { store: opt_store, seed } = opts;
449        let render_state = cc
450            .wgpu_render_state
451            .as_ref()
452            .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
453
454        // The viewport owns the render core + blit pipeline, built from eframe's
455        // SHARED device/queue/format.
456        let viewport = Viewport::new(render_state);
457
458        // The session's diagnostics, taken from that same render state: the
459        // adapter about to draw every frame is the one a report must name. The
460        // WebGPU-vs-WebGL2 decision has already been made by the time we get
461        // here (eframe drops `BROWSER_WEBGPU` from the backend set when the
462        // browser offers no WebGPU adapter), so this READS the outcome — asking
463        // again later would be a second question with its own answer.
464        let diagnostics = crate::diagnostics::Diagnostics::from_render_state(render_state);
465
466        // --- storage seam: load the persisted settings ------------------------
467        let model_store = opt_store.unwrap_or_else(default_model_store);
468        // wasm: hand the store the egui context so an async file-upload load
469        // callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
470        #[cfg(target_arch = "wasm32")]
471        crate::store::set_repaint_ctx(cc.egui_ctx.clone());
472        let saved_settings = model_store.read(SETTINGS_KEY);
473
474        // --- how a document's engine is built --------------------------------
475        // Every tab gets its OWN engine, and therefore its own history runner —
476        // a runner owns the resident kernel state of the document it executes,
477        // so one shared between documents would apply a background run against
478        // the wrong registry. See `crate::document`.
479        let engine_factory: EngineFactory = Box::new(move || {
480            let mut state = EngineState::new();
481            // Native: run the whole history — and per-object measurement queries — on a
482            // persistent background thread so the UI never freezes during a run or a
483            // selection (M2b). Installed BEFORE anything loads so it builds through it.
484            #[cfg(not(target_arch = "wasm32"))]
485            state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
486            // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
487            // single-threaded wasm UI stays responsive during a run. Same seam. Tests
488            // (which never hit this wasm path) keep the default synchronous InlineRunner.
489            #[cfg(target_arch = "wasm32")]
490            state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
491            state.set_viewcube_enabled(true);
492            // Partial-override apply: unknown/absent keys keep their defaults.
493            if let Some(saved) = &saved_settings {
494                let _ = state.apply_settings_json(saved);
495            }
496            state
497        });
498
499        // Start with the seed model on every launch (7626103c2). Reopening the
500        // previous session can immediately rerun a problematic document and
501        // prevent the user from recovering by restarting the app, so which
502        // documents were open is not persisted at all: saved models come back
503        // through the file dialog, and work that was never saved comes back as
504        // the crash-recovery OFFER below — a prompt, never an automatic load.
505        let mut docs = Documents::new(engine_factory);
506        let _ = docs.engine_mut().set_history_json(if seed { seed_history_json() } else { crate::document::EMPTY_DOCUMENT.to_string() }.as_str());
507        docs.engine_mut().zoom_to_fit();
508        docs.active_mut().mark_clean();
509
510        // The autosave blob from a session that ended with unsaved work: offer
511        // it back (a prompt, never an automatic restore — see `crate::recovery`).
512        let mut recovery = crate::recovery::RecoveryPanel::new();
513        recovery.arm(crate::recovery::read_entries(model_store.as_ref()));
514
515        // The settings panel seeds its working JSON from the (post-load) engine
516        // settings so the widgets reflect the persisted state on first paint.
517        let settings = SettingsPanel::new();
518        let part_properties = PartPropertiesPanel::new();
519
520        // New / Open / Save / Save As. Holds no document identity — that lives
521        // on each `Document`.
522        let file = FileDialog::new();
523
524        // Boot at the saved UI scale.
525        let applied_ui_scale = docs.engine().settings.ui_scale;
526        let active_document = docs.active_id();
527
528        // The dock layout (loads the persisted tree, or the default). Built before
529        // `model_store` is moved into `Self`.
530        let dock = DockState::new(model_store.as_ref());
531
532        // Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
533        // fetching that model NOW; the seed still loads this frame and the fetched
534        // model REPLACES it when it lands (drained in `ui`). See the drain block.
535        crate::offsite::prefetch();
536        #[cfg(target_arch = "wasm32")]
537        let pending_boot_load = web_sys::window()
538            .and_then(|w| w.location().search().ok())
539            .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
540            .and_then(|params| params.get("loadModel"))
541            .filter(|url| !url.is_empty())
542            .map(|url| crate::offsite::fetch_text(&cc.egui_ctx, "Opening a model from a link", url));
543        #[cfg(not(target_arch = "wasm32"))]
544        let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
545        #[cfg(target_arch = "wasm32")]
546        let plm_launch = crate::panels::plm_launch::Launch::from_page();
547        #[cfg(not(target_arch = "wasm32"))]
548        let plm_launch = None;
549
550        Ok(Self {
551            docs,
552            viewport,
553            toolbar: ToolbarPanel::new(),
554            workbench_toolbar: WorkbenchToolbarPanel::new(),
555            model_store,
556            file,
557            stl_import: None,
558            bug_report: BugReportPanel::new(),
559            info: crate::panels::info::InfoPanel::new(),
560            diagnostics,
561            settings,
562            part_properties,
563            history: HistoryPanel::new(),
564            scene: ScenePanel::new(),
565            bom: BomPanel::new(),
566            assembly_constraints: AssemblyConstraintsPanel::new(),
567            wire_harness: WireHarnessPanel::new(),
568            pmi: crate::panels::pmi::PmiPanel::new(),
569            sheets: crate::panels::sheets::SheetsPanel::new(),
570            update_components: UpdateComponents::new(),
571            expressions: ExpressionsPanel::new(),
572            qualify: crate::panels::qualify::QualifyPanel::new(),
573            family_table: crate::panels::family_table_editor::FamilyTableEditor::new(),
574            plm: crate::panels::plm_host::PlmHost::new(),
575            info_windows: InfoWindows::new(),
576            interference: crate::panels::interference::InterferenceWindow::new(),
577            auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow::new(),
578            step_parts: crate::panels::step_parts::StepPartsPanel::new(),
579            selection: SelectionPanel::new(),
580            context_bar: ContextBarPanel::new(),
581            sketch: SketchPanel::new(),
582            mode_bar: ModeBar::new(),
583            toasts: Toasts::new(),
584            busy: crate::panels::busy::BusyIndicator::new(),
585            dock,
586            first_run_framed: false,
587            pending_boot_load,
588            plm_launch,
589            active_document,
590            sheets_pane_shown: false,
591            document_tab_hits: Vec::new(),
592            params_blob: (u64::MAX, usize::MAX, u64::MAX, String::new()),
593            #[cfg(feature = "automation")]
594            spin: None,
595            autosave: crate::recovery::Autosave::new(),
596            recovery,
597            applied_ui_scale,
598            #[cfg(feature = "automation")]
599            automation: {
600                let q = crate::automation::queue::AutomationQueue::new();
601                q.attach(&cc.egui_ctx);
602                q
603            },
604        })
605    }
606
607    /// Arm a measured camera spin: `frames` pointer steps of `step` logical px
608    /// each, starting at the centre of the 3D viewport. Replaces any spin in
609    /// flight. See [`Spin`].
610    #[cfg(feature = "automation")]
611    pub fn start_spin(&mut self, frames: u32, step: (f64, f64)) {
612        let rect = self.viewport.last_rect();
613        let origin = rect
614            .map(|r| (r.width() as f64 * 0.5, r.height() as f64 * 0.5))
615            .unwrap_or((320.0, 240.0));
616        self.spin = Some(Spin { left: frames.max(1), origin, step, made: 0, pressed: false });
617    }
618
619    /// Is a measured spin still running?
620    #[cfg(feature = "automation")]
621    pub fn spinning(&self) -> bool {
622        self.spin.is_some()
623    }
624
625    /// Advance an in-flight spin by ONE pointer step and keep the frame loop
626    /// awake. The drag sweeps out and back over a fixed span so it never leaves
627    /// the viewport however many frames are asked for; the camera keeps turning
628    /// either way, which is all the measurement needs.
629    #[cfg(feature = "automation")]
630    fn step_spin(&mut self, ctx: &egui::Context) {
631        let Some(mut spin) = self.spin.take() else { return };
632        {
633            let state = self.docs.engine_mut();
634            if !spin.pressed {
635                state.pointer_down(spin.origin.0, spin.origin.1, brep_render::controls::BUTTON_LEFT);
636                spin.pressed = true;
637            }
638            const SPAN: f64 = 60.0;
639            let phase = f64::from(spin.made % (2 * SPAN as u32));
640            let t = if phase <= SPAN { phase } else { 2.0 * SPAN - phase };
641            state.pointer_move(spin.origin.0 + spin.step.0 * t, spin.origin.1 + spin.step.1 * t);
642            spin.made += 1;
643            spin.left -= 1;
644            if spin.left == 0 {
645                state.pointer_up();
646            }
647        }
648        if spin.left > 0 {
649            self.spin = Some(spin);
650        }
651        ctx.request_repaint();
652    }
653
654    /// The `__brepParams` JSON, rebuilt only when the document it describes can
655    /// have changed. The `params_blob` FIELD carries the reason this is not
656    /// simply built afresh each frame.
657    fn params_blob(&mut self) -> &str {
658        let key = (
659            self.docs.active_id(),
660            self.docs.engine().history_rollback(),
661            self.docs.engine().history_revision(),
662        );
663        if (self.params_blob.0, self.params_blob.1, self.params_blob.2) != key {
664            self.params_blob = (
665                key.0,
666                key.1,
667                key.2,
668                self.docs.engine().feature_params_json(key.1),
669            );
670        }
671        &self.params_blob.3
672    }
673
674    /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
675    /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection
676    /// — unless a popup or menu is up, which the Escape closes instead.
677    ///
678    /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
679    /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
680    /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
681    /// consumed BEFORE undo because egui's `consume_key` matches modifiers
682    /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
683    fn handle_shortcuts(&mut self, ctx: &egui::Context) {
684        // A modal owns every key: its Escape closes it, and nothing under it —
685        // undo, a Delete, the selection — is the key's to change.
686        if ctx.text_edit_focused() || crate::viewport::modal_open_last_pass(ctx) {
687            return;
688        }
689        use egui::{Key, Modifiers};
690        // A popup or menu that was up last frame owns this Escape: it closes on
691        // `key_pressed(Escape)`, which a consumed key never is, so the key is
692        // left in the input for it and nothing below acts on it — not the
693        // sketch tool, not a pick, and not the selection its menu is about.
694        let popup_open = crate::viewport::popup_open_last_pass(ctx);
695        // On an eCAD workbench, Delete and Escape are the editor's keys, and
696        // whether they reach it is the editors' ONE rule, `keys_reach_editor`
697        // (a text field, a menu or combo box, a modal), not a copy of it here.
698        // The watch above is the host's own addition: egui's popup memory does
699        // not see the host's `open_bool` menus (a tree row's), and those own
700        // their Escape on every workbench.
701        let ecad_target = crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench);
702        let keys_free = match ecad_target {
703            Some(_) => !popup_open && brep_ecad_egui::keys_reach_editor(ctx),
704            None => !popup_open,
705        };
706        let (redo, undo, esc) = ctx.input_mut(|i| {
707            let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
708                || i.consume_key(Modifiers::COMMAND, Key::Y);
709            let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
710            let esc = keys_free && i.consume_key(Modifiers::NONE, Key::Escape);
711            (redo, undo, esc)
712        });
713        // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
714        // history (S6a), not the model-level undo — this global router consumes the
715        // keys first (before the viewport), so it must intercept here. Esc drops the
716        // active draw/trim/pick tool back to Select/drag (clearing any in-progress
717        // placement): this is the ONLY reliable capture point, since `consume_key`
718        // above already swallowed the Escape before the viewport can see it.
719        if self.docs.engine().sketch_mode() {
720            if redo {
721                self.docs.engine_mut().sketch_redo();
722            }
723            if undo {
724                self.docs.engine_mut().sketch_undo();
725            }
726            if esc {
727                self.docs.engine_mut().sketch_set_tool(Some("select"));
728            }
729            return;
730        }
731        if redo {
732            self.docs.engine_mut().redo();
733        }
734        if undo {
735            self.docs.engine_mut().undo();
736        }
737        if let Some(target) = ecad_target {
738            if keys_free && !self.docs.engine().ref_select_active() {
739                let delete = ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete)
740                    || i.consume_key(Modifiers::NONE, Key::Backspace));
741                if delete {
742                    self.docs.active_mut().ecad.selection_key(target, true);
743                }
744            }
745        } else {
746            self.delete_selected_annotation(ctx);
747        }
748        if esc {
749            // An open pick-list popup owns the first Escape: close it WITHOUT
750            // clearing the selection (a popup-built multi-selection must survive
751            // dismissing the list); the next Escape clears as before.
752            if self.viewport.close_candidate_popup() {
753                return;
754            }
755            // A REFERENCE PICK owns the next one — a 3D pick or anchors on a
756            // drawing sheet alike — and Escape has to CANCEL it
757            // rather than merely clear the selection. Beginning a pick ROLLS THE
758            // MODEL BACK to the step before the edited feature so the picker sees
759            // the geometry that feature is about to consume; Finish and Cancel
760            // both restore that roll through `end_ref_select`. Escape used to do
761            // neither: it cleared the selection, left the picker open and left the
762            // history PARKED before the feature, so the feature stopped running
763            // and the viewport quietly showed the model without it. Measured on
764            // the retired `rotate-face.json`, which read the seed volume back at its
765            // closed-form step with the feature never re-run.
766            if self.docs.engine().ref_select_active() {
767                self.docs.engine_mut().cancel_ref_select();
768                return;
769            }
770            // A family-row preview owns the next one: Escape leaves the
771            // preview (the family's own values again) and keeps the selection.
772            if self.family_table.escape_preview(self.docs.engine_mut()) {
773                return;
774            }
775            // The global router owns Escape before the viewport runs its keys.
776            if let Some(target) = ecad_target {
777                self.docs.active_mut().ecad.selection_key(target, false);
778                return;
779            }
780            self.docs.engine_mut().clear_selection();
781        }
782    }
783
784    /// DELETE (or Backspace) removes the selected object of the OPEN drawing
785    /// sheet — a placed view, a sheet dimension or an ordinate set, picked on
786    /// the paper or in the Sheets tree — or, with no sheet open, the selected
787    /// PMI annotation while the PMI workbench is up. Either goes through the
788    /// destructive entry of the object's own row menu, so the key and the menu
789    /// do the same thing (and it is one undo step, like the menu's).
790    ///
791    /// The key is consumed only when there IS something to delete. Never a
792    /// SHEET or a PMI VIEW: each takes everything placed on it with it, and
793    /// both stay behind their menus' Delete. Nothing while a reference pick is
794    /// up — deleting the object whose references are being picked would
795    /// strand the picker. (Called after the text-edit and sketch-mode returns,
796    /// so a Backspace typed into a field, or a sketch's own Delete, never gets
797    /// here.)
798    fn delete_selected_annotation(&mut self, ctx: &egui::Context) {
799        use egui::{Key, Modifiers};
800        let engine = self.docs.engine();
801        if engine.ref_select_active() {
802            return;
803        }
804        let target = if engine.sheet_open().is_some() {
805            engine.sheet_selected_object().map(|id| (true, id.to_string()))
806        } else if engine.pmi_workbench_entered() {
807            engine.pmi_selected_annotation().map(|id| (false, id.to_string()))
808        } else {
809            None
810        };
811        let Some((on_sheet, id)) = target else {
812            return;
813        };
814        let pressed = ctx.input_mut(|i| {
815            i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)
816        });
817        if !pressed {
818            return;
819        }
820        let engine = self.docs.engine_mut();
821        let result = if on_sheet {
822            crate::panels::sheets::delete_held_object(engine, &id)
823        } else {
824            crate::panels::pmi::delete_annotation(engine, &id)
825        };
826        if let Err(error) = result {
827            engine.push_notice(format!("{}: {error}", if on_sheet { "Sheets" } else { "PMI" }));
828        }
829    }
830
831    /// EDIT PART (assemblies §8.5): open the component's SOURCE document in its
832    /// own tab — or focus the tab already holding it. Editing a component IS
833    /// opening its part now; the assembly picks the change up through the
834    /// outdated badge / Update Components once the part is saved, so there is no
835    /// session to finish and nothing to stash.
836    ///
837    /// A part with no store document under its `sourceKey` (an embedded-only
838    /// part — a headless STEP import, or one whose write failed) has no file to
839    /// open, and says so rather than doing nothing.
840    fn edit_part(&mut self, component_id: &str) {
841        let source =
842            crate::panels::component_actions::part_source_key(self.docs.engine(), component_id);
843        let now = source.as_deref().map(|key| crate::store::read_now(self.model_store.as_ref(), key));
844        match source {
845            Some(key) if matches!(now, Some(crate::store::ReadNow::Ready(_))) => {
846                self.file
847                    .open_document(&mut self.docs, self.model_store.as_ref(), &key);
848            }
849            Some(key) if now == Some(crate::store::ReadNow::Loading) => {
850                self.docs.engine_mut().push_notice(format!(
851                    "This part's source document '{key}' is still loading — Edit Part again in a moment"
852                ))
853            }
854            Some(key) => self.docs.engine_mut().push_notice(format!(
855                "This part's source document '{key}' is no longer in storage — nothing to open"
856            )),
857            None => self.docs.engine_mut().push_notice(
858                "This part is embedded in the assembly — it has no source document to open"
859                    .to_string(),
860            ),
861        }
862    }
863
864    /// Draw an isolated preview while the destination document remains untouched.
865    /// Hand the working indicator this frame's work, most important first:
866    /// the active document's runner queues, a boot load, the STEP parts
867    /// library's downloads, then every background document's queues.
868    fn collect_busy(&mut self, ctx: &egui::Context) {
869        use crate::panels::busy::{engine_activities, Activity};
870        let active = self.docs.active_index();
871        let mut activities = engine_activities(self.docs.engine(), self.docs.active_id(), None);
872        if self.pending_boot_load.is_some() {
873            activities.push(Activity::new("boot", "boot", "Loading document"));
874        }
875        activities.extend(self.step_parts.busy_activities());
876        activities.extend(self.file.busy_activities());
877        // A write-behind store (the browser's IndexedDB) still settling saves.
878        let saving = self.model_store.pending_writes();
879        if saving > 0 {
880            let label = match saving {
881                1 => "Saving to browser storage".to_string(),
882                n => format!("Saving to browser storage ({n} writes)"),
883            };
884            activities.push(Activity::new("storeWrite", "storeWrite", label));
885        }
886        for (index, doc) in self.docs.iter().enumerate() {
887            if index != active {
888                activities.extend(engine_activities(&doc.engine, doc.id(), Some(&doc.title())));
889            }
890        }
891        self.busy.update(activities, ctx.input(|i| i.time));
892        self.busy.request_repaint(ctx);
893    }
894
895    fn show_stl_preview(&mut self, ui: &mut egui::Ui) -> bool {
896        use crate::panels::stl_import::PreviewAction;
897        let Some(preview) = self.stl_import.as_mut() else {
898            return false;
899        };
900        let action = if preview.destination != self.docs.active_id() {
901            PreviewAction::Cancel
902        } else {
903            preview.show(ui, &mut self.viewport)
904        };
905        if crate::automation::registry::enabled() {
906            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
907            crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", &preview.state_json());
908            crate::automation::registry::publish("__brepImportPreviewHit", "import preview dialog widget rects", &preview.hits_json());
909            crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &preview.engine.camera_state_json());
910            crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{}", ui.ctx().pixels_per_point()));
911            crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
912            crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
913        }
914        let close = match action {
915            PreviewAction::Accept => {
916                match preview.accept_into(self.docs.active_id(), self.docs.engine_mut()) {
917                    Ok(()) => true,
918                    Err(error) => {
919                        self.docs.engine_mut().push_notice(error);
920                        false
921                    }
922                }
923            }
924            PreviewAction::Cancel => true,
925            PreviewAction::None => false,
926        };
927        if close {
928            self.stl_import = None;
929            self.viewport.forget_document();
930            ui.ctx().request_repaint();
931        }
932        true
933    }
934
935    /// Phase 3 of the automation frame (§4.4): read-only commands answer from
936    /// THIS frame's registry and layout. A method rather than a block at the
937    /// end of `ui` because the import preview returns from that function early
938    /// and still owes the frame its reads.
939    #[cfg(feature = "automation")]
940    fn drain_reads(&mut self, ctx: &egui::Context) {
941        let queue = self.automation.clone();
942        let frame = ctx.cumulative_frame_nr();
943        queue.drain_app(
944            crate::automation::command::Phase::Read,
945            &mut crate::automation::command::Ctx { app: self, egui: ctx },
946            frame,
947        );
948    }
949
950    /// Reset everything the shared panels and the viewport hold ABOUT ONE
951    /// DOCUMENT, run at the top of the first frame that sees a different active
952    /// document.
953    ///
954    /// Panel state is deliberately NOT per-document (one History panel, one
955    /// Scene tree, …): a second copy per tab would double every panel's state
956    /// for a benefit — remembering which feature form was open in a background
957    /// tab — nobody asked for. The price is that the transient state has to be
958    /// dropped on a switch, because every bit of it (expansion sets, hit maps,
959    /// an open feature form, a pinned Info window's object name) refers to the
960    /// document that just went away.
961    fn reset_document_scoped_state(&mut self) {
962        self.history = HistoryPanel::new();
963        self.scene = ScenePanel::new();
964        self.bom = BomPanel::new();
965        self.assembly_constraints = AssemblyConstraintsPanel::new();
966        self.wire_harness = WireHarnessPanel::new();
967        self.pmi = crate::panels::pmi::PmiPanel::new();
968        self.sheets = crate::panels::sheets::SheetsPanel::new();
969        self.expressions = ExpressionsPanel::new();
970        self.qualify = crate::panels::qualify::QualifyPanel::new();
971        self.family_table = crate::panels::family_table_editor::FamilyTableEditor::new();
972        // A family-row preview left on in a document the user comes back to
973        // would have no pane state to show or end it: switching ends it.
974        self.docs.engine_mut().set_expression_preview(None);
975        // Pinned to object NAMES of the old document ("Box" exists in most of
976        // them), so these would silently retarget rather than go blank.
977        self.info_windows = InfoWindows::new();
978        self.interference = crate::panels::interference::InterferenceWindow::new();
979        self.auto_constraints = crate::panels::auto_constraints::AutoConstraintsWindow::new();
980        // The outdated-parts cache keys on `(applied_generation, save_generation)`,
981        // and two documents' generations are unrelated — a switch can land on the
982        // same key with an entirely different parts library.
983        self.update_components.invalidate();
984        self.viewport.forget_document();
985    }
986
987    /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
988    /// per-solid triangle count + bbox, and total triangles. Published to JS so
989    /// the headed verifier can prove each roll / edit produced different geometry
990    /// (names alone don't: a SUBTRACT reuses the target's name).
991    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
992    fn model_signature_json(&self) -> String {
993        let solids: Vec<serde_json::Value> = self
994            .docs
995            .engine()
996            .scene
997            .solids()
998            .iter()
999            .map(|s| {
1000                serde_json::json!({
1001                    "name": s.name,
1002                    "tris": s.mesh.indices.len() / 3,
1003                    "min": s.bbox.min,
1004                    "max": s.bbox.max,
1005                })
1006            })
1007            .collect();
1008        let total_tris: usize = self
1009            .docs
1010            .engine()
1011            .scene
1012            .solids()
1013            .iter()
1014            .map(|s| s.mesh.indices.len() / 3)
1015            .sum();
1016        serde_json::json!({
1017            "step": self.docs.engine().history_rollback(),
1018            "solidCount": solids.len(),
1019            "totalTris": total_tris,
1020            "solids": solids,
1021        })
1022        .to_string()
1023    }
1024
1025    /// Open or close the floating Settings window — the toolbar gear's flag,
1026    /// reachable from the automation layer (`settings_window`).
1027    pub fn set_settings_window_open(&mut self, open: bool) {
1028        self.settings.open = open;
1029    }
1030
1031    /// Open or close the floating Part Properties window — the toolbar tag
1032    /// button's flag, reachable from the automation layer
1033    /// (`part_properties_window`).
1034    pub fn set_part_properties_window_open(&mut self, open: bool) {
1035        self.part_properties.open = open;
1036    }
1037
1038    /// Open or close the floating Info window — the toolbar info button's flag,
1039    /// reachable from the automation layer (`info_window`).
1040    pub fn set_info_window_open(&mut self, open: bool) {
1041        self.info.open = open;
1042    }
1043
1044    /// What this session is running on. The app's ONE record of it: the Info
1045    /// window draws it, a problem report carries it, the `diagnostics` command
1046    /// returns it and the MCP banner names its adapter — all from here.
1047    pub fn diagnostics(&self) -> &crate::diagnostics::Diagnostics {
1048        &self.diagnostics
1049    }
1050
1051    /// Begin the in-app problem report: the same call the Submit Bug button
1052    /// makes, and for the same reason it makes it THIS frame — `request`
1053    /// captures the current frame before its own dialog exists.
1054    pub fn begin_bug_report(&mut self, ctx: &egui::Context) {
1055        self.bug_report.request(ctx, self.docs.engine(), &self.diagnostics);
1056    }
1057
1058    /// Bring a dock pane to the front (`show_pane`).
1059    pub fn show_pane(&mut self, kind: PaneKind) {
1060        self.dock.show_pane(kind);
1061    }
1062
1063    /// The parts-library staleness check and its refresh — the Constraints
1064    /// header's "Update components (N)" button, reachable as the
1065    /// `component_update` command. Returns `(outdated, missing, refreshed)`;
1066    /// `run` is skipped when nothing is outdated.
1067    pub fn update_components(&mut self, run: bool) -> Result<(usize, Vec<String>, usize), String> {
1068        let store = self.model_store.as_ref();
1069        self.update_components.ensure_current(self.docs.engine_mut(), store, self.file.save_generation());
1070        let outdated = self.update_components.outdated_count();
1071        let missing = self.update_components.missing().to_vec();
1072        let refreshed = if run { self.update_components.run(self.docs.engine_mut(), store)? } else { 0 };
1073        Ok((outdated, missing, refreshed))
1074    }
1075
1076    /// Frame what the central tile is actually DRAWING: the OPEN SHEET's paper
1077    /// when a sheet is open, the 3D scene otherwise.
1078    ///
1079    /// This is where the fit branch lives, and it is here rather than in the
1080    /// toolbar because the two halves sit in different places — the 3D fit is an
1081    /// engine camera op (`EngineState::zoom_to_fit`) while the sheet's pan/zoom
1082    /// is viewport-transient state, refitted by clearing the marker the sheet
1083    /// paint compares against. One method, so the toolbar's Zoom-to-fit button
1084    /// and the `zoom_to_fit` command can never mean different things — and so
1085    /// the paper needs no Fit button of its own.
1086    ///
1087    /// The toolbar draws BEFORE the dock in `ui`, so a fit requested from the
1088    /// button lands on this very frame.
1089    pub fn zoom_to_fit(&mut self) {
1090        if self.docs.engine().sheet_open().is_some() {
1091            self.viewport.request_sheet_fit();
1092        } else {
1093            self.docs.engine_mut().zoom_to_fit();
1094        }
1095    }
1096
1097    /// Run what a WORKBENCH TOOLBAR button does, by its `WorkbenchButton::id`.
1098    ///
1099    /// Split out of `ui` so a toolbar click and the `workbench_button` command
1100    /// run the SAME arms: the workbench registry declares a button, this
1101    /// dispatches it, and the automation surface owns no second copy of the
1102    /// list. Returns whether the id was known.
1103    pub fn dispatch_workbench_button(&mut self, id: &str) -> bool {
1104        // The eCAD workbenches' buttons (Diagram, PCB, Symbol, Pads) are
1105        // eCAD's own actions: run on the active document's editors with
1106        // eCAD's `run_action`, so a press does exactly what eCAD's toolbar
1107        // button does. `None` is any other id, which falls through to the
1108        // arms below.
1109        if crate::workbench::ecad::dispatch(&mut self.docs.active_mut().ecad, id).is_some() {
1110            return true;
1111        }
1112        // The SHEET's constructions are the Drawing workbench's two tables plus
1113        // the section and the detail, and each press is the PMI annotation
1114        // buttons' workflow: the object is created on the open sheet with
1115        // nothing picked yet, and its dialog opens (through the dialog door,
1116        // which surfaces the Sheets pane) — where its reference rows are picked
1117        // on the paper with the reference picker.
1118        if let Some((kind, alignment)) = crate::workbench::drawing::dim_tool(id) {
1119            let engine = self.docs.engine_mut();
1120            if let Err(error) = engine.sheet_add_dimension(None, kind, Some(alignment), Vec::new()) {
1121                engine.push_notice(format!("Sheet dimension: {error}"));
1122            }
1123            return true;
1124        }
1125        if let Some(axis) = crate::workbench::drawing::ord_tool(id) {
1126            let engine = self.docs.engine_mut();
1127            if let Err(error) = engine.sheet_add_ordinate(None, axis, None, Vec::new()) {
1128                engine.push_notice(format!("Ordinate set: {error}"));
1129            }
1130            return true;
1131        }
1132        if id == crate::workbench::drawing::SECTION_BUTTON_ID {
1133            let engine = self.docs.engine_mut();
1134            if let Err(error) = engine.sheet_new_section(None) {
1135                engine.push_notice(format!("Section view: {error}"));
1136            }
1137            return true;
1138        }
1139        if id == crate::workbench::drawing::DETAIL_BUTTON_ID {
1140            let engine = self.docs.engine_mut();
1141            if let Err(error) = engine.sheet_new_detail(None) {
1142                engine.push_notice(format!("Detail view: {error}"));
1143            }
1144            return true;
1145        }
1146        // The SKETCH mode's draw tools are one table too, and they are SHARED —
1147        // every workbench's row carries them while a sketch is being edited, so
1148        // this arm answers for all of them whatever workbench is active.
1149        // Pressing the LIT tool puts it down (back to Select), matching the
1150        // sheet constructions above; pressing the lit Select is already Select.
1151        if let Some(tool) = crate::workbench::sketch::draw_tool(id) {
1152            let engine = self.docs.engine_mut();
1153            let armed = engine.sketch_active_tool().unwrap_or("select") == tool;
1154            engine.sketch_set_tool(Some(if armed { "select" } else { tool }));
1155            return true;
1156        }
1157        match id {
1158            // The sketch strip's one-shot action, now the row's: infer the
1159            // constraints the rough-in geometry already implies.
1160            crate::workbench::sketch::AUTOCONSTRAIN_BUTTON_ID => {
1161                self.docs.engine_mut().sketch_auto_constrain();
1162            }
1163            // Sheet Metal's flat pattern: open the export modal in its DXF /
1164            // SVG mode; the engine reports "no sheet-metal body in the part"
1165            // as a toast on export.
1166            "sheetmetal.flat_pattern" => {
1167                self.file.dispatch(FileAction::ExportFlatPattern, &mut self.docs, self.model_store.as_ref());
1168            }
1169            // Assembly's Add Component: open the insert-component modal (the
1170            // same flow as the ACOMP palette pick). ONE arm for both rows that
1171            // carry it — PCB BORROWS this very button (`workbench::BORROWED`)
1172            // rather than declaring a second id for the same action.
1173            crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID => {
1174                self.file.dispatch(FileAction::InsertComponent, &mut self.docs, self.model_store.as_ref());
1175            }
1176            // Assembly's interference check: run the engine's pairwise
1177            // intersect sweep NOW and open the results window.
1178            "assembly.interference" => {
1179                self.interference.open_and_run(self.docs.engine_mut());
1180            }
1181            // Assembly's Auto Constraints: open the inference window and scan
1182            // the current placement NOW, so it opens showing real counts.
1183            "assembly.auto_constrain" => {
1184                self.auto_constraints.open_and_scan(self.docs.engine_mut());
1185            }
1186            // Assembly's step.parts library: open the online-library browser
1187            // (search → thumbnails → import a STEP part → add as an ACOMP).
1188            "assembly.step_parts_library" => {
1189                self.step_parts.open();
1190            }
1191            // Wire harness's Declare connection point: the part's FIRST port
1192            // group, with one point at the origin. It exists because the
1193            // Qualify pane is on screen only while the part declares some — so
1194            // without it a 3D-only part could never get its first one from the
1195            // UI at all. The pane is surfaced by the dock's own ports door on
1196            // the next frame, once the visibility pass has let it back in.
1197            crate::workbench::wire_harness::DECLARE_POINT_BUTTON_ID => {
1198                let engine = self.docs.engine_mut();
1199                engine.history.set_ports_block(
1200                    Some(serde_json::json!([{
1201                        "name": crate::workbench::wire_harness::FIRST_GROUP,
1202                        "purpose": crate::workbench::wire_harness::FIRST_PURPOSE,
1203                        "points": [{
1204                            "name": crate::workbench::wire_harness::FIRST_POINT,
1205                            "transform": {
1206                                "position": [0.0, 0.0, 0.0],
1207                                "rotationEuler": [0.0, 0.0, 0.0],
1208                            },
1209                        }],
1210                    }])),
1211                    None,
1212                );
1213            }
1214            // PMI's Capture view: snapshot the camera + visibility into a new
1215            // active view and surface the PMI pane so its row is seen.
1216            crate::workbench::pmi::CAPTURE_BUTTON_ID => {
1217                self.docs.engine_mut().pmi_capture_view(None);
1218                self.dock.show_pane(PaneKind::Pmi);
1219            }
1220            // Drawing's Back to 3D: leave the open sheet. The paper's own
1221            // widgets go with it, and the sheet keeps its contents — the same
1222            // edit the Sheets pane's Close sheet makes.
1223            crate::workbench::drawing::SHEET_CLOSE_BUTTON_ID => {
1224                let _ = self.docs.engine_mut().sheet_set_open(None);
1225                self.viewport.forget_sheet();
1226            }
1227            // Drawing's Add sheet: a new sheet, opened on the paper, and the
1228            // Sheets pane brought forward so its row is seen.
1229            crate::workbench::drawing::ADD_SHEET_BUTTON_ID => {
1230                self.docs.engine_mut().sheet_add(None);
1231                self.dock.show_pane(PaneKind::Sheets);
1232            }
1233            // Drawing's Place view: the first saved PMI view on the open sheet;
1234            // the placement's form opens (the door surfaces the pane), and its
1235            // `view` field is where a different view is chosen.
1236            crate::workbench::drawing::PLACE_VIEW_BUTTON_ID => {
1237                let engine = self.docs.engine_mut();
1238                let placed = match engine.sheet_pmi_view_ids().first().cloned() {
1239                    Some(view) => engine.sheet_place_view(None, &view, None, None).map(|_| ()),
1240                    None => Err("capture a PMI view in the PMI workbench first".to_string()),
1241                };
1242                if let Err(error) = placed {
1243                    engine.push_notice(format!("Place view: {error}"));
1244                }
1245            }
1246            _ => return false,
1247        }
1248        true
1249    }
1250
1251}
1252
1253impl eframe::App for BrepApp {
1254    /// Phase 1 of the automation frame (§4.4): queued input commands become
1255    /// egui events; a screenshot that landed completes its reply. Called by
1256    /// every eframe runner before `ui`; the headless host calls it itself.
1257    #[cfg(feature = "automation")]
1258    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
1259        let view = self.viewport.last_rect();
1260        self.automation.drain_input(raw_input, ctx.cumulative_frame_nr(), ctx.pixels_per_point(), view, ctx);
1261    }
1262
1263
1264    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
1265        // Open this frame's timing window (banks the previous frame's phase
1266        // totals and its period) and bracket the whole body. See `perf`: on a
1267        // single-threaded shell a long frame IS a frozen UI, so what the frame
1268        // spends is the whole diagnosis.
1269        crate::perf::tick();
1270        let _ui_span = crate::perf::span(crate::perf::Phase::Ui);
1271
1272        // The native store and PLM queue (`plm::native`): every task whose I/O
1273        // answered since the last frame finishes here, on this thread, FIRST —
1274        // before any early return below (the mesh preview's replaces the whole
1275        // shell) can skip it. The browser drains its microtasks between events
1276        // the same way, whatever the page is showing.
1277        #[cfg(not(target_arch = "wasm32"))]
1278        crate::plm::native::run_pending();
1279        // A PLM save made mid-rebuild is pictured once its run lands.
1280        crate::plm::thumbnail::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
1281
1282        // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
1283        // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
1284        // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
1285        // `&mut self` call).
1286        let ctx = ui.ctx().clone();
1287
1288        // Phase 2 of the automation frame (§4.4): mutations run before any
1289        // panel draws, so this frame shows their effect.
1290        #[cfg(feature = "automation")]
1291        {
1292            let queue = self.automation.clone();
1293            let frame = ctx.cumulative_frame_nr();
1294            queue.drain_app(
1295                crate::automation::command::Phase::Mutate,
1296                &mut crate::automation::command::Ctx { app: self, egui: &ctx },
1297                frame,
1298            );
1299        }
1300
1301        // A measured spin (`perf_spin`) advances one pointer step per frame, here
1302        // — after the mutation drain that may have armed it, so the spin starts
1303        // on the frame it was asked for.
1304        #[cfg(feature = "automation")]
1305        self.step_spin(&ctx);
1306
1307        // --- a different document is active than the panels were drawn for ----
1308        // Checked FIRST, before anything draws: the switch itself happened late
1309        // in some earlier frame (a tab click, a close, an Open), and every
1310        // shared panel is still holding the previous document's transient state.
1311        if self.active_document != self.docs.active_id() {
1312            self.active_document = self.docs.active_id();
1313            self.reset_document_scoped_state();
1314        }
1315
1316        // --- the workbench decides whether placed parts show their ports -------
1317        // A workbench that shows the Wire Harness panel (Wire harness, All)
1318        // draws the ports components carry; the others — Modeling included,
1319        // though it offers the Port feature — keep the assembly clean. A no-op
1320        // when nothing changed.
1321        {
1322            // The document's name lives in the SHELL (the tab, the file), and a
1323            // drawing sheet's title block prints it. Told to the engine here,
1324            // every frame, because that is where the active tab is known; a
1325            // no-op when it has not changed. It is the DISPLAY name, the one the
1326            // tab shows: a stamp reading `bracket.nbrep` would be naming our
1327            // file format rather than the drawing.
1328            let name = self
1329                .docs
1330                .active()
1331                .name()
1332                .map(crate::store::model_display_name)
1333                .unwrap_or_default();
1334            let engine = self.docs.engine_mut();
1335            engine.set_document_name(&name);
1336            let show = crate::workbench::panel_visible(
1337                &engine.settings.workbench,
1338                crate::workbench::wire_harness::PANEL_ID,
1339                &crate::workbench::ButtonState::of(engine),
1340            );
1341            engine.set_component_ports_visible(show);
1342            // The PMI workbench IS the PMI editing mode: a workbench that shows
1343            // the PMI panel (PMI, All) enters it — the modeling camera /
1344            // visibility / wireframe are remembered — and one that hides it
1345            // leaves it, deactivating the view and restoring them.
1346            let pmi_shown = crate::workbench::panel_visible(
1347                &engine.settings.workbench,
1348                crate::workbench::pmi::PANEL_ID,
1349                &crate::workbench::ButtonState::of(engine),
1350            );
1351            if pmi_shown && !engine.pmi_workbench_entered() {
1352                engine.pmi_enter_workbench();
1353            } else if !pmi_shown && engine.pmi_workbench_entered() {
1354                engine.pmi_leave_workbench();
1355            }
1356            // The DRAWING workbench owns the paper: LEAVING it for a workbench
1357            // that hides the Sheets pane closes an open sheet, so the central
1358            // tile is not left drawing paper whose tools, pane and way back are
1359            // all hidden. It is a MODE change — the sheets and their contents
1360            // are untouched — and an edge, see `sheets_pane_shown`.
1361            let sheets_shown = crate::workbench::panel_visible(
1362                &engine.settings.workbench,
1363                crate::workbench::drawing::SHEETS_PANEL_ID,
1364                &crate::workbench::ButtonState::of(engine),
1365            );
1366            if self.sheets_pane_shown && !sheets_shown && engine.sheet_open().is_some() {
1367                let _ = engine.sheet_set_open(None);
1368                self.viewport.forget_sheet();
1369            }
1370            self.sheets_pane_shown = sheets_shown;
1371        }
1372
1373        // --- GUI chrome theme -------------------------------------------------
1374        // Apply the user's theme preference to the egui chrome every frame
1375        // (idempotent: `set_theme` just stores the preference). Auto follows the
1376        // OS/system theme (prefers-color-scheme on web); egui falls back to dark
1377        // when no OS signal is available. This controls panels/windows/toolbar/
1378        // text only — the 3D viewport `background` is a separate setting.
1379        ctx.set_theme(match self.docs.engine().settings.theme {
1380            ThemeMode::Auto => egui::ThemePreference::System,
1381            ThemeMode::Light => egui::ThemePreference::Light,
1382            ThemeMode::Dark => egui::ThemePreference::Dark,
1383        });
1384
1385        // --- global UI size scale --------------------------------------------
1386        // Apply the user's "UI scale" to the whole egui chrome every frame. This
1387        // is idempotent when unchanged (`set_zoom_factor` only repaints on an
1388        // actual change) and composes with the native device pixel ratio
1389        // (pixels_per_point = zoom_factor * native_pixels_per_point).
1390        //
1391        // Defer live UI rescale while the user drags the Settings "UI scale" slider:
1392        // the slider value updates continuously, but only commit it to the actual egui
1393        // zoom once the pointer is released, so the whole UI doesn't rescale under the
1394        // cursor mid-drag.
1395        let pointer_down = ctx.input(|i| i.pointer.any_down());
1396        if !pointer_down {
1397            self.applied_ui_scale = self.docs.engine().settings.ui_scale;
1398        }
1399        ctx.set_zoom_factor(self.applied_ui_scale);
1400
1401        // --- history-runner pump ---------------------------------------------
1402        // Apply any completed background history run BEFORE panels read the scene.
1403        // For the synchronous InlineRunner this is a no-op (`rerun_history` already
1404        // pumped its own submit), so nothing changes today; it is the seam a future
1405        // native-thread / wasm-worker runner lands its reply through. While a run is
1406        // still in flight, keep the frame loop alive so its reply gets pumped — for
1407        // Inline `run_pending()` is always false, so this never fires.
1408        //
1409        // EVERY open document is pumped, not just the active one — see
1410        // `Documents::pump_all`, which also keeps the active document's parts
1411        // library in the kernel's store when a background tab's run lands.
1412        let work_in_flight = self.docs.pump_all();
1413        if work_in_flight {
1414            ctx.request_repaint();
1415        }
1416        if self.show_stl_preview(ui) {
1417            // The preview REPLACES the shell for the frame, and this early
1418            // return used to take the frame's READ drain with it: while a
1419            // preview was open every read-only automation command — `ping`,
1420            // `state_get`, `hit_rects`, a screenshot — went unanswered until
1421            // the host gave up on it, so no script could observe the preview it
1422            // had just opened. The preview publishes its own state and rects
1423            // above; this answers the reads that ask for them.
1424            #[cfg(feature = "automation")]
1425            self.drain_reads(&ctx);
1426            return;
1427        }
1428        if crate::automation::registry::enabled() {
1429            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
1430            crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", "null");
1431        }
1432
1433        // The tab strip's dirty dots, refreshed once per frame (cheap — see
1434        // `Document::refresh_dirty_marker`).
1435        self.docs.refresh_dirty_markers();
1436
1437        // --- boot-load (?loadModel=): apply the fetched model once it lands ----
1438        // Replaces the seed with the URL-specified document (armed in `new`). The
1439        // ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
1440        // `load_model_and_fit` arms deferred framing; the pump above reframes it
1441        // next frame. `mark_clean` opens it as a non-dirty document.
1442        if self.pending_boot_load.is_some() {
1443            let received = self
1444                .pending_boot_load
1445                .as_ref()
1446                .and_then(|rx| rx.try_recv().ok());
1447            if let Some(result) = received {
1448                self.pending_boot_load = None;
1449                match result {
1450                    Ok(json) => {
1451                        // REPLACES the seed in place rather than adding a tab.
1452                        // It lands on the document that is already active,
1453                        // whatever the session restored.
1454                        let _ = self.docs.engine_mut().load_model_and_fit(&json);
1455                        self.docs.active_mut().mark_clean();
1456                    }
1457                    Err(e) => self
1458                        .docs
1459                        .engine_mut()
1460                        .push_notice(format!("Could not load model from URL: {e}")),
1461                }
1462            }
1463        }
1464
1465        // --- "Open in CAD" (?open=part/…/rev/…) --------------------------------
1466        if let Some(launch) = self.plm_launch.as_mut() {
1467            use crate::panels::plm_launch::Step;
1468            let step = launch.step(self.model_store.as_ref(), &mut self.docs, &mut self.file);
1469            match &step {
1470                Step::SignIn(sentence) => {
1471                    self.settings.show_plm_tab();
1472                    self.docs.engine_mut().push_notice(sentence.clone());
1473                }
1474                Step::Refused(sentence) => self.docs.engine_mut().push_notice(sentence.clone()),
1475                _ => {}
1476            }
1477            if step.settles() {
1478                crate::panels::plm_launch::forget_query();
1479            }
1480            if launch.is_done() {
1481                self.plm_launch = None;
1482            } else {
1483                ui.ctx().request_repaint();
1484            }
1485        }
1486
1487        crate::panels::ecad_parts::sync(self.docs.active_mut());
1488        let requests = crate::panels::ecad_parts::take_open_requests(self.docs.active_mut());
1489        for key in requests {
1490            self.file.open_document(&mut self.docs, self.model_store.as_ref(), &key);
1491        }
1492        // The parts pane's Add part button presses Assembly's Add Component: one arm.
1493        if crate::panels::ecad_parts::take_add_part_request(self.docs.active_mut()) {
1494            self.dispatch_workbench_button(crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID);
1495        }
1496        // The eCAD host's Update parts button is Constraints' Update components.
1497        if std::mem::take(&mut self.docs.active_mut().ecad_update_requested) {
1498            if let Err(error) = self.update_components.run(self.docs.engine_mut(), self.model_store.as_ref()) {
1499                self.docs.engine_mut().push_notice(format!("Update components: {error}"));
1500            }
1501        }
1502
1503        // --- update-components badge freshness ---------------------------------
1504        // Keep the outdated-parts checker current BEFORE any assembly panel draws
1505        // (the structure tree renders per-node badges ahead of the constraints
1506        // header). Cheap: a real recompute happens only when an applied run or a
1507        // successful store save moved the generation key.
1508        self.update_components.ensure_current(
1509            self.docs.engine_mut(),
1510            self.model_store.as_ref(),
1511            self.file.save_generation(),
1512        );
1513        // …and the eCAD host marks the components placed from those parts.
1514        self.docs.active_mut().ecad_outdated = self.update_components.outdated().to_vec();
1515
1516        // --- async-safe first-model framing -----------------------------------
1517        // The seed run is async under a background runner (native thread / wasm
1518        // worker), so the boot `zoom_to_fit` may have run before any solid existed.
1519        // Frame the model ONCE, the first frame the seed run has fully landed (solids
1520        // present AND no run still in flight). Under the synchronous Inline runner
1521        // (tests) both hold on the very first frame, so this is identical to today.
1522        if !self.first_run_framed
1523            && !self.docs.engine().run_pending()
1524            && self.docs.engine().has_solids()
1525        {
1526            self.docs.engine_mut().zoom_to_fit();
1527            self.first_run_framed = true;
1528        }
1529
1530        self.handle_shortcuts(&ctx);
1531
1532        // --- top toolbar: primary actions, drawn FIRST so its top strip is
1533        // reserved above the left panel + central viewport. A clicked File button
1534        // returns an action the file dialog acts on (open its modal / save / new).
1535        let doc = self.docs.active_mut();
1536        let toolbar_outcome = self.toolbar.show(
1537            ui,
1538            &mut doc.engine,
1539            Some(&doc.ecad),
1540            self.model_store.as_ref(),
1541            &mut self.settings.open,
1542            &mut self.part_properties.open,
1543            &mut self.info.open,
1544        );
1545        // An inbox row opens its review or change order in the PLM pane.
1546        for event in toolbar_outcome.plm_review {
1547            self.plm.open_review(event);
1548        }
1549        if let Some(action) = toolbar_outcome.file {
1550            self.file
1551                .dispatch(action, &mut self.docs, self.model_store.as_ref());
1552        }
1553        // Submit Bug: begin the screenshot-capture + report flow. `request`
1554        // grabs the current frame (before its dialog exists) and the model, so
1555        // it must run THIS frame while the shot is still dialog-free.
1556        if toolbar_outcome.bug_report {
1557            self.bug_report.request(&ctx, self.docs.engine(), &self.diagnostics);
1558        }
1559        // A workbench toolbar button click surfaces its id here; the dispatch
1560        // itself is a METHOD so the `workbench_button` command runs the very
1561        // same arms (a button an agent can only reach by clicking is a button
1562        // an agent cannot reach when something covers it).
1563        if let Some(id) = toolbar_outcome.workbench_button {
1564            self.dispatch_workbench_button(id);
1565        }
1566        // Zoom-to-fit: the ONE button frames whatever the tile draws — the open
1567        // sheet's paper, or the 3D scene. See `BrepApp::zoom_to_fit`.
1568        if toolbar_outcome.zoom_to_fit {
1569            self.zoom_to_fit();
1570        }
1571
1572        // --- workbench actions toolbar: a second strip directly UNDER the
1573        // primary toolbar (egui stacks top panels in call order) listing the
1574        // active workbench's creatable features + the constraint types. Off via
1575        // the Settings checkbox, and never in sketch / reference-selection mode
1576        // (the panel decides — see `WorkbenchToolbarPanel::visible`). A feature
1577        // click adds exactly what the palette pick would, so ACOMP still routes
1578        // to the component selector; a constraint click is the context bar's
1579        // constraint offer, seeded from the current selection.
1580        // A read-only document (a PLM revision not checked out, or released)
1581        // draws the strip disabled: every button on it creates something.
1582        self.workbench_toolbar.read_only = !self.docs.active().access().is_editable();
1583        let actions = self.workbench_toolbar.show(ui, self.docs.engine());
1584        if let Some(type_code) = actions.feature {
1585            self.history
1586                .add_feature_of_type(self.docs.engine_mut(), &type_code);
1587            // No `show_pane` here: `HistoryPanel::open_form` moved the target and
1588            // the dialog door surfaces History for it (`surface_open_dialogs`
1589            // below). ACOMP opens the component-selector MODAL instead of a
1590            // feature dialog, so it moves no target and surfaces no pane.
1591            if self.history.take_insert_component_request() {
1592                self.file.dispatch(
1593                    FileAction::InsertComponent,
1594                    &mut self.docs,
1595                    self.model_store.as_ref(),
1596                );
1597            }
1598        }
1599        if let Some(type_id) = actions.constraint {
1600            // The strip offers every type regardless of the selection (unlike the
1601            // context bar's gated offers), so a refusal has to be SAID: a toast,
1602            // never a silent no-op. A document with no components has no assembly
1603            // session to add to (the kernel's own message names the session, not
1604            // the cause) — say what is actually missing.
1605            let engine = self.docs.engine_mut();
1606            if !engine.history_has_assembly() {
1607                engine.push_notice(
1608                    "Add constraint: the document has no components — insert a component first"
1609                        .to_string(),
1610                );
1611            } else {
1612                // The add leaves the new constraint OPEN (the kernel mints it
1613                // that way), so the dialog door surfaces the Constraints pane.
1614                if let Err(error) =
1615                    crate::panels::context_bar::add_constraint_from_selection(engine, &type_id)
1616                {
1617                    engine.push_notice(format!("Add constraint: {error}"));
1618                }
1619            }
1620        }
1621        if let Some(type_id) = actions.annotation {
1622            // The context bar's annotation offer, from the strip: seeded from
1623            // the selection, and the new annotation's form opens through the
1624            // dialog door. The strip offers every type whatever is selected, so
1625            // a refusal — no active view to add to — is said, not swallowed.
1626            let engine = self.docs.engine_mut();
1627            if let Err(error) = crate::panels::context_bar::add_pmi_from_selection(engine, &type_id) {
1628                engine.push_notice(format!("Add annotation: {error}"));
1629            }
1630        }
1631
1632        // --- sketch mode has NO strip of its own: its draw tools are workbench
1633        // buttons in the row above (`workbench::sketch`), offered while a sketch
1634        // is being edited, so the 3D viewport is the full-width sketching
1635        // surface with nothing between it and the one toolbar.
1636
1637        // --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
1638        // chosen by context each frame. Drawn AFTER the top bars but BEFORE the
1639        // left panel(s) so it reserves the FULL bottom width and the left column
1640        // stops above it (egui resolves reserved space by call order). It is a
1641        // HOST: the branch below picks what to draw. A NEW context is added by
1642        // extending this branch (e.g. `else if engine.some_mode() { … }`) and
1643        // routing through the owning panel's `show_status_bar` for DRY styling.
1644        // Its top is the floor the toasts stack up from (`Toasts::show`).
1645        //
1646        // Its RIGHT END is the app-wide working indicator (`panels::busy`),
1647        // whatever the context: collected here from every source, drawn first
1648        // in a right-to-left row so the context content gets what is left of
1649        // the width and never runs under it.
1650        self.collect_busy(&ctx);
1651        let status_bar_top = egui::containers::panel::Panel::bottom("brep-status-bar")
1652            .resizable(false)
1653            .min_size(30.0)
1654            .show(ui, |ui| {
1655                ui.add_space(2.0);
1656                // `Align::Min`, not `Center`: the bar's height is not known
1657                // until its content is laid out, and centring against the
1658                // unbounded space below doubled the bar with the indicator on
1659                // a row of its own.
1660                ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
1661                    self.busy.show(ui);
1662                    ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
1663                        if self.docs.engine().sketch_mode() {
1664                            // Sketch context: the status row (title / DOF / N selected /
1665                            // undo-redo / Lock). The selection-filter row is NOT drawn
1666                            // now, so drop its stale hit-rects (the verifier must never
1667                            // click a phantom rect for an off-screen widget).
1668                            self.selection.clear_hits();
1669                            self.sketch.show_status_bar(ui, self.docs.engine_mut());
1670                        } else if crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench).is_some() {
1671                            // An eCAD editor owns the central tile, and a click there
1672                            // picks nothing 3D: the pickable-kinds filter would be a
1673                            // row of dead checkboxes. The strip carries the editor's
1674                            // own line instead: where the pointer is and what is under it.
1675                            self.selection.clear_hits();
1676                            self.viewport.show_ecad_status(ui);
1677                        } else {
1678                            // Modeling context: the selection filter (pickable kinds).
1679                            self.selection.show_status_bar(ui, self.docs.engine_mut());
1680                        }
1681                    });
1682                });
1683                ui.add_space(2.0);
1684            })
1685            .response
1686            .rect
1687            .top();
1688        // Cancel on the working indicator: sent to the document whose run it
1689        // named, which a background tab's run need not be the active one.
1690        if let Some(activity) = self.busy.take_cancel() {
1691            if let Some(doc) = self.docs.iter_mut().find(|doc| Some(doc.id()) == activity.document) {
1692                doc.engine.cancel_run();
1693            }
1694        }
1695
1696        // --- central region: the dock tree, OR (special modes) the bare 3D view
1697        // ---------------------------------------------------------------------
1698        // Normal modeling mode: ONE egui_tiles tree fills the whole remaining
1699        // area between the top toolbar and the bottom status bar. Every side-panel
1700        // section AND the 3D viewport are tiles the user can split / tab / resize /
1701        // drag-rearrange, and the layout persists. Which side panes are visible is
1702        // filtered per-workbench inside the dock (`workbench::panel_visible`).
1703        //
1704        // Sketch mode and reference-selection are "special modes" that take over
1705        // the shell: they BYPASS the tree and draw the viewport directly, so the
1706        // modeling side panes don't appear (sketch's own entity-list panel + the
1707        // top-right mode card own those flows). Drawing the viewport HERE — before
1708        // the top-right overlay below — keeps `viewport.last_rect()` current-frame
1709        // so the overlay anchors to the live 3D-view rect with no lag.
1710        let sketch = self.docs.engine().sketch_mode();
1711        let ref_select = self.docs.engine().ref_select_active();
1712
1713        if sketch {
1714            // Sketch entity lists (Curves / Points / Constraints) + solver
1715            // settings — a dedicated left panel, drawn BEFORE the viewport so it
1716            // reserves the left and the viewport fills the rest.
1717            egui::containers::panel::Panel::left("sketch-entities")
1718                .resizable(true)
1719                .default_size(300.0)
1720                .size_range(200.0..=560.0)
1721                .show(ui, |ui| {
1722                    self.sketch.show_entity_lists(ui, self.docs.engine_mut());
1723                });
1724        }
1725
1726        self.history.sync_palette_display(self.model_store.as_ref());
1727        // The PLM panels (a PLM session only): each PLM document's access from
1728        // its revision, and the revisions the user asked to open.
1729        let plm = self.plm.sync(self.model_store.as_ref(), &mut self.docs, self.file.save_generation());
1730        for name in plm.open {
1731            self.file.open_document(&mut self.docs, self.model_store.as_ref(), &name);
1732        }
1733        // A PLM section asked for a file (Attach a file…, Add file…): the file
1734        // chooser answers, and the file goes back to the section that asked.
1735        if let Some((tag, title)) = plm.pick_file {
1736            if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
1737                self.file.status = error;
1738            }
1739        }
1740        for tag in self.plm.pick_tags() {
1741            if let Some(file) = self.file.take_picked_file(&tag) {
1742                self.plm.deliver_picked(&tag, file);
1743            }
1744        }
1745        if plm.refresh_inbox {
1746            self.toolbar.refresh_inbox();
1747        }
1748        // Save As → "A new part" (D9): the PLM pane's New part form, shown.
1749        if self.file.take_plm_new_part() {
1750            self.plm.open_new_part();
1751            self.show_pane(PaneKind::Plm);
1752        }
1753        if self.plm.busy() {
1754            ui.ctx().request_repaint();
1755        }
1756        // Following other clients (`panels::plm_follow`): when the feed moved,
1757        // the inbox asks again too; and the next question gets a frame.
1758        if self.plm.take_followed() {
1759            self.toolbar.refresh_inbox();
1760        }
1761        if let Some(next) = self.plm.next_follow_in() {
1762            ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(next.max(0.1)));
1763        }
1764        if sketch || ref_select {
1765            // Special mode: the viewport fills the remaining central area; no
1766            // dock, no modeling side panes — and therefore no DOCUMENT TAB
1767            // STRIP either, which is the guard that keeps a live sketch /
1768            // reference-pick session from having its document swapped out from
1769            // under it.
1770            self.viewport.show(ui, self.docs.engine_mut());
1771            DockState::retract_all(DockContext {
1772                docs: &mut self.docs,
1773                viewport: &mut self.viewport,
1774                history: &mut self.history,
1775                bom: &mut self.bom,
1776                assembly_constraints: &mut self.assembly_constraints,
1777                wire_harness: &mut self.wire_harness,
1778                pmi: &mut self.pmi,
1779                sheets: &mut self.sheets,
1780                scene: &mut self.scene,
1781                expressions: &mut self.expressions,
1782                qualify: &mut self.qualify,
1783                family_table: &mut self.family_table,
1784                update_components: &mut self.update_components,
1785                model_store: self.model_store.as_ref(),
1786                plm: &mut self.plm,
1787            });
1788        } else {
1789            // Normal mode: the dock owns the whole central area (the viewport is a
1790            // pane). Cross-panel requests the panels can't act on while their
1791            // borrows are held bubble OUT via the returned outcome — the SAME
1792            // requests the old left-panel closure produced.
1793            let outcome = self.dock.ui(
1794                ui,
1795                DockContext {
1796                    docs: &mut self.docs,
1797                    viewport: &mut self.viewport,
1798                    history: &mut self.history,
1799                    bom: &mut self.bom,
1800                    assembly_constraints: &mut self.assembly_constraints,
1801                    wire_harness: &mut self.wire_harness,
1802                    pmi: &mut self.pmi,
1803                    sheets: &mut self.sheets,
1804                    scene: &mut self.scene,
1805                    expressions: &mut self.expressions,
1806                    qualify: &mut self.qualify,
1807                    family_table: &mut self.family_table,
1808                    update_components: &mut self.update_components,
1809                    model_store: self.model_store.as_ref(),
1810                    plm: &mut self.plm,
1811                },
1812            );
1813
1814            // The ACOMP palette pick must open the COMPONENT SELECTOR, never a
1815            // bare feature dialog — the file dialog is shell-owned.
1816            if outcome.insert_component_requested {
1817                self.file.dispatch(
1818                    FileAction::InsertComponent,
1819                    &mut self.docs,
1820                    self.model_store.as_ref(),
1821                );
1822            }
1823            // The DOCUMENT TAB STRIP inside the viewport tile. Activation is
1824            // immediate; a close routes through the file dialog because a dirty
1825            // document has to be confirmed first, and that prompt lives there.
1826            if let Some(index) = outcome.document_tabs.activate {
1827                self.docs.activate(index);
1828            }
1829            if let Some(index) = outcome.document_tabs.close {
1830                self.file.request_close(&mut self.docs, index);
1831            }
1832            self.document_tab_hits = outcome.document_tabs.hits;
1833            // A structure-tree Edit — or a BOM row's action button, which
1834            // reports through the same outcome field so there is one arm and
1835            // not two — expands its feature in the history tree.
1836            if let Some(focus) = outcome.feature_focus {
1837                self.history.focus_feature(focus);
1838            }
1839            // Structure-tree interaction hooks route through the SAME dispatcher
1840            // as the context bar (one truth per action); document-level flows
1841            // (edit-in-place / open-part) come back as requests the shell runs.
1842            // A BOM row menu's document-level flow: its engine-mutating half
1843            // already ran inside the panel, through the same dispatcher.
1844            match outcome.component_request {
1845                Some(ComponentActionRequest::OpenPart { component_id }) => {
1846                    self.edit_part(&component_id);
1847                }
1848                None => {}
1849            }
1850        }
1851
1852        self.history.sync_palette_display(self.model_store.as_ref());
1853
1854        // --- file dialog: a ctx-level modal (like the command palette), drawn
1855        // after the panels so its backdrop dims the whole shell. Idempotent when
1856        // closed; also polls for a completed async import each frame.
1857        self.file
1858            .show(&ctx, &mut self.docs, self.model_store.as_ref());
1859
1860        // --- crash recovery: the boot prompt, then the debounced autosave -----
1861        // Drawn with the same ctx-level modal treatment as the file dialog. The
1862        // autosave ticks only once the prompt has resolved (or never existed).
1863        if self.recovery.is_open() {
1864            if let Some(resolution) =
1865                self.recovery.show(&ctx, &mut self.docs, self.model_store.as_ref())
1866            {
1867                self.autosave.note_cleared();
1868                if let crate::recovery::Resolution::Restored(count) = resolution {
1869                    self.docs.engine_mut().push_notice_as(NoticeSeverity::Info, format!(
1870                        "Restored {count} unsaved document{}",
1871                        if count == 1 { "" } else { "s" }
1872                    ));
1873                }
1874            }
1875        } else {
1876            self.recovery.clear_hits();
1877            let now = ctx.input(|i| i.time);
1878            if let Some(due) = self.autosave.tick(&self.docs, self.model_store.as_ref(), now) {
1879                // The frame loop idles between inputs; wake it when the write is due.
1880                ctx.request_repaint_after(std::time::Duration::from_secs_f64(due.max(0.05)));
1881            }
1882        }
1883
1884        if let Some((format, name, bytes)) = self.file.take_stl_import() {
1885            self.start_mesh_preview(format, name, bytes);
1886            ctx.request_repaint();
1887        }
1888
1889        // --- Submit Bug: the screenshot-capture state machine + report modal.
1890        // Drawn at ctx level like the file dialog; idempotent while idle. Draws
1891        // NOTHING during capture, so the screenshot it requested never contains
1892        // this dialog.
1893        self.bug_report.show(&ctx, self.docs.engine_mut());
1894
1895        // --- Info: the licences + this session's diagnostics, a floating window
1896        // toggled from the toolbar's info button. Idempotent when closed.
1897        self.info.show(&ctx, &self.diagnostics);
1898
1899        // --- Settings: a floating (movable + resizable) window, toggled from the
1900        // toolbar gear button, drawn at ctx level like Properties so it floats
1901        // over the shell. Idempotent when closed. Replaces the old sidebar section.
1902        self.settings.reconnect_blocker = self.reconnect_blocker();
1903        self.settings
1904            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
1905        // The PLM tab's Connect now: switch this session to the PLM live.
1906        #[cfg(not(target_arch = "wasm32"))]
1907        if let Some(root) = self.settings.take_reconnect_request() {
1908            let outcome = self.reconnect_plm(root);
1909            self.settings.reconnected(outcome);
1910        }
1911
1912        // --- Part Properties: the active document's own BOM attribute record,
1913        // in a floating window beside Settings. The title is passed in because
1914        // the panel takes only the engine, and a user with several tabs open
1915        // must be able to see WHICH part they are annotating.
1916        let part_title = self.docs.active().title();
1917        let part_document = self.docs.active().id();
1918        self.part_properties
1919            .show(&ctx, self.docs.engine_mut(), &part_title, part_document);
1920
1921        // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
1922        // for reference-selection / sketch mode) stacked ABOVE the selection-driven
1923        // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
1924        // top-right so they never overlap, and the context rail uses the SAME
1925        // renderer whether it is showing modeling actions or sketch actions
1926        // (`panels::action_rail`). A modeling create/edit action returns a feature
1927        // id to expand in the history tree.
1928        {
1929            let mut focus: Option<String> = None;
1930            let mut info_targets: Vec<String> = Vec::new();
1931            let mut component_request: Option<ComponentActionRequest> = None;
1932            // Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
1933            // tile), not the window — so it stays glued to the viewport wherever
1934            // docking frames it. The viewport was drawn earlier THIS frame, so its
1935            // rect is current. Before the first draw (`None`) fall back to the
1936            // window's top-right.
1937            let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
1938                .order(egui::Order::Foreground);
1939            overlay = match self.viewport.last_rect() {
1940                Some(rect) => overlay
1941                    .fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
1942                    .pivot(egui::Align2::RIGHT_TOP),
1943                None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
1944            };
1945            overlay
1946                .show(&ctx, |ui| {
1947                    // 0. The family-row preview's card: which row, and Exit.
1948                    self.family_table.preview_card(ui, self.docs.engine_mut());
1949                    // 1. Exit controls for whatever special mode is active.
1950                    self.mode_bar.card(ui, self.docs.engine_mut());
1951                    // 2. Context actions: sketch actions in sketch mode, else the
1952                    // modeling selection actions. Same rail, mode-appropriate items.
1953                    if self.docs.engine().sketch_mode() {
1954                        self.sketch.context_card(ui, self.docs.engine_mut());
1955                    } else {
1956                        let outcome = self.context_bar.card(ui, self.docs.engine_mut());
1957                        focus = outcome.focus;
1958                        info_targets = outcome.info_targets;
1959                        component_request = outcome.component;
1960                    }
1961                });
1962            if let Some(focus) = focus {
1963                self.history.focus_feature(focus);
1964            }
1965            // The Info action returns one target per selected entity — open (or, on
1966            // dedup, keep) a pinned Info window for each. Drawn below.
1967            if !info_targets.is_empty() {
1968                self.info_windows.open_for(&info_targets, self.viewport.last_rect());
1969            }
1970            // Component document-level flows (the engine-mutating component
1971            // actions already ran inside the bar).
1972            match component_request {
1973                Some(ComponentActionRequest::OpenPart { component_id }) => {
1974                    self.edit_part(&component_id);
1975                }
1976                None => {}
1977            }
1978        }
1979
1980        // --- THE DIALOG DOOR: every dynamically generated dialog that opened
1981        // this frame makes its host pane's tab current.
1982        //
1983        // ONE call, placed after everything that can open one — the dock's panes
1984        // and viewport, and the context bar's overlay above — so a form opened
1985        // anywhere is surfaced in the same frame, before `__brepDock` publishes
1986        // the active tab below. It watches the TARGETS rather than the callers
1987        // because most of them set the target inside the engine or inside a
1988        // panel's draw, with no dock in reach; `panels::dock::DialogTargets`
1989        // carries that reasoning and the openers it covers.
1990        let targets = crate::panels::dock::DialogTargets::read(
1991            &self.history,
1992            self.docs.engine(),
1993            self.docs.active_id(),
1994        );
1995        self.dock.surface_opened_dialogs(targets);
1996
1997        // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
1998        // level like the file dialog so they float over the shell. Each is pinned to
1999        // its open-time object name (selection changes never retarget them); closed
2000        // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
2001        // window opened THIS frame paints this frame.
2002        self.info_windows.show(&ctx, self.docs.engine_mut());
2003
2004        // --- interference results window: same floating idiom, owned report;
2005        // its Re-run button re-drives the engine check.
2006        self.interference.show(&ctx, self.docs.engine_mut());
2007
2008        // --- auto-constraints window: the inference scan + its Create button.
2009        self.auto_constraints.show(&ctx, self.docs.engine_mut());
2010        self.step_parts
2011            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2012
2013        // --- transient toasts: drain the engine's queued notices (e.g. a sketch
2014        // solve that failed after an edit) and show each briefly. Drawn last so
2015        // the cards float over the whole shell.
2016        let now = ctx.input(|i| i.time);
2017        // All three sources gathered FIRST, because the automation queue must see
2018        // every one of them: `Toasts::extend` keeps only the last few on screen,
2019        // and a message dropped for space is exactly the one a host wants told.
2020        use brep_render::engine_state::NoticeSeverity;
2021        let mut raised = self.docs.engine_mut().take_graded_notices();
2022        // Same lane for STORAGE failures the store could only discover after its
2023        // synchronous `write` returned `Ok` (the browser backend writes behind an
2024        // in-memory mirror). A save that did not persist must never be silent.
2025        let errors = self.model_store.take_persistence_errors().into_iter().chain(self.autosave.take_errors());
2026        raised.extend(errors.map(|text| (NoticeSeverity::Error, text)));
2027        // An edit a read-only document refused (a PLM revision not checked out,
2028        // or released): its history kept the saved bytes, and this says why.
2029        for doc in self.docs.iter_mut() {
2030            if let Some(text) = doc.take_refused() {
2031                raised.push((NoticeSeverity::Warning, text));
2032            }
2033        }
2034        // The automation channel's `notices` drain: what the user would have SEEN.
2035        // Without this the drain has no producers at all and every script's
2036        // `{"notices": []}` asserts nothing.
2037        #[cfg(feature = "automation")]
2038        for (_, text) in &raised {
2039            self.automation.push_notice(
2040                crate::automation::command::NoticeKind::Toast,
2041                text.clone(),
2042            );
2043        }
2044        self.toasts.extend(raised, now);
2045        // An edit after a card was raised supersedes it (`Toasts::note_document`).
2046        self.toasts
2047            .note_document(self.docs.active_id(), self.docs.engine().history.undo_top(), now);
2048        self.toasts.show(&ctx, self.viewport.last_rect(), status_bar_top);
2049
2050        // The state registry (`automation::registry`): the live app + engine
2051        // state, published by name with a one-line doc so a host can read it
2052        // (and, on wasm, mirrored to `window.__brep*` for the verify scripts).
2053        // Purely additive; no render effect. Published AFTER the panels draw so
2054        // the hit-rects are for THIS frame's layout. Off unless a host enabled it.
2055        if crate::automation::registry::enabled() {
2056            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
2057            let ppp = ui.ctx().pixels_per_point();
2058            crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &self.docs.engine().camera_state_json());
2059            crate::automation::registry::publish("__brepSettings", "render and UI settings", &self.docs.engine().settings_json());
2060            crate::automation::registry::publish("__brepSolidColors", "per-solid colour overrides", &self.docs.engine().solid_color_overrides_json());
2061            crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
2062            crate::automation::registry::publish("__brepGizmo", "transform gizmo state", &self.docs.engine().gizmo_state_json());
2063            crate::automation::registry::publish("__brepFile", "file dialog state (mode, entries, current name)",
2064                &self.file.file_state_json(&self.docs, self.model_store.as_ref()),
2065            );
2066            crate::automation::registry::publish("__brepFileHit", "file dialog widget rects", &self.file.hits_json());
2067            crate::automation::registry::publish("__brepModel", "model signature: solid count, triangle count, per-solid bounds (change detection)", &self.model_signature_json());
2068            crate::automation::registry::publish("__brepReport", "last run report {featureErrors, featureNotes, featureFulfilment, featureRefusals (only when a feature failed with a typed refusal), unresolved, displayErrors, featureTimings, featureOutputs}", &self.docs.engine().history_report_json());
2069            // The in-flight run: whether one is pending, the feature the runner
2070            // says it is executing, and the feature a cancelled run was stuck on.
2071            crate::automation::registry::publish("__brepRun", "in-flight run {pending, progress:{generation,index,total,featureId,featureType}|null, cancelled}",
2072                &serde_json::json!({
2073                    "pending": self.docs.engine().run_pending(),
2074                    "progress": self.docs.engine().run_progress().map(|p| serde_json::json!({
2075                        "generation": p.generation,
2076                        "index": p.index,
2077                        "total": p.total,
2078                        "featureId": p.feature_id,
2079                        "featureType": p.feature_type,
2080                    })),
2081                    "cancelled": self.docs.engine().cancelled_run(),
2082                })
2083                .to_string(),
2084            );
2085            crate::automation::registry::publish("__brepBusy", "the status bar's working indicator {shown, activities:[{kind, label, document, cancellable, elapsed}]} — everything in flight, most important first; shown once work has lasted 0.2 s", &self.busy.state_json());
2086            crate::automation::registry::publish("__brepStatusHit", "status bar widget rects: busy, busy:more, busy:cancel (present only while the working indicator shows)", &self.busy.hits_json());
2087            crate::automation::registry::publish("__brepHit", "history panel widget rects: step:i edit:i del:i box:i add:menu form:* field:* panel:clip", &self.history.hits_json());
2088            crate::automation::registry::publish("__brepPaletteHit", "add-feature palette widget rects in screen points: input top item:type display display:mode (empty while closed)", &self.history.palette_hits_json());
2089            crate::automation::registry::publish("__brepExprHit", "expressions panel widget rects", &self.expressions.hits_json());
2090            crate::automation::registry::publish("__brepExpr", "expressions script, its variables and the configurator",
2091                &serde_json::json!({
2092                    "expressions": self.docs.engine().expressions_json(),
2093                    "variables": serde_json::from_str::<serde_json::Value>(
2094                        &self.docs.engine().expression_variables_json()
2095                    )
2096                    .unwrap_or(serde_json::Value::Null),
2097                    "configurator": serde_json::from_str::<serde_json::Value>(
2098                        &self.docs.engine().configurator_json()
2099                    )
2100                    .unwrap_or(serde_json::Value::Null),
2101                })
2102                .to_string(),
2103            );
2104            crate::automation::registry::publish("__brepToolbar", "primary toolbar rects: file:* undo redo fit projection wireframe show:* properties settings help info bug workbench workbench:item:<id> workbench:btn:<id> (all toolbar controls wrap to the available width; explicit menu entries publish workbench:btn: while open)", &self.toolbar.hits_json());
2105            // The workbench actions strip's button rects (`wbtb:feature:<type>` /
2106            // `wbtb:constraint:<type>` / `wbtb:annotation:<type>`); an empty map
2107            // while the strip is hidden.
2108            crate::automation::registry::publish("__brepWorkbenchToolbar", "workbench actions strip rects: wbtb:* feature:type constraint:type annotation:type (empty while hidden)", &self.workbench_toolbar.hits_json());
2109            // The PLM pane (a PLM session only): its sections' widget rects,
2110            // `plm/section:<id>` and `plm/<section>:<widget>`, and the active
2111            // document's access and revision.
2112            crate::automation::registry::publish("__brepPlmHit", "PLM pane rects: section:<id>, lifecycle:revision:<label>, lifecycle:verb:<check-out|check-in|break-lock|release|new-revision|submit|review|open-change-order>, lifecycle:new-label, lifecycle:history:load, review:<submit:<reviewers|due|note|go>|decide:<comment|approve|reject>|comment:<body|post>|eco:<note|submit|release|item:<rev id>>|close>, attachments:<export:pdf|export:step|reload|file:<id>|download:<id>|staged:<kind|part|revision|note|attach|cancel>>", &self.plm.hits_json());
2113            crate::automation::registry::publish("__brepPlmInbox", "the toolbar's review inbox badge: {count, open, waitingOnMe:[{kind,title,target,revision,key}], submitted:[…], problem}; null until the first answer (and outside a PLM session)", &self.toolbar.inbox().state_json());
2114            crate::automation::registry::publish("__brepPlmReview", "the PLM pane's review section: {subject:{kind:revision,part,revision}|{kind:eco,eco}, state, round:{status,approvals,stale,required,met,live,canDecide}, comments, message:{refused}|{warned}, busy}; null when nothing is open", &self.plm.review_json());
2115            crate::automation::registry::publish("__brepPlmDoc", "the active document on the PLM: {access:{editable, reason}, plmDocument, revision:{id,label,lifecycle,lockedBy,lockedByMe}, busy, message, offered:[verb key], files:{staged:{name,mediaType,kind,size,target}, revision:[name], part:[name], problem, notice, status, busy}}; null with no PLM session", &self.plm.state_json(&self.docs));
2116            // The queued toast texts — the only trace of a refusal the app shows
2117            // as a transient card (e.g. a constraint the strip could not add).
2118            crate::automation::registry::publish("__brepNotices", "queued toast texts", &self.toasts.texts_json());
2119            crate::automation::registry::publish("__brepToasts", "toast cards {cards: [{text, severity (error|warning|info), count, shown, superseded}], shown, folded}", &self.toasts.state_json());
2120            crate::automation::registry::publish("__brepToastsHit", "toast card rects (toast:<i> top to bottom, toast:more)", &self.toasts.hits_json());
2121            crate::automation::registry::publish("__brepBug", "bug report panel state", &self.bug_report.state_json());
2122            crate::automation::registry::publish("__brepDiagnostics", "what this session is running on: renderer, adapter, texture ceiling, version, platform", &self.diagnostics.json().to_string());
2123            // Where the last ~120 frames' milliseconds went. `__brepDiagnostics`
2124            // says WHICH renderer is drawing; this says what it costs, in the
2125            // same split the Info window's Performance rows show.
2126            crate::automation::registry::publish("__brepPerf", "rolling per-frame timings in ms {frames, window, dt, ui, publish, sync, fit, overlays, draw} each {avg,p95,max}", &crate::perf::json());
2127            crate::automation::registry::publish("__brepBugHit", "bug report panel widget rects", &self.bug_report.hits_json());
2128            // The wire harness: the document's connections + the last run's
2129            // routing report (engine truth), and the panel's widget rects.
2130            crate::automation::registry::publish("__brepWireHarness", "wire harness connections and the last routing report", &self.docs.engine().wire_harness_state_json());
2131            crate::automation::registry::publish("__brepWireHarnessHit", "wire harness panel widget rects", &self.wire_harness.hits_json());
2132            // The part's declared connection points (the `ports` block) and the
2133            // ports tail's report, beside the Qualify panel's own selection and
2134            // its rects (`qualify:*`).
2135            crate::automation::registry::publish("__brepPorts", "the part's declared connection points (the `ports` block) and the ports tail's report of the last run", &self.docs.engine().ports_state_json());
2136            crate::automation::registry::publish("__brepQualify", "Qualify panel state {selected: the connection-point address it has selected}", &self.qualify.state_json());
2137            crate::automation::registry::publish("__brepQualifyHit", "Qualify panel widget rects (qualify:*)", &self.qualify.hits_json());
2138            // The family table as the Family table pane last read it, its
2139            // problems, selection and the last Generate report; and its rects.
2140            crate::automation::registry::publish("__brepFamily", "Family table pane state {table, expressions, notColumns, selected, editing, problems, columnProblems, values, generateCalls, report, status, preview: {row, partNumber, applied, showing, message, featureErrors} or null, runsReplied}", &self.family_table.state_json());
2141            crate::automation::registry::publish("__brepFamilyHit", "Family table pane widget rects (family:*)", &self.family_table.hits_json());
2142            // The PMI block + report + active view / open annotation, and the
2143            // PMI panel's rects (`pmi:capture`, `pmi:add`, `pmi:add:<type>`,
2144            // `pmi:row:<id>`, `pmi:cell:<id>:<column>`, `pmi:menu:<id>`, the
2145            // form's `pmi:` keys).
2146            crate::automation::registry::publish("__brepPmi", "PMI block, report, active view, open annotation or view dialog, and the selected annotation or view", &self.docs.engine().pmi_state_json());
2147            crate::automation::registry::publish("__brepPmiHit", "PMI panel widget rects (pmi:*)", &self.pmi.hits_json());
2148            // The `sheets` block, which sheet the viewport draws, and the OPEN
2149            // sheet's whole projection — the paper, the visible edge runs and
2150            // the annotations that passed the parallel rule, in millimetres.
2151            // The panel's rects (`sheets:add`, `sheets:place:<view>`,
2152            // `sheets:row:<id>`, the form's `sheets:` keys) ride beside it.
2153            crate::automation::registry::publish("__brepSheets", "drawing sheets, the open sheet and its projection, the open and the selected sheet object", &self.docs.engine_mut().sheet_state_json());
2154            crate::automation::registry::publish("__brepSheetsHit", "Sheets panel widget rects (sheets:*)", &self.sheets.hits_json());
2155            // The workbench logical state (resolved current id + available ids) so
2156            // the verifier can drive the dropdown and confirm the active workbench.
2157            // Hit-rects for the dropdown ride in `__brepToolbar` (self.toolbar.hits).
2158            crate::automation::registry::publish("__brepWorkbench", "active workbench id and the available ids",
2159                &crate::workbench::workbench_state_json(&self.docs.engine().settings.workbench),
2160            );
2161            crate::automation::registry::publish("__brepSelection", "selection {solids, faces, edges, datums, vertices}; the name arrays are in pick order, oldest first", &self.docs.engine().selection_json());
2162            crate::automation::registry::publish("__brepInfoWindows", "open info windows (mass properties, topology, metadata)",
2163                &self.info_windows.published_json(self.docs.engine_mut()),
2164            );
2165            crate::automation::registry::publish("__brepInfoWindowsHit", "info window widget rects", &self.info_windows.hits_json());
2166            crate::automation::registry::publish("__brepInterference", "interference check state", &self.interference.state_json());
2167            crate::automation::registry::publish("__brepInterferenceHit", "interference panel widget rects", &self.interference.hits_json());
2168            crate::automation::registry::publish("__brepAutoConstraints", "auto-constraint inference state", &self.auto_constraints.state_json());
2169            crate::automation::registry::publish("__brepAutoConstraintsHit", "auto-constraints widget rects", &self.auto_constraints.hits_json());
2170            crate::automation::registry::publish("__brepStepParts", "STEP parts library panel state", &self.step_parts.state_json());
2171            crate::automation::registry::publish("__brepStepPartsHit", "STEP parts library widget rects", &self.step_parts.hits_json());
2172            crate::automation::registry::publish("__brepSelectionFilter", "which entity kinds a viewport click may pick", &self.docs.engine().selection_filter_json());
2173            crate::automation::registry::publish("__brepSelectionHit", "selection bar widget rects (filter:kind, clear, hide)", &self.selection.hits_json());
2174            crate::automation::registry::publish("__brepContext", "context action bar state (offers for the selection)", &self.context_bar.state_json());
2175            crate::automation::registry::publish("__brepContextHit", "context action bar widget rects", &self.context_bar.hits_json());
2176            crate::automation::registry::publish("__brepModeBarHit", "mode bar widget rects (refsel:*, Sketch:*)", &self.mode_bar.hits_json());
2177            // The DOCUMENT TABS: the open models, which one is active, and the
2178            // strip's per-tab hit-rects, so an e2e script can switch and close
2179            // documents the way a user does.
2180            crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
2181            crate::automation::registry::publish(
2182                "__brepDocumentsHit",
2183                "document tab strip widget rects",
2184                &crate::automation::hit_rects::hits_json(
2185                    self.document_tab_hits.iter().map(|(key, rect)| (key, rect)),
2186                ),
2187            );
2188            // The boot-time recovery prompt: its entries and its two buttons.
2189            crate::automation::registry::publish("__brepRecovery", "boot-time recovery prompt entries", &self.recovery.state_json());
2190            crate::automation::registry::publish("__brepRecoveryHit", "recovery prompt widget rects", &self.recovery.hits_json());
2191            crate::automation::registry::publish("__brepComponentMove", "assembly component move state", &self.docs.engine().component_move_json());
2192            crate::automation::registry::publish("__brepSketch", "sketch mode state (session, tool, selection, constraints)", &self.sketch.published_json(self.docs.engine()));
2193            crate::automation::registry::publish("__brepSketchHit", "sketch-mode overlay widget rects: the SKETCH ACTIONS card (constraint:glyph fix construction cleanup delete) \u{2014} empty out of sketch mode. The draw tools are NOT here: they are workbench buttons in the toolbar row (toolbar/workbench:btn:sketch.tool.* and sketch.autoconstrain)", &self.sketch.hits_json(self.docs.engine()));
2194            crate::automation::registry::publish("__brepSketchListHit", "sketch entity-list widget rects: section:* geometry:id point:id constraint:id del:kind:id panel:clip (empty out of sketch mode)", &self.sketch.list_hits_json(self.docs.engine()));
2195            crate::automation::registry::publish("__brepWireframe", "wireframe toggle", &format!("{}", self.docs.engine().settings.wireframe));
2196            crate::automation::registry::publish("__brepRefSelect", "reference-selection picker {active, prompt, names}",
2197                &serde_json::json!({
2198                    "active": self.docs.engine().ref_select_active(),
2199                    "prompt": self.docs.engine().ref_select_prompt(),
2200                    "names": self.docs.engine().ref_select_names(),
2201                })
2202                .to_string(),
2203            );
2204            // Viewport origin + projected probe points (viewport-local logical
2205            // px) so the verifier can click precise spots ON the Box and ON the
2206            // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
2207            // pin; indices 1..4 are points on the Pin's cylindrical stub that
2208            // protrudes above the Box top (y=20), on the camera-facing sides — the
2209            // verifier tries them until one picks "Pin".
2210            crate::automation::registry::publish("__brepView", "the 3D viewport rect {x,y,w,h} in egui points", &self.viewport.viewport_rect_json());
2211            // Dock layout snapshot (per-pane visible / rendered) so the verifier
2212            // can see which side panels are on-screen and, once a user tabs panels
2213            // together, activate the right tab before asserting on its widgets.
2214            // `active=false` in sketch / ref-select (the dock is bypassed).
2215            crate::automation::registry::publish("__brepDock", "dock layout in tab-strip order: per-pane visible/activeTab/rendered", &self.dock.state_json(!sketch && !ref_select));
2216            crate::automation::registry::publish("__brepProbe", "projected seed-model probe points (viewport-local px) for the verifier",
2217                &self
2218                    .docs
2219                    .engine()
2220                    .world_to_screen_json(
2221                        "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
2222                          [10.0,22.5,16.0],[10.0,25.0,10.0]]",
2223                    )
2224                    .unwrap_or_else(|_| "[]".to_string()),
2225            );
2226            crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{ppp}"));
2227            crate::automation::registry::publish("__brepStep", "the rolled-to feature index", &format!("{}", self.docs.engine().history_rollback()));
2228            crate::automation::registry::publish("__brepParams", "inputParams of the rolled-to feature", self.params_blob());
2229        }
2230
2231        // Phase 3 of the automation frame (§4.4): reads answer from THIS frame's
2232        // registry and layout.
2233        #[cfg(feature = "automation")]
2234        self.drain_reads(&ctx);
2235
2236        // NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
2237        // earlier this frame (normal mode) or drawn directly in the sketch /
2238        // ref-select branch above. Drawing it before the top-right overlay is what
2239        // keeps that overlay anchored to the live viewport rect.
2240    }
2241}
2242
2243
2244/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
2245
2246/// The seed model handed to the engine at startup: a 3-feature history so the
2247/// tree / roll / edit are real —
2248///   0. `P.CU` "Box"  — a 20 mm cube at the origin (spans `[0,20]³`).
2249///   1. `P.CY` "Pin"  — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
2250///      cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
2251///   2. `B`    "Cut"  — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
2252///      with a cylindrical through-hole (the ref-select field is visible for the
2253///      next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
2254///
2255/// This is just the INITIAL document — once handed to `EngineState`, the engine
2256/// OWNS the mutable history; the app keeps no copy.
2257pub(crate) fn seed_history_json() -> String {
2258    serde_json::json!({
2259        "expressions": "",
2260        "configurator": {},
2261        "features": [
2262            {
2263                "type": "P.CU",
2264                "inputParams": {
2265                    "id": "Box",
2266                    "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
2267                    "transform": {
2268                        "position": [0.0, 0.0, 0.0],
2269                        "rotationEuler": [0.0, 0.0, 0.0],
2270                        "scale": [1.0, 1.0, 1.0]
2271                    },
2272                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
2273                },
2274                "persistentData": {}
2275            },
2276            {
2277                "type": "P.CY",
2278                "inputParams": {
2279                    "id": "Pin",
2280                    "radius": 6.0, "height": 30.0,
2281                    "transform": {
2282                        "position": [10.0, -5.0, 10.0],
2283                        "rotationEuler": [0.0, 0.0, 0.0],
2284                        "scale": [1.0, 1.0, 1.0]
2285                    },
2286                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
2287                },
2288                "persistentData": {}
2289            },
2290            {
2291                "type": "B",
2292                "inputParams": {
2293                    "id": "Cut",
2294                    "targetSolid": "Box",
2295                    "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
2296                },
2297                "persistentData": {}
2298            }
2299        ]
2300    })
2301    .to_string()
2302}