Skip to main content

brep_app/
app.rs

1//! `BrepApp` — the THIN shell of the engine-native UI.
2//!
3//! One eframe [`App`](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::sketch3d::Sketch3dPanel;
38use crate::panels::part_properties::PartPropertiesPanel;
39use crate::panels::settings::SettingsPanel;
40use crate::panels::toasts::Toasts;
41use crate::panels::toolbar::ToolbarPanel;
42use crate::panels::workbench_toolbar::WorkbenchToolbarPanel;
43use crate::panels::update_components::UpdateComponents;
44use crate::panels::bom::BomPanel;
45use crate::panels::dock::{DockContext, DockState, PaneKind};
46use crate::panels::document_tabs;
47use crate::store::{default_model_store, ModelStore, SETTINGS_KEY};
48use crate::viewport::Viewport;
49use brep_render::engine_state::EngineState;
50use brep_render::style::ThemeMode;
51use eframe::egui;
52
53/// A measured camera spin: an orbit drag the app steps ITSELF, one pointer move
54/// per frame, from `perf_spin`. See `BrepApp::spin` for why it is not driven
55/// from outside.
56///
57/// The steps run through the same `EngineState::pointer_down/move/up` the
58/// viewport's real drag uses (see `viewport::interaction`), so a spin costs
59/// exactly what dragging the mouse costs — no shortcut past the controls.
60#[cfg(feature = "automation")]
61pub struct Spin {
62    /// Pointer steps still to make; the drag ends (pointer_up) on the last one.
63    pub left: u32,
64    /// Where the drag started, in viewport-local logical px.
65    pub origin: (f64, f64),
66    /// Pointer step, in viewport-local logical px per frame.
67    pub step: (f64, f64),
68    /// Steps already made — the drag is a straight sweep from `origin`, and
69    /// reversing at the halfway mark keeps it inside the viewport however many
70    /// frames are asked for.
71    pub made: u32,
72    /// False until the first frame has pressed the button.
73    pub pressed: bool,
74}
75
76pub struct BrepApp {
77    /// Every OPEN MODEL and which one is active. Each document owns a full
78    /// `EngineState` (the shared viewer brain `desktop.rs` / the wasm shell
79    /// wrap) plus its file identity; panels borrow the active one.
80    pub(crate) docs: Documents,
81    /// The central 3D viewport: engine render core + offscreen texture + blit +
82    /// input routing.
83    pub(crate) viewport: Viewport,
84    /// The single persistence seam for settings, layout, and model documents
85    /// (native filesystem / wasm IndexedDB + download/upload).
86    pub(crate) model_store: Box<dyn ModelStore>,
87
88    // --- one small state value per panel --------------------------------------
89    /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
90    /// and the File-actions seam (owned by the concurrent file panel).
91    pub(crate) toolbar: ToolbarPanel,
92    /// New / Open / Save / Save As of the model document (the `.nbrep`
93    /// recipe) — a reusable modal file dialog opened from the toolbar.
94    file: FileDialog,
95    stl_import: Option<crate::panels::stl_import::StlImportPreview>,
96    /// The "Submit Bug" flow: on the toolbar bug button it screenshots the app
97    /// (UI + 3D model) BEFORE its own dialog opens, then collects a description
98    /// (+ optional email) and POSTs the model + screenshot to the public reports
99    /// endpoint. Native + wasm, one path.
100    bug_report: BugReportPanel,
101    /// The Info window: the licences and this session's diagnostics, toggled
102    /// from the toolbar's info button.
103    info: crate::panels::info::InfoPanel,
104    /// What this session is running on — captured ONCE, from the adapter eframe
105    /// actually gave us (never re-probed), and read by BOTH the Info window and
106    /// the problem report so the two cannot disagree. See
107    /// [`crate::diagnostics`].
108    diagnostics: crate::diagnostics::Diagnostics,
109    /// Workbench actions toolbar: the second top strip under the primary
110    /// toolbar — one button per feature the active workbench offers, plus the
111    /// constraint types where the Constraints panel is shown. Gated by the
112    /// `showWorkbenchToolbar` setting and hidden in the special modes. A click
113    /// flows out as the type to add; the shell adds it through the SAME paths
114    /// the palette / context bar use.
115    workbench_toolbar: WorkbenchToolbarPanel,
116    /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
117    /// (movable + resizable, toggled from the toolbar gear button), no longer a
118    /// left-panel section.
119    settings: SettingsPanel,
120    pub(crate) plugins: crate::plugins::PluginsPanel,
121    pub(crate) javascript: crate::javascript::JavaScriptEditor,
122    /// The active document's OWN BOM attributes (Part Number, Material, Mass,
123    /// …) as a floating window, toggled from the toolbar's Properties button.
124    /// Document-level, not selection-level: entity inspection is the context
125    /// bar's `info_windows`.
126    part_properties: PartPropertiesPanel,
127    /// The Replace component chooser + result summary.
128    replace_dialog: crate::panels::replace_component::ReplaceDialog,
129    /// History feature-tree + schema-driven feature dialog panel (Phase 2).
130    /// NOTE: the editable history is NOT owned here — it lives in the engine core
131    /// (`EngineState.history`), the single source of truth; this panel only reads
132    /// it back to draw and calls the engine's `history_*` methods to mutate.
133    history: HistoryPanel,
134    /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
135    /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
136    /// engine scene/emphasis; owns only transient expand + hit state.
137    scene: ScenePanel,
138    /// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
139    /// the scene's component records — per-instance fixed/visibility/status
140    /// adornments, actions routed to the owning ACOMP feature.
141    /// The BOM panel (claimed by the Assembly workbench): the parts list on
142    /// the shared column-tree widget, with the editable part/occurrence
143    /// attribute columns the Settings "Assemblies" section configures.
144    bom: BomPanel,
145    /// Assembly Constraints panel (claimed by the Assembly workbench): the
146    /// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
147    assembly_constraints: AssemblyConstraintsPanel,
148    /// The wire-harness connection list (a Wire harness workbench pane): add /
149    /// edit / remove wires, read their routed length + status, hover to
150    /// highlight. Document data lives in the engine; this holds the widget's
151    /// transient state.
152    wire_harness: WireHarnessPanel,
153    /// The PMI view tree + annotation forms (the PMI workbench's pane).
154    pmi: crate::panels::pmi::PmiPanel,
155    /// The CAM browser tree (the CAM workbench's pane): setups and their
156    /// operations, NC programs and the tool library over the document's
157    /// `cam` block.
158    pub(crate) cam: crate::panels::cam::CamPanel,
159    pub(crate) instructions: crate::panels::instructions::InstructionsPanel,
160    pub(crate) instructions_player: crate::panels::instructions_player::InstructionsPlayerPanel,
161    /// The Simulation workbench's pane: the studies and the active timeline.
162    pub(crate) simulation: crate::panels::simulation::SimulationPanel,
163    /// The drawing sheets + their forms (the Drawing workbench's pane).
164    sheets: crate::panels::sheets::SheetsPanel,
165    /// Update-components checker: compares each parts-library entry's
166    /// `sourceSignature` against the model store's current content. Kept
167    /// current once per frame (cheap generation key: applied run + store
168    /// save); the constraints header reads the count + runs the batch refresh,
169    /// the structure tree reads per-part badges.
170    update_components: UpdateComponents,
171    /// Expressions / parameters panel: the variable sheet (engine-owned history
172    /// `expressions`) feature params reference. Owns only its editor buffer.
173    expressions: ExpressionsPanel,
174    /// Qualify: the part's declared CONNECTION POINTS, on whichever surface is
175    /// on screen (symbol, pads or 3D). Holds only the selected address; the
176    /// block and both consistency reports come from the engine.
177    qualify: crate::panels::qualify::QualifyPanel,
178    /// The family table editor: a `.fbrep` family seed's member table. Holds
179    /// the selection, the cell being typed and the last Generate report; the
180    /// table itself is the document's `familyTable` block.
181    family_table: crate::panels::family_table_editor::FamilyTableEditor,
182    /// Every PLM panel (`panels::plm_host`): empty in a file-based session.
183    pub(crate) plm: crate::panels::plm_host::PlmHost,
184    /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
185    /// selection-driven context bar's Info action. Each floating (movable +
186    /// resizable) window is PINNED to one object name at open time — a Metadata
187    /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
188    /// and keeps showing that entity regardless of later selection changes. Replaces
189    /// the old single Properties window.
190    info_windows: InfoWindows,
191    /// Interference results window: opened by the Assembly workbench's `∩`
192    /// toolbar button, which runs the engine's pairwise-intersect check; a
193    /// floating window like the Info windows with a row per interfering pair
194    /// (click = select both components), a green all-clear pass line, and a
195    /// Re-run button.
196    interference: crate::panels::interference::InterferenceWindow,
197    /// Auto Constraints window (Assembly workbench): opened by the `⚿` toolbar
198    /// button, it lists the constraint types the kernel's inference lane can
199    /// read out of the components' current placement, with what a scan found
200    /// for each, and creates the whole accepted set as one undo step.
201    auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow,
202    /// step.parts online model library browser (Assembly workbench): a ctx-level
203    /// window (opened by the library toolbar button) that searches the public
204    /// step.parts v1 API, shows results with thumbnails, and imports a chosen
205    /// STEP model as a new part document + adds it to the assembly as an ACOMP.
206    step_parts: crate::panels::step_parts::StepPartsPanel,
207    /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
208    /// click may select — honored by the engine's `select_top_at`). The filter +
209    /// selection live in `EngineState`; this panel only reads/writes them.
210    selection: SelectionPanel,
211    /// Context action toolbar: the selection-driven action bar (Clear / Hide /
212    /// Edit-owning-feature + the feature-from-selection actions whose primary
213    /// reference accepts the selected kind). Shown only while something is
214    /// selected; drives the engine directly and returns a feature id for the shell
215    /// to expand in the history tree.
216    context_bar: ContextBarPanel,
217    /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
218    /// + circle to the `set_overlay` channel colored by solver mobility, and shows
219    /// the DOF status readout. The engine-native sketcher's foundation surface.
220    sketch: SketchPanel,
221    sketch3d: Sketch3dPanel,
222    /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
223    /// corner — a card-based reference pick, sketch mode, and any future
224    /// special mode. A form field's pick draws no card: its field is the UI.
225    mode_bar: ModeBar,
226    pick_bar: crate::panels::pick_bar::PickBar,
227    /// Transient toast overlay: drains the engine's queued notices each frame
228    /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
229    pub(crate) toasts: Toasts,
230    /// The status bar's app-wide working indicator (`panels::busy`): what is
231    /// in flight, collected from every source each frame.
232    busy: crate::panels::busy::BusyIndicator,
233    /// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
234    /// tree that hosts every side-panel section AND the 3D viewport as tiles the
235    /// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
236    /// each panel + the engine per frame through [`DockContext`]. Drawn in normal
237    /// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
238    dock: DockState,
239
240    /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
241    /// under a background runner (native thread / wasm worker), so the boot
242    /// `zoom_to_fit` can run before the first solids exist → an unframed first
243    /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
244    /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
245    /// (tests) the scene is already populated, so this fires on the very first frame.
246    first_run_framed: bool,
247
248    /// A model fetch kicked off at boot from a `?loadModel=<url>` query param
249    /// (wasm only). When the fetch lands it REPLACES the seed model. `None` on
250    /// native and once applied.
251    pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
252
253    /// The PLM tab's Connect now, on its way: the sign-in and index load run on
254    /// the frame loop's executor and the frame switches stores when they end.
255    #[cfg(not(target_arch = "wasm32"))]
256    reconnecting: Option<crate::store::PendingBoot>,
257
258    /// "Open in CAD" from the PLM's page: the `?open=part/…/rev/…` this page
259    /// was started with (wasm only), driven once a frame until it settles.
260    plm_launch: Option<crate::panels::plm_launch::Launch>,
261
262    /// The document handle the shared panels were last reset for. Compared to
263    /// `docs.view_id()` at the top of every frame: ONE check catches a switch
264    /// from any source (a tab click, a close, New, Open, Edit Part, a Work
265    /// part entered or left in place) instead of a hook per call site, and it
266    /// runs BEFORE any panel draws this frame.
267    active_document: u64,
268    /// The TAB the panels were last drawn for. A Work part entered or left in
269    /// place changes the view but not the tab, and the BOM — the assembly's
270    /// product structure in both — keeps its packed/expanded state.
271    active_tab: u64,
272
273    /// Whether the active workbench showed the Sheets pane LAST frame — the
274    /// edge the paper is closed on. Closing is a transition, not a condition:
275    /// a sheet opened while another workbench is active (a command, a script)
276    /// stays open until the Drawing workbench is LEFT, exactly as PMI's view
277    /// deactivates on leaving rather than on every frame outside it.
278    sheets_pane_shown: bool,
279
280    /// The DOCUMENT TAB STRIP's per-tab hit-rects from the last dock frame,
281    /// published for the headed verifier. The strip is drawn inside the dock's
282    /// viewport pane, so its rects have to ride back out through the outcome.
283    document_tab_hits: Vec<(String, egui::Rect)>,
284
285    /// The `__brepParams` blob and the document state it was built from:
286    /// `(document, rolled-to step, history revision, json)`.
287    ///
288    /// The blob is the rolled-to feature's whole `inputParams`, and for an
289    /// `IMPORT3D` those params carry the embedded model text — 681 KB on the
290    /// heaviest corpus model. It was rebuilt EVERY frame, which on the browser
291    /// build (where the state registry is always on) also transcoded it into a
292    /// fresh JS string every frame, on the one thread that also has to answer
293    /// the pointer. Nothing reads it per frame; it only has to be correct when
294    /// someone asks. The revision is a mutation counter the document hands out
295    /// (`EngineState::history_revision`), so this cannot go stale without the
296    /// borrow checker having let a mutation past the door that bumps it.
297    params_blob: (u64, usize, u64, String),
298    /// Plain-state blobs rebuilt only when their whole input changed (see
299    /// [`StateBlobCache`]).
300    state_blob_cache: StateBlobCache,
301
302    /// A measured camera spin in flight (`perf_spin`): the orbit drag the app
303    /// drives ITSELF, one pointer step per frame, so the frames run back to back.
304    /// Driving it from a host instead would put a round trip between every pair
305    /// of frames, and `dt` — the frame period, the number a user feels as a
306    /// freeze — would measure the host's latency rather than the app's.
307    #[cfg(feature = "automation")]
308    spin: Option<Spin>,
309
310    /// The debounced autosave of every dirty document (`crate::recovery`), and
311    /// the boot-time **Recover unsaved work?** prompt its blob feeds. The
312    /// autosave is held while the prompt is open: the clean seed tab would
313    /// otherwise remove the very blob being offered.
314    autosave: crate::recovery::Autosave,
315    /// Closing (the window's close button, or `app_close`): when it was asked
316    /// (egui clock). The close waits until the store's writes have landed.
317    closing: Option<f64>,
318    /// A close the automation layer asked for, taken by the next frame.
319    pub(crate) close_asked: bool,
320    /// The app let the window close: nothing was left on its way.
321    closed: bool,
322    /// Where a close stands once the store's writes have settled: `0` not
323    /// yet, `1` one frame given to the failure drain (a write that failed
324    /// makes its tab unsaved), `2` the recovery blob written again.
325    close_stage: u8,
326    recovery: crate::recovery::RecoveryPanel,
327    /// The desktop update check and its notice. Inert until the desktop
328    /// binary's `main` starts it, so a harness or a test never checks.
329    #[cfg(not(target_arch = "wasm32"))]
330    updater: crate::update::Updater,
331    /// The reports this user submitted, checked for resolution
332    /// (`crate::report_followup`). Inert until the entry point starts it.
333    report_followup: crate::report_followup::ReportFollowup,
334
335    /// The UI zoom scale CURRENTLY applied to the egui context. Tracks
336    /// `settings.ui_scale` but is only synced to it while the pointer is up, so
337    /// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
338    /// the cursor mid-drag — the settled value is committed on release. See the
339    /// zoom-apply block in `ui`.
340    applied_ui_scale: f32,
341    /// The automation channel (hosts submit commands; the frame drains them at
342    /// three fixed points — see `automation::queue`).
343    #[cfg(feature = "automation")]
344    pub(crate) automation: std::sync::Arc<crate::automation::queue::AutomationQueue>,
345}
346
347/// The caption text size, points (egui's own `Small` is 9). See `app_style`.
348pub const SMALL_TEXT_SIZE: f32 = 10.5;
349
350/// The app's tweaks over egui's style, applied to both themes at startup.
351pub(crate) fn app_style(style: &mut egui::Style) {
352    // Keep overflow discoverable without hovering or scrolling first, in
353    // both themes and every child UI (including eCAD and plugin panels).
354    // ScrollArea's default VisibleWhenNeeded still hides bars that aren't needed.
355    style.spacing.scroll = egui::style::ScrollStyle::solid();
356    // Captions (`ui.small`, `RichText::small`): egui's 9 pt was too small to
357    // read beside 12.5 pt body text in the Info window, the eCAD inspectors and
358    // the Selection actions summary (lane U audit item 9). 10.5 keeps them
359    // visibly secondary. The toolbar/Ribbon group captions stay at 9
360    // (`ribbon::CHROME_CAPTION_SIZE`) so the chrome keeps its height.
361    style.text_styles.insert(egui::TextStyle::Small, egui::FontId::new(SMALL_TEXT_SIZE, egui::FontFamily::Proportional));
362}
363
364/// Land a `?loadModel=<url>` fetch (`result`: the fetched text, or why the
365/// fetch failed) on the active document.
366///
367/// The model REPLACES the active document in place rather than adding a tab.
368///
369/// A fetch that fails, or a document the engine rejects (it does not parse,
370/// or the history is locked), is shown to the user as an error toast with
371/// the reason; it used to be dropped silently, leaving the seed cube on
372/// screen as if it were the linked model. The untouched boot seed (clean
373/// and unnamed) is then emptied, so nothing on screen passes for the model
374/// that did not open. A named document (a file the user opened while the
375/// fetch was pending) or one with unsaved edits is left as it is: emptying
376/// a named one would let its next Save overwrite the user's file.
377/// A document that loads with some features in error is NOT a failure: it
378/// opens with those features marked in the tree.
379pub(crate) fn apply_linked_model(docs: &mut crate::document::Documents, result: Result<String, String>) {
380    // Read before the load: a rejected document can already have reset parts
381    // of the engine (its metadata, its workbench) on the way to the refusal.
382    let untouched_seed = !docs.active().is_dirty() && docs.active().name().is_none();
383    let failure = match result {
384        // Text that is not JSON at all (an HTML page served 200, a truncated
385        // body) is refused before the engine sees it: `set_history_json`
386        // resets per-document state before its own parse fails.
387        Ok(json) => match serde_json::from_str::<serde::de::IgnoredAny>(&json) {
388            Err(error) => format!("the document could not be opened: history parse: {error}"),
389            Ok(_) => match docs.engine_mut().load_model_and_fit(&json) {
390                Ok(_) => {
391                    docs.active_mut().mark_clean();
392                    return;
393                }
394                Err(error) => format!("the document could not be opened: {error}"),
395            },
396        },
397        Err(error) => format!("the download failed: {error}"),
398    };
399    docs.engine_mut().push_notice(format!("Could not open the linked model: {failure}"));
400    if untouched_seed {
401        if let Err(error) = docs.engine_mut().set_history_json(crate::document::EMPTY_DOCUMENT) {
402            log::error!("Clearing the seed after a failed linked model: {error}");
403        }
404        docs.active_mut().mark_clean();
405    }
406}
407
408
409
410/// How long a close waits for writes still on their way to the store (a PLM
411/// server that stopped answering must not keep the app open forever).
412const CLOSE_WAIT_SECS: f64 = 30.0;
413
414impl BrepApp {
415    /// Closing the window. What is unsaved is written for recovery at once
416    /// (the next start offers it, as after a crash), and the window stays open
417    /// until every write the store issued (a Save, that recovery blob) has
418    /// landed: a write-behind store would otherwise lose a Save made just
419    /// before. After [`CLOSE_WAIT_SECS`] it closes anyway.
420    fn close_when_written(&mut self, ctx: &egui::Context) {
421        let asked = ctx.input(|i| i.viewport().close_requested()) || std::mem::take(&mut self.close_asked);
422        if self.closed {
423            return;
424        }
425        let now = ctx.input(|i| i.time);
426        if asked && self.closing.is_none() {
427            if !self.recovery.is_open() {
428                self.autosave.flush(&self.docs, self.model_store.as_ref());
429            }
430            self.closing = Some(now);
431        }
432        let Some(since) = self.closing else { return };
433        let pending = self.model_store.pending_writes();
434        let timed_out = now - since > CLOSE_WAIT_SECS;
435        if pending == 0 || timed_out {
436            match self.close_stage {
437                // The writes settled. A failed one (the server down) makes its
438                // tab unsaved when the failures are drained, later in this
439                // frame: give that a frame…
440                0 => self.close_stage = 1,
441                // …then write the recovery blob again, so a Save that never
442                // reached the server is offered at the next start.
443                1 => {
444                    if !self.recovery.is_open() {
445                        self.autosave.flush(&self.docs, self.model_store.as_ref());
446                    }
447                    self.close_stage = 2;
448                }
449                _ if self.model_store.pending_writes() == 0 || timed_out => {
450                    self.closed = true;
451                    ctx.send_viewport_cmd(egui::ViewportCommand::Close);
452                    return;
453                }
454                _ => {}
455            }
456            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
457            ctx.request_repaint();
458            return;
459        }
460        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
461        egui::Area::new(egui::Id::new("brep-closing"))
462            .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
463            .order(egui::Order::Foreground)
464            .show(ctx, |ui| {
465                egui::Frame::popup(ui.style()).show(ui, |ui| {
466                    ui.horizontal(|ui| {
467                        ui.spinner();
468                        ui.label(format!("Finishing {pending} save(s) before closing…"));
469                    });
470                });
471            });
472        ctx.request_repaint_after(std::time::Duration::from_millis(50));
473    }
474
475    /// The close state, for automation (`__brepClosing`).
476    pub(crate) fn closing_json(&self) -> String {
477        serde_json::json!({
478            "closing": self.closing.is_some(),
479            "closed": self.closed,
480            "pending": self.model_store.pending_writes(),
481        })
482        .to_string()
483    }
484
485    /// The automation queue a host submits commands to.
486    #[cfg(feature = "automation")]
487    pub fn automation(&self) -> &std::sync::Arc<crate::automation::queue::AutomationQueue> {
488        &self.automation
489    }
490
491    /// Open a file named on the command line — `brep-app model.nbrep`, which
492    /// is also what a desktop file association hands us (`Exec=… %f`).
493    ///
494    /// Routed by extension (any case):
495    /// * a model document (`.nbrep`, `.fbrep`, `.tbrep`, or a name with no
496    ///   extension) goes through the same `FileDialog::open_document` door as
497    ///   File>Open, so the document keeps the full path as its identity and a
498    ///   later plain Save writes back to the file the user double-clicked,
499    ///   rather than to a copy in the app's own models directory;
500    /// * a file File > Import takes (STEP, IGES, STL, 3MF, OBJ, KiCad — the
501    ///   import table in `panels/file.rs`) is imported by that same importer
502    ///   into a NEW untitled tab named after the file's stem, so a plain Save
503    ///   asks for a name instead of writing a document over the STEP file;
504    /// * anything else is refused by name.
505    ///
506    /// A path that does not open or import leaves the status line and a
507    /// notice saying so, and the seed document in place — a bad argument must
508    /// not cost the user a window.
509    ///
510    /// Native only: called once, before the first frame.
511    #[cfg(not(target_arch = "wasm32"))]
512    pub fn open_path_from_command_line(&mut self, path: &std::path::Path) {
513        // Absolute, so the store resolves it as a real file rather than as a
514        // name inside the models directory (`store::resolve` routes anything
515        // with a separator straight to the filesystem).
516        let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
517        let Some(name) = path.to_str() else {
518            log::warn!("brep-app: {} is not valid UTF-8; not opening it", path.display());
519            return;
520        };
521        match command_line_lane(name) {
522            CommandLineLane::Open => {
523                // Field-by-field, because `open_document` borrows the documents
524                // and the store while `self.file` is borrowed mutably.
525                let Self { file, docs, model_store, .. } = self;
526                file.open_document(docs, model_store.as_ref(), name);
527            }
528            CommandLineLane::Import => match std::fs::read(&path) {
529                Ok(bytes) => {
530                    if let Err(e) = self.import_file_into_new_tab(name, bytes) {
531                        log::warn!("brep-app: {e}");
532                    }
533                }
534                Err(e) => self.refuse_command_line_path(format!("import failed: cannot read {name}: {e}")),
535            },
536            CommandLineLane::Refuse => self.refuse_command_line_path(format!(
537                "cannot open {name}: not a BREP document (.nbrep, .fbrep, .tbrep) or a file BREP imports (STEP, IGES, STL, 3MF, OBJ, KiCad)"
538            )),
539        }
540    }
541
542    /// Import a file (its path or name, and its bytes) as File > Import does,
543    /// into a new untitled tab named after it: the door a STEP or mesh file
544    /// opened from the OS takes, and the automation's `doc_open_with`.
545    pub(crate) fn import_file_into_new_tab(&mut self, name: &str, bytes: Vec<u8>) -> Result<(), String> {
546        let Self { file, docs, model_store, .. } = self;
547        file.import_into_new_tab(docs, model_store.as_ref(), name, bytes)
548    }
549
550    /// Say why a command-line path was not opened: the status line and a
551    /// notice on the seed document, which stays as it was.
552    #[cfg(not(target_arch = "wasm32"))]
553    fn refuse_command_line_path(&mut self, message: String) {
554        log::warn!("brep-app: {message}");
555        self.docs.engine_mut().push_notice(message.clone());
556        self.file.status = message;
557    }
558
559    /// The 3D viewport rect of the last frame, in egui points.
560    pub fn view_rect(&self) -> Option<egui::Rect> {
561        self.viewport.last_rect()
562    }
563
564    /// The session's store, read-only (the automation layer's `frame_info`
565    /// counts its writes still on their way).
566    pub(crate) fn model_store(&self) -> &dyn ModelStore {
567        self.model_store.as_ref()
568    }
569
570    /// Why this session cannot switch stores live, or `None` when it can: every
571    /// open document untitled and clean, the state a fresh start has. A named
572    /// document's identity belongs to the store it came from, and a dirty one's
573    /// work has nowhere to go in a new store.
574    pub(crate) fn reconnect_blocker(&self) -> Option<String> {
575        self.reconnect_blocker_with(crate::document::Document::is_dirty)
576    }
577
578    fn reconnect_blocker_with(&self, is_dirty: impl Fn(&crate::document::Document) -> bool) -> Option<String> {
579        let named = self.docs.iter().filter(|d| d.name().is_some()).count();
580        let dirty = self.docs.iter().filter(|d| is_dirty(d)).count();
581        match (named, dirty) {
582            (0, 0) => None,
583            (_, 0) => Some(format!("{named} document(s) from this store are open: close them, then Connect now (or restart)")),
584            _ => Some(format!("{dirty} document(s) have unsaved work: save or close them, then Connect now (or restart)")),
585        }
586    }
587
588    /// Start switching this session to the PLM configured at `root`, live
589    /// (S1's named limit): the native boot S2 runs at start, without blocking
590    /// the frame; [`Self::finish_reconnect`] takes the store when it ends.
591    /// Only while [`Self::reconnect_blocker`] is `None`.
592    #[cfg(not(target_arch = "wasm32"))]
593    pub(crate) fn start_reconnect(&mut self, root: std::path::PathBuf) -> Result<(), String> {
594        if self.reconnecting.is_some() {
595            return Err("already connecting".into());
596        }
597        if let Some(why) = self.reconnect_blocker() {
598            return Err(why);
599        }
600        let config = crate::plm::config::resolve(&root, &|_| None, &Default::default())?
601            .ok_or_else(|| format!("no PLM is configured in {}", root.display()))?;
602        self.reconnecting = Some(crate::store::start_native_boot(root, config));
603        Ok(())
604    }
605
606    /// A Connect now is on its way (settle waits for it).
607    #[cfg(not(target_arch = "wasm32"))]
608    pub(crate) fn reconnecting(&self) -> bool {
609        self.reconnecting.is_some()
610    }
611
612    /// The Connect now under way, once its boot has ended: switch stores (or
613    /// say why not) and tell the PLM tab.
614    #[cfg(not(target_arch = "wasm32"))]
615    fn poll_reconnect(&mut self) {
616        let Some(store) = self.reconnecting.as_mut().and_then(crate::store::PendingBoot::poll) else { return };
617        self.reconnecting = None;
618        let outcome = self.finish_reconnect(store);
619        self.settings.reconnected(outcome);
620    }
621
622    /// Switch this session to `store`, the boot a Connect now started: then
623    /// everything the app read from the store when it was built is read again
624    /// from the new one. A refused boot, or a session that took a named or
625    /// dirty document meanwhile, changes nothing and says why.
626    #[cfg(not(target_arch = "wasm32"))]
627    fn finish_reconnect(&mut self, store: Box<dyn ModelStore>) -> Result<String, String> {
628        use crate::plm::connection::Status;
629        if let Some(why) = self.reconnect_blocker() {
630            return Err(why);
631        }
632        let Some(Status::Connected { url, username }) = store.plm_session().map(|s| s.status) else {
633            let why = store.take_persistence_errors().join("; ");
634            return Err(if why.is_empty() { "the PLM did not take this session".into() } else { why });
635        };
636        self.model_store = store;
637        self.docs.set_canonicalizer(self.model_store.canonicalizer());
638        // Settings: every open engine, and every later tab, starts from the
639        // defaults with the new store's settings over them, as a fresh boot
640        // would; nothing of the old store's carries over.
641        let saved = self.model_store.read(SETTINGS_KEY);
642        let fresh = move |mut state: EngineState| {
643            state.settings = brep_render::style::RenderSettings::default();
644            let defaults = state.settings.to_json();
645            let _ = state.apply_settings_json(&defaults);
646            if let Some(saved) = &saved {
647                let _ = state.apply_settings_json(saved);
648            }
649            state
650        };
651        for doc in self.docs.iter_mut() {
652            let engine = std::mem::replace(&mut doc.engine, EngineState::new());
653            doc.engine = fresh(engine);
654        }
655        self.docs.wrap_engine_factory(fresh);
656        self.dock = DockState::new(self.model_store.as_ref());
657        self.recovery.arm_from_store(
658            self.model_store.as_ref(),
659            self.docs.engine().settings.disable_recovery_prompt,
660        );
661        self.file = FileDialog::new();
662        self.update_components = UpdateComponents::new();
663        Ok(format!("connected to {url} as {username}"))
664    }
665
666    /// Keep automation waits active until the File dialog has delivered the PDF.
667    pub(crate) fn instructions_pdf_pending(&self) -> bool {
668        self.file.instructions_pdf_pending()
669    }
670
671    /// Keep automation waits active until a PLM Save As has landed or failed.
672    pub(crate) fn plm_save_as_pending(&self) -> bool {
673        self.file.plm_save_as_pending()
674    }
675
676    /// The file dialog, the documents and the store together — for the
677    /// automation commands that drive a document-class flow (Generate, a
678    /// store read-back) exactly as the panels do.
679    pub(crate) fn file_docs_store(&mut self) -> (&mut FileDialog, &mut Documents, &dyn ModelStore) {
680        (&mut self.file, &mut self.docs, self.model_store.as_ref())
681    }
682
683    /// The active document's engine.
684    pub fn docs_engine(&self) -> &EngineState {
685        self.docs.engine()
686    }
687
688    /// Open the mesh import preview for the file `name` (`.stl` or `.3mf`)
689    /// against the active document — what activating it in the Import explorer
690    /// does. The preview replaces the shell until it is accepted or cancelled.
691    pub fn open_mesh_preview(&mut self, name: String, bytes: Vec<u8>) -> Result<(), String> {
692        use brep_render::runner::MeshImportFormat;
693        let format = match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
694            Some("stl") => MeshImportFormat::Stl,
695            Some("3mf") => MeshImportFormat::ThreeMf,
696            _ => return Err(format!("{name} is not an STL or 3MF file")),
697        };
698        self.start_mesh_preview(format, name, bytes);
699        Ok(())
700    }
701
702    fn start_mesh_preview(&mut self, format: brep_render::runner::MeshImportFormat, name: String, bytes: Vec<u8>) {
703        self.stl_import = Some(crate::panels::stl_import::StlImportPreview::new(
704            self.docs.active_id(), format, name, bytes, self.docs.spawn_engine(),
705        ));
706        self.viewport.forget_document();
707    }
708
709    /// Whether the mesh import preview is open.
710    pub fn mesh_preview_open(&self) -> bool {
711        self.stl_import.is_some()
712    }
713
714    /// The engine the 3D viewport is drawing: the mesh import preview's own
715    /// while it is open (it replaces the shell), else the active document's.
716    /// What a pointer script's `locate` must project through.
717    pub fn viewport_engine(&self) -> &EngineState {
718        match &self.stl_import {
719            Some(preview) => &preview.engine,
720            None => self.docs.engine(),
721        }
722    }
723
724    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
725        Self::new_with(cc, crate::automation::AppOptions::default())
726    }
727
728    /// Build the app with host-supplied options: an isolated store (a host
729    /// MUST pass one) and whether to start on the seed model.
730    pub fn new_with(cc: &eframe::CreationContext<'_>, opts: crate::automation::AppOptions) -> Result<Self, String> {
731        // Solid scroll bars and readable captions, in both themes.
732        cc.egui_ctx.all_styles_mut(app_style);
733        // The action registry's view of each pass (`activate_widget`).
734        #[cfg(feature = "automation")]
735        crate::automation::actions::install(&cc.egui_ctx);
736        let crate::automation::AppOptions { store: opt_store, seed } = opts;
737        let render_state = cc
738            .wgpu_render_state
739            .as_ref()
740            .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
741
742        // The viewport owns the render core + blit pipeline, built from eframe's
743        // SHARED device/queue/format.
744        let viewport = Viewport::new(render_state);
745
746        // The session's diagnostics, taken from that same render state: the
747        // adapter about to draw every frame is the one a report must name. The
748        // WebGPU-vs-WebGL2 decision has already been made by the time we get
749        // here (eframe drops `BROWSER_WEBGPU` from the backend set when the
750        // browser offers no WebGPU adapter), so this READS the outcome — asking
751        // again later would be a second question with its own answer.
752        let diagnostics = crate::diagnostics::Diagnostics::from_render_state(render_state);
753
754        // --- storage seam: load the persisted settings ------------------------
755        let model_store = opt_store.unwrap_or_else(default_model_store);
756        // wasm: hand the store the egui context so an async file-upload load
757        // callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
758        #[cfg(target_arch = "wasm32")]
759        crate::store::set_repaint_ctx(cc.egui_ctx.clone());
760        let saved_settings = model_store.read(SETTINGS_KEY);
761
762        // --- how a document's engine is built --------------------------------
763        // Every tab gets its OWN engine, and therefore its own history runner —
764        // a runner owns the resident kernel state of the document it executes,
765        // so one shared between documents would apply a background run against
766        // the wrong registry. See `crate::document`.
767        let plugins = crate::plugins::PluginsPanel::new(model_store.as_ref());
768        let javascript = crate::javascript::JavaScriptEditor::new(model_store.as_ref());
769        let plugin_packages = plugins.packages.clone();
770        let engine_factory: EngineFactory = Box::new(move || {
771            let mut state = EngineState::new();
772            // Native: run the whole history — and per-object measurement queries — on a
773            // persistent background thread so the UI never freezes during a run or a
774            // selection (M2b). Installed BEFORE anything loads so it builds through it.
775            #[cfg(not(target_arch = "wasm32"))]
776            state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
777            // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
778            // single-threaded wasm UI stays responsive during a run. Same seam. Tests
779            // (which never hit this wasm path) keep the default synchronous InlineRunner.
780            #[cfg(target_arch = "wasm32")]
781            state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
782            if !plugin_packages.borrow().is_empty() {
783                if let Err(error) = crate::plugins::restore(&mut state, &plugin_packages.borrow()) {
784                    log::error!("Plugin restore failed: {error}");
785                }
786            }
787            state.set_viewcube_enabled(true);
788            // Partial-override apply: unknown/absent keys keep their defaults.
789            if let Some(saved) = &saved_settings {
790                let _ = state.apply_settings_json(saved);
791            }
792            state
793        });
794
795        // Start with the seed model on every launch (7626103c2). Reopening the
796        // previous session can immediately rerun a problematic document and
797        // prevent the user from recovering by restarting the app, so which
798        // documents were open is not persisted at all: saved models come back
799        // through the file dialog, and work that was never saved comes back as
800        // the crash-recovery OFFER below — a prompt, never an automatic load.
801        let mut docs = Documents::new(engine_factory);
802        docs.set_canonicalizer(model_store.canonicalizer());
803        if starts_on_seed_model(seed, &docs.engine().settings) {
804            let _ = docs.engine_mut().set_history_json(&seed_history_json());
805        }
806        docs.engine_mut().zoom_to_fit();
807        docs.active_mut().mark_clean();
808
809        // The autosave blob from a session that ended with unsaved work: offer
810        // it back (a prompt, never an automatic restore — see `crate::recovery`).
811        let mut recovery = crate::recovery::RecoveryPanel::new();
812        recovery.arm_from_store(model_store.as_ref(), docs.engine().settings.disable_recovery_prompt);
813
814        // The settings panel seeds its working JSON from the (post-load) engine
815        // settings so the widgets reflect the persisted state on first paint.
816        let settings = SettingsPanel::new();
817        let part_properties = PartPropertiesPanel::new();
818
819        // New / Open / Save / Save As. Holds no document identity — that lives
820        // on each `Document`.
821        let file = FileDialog::new();
822
823        // Boot at the saved UI scale.
824        let applied_ui_scale = docs.engine().settings.ui_scale;
825        let active_document = docs.active_id();
826
827        // The dock layout (loads the persisted tree, or the default). Built before
828        // `model_store` is moved into `Self`.
829        let dock = DockState::new(model_store.as_ref());
830
831        // Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
832        // fetching that model NOW; the seed still loads this frame and the fetched
833        // model REPLACES it when it lands (drained in `ui`). See the drain block.
834        crate::offsite::prefetch();
835        #[cfg(target_arch = "wasm32")]
836        let pending_boot_load = web_sys::window()
837            .and_then(|w| w.location().search().ok())
838            .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
839            .and_then(|params| params.get("loadModel"))
840            .filter(|url| !url.is_empty())
841            .map(|url| crate::offsite::fetch_text(&cc.egui_ctx, "Opening a model from a link", url));
842        #[cfg(not(target_arch = "wasm32"))]
843        let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
844        #[cfg(target_arch = "wasm32")]
845        let plm_launch = crate::panels::plm_launch::Launch::from_page();
846        #[cfg(not(target_arch = "wasm32"))]
847        let plm_launch = None;
848
849        Ok(Self {
850            docs,
851            viewport,
852            toolbar: ToolbarPanel::new(),
853            workbench_toolbar: WorkbenchToolbarPanel::new(),
854            model_store,
855            file,
856            stl_import: None,
857            bug_report: BugReportPanel::new(),
858            info: crate::panels::info::InfoPanel::new(),
859            diagnostics,
860            settings,
861            plugins,
862            javascript,
863            part_properties,
864            replace_dialog: Default::default(),
865            history: HistoryPanel::new(),
866            scene: ScenePanel::new(),
867            bom: BomPanel::new(),
868            assembly_constraints: AssemblyConstraintsPanel::new(),
869            wire_harness: WireHarnessPanel::new(),
870            pmi: crate::panels::pmi::PmiPanel::new(),
871            cam: crate::panels::cam::CamPanel::new(),
872            instructions: crate::panels::instructions::InstructionsPanel::new(),
873            instructions_player: crate::panels::instructions_player::InstructionsPlayerPanel::new(),
874            simulation: crate::panels::simulation::SimulationPanel::new(),
875            sheets: crate::panels::sheets::SheetsPanel::new(),
876            update_components: UpdateComponents::new(),
877            expressions: ExpressionsPanel::new(),
878            qualify: crate::panels::qualify::QualifyPanel::new(),
879            family_table: crate::panels::family_table_editor::FamilyTableEditor::new(),
880            plm: crate::panels::plm_host::PlmHost::new(),
881            info_windows: InfoWindows::new(),
882            interference: crate::panels::interference::InterferenceWindow::new(),
883            auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow::new(),
884            step_parts: crate::panels::step_parts::StepPartsPanel::new(),
885            selection: SelectionPanel::new(),
886            context_bar: ContextBarPanel::new(),
887            sketch: SketchPanel::new(),
888            sketch3d: Sketch3dPanel::new(),
889            mode_bar: ModeBar::new(),
890            pick_bar: crate::panels::pick_bar::PickBar::new(),
891            toasts: Toasts::new(),
892            busy: crate::panels::busy::BusyIndicator::new(),
893            dock,
894            first_run_framed: false,
895            pending_boot_load,
896            #[cfg(not(target_arch = "wasm32"))]
897            reconnecting: None,
898            plm_launch,
899            active_document,
900            active_tab: active_document,
901            sheets_pane_shown: false,
902            document_tab_hits: Vec::new(),
903            params_blob: (u64::MAX, usize::MAX, u64::MAX, String::new()),
904            state_blob_cache: StateBlobCache::default(),
905            #[cfg(feature = "automation")]
906            spin: None,
907            autosave: crate::recovery::Autosave::new(),
908            closing: None,
909            close_asked: false,
910            closed: false,
911            close_stage: 0,
912            recovery,
913            #[cfg(not(target_arch = "wasm32"))]
914            updater: crate::update::Updater::inert(),
915            report_followup: crate::report_followup::ReportFollowup::new(),
916            applied_ui_scale,
917            #[cfg(feature = "automation")]
918            automation: {
919                let q = crate::automation::queue::AutomationQueue::new();
920                q.attach(&cc.egui_ctx);
921                q
922            },
923        })
924    }
925
926    /// Start the desktop update check (`crate::update`). Called by the
927    /// desktop binary's `main` only.
928    #[cfg(not(target_arch = "wasm32"))]
929    pub fn start_update_check(&mut self, ctx: &egui::Context) {
930        self.updater = crate::update::Updater::start(ctx);
931    }
932
933    /// Remember submitted problem reports and check them for resolution
934    /// (`crate::report_followup`). Called by the desktop `main` and the web
935    /// entry point only.
936    pub fn start_report_followup(&mut self, ctx: &egui::Context) {
937        self.report_followup.start(ctx);
938    }
939
940    /// Arm a measured camera spin: `frames` pointer steps of `step` logical px
941    /// each, starting at the centre of the 3D viewport. Replaces any spin in
942    /// flight. See [`Spin`].
943    #[cfg(feature = "automation")]
944    pub fn start_spin(&mut self, frames: u32, step: (f64, f64)) {
945        let rect = self.viewport.last_rect();
946        let origin = rect
947            .map(|r| (r.width() as f64 * 0.5, r.height() as f64 * 0.5))
948            .unwrap_or((320.0, 240.0));
949        self.spin = Some(Spin { left: frames.max(1), origin, step, made: 0, pressed: false });
950    }
951
952    /// Is a measured spin still running?
953    #[cfg(feature = "automation")]
954    pub fn spinning(&self) -> bool {
955        self.spin.is_some()
956    }
957
958    /// Advance an in-flight spin by ONE pointer step and keep the frame loop
959    /// awake. The drag sweeps out and back over a fixed span so it never leaves
960    /// the viewport however many frames are asked for; the camera keeps turning
961    /// either way, which is all the measurement needs.
962    #[cfg(feature = "automation")]
963    fn step_spin(&mut self, ctx: &egui::Context) {
964        let Some(mut spin) = self.spin.take() else { return };
965        {
966            let state = self.docs.engine_mut();
967            if !spin.pressed {
968                state.pointer_down(spin.origin.0, spin.origin.1, brep_render::controls::BUTTON_LEFT);
969                spin.pressed = true;
970            }
971            const SPAN: f64 = 60.0;
972            let phase = f64::from(spin.made % (2 * SPAN as u32));
973            let t = if phase <= SPAN { phase } else { 2.0 * SPAN - phase };
974            state.pointer_move(spin.origin.0 + spin.step.0 * t, spin.origin.1 + spin.step.1 * t);
975            spin.made += 1;
976            spin.left -= 1;
977            if spin.left == 0 {
978                state.pointer_up();
979            }
980        }
981        if spin.left > 0 {
982            self.spin = Some(spin);
983        }
984        ctx.request_repaint();
985    }
986
987    /// The `__brepParams` JSON, rebuilt only when the document it describes can
988    /// have changed. The `params_blob` FIELD carries the reason this is not
989    /// simply built afresh each frame.
990    fn params_blob(&mut self) -> &str {
991        let key = (
992            self.docs.active_id(),
993            self.docs.engine().history_rollback(),
994            self.docs.engine().history_revision(),
995        );
996        if (self.params_blob.0, self.params_blob.1, self.params_blob.2) != key {
997            self.params_blob = (
998                key.0,
999                key.1,
1000                key.2,
1001                self.docs.engine().feature_params_json(key.1),
1002            );
1003        }
1004        &self.params_blob.3
1005    }
1006
1007    /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
1008    /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection
1009    /// — unless a popup or menu is up, which the Escape closes instead.
1010    ///
1011    /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
1012    /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
1013    /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
1014    /// consumed BEFORE undo because egui's `consume_key` matches modifiers
1015    /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
1016    fn handle_shortcuts(&mut self, ctx: &egui::Context) {
1017        // A modal owns every key: its Escape closes it, and nothing under it —
1018        // undo, a Delete, the selection — is the key's to change.
1019        if ctx.text_edit_focused() || crate::viewport::modal_open_last_pass(ctx) {
1020            return;
1021        }
1022        use egui::{Key, Modifiers};
1023        // A popup or menu that was up last frame owns this Escape: it closes on
1024        // `key_pressed(Escape)`, which a consumed key never is, so the key is
1025        // left in the input for it and nothing below acts on it — not the
1026        // sketch tool, not a pick, and not the selection its menu is about.
1027        let popup_open = crate::viewport::popup_open_last_pass(ctx);
1028        // On an eCAD workbench, Delete and Escape are the editor's keys, and
1029        // whether they reach it is the editors' ONE rule, `keys_reach_editor`
1030        // (a text field, a menu or combo box, a modal), not a copy of it here.
1031        // The watch above is the host's own addition: egui's popup memory does
1032        // not see the host's `open_bool` menus (a tree row's), and those own
1033        // their Escape on every workbench.
1034        let ecad_target = crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench);
1035        let keys_free = match ecad_target {
1036            Some(_) => !popup_open && brep_ecad_egui::keys_reach_editor(ctx),
1037            None => !popup_open,
1038        };
1039        if crate::javascript::JavaScriptEditor::editing_source(ctx) {
1040            return;
1041        }
1042        let sketching_3d = self.docs.engine().sketch3d_mode();
1043        let (redo, undo, esc, tab) = ctx.input_mut(|i| {
1044            let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
1045                || i.consume_key(Modifiers::COMMAND, Key::Y);
1046            let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
1047            let esc = keys_free && i.consume_key(Modifiers::NONE, Key::Escape);
1048            // Tab cycles the 3D sketch's working plane; outside that mode it
1049            // stays egui's focus key.
1050            let tab = sketching_3d && keys_free && i.consume_key(Modifiers::NONE, Key::Tab);
1051            (redo, undo, esc, tab)
1052        });
1053        // Delete / Backspace: THE delete-selection command
1054        // (`crate::delete_selection`), whatever the selection is — sketch,
1055        // 3D sketch, eCAD, sheet object, PMI annotation, constraint, component
1056        // or feature — before the sketch modes return below.
1057        if keys_free {
1058            self.delete_selection_key(ctx);
1059        }
1060        // The 3D sketch editor: the same per-session undo routing as the
1061        // planar one; Esc drops the armed tool's pending clicks first and the
1062        // tool itself second.
1063        if sketching_3d {
1064            if redo {
1065                self.docs.engine_mut().sketch3d_redo();
1066            }
1067            if undo {
1068                self.docs.engine_mut().sketch3d_undo();
1069            }
1070            if esc {
1071                let engine = self.docs.engine_mut();
1072                if engine.sketch3d_pending_len() > 0 {
1073                    engine.sketch3d_tool_cancel();
1074                } else {
1075                    let _ = engine.sketch3d_set_tool(Some("select"));
1076                }
1077            }
1078            if tab {
1079                self.docs.engine_mut().sketch3d_cycle_work_plane();
1080            }
1081            return;
1082        }
1083        // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
1084        // history (S6a), not the model-level undo — this global router consumes the
1085        // keys first (before the viewport), so it must intercept here. Esc drops the
1086        // active draw/trim/pick tool back to Select/drag (clearing any in-progress
1087        // placement): this is the ONLY reliable capture point, since `consume_key`
1088        // above already swallowed the Escape before the viewport can see it.
1089        if self.docs.engine().sketch_mode() {
1090            if redo {
1091                self.docs.engine_mut().sketch_redo();
1092            }
1093            if undo {
1094                self.docs.engine_mut().sketch_undo();
1095            }
1096            if esc {
1097                self.docs.engine_mut().sketch_set_tool(Some("select"));
1098            }
1099            return;
1100        }
1101        if redo {
1102            self.docs.engine_mut().redo();
1103        }
1104        if undo {
1105            self.docs.engine_mut().undo();
1106        }
1107        if esc {
1108            // An open pick-list popup owns the first Escape: close it WITHOUT
1109            // clearing the selection (a popup-built multi-selection must survive
1110            // dismissing the list); the next Escape clears as before.
1111            if self.viewport.close_candidate_popup() {
1112                return;
1113            }
1114            // An open Replace component chooser closes on Escape (its own
1115            // keys never see it: this handler takes Escape first). A repair
1116            // pick in its result window is cancelled the same way.
1117            if self.replace_dialog.escape() {
1118                return;
1119            }
1120            // A REFERENCE PICK owns the next one — a 3D pick or anchors on a
1121            // drawing sheet alike — and Escape has to CANCEL it
1122            // rather than merely clear the selection. Beginning a pick ROLLS THE
1123            // MODEL BACK to the step before the edited feature so the picker sees
1124            // the geometry that feature is about to consume; Finish and Cancel
1125            // both restore that roll through `end_ref_select`. Escape used to do
1126            // neither: it cleared the selection, left the picker open and left the
1127            // history PARKED before the feature, so the feature stopped running
1128            // and the viewport quietly showed the model without it. Measured on
1129            // the retired `rotate-face.json`, which read the seed volume back at its
1130            // closed-form step with the feature never re-run.
1131            if self.docs.engine().ref_select_active() {
1132                self.docs.engine_mut().cancel_ref_select();
1133                return;
1134            }
1135            // A family-row preview owns the next one: Escape leaves the
1136            // preview (the family's own values again) and keeps the selection.
1137            if self.family_table.escape_preview(self.docs.engine_mut()) {
1138                return;
1139            }
1140            // The global router owns Escape before the viewport runs its keys.
1141            if let Some(target) = ecad_target {
1142                self.docs.active_mut().ecad.selection_key(target, false);
1143                return;
1144            }
1145            self.docs.engine_mut().clear_selection();
1146        }
1147    }
1148
1149    /// A FORM FIELD's reference pick, settled at the end of the frame (after
1150    /// the dock has drawn every pane, the viewport's click included):
1151    ///
1152    /// * a SINGLE-valued field finishes on its first pick — there is nothing
1153    ///   left to wait for, and the feature's result appears at once;
1154    /// * a form that asked for the pick to end (`ref_select_request_finish`:
1155    ///   an edit elsewhere on a feature's form) gets it now, after the frame's
1156    ///   viewport click has landed in the pick;
1157    /// * a pick NO form claimed this frame (`ref_select_claim`) is finished:
1158    ///   its pane was tabbed away, closed, or its subject is gone, and a pick
1159    ///   with no field on screen has nothing to end it.
1160    ///
1161    /// Card-based picks are not this method's: their card is always on screen.
1162    fn settle_form_pick(&mut self) {
1163        let engine = self.docs.engine_mut();
1164        let Some(field) = engine.ref_select_field() else {
1165            return;
1166        };
1167        if !engine.ref_select_in_form() {
1168            return;
1169        }
1170        let claimed = engine.ref_select_take_claim();
1171        let single_done = !field.multiple && field.picks > 0;
1172        if single_done || engine.ref_select_finish_requested() || !claimed {
1173            engine.finish_ref_select();
1174        }
1175    }
1176
1177    /// DELETE (or Backspace) runs THE delete-selection command
1178    /// ([`crate::delete_selection`]) on whatever is selected: the same call as
1179    /// that object's row ✕ / menu Delete, so the key and the menu do the same
1180    /// thing (one undo step, no extra confirmation). The key is consumed only
1181    /// when there IS something to delete, and left to the family-table grid
1182    /// when it owns the keyboard. Never a SHEET, PMI VIEW or study: each takes
1183    /// everything placed on it with it and stays behind its menu's Delete.
1184    /// Called after the text-edit / modal / popup guards, so a Backspace typed
1185    /// into a field never gets here.
1186    fn delete_selection_key(&mut self, ctx: &egui::Context) {
1187        use egui::{Key, Modifiers};
1188        if self.family_table.owns_delete_key(ctx) {
1189            return;
1190        }
1191        let Some(target) = crate::delete_selection::target(&self.docs) else {
1192            return;
1193        };
1194        let pressed = ctx.input_mut(|i| {
1195            i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)
1196        });
1197        if !pressed {
1198            return;
1199        }
1200        if let Err(error) = crate::delete_selection::apply(&mut self.docs, &target) {
1201            self.docs.engine_mut().push_notice(format!("Delete: {error}"));
1202        }
1203    }
1204
1205    /// Open the component source, offering to recover a missing file from the
1206    /// assembly's embedded part definition.
1207    /// Open the Replace component chooser on `component_id` (the component
1208    /// menus' Replace…).
1209    pub(crate) fn open_replace(&mut self, component_id: &str) {
1210        if let Err(error) = crate::panels::replace_component::finish_work_part_for_replace(&mut self.docs) {
1211            self.docs.engine_mut().push_notice(format!("Replace: {error}"));
1212            return;
1213        }
1214        let document = self.docs.active().name().map(str::to_string);
1215        self.replace_dialog.open(self.docs.engine(), self.model_store.as_ref(), component_id, document.as_deref());
1216    }
1217
1218    /// Open the Replace summary alone, for mates an earlier replace holds.
1219    pub(crate) fn review_replace(&mut self) {
1220        self.replace_dialog.review();
1221    }
1222
1223    /// A component menu's document-level flow (`component_actions`): every
1224    /// menu — the context bar, the viewport's right-click, the BOM row — hands
1225    /// its request here, so each flow has one implementation.
1226    fn run_component_request(&mut self, request: ComponentActionRequest, compact: bool) {
1227        match request {
1228            ComponentActionRequest::SavePartAs { component_id } => self.edit_part(&component_id),
1229            ComponentActionRequest::Replace { component_id } => self.open_replace(&component_id),
1230            ComponentActionRequest::WorkPart { component_path, in_place } => {
1231                let mode = if in_place {
1232                    crate::document::work_part::WorkPartMode::InPlace
1233                } else {
1234                    crate::document::work_part::WorkPartMode::NewTab
1235                };
1236                if let Err(error) = self.docs.begin_work_part_as(&component_path, mode) {
1237                    self.docs.engine_mut().push_notice(format!("Work part: {error}"));
1238                }
1239            }
1240            ComponentActionRequest::Info { targets } => {
1241                self.info_windows.open_for(&targets, self.viewport.last_rect());
1242            }
1243            ComponentActionRequest::EditFeature { feature_id } => {
1244                self.history.focus_feature(feature_id);
1245                if compact {
1246                    self.dock.show_pane(PaneKind::History);
1247                }
1248            }
1249        }
1250    }
1251
1252    fn edit_part(&mut self, component_id: &str) {
1253        self.file.edit_component_part(
1254            &mut self.docs,
1255            self.model_store.as_ref(),
1256            component_id,
1257        );
1258    }
1259
1260    /// Draw an isolated preview while the destination document remains untouched.
1261    /// Hand the working indicator this frame's work, most important first:
1262    /// the active document's runner queues, a boot load, the STEP parts
1263    /// library's downloads, then every background document's queues.
1264    fn collect_busy(&mut self, ctx: &egui::Context) {
1265        use crate::panels::busy::{engine_activities, Activity};
1266        let active = self.docs.active_index();
1267        let mut activities = engine_activities(self.docs.engine(), self.docs.active_id(), None);
1268        if self.pending_boot_load.is_some() {
1269            activities.push(Activity::new("boot", "boot", "Loading document"));
1270        }
1271        #[cfg(not(target_arch = "wasm32"))]
1272        if self.reconnecting.is_some() {
1273            activities.push(Activity::new("plmConnect", "plmConnect", "Connecting to the PLM"));
1274        }
1275        activities.extend(self.step_parts.busy_activities());
1276        activities.extend(self.file.busy_activities());
1277        // A write-behind store still settling saves: the browser's IndexedDB
1278        // or a PLM server (not the desktop's own settings/layout files).
1279        let saving = self.model_store.pending_saves();
1280        if saving > 0 {
1281            #[cfg(target_arch = "wasm32")]
1282            let label = match saving {
1283                1 => "Saving to browser storage".to_string(),
1284                n => format!("Saving to browser storage ({n} writes)"),
1285            };
1286            #[cfg(not(target_arch = "wasm32"))]
1287            let label = match saving {
1288                1 => "Saving".to_string(),
1289                n => format!("Saving ({n} writes)"),
1290            };
1291            activities.push(Activity::new("storeWrite", "storeWrite", label));
1292        }
1293        for (index, doc) in self.docs.iter().enumerate() {
1294            if index != active {
1295                activities.extend(engine_activities(&doc.engine, doc.id(), Some(&doc.title())));
1296            }
1297        }
1298        self.busy.update(activities, ctx.input(|i| i.time));
1299        self.busy.request_repaint(ctx);
1300    }
1301
1302    fn show_stl_preview(&mut self, ui: &mut egui::Ui) -> bool {
1303        use crate::panels::stl_import::PreviewAction;
1304        let Some(preview) = self.stl_import.as_mut() else {
1305            return false;
1306        };
1307        let action = if preview.destination != self.docs.active_id() {
1308            PreviewAction::Cancel
1309        } else {
1310            preview.show(ui, &mut self.viewport)
1311        };
1312        if crate::automation::registry::enabled() {
1313            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
1314            crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", &preview.state_json());
1315            crate::automation::registry::publish("__brepImportRegions", "interactive mesh import: phase, region list (proposed kind, status, residual, refits), selected region and its form; null when no preview is open", &preview.regions_json());
1316            crate::automation::registry::publish("__brepImportPreviewHit", "import preview dialog widget rects", &preview.hits_json());
1317            crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &preview.engine.camera_state_json());
1318            crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{}", ui.ctx().pixels_per_point()));
1319            crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
1320            crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
1321        }
1322        let close = match action {
1323            PreviewAction::Accept => {
1324                match preview.accept_into(self.docs.active_id(), self.docs.engine_mut()) {
1325                    Ok(()) => true,
1326                    Err(error) => {
1327                        self.docs.engine_mut().push_notice(error);
1328                        false
1329                    }
1330                }
1331            }
1332            PreviewAction::Cancel => true,
1333            PreviewAction::None => false,
1334        };
1335        if close {
1336            self.stl_import = None;
1337            self.viewport.forget_document();
1338            ui.ctx().request_repaint();
1339        }
1340        true
1341    }
1342
1343    /// Phase 3 of the automation frame (§4.4): read-only commands answer from
1344    /// THIS frame's registry and layout. A method rather than a block at the
1345    /// end of `ui` because the import preview returns from that function early
1346    /// and still owes the frame its reads.
1347    #[cfg(feature = "automation")]
1348    fn drain_reads(&mut self, ctx: &egui::Context) {
1349        let _t = crate::perf::trace("drain reads + publish_view");
1350        let queue = self.automation.clone();
1351        let frame = ctx.cumulative_frame_nr();
1352        queue.drain_app(
1353            crate::automation::command::Phase::Read,
1354            &mut crate::automation::command::Ctx { app: self, egui: ctx },
1355            frame,
1356        );
1357        // The frame drawn now shows this document: a capture landing next
1358        // frame is of it.
1359        let view = self.docs.view();
1360        queue.publish_view(view.handle(), view.committed_revision());
1361        // This pass's layout is the current one for a layout read that comes
1362        // before anything changes it (`automation::queue::Need::Layout`).
1363        queue.note_pass();
1364    }
1365
1366    /// Every notice raised since the last gather — the engine's, storage
1367    /// failures found after a write returned, refused edits — mirrored to the
1368    /// automation queue's `notices` drain. Called by the frame (which then
1369    /// shows them as toasts) and by the frame-free service step, so a hidden
1370    /// window still reports what the user would have seen.
1371    fn gather_notices(&mut self) -> Vec<(brep_render::engine_state::NoticeSeverity, String)> {
1372        use brep_render::engine_state::NoticeSeverity;
1373        let mut raised = self.docs.engine_mut().take_graded_notices();
1374        // Same lane for STORAGE failures the store could only discover after its
1375        // synchronous `write` returned `Ok` (the browser backend writes behind an
1376        // in-memory mirror). A save that did not persist must never be silent.
1377        let errors = self.model_store.take_persistence_errors().into_iter().chain(self.autosave.take_errors());
1378        let failures: Vec<String> = errors.filter_map(|text| crate::panels::plm_host::plain_write_failure(&text)).collect();
1379        if let Some(last) = failures.last() {
1380            // The status line said "saved …" when the write was issued.
1381            self.file.status = last.clone();
1382        }
1383        if failures.iter().any(|text| text.contains("the PLM server is not reachable")) {
1384            self.plm.note_unreachable();
1385        }
1386        raised.extend(failures.into_iter().map(|text| (NoticeSeverity::Error, text)));
1387        // ...and the tab whose Save that was is unsaved again: it was marked
1388        // clean when the write was issued, and a clean tab closes without a
1389        // question.
1390        let failed = self.model_store.take_failed_writes();
1391        for name in &failed {
1392            self.docs.mark_write_failed(self.model_store.as_ref(), name);
1393        }
1394        // A part an open assembly holds a copy of (saved with it): the copy
1395        // keeps the change, marked, so the next Save writes it again.
1396        for name in &failed {
1397            self.docs.mark_failed_copies(self.model_store.as_ref(), name);
1398        }
1399        // An edit a read-only document refused (a PLM revision not checked out,
1400        // or released): its history kept the saved bytes, and this says why.
1401        for doc in self.docs.iter_mut() {
1402            if let Some(text) = doc.take_refused() {
1403                raised.push((NoticeSeverity::Warning, text));
1404            }
1405        }
1406        // The automation channel's `notices` drain: what the user would have SEEN.
1407        // Without this the drain has no producers at all and every script's
1408        // `{"notices": []}` asserts nothing.
1409        #[cfg(feature = "automation")]
1410        for (_, text) in &raised {
1411            self.automation.push_notice(
1412                crate::automation::command::NoticeKind::Toast,
1413                text.clone(),
1414            );
1415        }
1416        raised
1417    }
1418
1419    /// Application-command dispatch without a frame (the desktop loop's
1420    /// service step; see `automation::queue`): the PLM/store tasks that
1421    /// answered, the queue's frame-free head, the background runs it started,
1422    /// and the notices they raised. Draws nothing. Returns whether work it
1423    /// must keep servicing is still in flight.
1424    #[cfg(feature = "automation")]
1425    pub fn service_automation(&mut self, ctx: &egui::Context) -> bool {
1426        #[cfg(not(target_arch = "wasm32"))]
1427        crate::plm::native::run_pending();
1428        let queue = self.automation.clone();
1429        let polled = queue.drain_frameless(&mut crate::automation::command::Ctx { app: self, egui: ctx });
1430        let running = self.docs.pump_all();
1431        let raised = self.gather_notices();
1432        if polled || running || !raised.is_empty() {
1433            // Work in flight or landing moves what a pass would show.
1434            queue.mark_layout_dirty();
1435        }
1436        if !raised.is_empty() {
1437            let now = ctx.input(|i| i.time);
1438            self.toasts.extend(raised, now);
1439        }
1440        // A window that IS drawn shows the effect on its next frame.
1441        ctx.request_repaint();
1442        polled || running || queue.has_frameless_work()
1443    }
1444
1445    /// Reset everything the shared panels and the viewport hold ABOUT ONE
1446    /// DOCUMENT, run at the top of the first frame that sees a different active
1447    /// document.
1448    ///
1449    /// Panel state is deliberately NOT per-document (one History panel, one
1450    /// Scene tree, …): a second copy per tab would double every panel's state
1451    /// for a benefit — remembering which feature form was open in a background
1452    /// tab — nobody asked for. The price is that the transient state has to be
1453    /// dropped on a switch, because every bit of it (expansion sets, hit maps,
1454    /// an open feature form, a pinned Info window's object name) refers to the
1455    /// document that just went away.
1456    fn reset_document_scoped_state(&mut self) {
1457        self.history = HistoryPanel::new();
1458        self.scene = ScenePanel::new();
1459        self.bom = BomPanel::new();
1460        self.assembly_constraints = AssemblyConstraintsPanel::new();
1461        self.wire_harness = WireHarnessPanel::new();
1462        self.pmi = crate::panels::pmi::PmiPanel::new();
1463        self.cam = crate::panels::cam::CamPanel::new();
1464        self.sheets = crate::panels::sheets::SheetsPanel::new();
1465        self.expressions = ExpressionsPanel::new();
1466        self.qualify = crate::panels::qualify::QualifyPanel::new();
1467        self.family_table = crate::panels::family_table_editor::FamilyTableEditor::new();
1468        // A family-row preview left on in a document the user comes back to
1469        // would have no pane state to show or end it: switching ends it.
1470        self.docs.engine_mut().set_expression_preview(None);
1471        // Pinned to object NAMES of the old document ("Box" exists in most of
1472        // them), so these would silently retarget rather than go blank.
1473        self.info_windows = InfoWindows::new();
1474        self.interference = crate::panels::interference::InterferenceWindow::new();
1475        self.auto_constraints = crate::panels::auto_constraints::AutoConstraintsWindow::new();
1476        // The outdated-parts cache keys on `(applied_generation, save_generation)`,
1477        // and two documents' generations are unrelated — a switch can land on the
1478        // same key with an entirely different parts library.
1479        self.update_components.invalidate();
1480        self.viewport.forget_document();
1481    }
1482
1483    /// Publish every plain-state blob ([`state_blobs::STATE_BLOBS`]) from its
1484    /// producer. The frame calls this; `state_get` asks
1485    /// [`state_blob_json`](Self::state_blob_json) itself, so a read of one of
1486    /// these never waits for a frame.
1487    ///
1488    /// [`state_blobs::STATE_BLOBS`]: crate::automation::state_blobs::STATE_BLOBS
1489    fn publish_state_blobs(&mut self, ctx: &egui::Context) {
1490        for (name, doc) in crate::automation::state_blobs::STATE_BLOBS {
1491            if let Some(json) = self.state_blob_json(name, ctx) {
1492                crate::automation::registry::publish(name, doc, &json);
1493            }
1494        }
1495    }
1496
1497    /// One plain-state blob by name, built from the app's data now. `None`
1498    /// for a name that is not one of [`STATE_BLOBS`] (a layout blob, which
1499    /// only a drawn pass publishes).
1500    ///
1501    /// [`STATE_BLOBS`]: crate::automation::state_blobs::STATE_BLOBS
1502    pub fn state_blob_json(&mut self, name: &str, ctx: &egui::Context) -> Option<String> {
1503        if name == "__brepSelectionInspection" {
1504            let document = self.docs.active_id();
1505            return Some(self.selection.inspection_json(self.docs.engine_mut(), document));
1506        }
1507        let engine = self.docs.engine();
1508        Some(match name {
1509            // The import preview draws its own engine while it is open.
1510            "__brepCamera" => match &self.stl_import {
1511                Some(preview) => preview.engine.camera_state_json(),
1512                None => engine.camera_state_json(),
1513            },
1514            "__brepImportPreview" => self.stl_import.as_ref().map_or_else(|| "null".to_string(), |p| p.state_json()),
1515            "__brepImportRegions" => self.stl_import.as_ref().map_or_else(|| "null".to_string(), |p| p.regions_json()),
1516            "__brepPpp" => format!("{}", ctx.pixels_per_point()),
1517            // Keyed on the settings themselves and the projection, the only
1518            // inputs `settings_json` reads.
1519            "__brepSettings" => {
1520                let orthographic = matches!(engine.camera.projection, brep_render::view::Projection::Orthographic { .. });
1521                let key = (engine.settings.clone(), orthographic);
1522                StateBlobCache::keyed(&mut self.state_blob_cache.settings, key, || engine.settings_json())
1523            }
1524            "__brepSolidColors" => engine.solid_color_overrides_json(),
1525            "__brepHistory" => engine.history_listing_json(),
1526            "__brepGizmo" => engine.gizmo_state_json(),
1527            "__brepFile" => self.file.file_state_json(&self.docs, self.model_store.as_ref()),
1528            // Keyed on exactly what the signature reports (rollback step, each
1529            // solid's name, triangle count and bounds), so building the key
1530            // skips only the JSON, never a change.
1531            "__brepModel" => {
1532                let key = model_signature_key(engine);
1533                match StateBlobCache::hit(&self.state_blob_cache.model, &key) {
1534                    Some(json) => json,
1535                    None => {
1536                        let json = self.model_signature_json();
1537                        self.state_blob_cache.model = Some((key, json.clone()));
1538                        json
1539                    }
1540                }
1541            }
1542            "__brepReport" => engine.history_report_json(),
1543            // The in-flight run: whether one is pending, the feature the runner
1544            // says it is executing, and the feature a cancelled run was stuck on.
1545            "__brepRun" => serde_json::json!({
1546                "pending": engine.run_pending(),
1547                "progress": engine.run_progress().map(|p| serde_json::json!({
1548                    "generation": p.generation,
1549                    "index": p.index,
1550                    "total": p.total,
1551                    "featureId": p.feature_id,
1552                    "featureType": p.feature_type,
1553                })),
1554                "cancelled": engine.cancelled_run(),
1555            })
1556            .to_string(),
1557            "__brepBusy" => self.busy.state_json(),
1558            "__brepExpr" => serde_json::json!({
1559                "expressions": engine.expressions_json(),
1560                "variables": serde_json::from_str::<serde_json::Value>(&engine.expression_variables_json())
1561                    .unwrap_or(serde_json::Value::Null),
1562                "configurator": serde_json::from_str::<serde_json::Value>(&engine.configurator_json())
1563                    .unwrap_or(serde_json::Value::Null),
1564            })
1565            .to_string(),
1566            "__brepPlmDoc" => self.plm.state_json(&self.docs),
1567            // The queued toast texts — the only trace of a refusal the app shows
1568            // as a transient card (e.g. a constraint the strip could not add).
1569            "__brepNotices" => self.toasts.texts_json(),
1570            "__brepToasts" => self.toasts.state_json(),
1571            "__brepBug" => self.bug_report.state_json(),
1572            "__brepDiagnostics" => self.diagnostics.json().to_string(),
1573            // Where the last ~120 frames' milliseconds went. `__brepDiagnostics`
1574            // says WHICH renderer is drawing; this says what it costs, in the
1575            // same split the Info window's Performance rows show.
1576            "__brepPerf" => crate::perf::json(),
1577            "__brepWireHarness" => engine.wire_harness_state_json(),
1578            "__brepCam" => {
1579                self.cam.refresh_geometry(self.docs.engine());
1580                crate::panels::cam::cam_state_json(self.docs.engine(), &self.cam)
1581            }
1582            "__brepInstructions" => self.instructions.state_json(),
1583            "__brepInstructionsPlayer" => self.instructions_player.state_json(engine),
1584            "__brepSimulation" => crate::panels::simulation::state_json(engine, &self.simulation),
1585            "__brepCamSim" => self.cam.sim.state_json().to_string(),
1586            "__brepPorts" => engine.ports_state_json(),
1587            "__brepQualify" => self.qualify.state_json(),
1588            "__brepFamily" => self.family_table.state_json(),
1589            "__brepPmi" => engine.pmi_state_json(),
1590            "__brepSheets" => self.docs.engine_mut().sheet_state_json(),
1591            // The workbench logical state (resolved current id + available
1592            // ids); the dropdown's rects ride in `__brepToolbar`.
1593            "__brepWorkbench" => {
1594                // Its inputs: the stored workbench and the plugins' workbenches.
1595                let key = (engine.settings.workbench.clone(), engine.plugin_workbenches());
1596                StateBlobCache::keyed(&mut self.state_blob_cache.workbench, key, || {
1597                    crate::workbench::workbench_state_scoped(engine).to_string()
1598                })
1599            }
1600            "__brepSelection" => engine.selection_json(),
1601            "__brepInfoWindows" => self.info_windows.published_json(self.docs.engine_mut()),
1602            "__brepInterference" => self.interference.state_json_in(engine),
1603            "__brepAutoConstraints" => self.auto_constraints.state_json(),
1604            "__brepStepParts" => self.step_parts.state_json(),
1605            "__brepSelectionFilter" => engine.selection_filter_json(),
1606            "__brepFilterButton" => self.selection.button_json(),
1607            "__brepContext" => self.context_bar.state_json(),
1608            "__brepPickBar" => self.pick_bar.state_json(),
1609            "__brepDocuments" => document_tabs::state_json(&self.docs),
1610            "__brepRecovery" => self.recovery.state_json(),
1611            "__brepClosing" => self.closing_json(),
1612            "__brepComponentMove" => engine.component_move_json(),
1613            "__brepSketch" => self.sketch.published_json(engine),
1614            "__brepSketch3d" => self.sketch3d.published_json(engine),
1615            "__brepWireframe" => format!("{}", engine.settings.wireframe),
1616            // The pick's `subject` and `path` name the FORM FIELD it runs in
1617            // (`<panel>/field:<path>#activate` is that field's button, Done
1618            // while the pick runs); `inForm` is false for the card-based
1619            // picks, whose controls are `modebar/refsel:*`.
1620            "__brepRefSelect" => {
1621                let field = engine.ref_select_field();
1622                serde_json::json!({
1623                    "active": engine.ref_select_active(),
1624                    "prompt": engine.ref_select_prompt(),
1625                    "names": engine.ref_select_names(),
1626                    "subject": field.as_ref().map(|f| f.subject.clone()),
1627                    "path": field.as_ref().map(|f| f.path.join(".")),
1628                    "multiple": field.as_ref().map(|f| f.multiple),
1629                    "picks": field.as_ref().map(|f| f.picks),
1630                    "inForm": engine.ref_select_in_form(),
1631                    "ghostsLit": engine.ref_select_ghosts_lit(),
1632                })
1633                .to_string()
1634            }
1635            "__brepStep" => format!("{}", engine.history_rollback()),
1636            "__brepParams" => self.params_blob().to_string(),
1637            _ => return None,
1638        })
1639    }
1640
1641    /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
1642    /// per-solid triangle count + bbox, and total triangles. Published to JS so
1643    /// the headed verifier can prove each roll / edit produced different geometry
1644    /// (names alone don't: a SUBTRACT reuses the target's name).
1645    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1646    pub(crate) fn model_signature_json(&self) -> String {
1647        let solids: Vec<serde_json::Value> = self
1648            .docs
1649            .engine()
1650            .scene
1651            .solids()
1652            .iter()
1653            .map(|s| {
1654                serde_json::json!({
1655                    "name": s.name,
1656                    "tris": s.mesh.indices.len() / 3,
1657                    "min": s.bbox.min,
1658                    "max": s.bbox.max,
1659                })
1660            })
1661            .collect();
1662        let total_tris: usize = self
1663            .docs
1664            .engine()
1665            .scene
1666            .solids()
1667            .iter()
1668            .map(|s| s.mesh.indices.len() / 3)
1669            .sum();
1670        serde_json::json!({
1671            "step": self.docs.engine().history_rollback(),
1672            "solidCount": solids.len(),
1673            "totalTris": total_tris,
1674            "solids": solids,
1675        })
1676        .to_string()
1677    }
1678
1679    /// Open or close the floating Settings window — the toolbar gear's flag,
1680    /// reachable from the automation layer (`settings_window`).
1681    pub fn set_settings_window_open(&mut self, open: bool) {
1682        self.settings.open = open;
1683    }
1684
1685    /// Open or close the floating Part Properties window — the toolbar tag
1686    /// button's flag, reachable from the automation layer
1687    /// (`part_properties_window`).
1688    pub fn set_part_properties_window_open(&mut self, open: bool) {
1689        self.part_properties.open = open;
1690    }
1691
1692    /// Open or close the floating Info window — the toolbar info button's flag,
1693    /// reachable from the automation layer (`info_window`).
1694    pub fn set_info_window_open(&mut self, open: bool) {
1695        self.info.open = open;
1696    }
1697
1698    /// What this session is running on. The app's ONE record of it: the Info
1699    /// window draws it, a problem report carries it, the `diagnostics` command
1700    /// returns it and the MCP banner names its adapter — all from here.
1701    /// Where the JavaScript editor's Run sends a program (a host's own
1702    /// script runner; the desktop app installs its server's on its own).
1703    pub fn set_script_host(&mut self, host: Box<dyn crate::javascript::ScriptHost>) {
1704        self.javascript.host = Some(host);
1705    }
1706
1707    pub fn diagnostics(&self) -> &crate::diagnostics::Diagnostics {
1708        &self.diagnostics
1709    }
1710
1711    /// Begin the in-app problem report: the same call the Submit Bug button
1712    /// makes, and for the same reason it makes it THIS frame — `request`
1713    /// captures the current frame before its own dialog exists.
1714    pub fn begin_bug_report(&mut self, ctx: &egui::Context) {
1715        self.bug_report.request(ctx, self.docs.engine(), &self.diagnostics);
1716    }
1717
1718    /// Activate an available declarative panel using its stable package ID.
1719    pub(crate) fn show_plugin_panel(&mut self, id: &str) -> Result<(), String> {
1720        self.dock.show_plugin_panel(self.docs.engine(), id)
1721    }
1722
1723    /// Bring a built-in dock pane to the front (`show_pane`).
1724    pub fn show_pane(&mut self, kind: PaneKind) {
1725        self.dock.show_pane(kind);
1726    }
1727
1728    /// Shared document activation / unsaved-close handling for both presentations.
1729    fn apply_document_actions(&mut self, activate: Option<usize>, close: Option<usize>) {
1730        if (activate.is_some() || close.is_some()) && self.docs.engine().ref_select_in_form() {
1731            self.docs.engine_mut().finish_ref_select();
1732        }
1733        if let Some(index) = activate { self.docs.activate(index); }
1734        if let Some(index) = close { self.file.request_close(&mut self.docs, index); }
1735    }
1736
1737    /// The parts-library staleness check and its refresh — the Constraints
1738    /// header's "Update components (N)" button, reachable as the
1739    /// `component_update` command. Returns `(outdated, missing, refreshed)`;
1740    /// `run` is skipped when nothing is outdated.
1741    pub fn update_components(&mut self, run: bool) -> Result<(usize, Vec<String>, usize), String> {
1742        let store = self.model_store.as_ref();
1743        let synced = self.docs.synced_signatures();
1744        self.update_components.ensure_current(self.docs.engine_mut(), store, self.file.save_generation(), &synced);
1745        let outdated = self.update_components.outdated_count();
1746        let missing = self.update_components.missing().to_vec();
1747        let refreshed = if run { self.update_components.run(self.docs.engine_mut(), store)? } else { 0 };
1748        Ok((outdated, missing, refreshed))
1749    }
1750
1751    /// Frame what the central tile is actually DRAWING: the OPEN SHEET's paper
1752    /// when a sheet is open, the 3D scene otherwise.
1753    ///
1754    /// This is where the fit branch lives, and it is here rather than in the
1755    /// toolbar because the two halves sit in different places — the 3D fit is an
1756    /// engine camera op (`EngineState::zoom_to_fit`) while the sheet's pan/zoom
1757    /// is viewport-transient state, refitted by clearing the marker the sheet
1758    /// paint compares against. One method, so the toolbar's Zoom-to-fit button
1759    /// and the `zoom_to_fit` command can never mean different things — and so
1760    /// the paper needs no Fit button of its own.
1761    ///
1762    /// The toolbar draws BEFORE the dock in `ui`, so a fit requested from the
1763    /// button lands on this very frame.
1764    pub fn zoom_to_fit(&mut self) {
1765        if self.docs.engine().sheet_open().is_some() {
1766            self.viewport.request_sheet_fit();
1767        } else {
1768            self.docs.engine_mut().zoom_to_fit();
1769        }
1770    }
1771
1772    /// Run what a WORKBENCH TOOLBAR button does, by its `WorkbenchButton::id`.
1773    ///
1774    /// Split out of `ui` so a toolbar click and the `workbench_button` command
1775    /// run the SAME arms: the workbench registry declares a button, this
1776    /// dispatches it, and the automation surface owns no second copy of the
1777    /// list. Returns whether the id was known.
1778    pub fn dispatch_workbench_button(&mut self, id: &str) -> bool {
1779        // The eCAD workbenches' buttons (Diagram, PCB, Symbol, Pads) are
1780        // eCAD's own actions: run on the active document's editors with
1781        // eCAD's `run_action`, so a press does exactly what eCAD's toolbar
1782        // button does. `None` is any other id, which falls through to the
1783        // arms below.
1784        if crate::workbench::ecad::dispatch(&mut self.docs.active_mut().ecad, id).is_some() {
1785            return true;
1786        }
1787        // The SHEET's constructions are the Drawing workbench's two tables plus
1788        // the section and the detail, and each press is the PMI annotation
1789        // buttons' workflow: the object is created on the open sheet with
1790        // nothing picked yet, and its dialog opens (through the dialog door,
1791        // which surfaces the Sheets pane) — where its reference rows are picked
1792        // on the paper with the reference picker.
1793        if let Some((kind, alignment)) = crate::workbench::drawing::dim_tool(id) {
1794            let engine = self.docs.engine_mut();
1795            if let Err(error) = engine.sheet_add_dimension(None, kind, Some(alignment), Vec::new()) {
1796                engine.push_notice(format!("Sheet dimension: {error}"));
1797            }
1798            return true;
1799        }
1800        if let Some(axis) = crate::workbench::drawing::ord_tool(id) {
1801            let engine = self.docs.engine_mut();
1802            if let Err(error) = engine.sheet_add_ordinate(None, axis, None, Vec::new()) {
1803                engine.push_notice(format!("Ordinate set: {error}"));
1804            }
1805            return true;
1806        }
1807        if id == crate::workbench::drawing::BOM_BUTTON_ID {
1808            let engine = self.docs.engine_mut();
1809            if let Err(error) = engine.sheet_insert_bom() { engine.push_notice(format!("BOM table: {error}")); }
1810            return true;
1811        }
1812        if id == crate::workbench::drawing::SECTION_BUTTON_ID {
1813            let engine = self.docs.engine_mut();
1814            if let Err(error) = engine.sheet_new_section(None) {
1815                engine.push_notice(format!("Section view: {error}"));
1816            }
1817            return true;
1818        }
1819        if id == crate::workbench::drawing::DETAIL_BUTTON_ID {
1820            let engine = self.docs.engine_mut();
1821            if let Err(error) = engine.sheet_new_detail(None) {
1822                engine.push_notice(format!("Detail view: {error}"));
1823            }
1824            return true;
1825        }
1826        // The SKETCH mode's draw tools are one table too, and they are SHARED —
1827        // every workbench's row carries them while a sketch is being edited, so
1828        // this arm answers for all of them whatever workbench is active.
1829        // Pressing the LIT tool puts it down (back to Select), matching the
1830        // sheet constructions above; pressing the lit Select is already Select.
1831        if let Some(tool) = crate::workbench::sketch::draw_tool(id) {
1832            let engine = self.docs.engine_mut();
1833            let armed = engine.sketch_active_tool().unwrap_or("select") == tool;
1834            engine.sketch_set_tool(Some(if armed { "select" } else { tool }));
1835            return true;
1836        }
1837        // The 3D sketch's tools and working-plane radio, shared the same way.
1838        if let Some(tool) = crate::workbench::sketch3d::draw_tool(id) {
1839            let engine = self.docs.engine_mut();
1840            let armed = engine.sketch3d_active_tool().unwrap_or("select") == tool;
1841            let _ = engine.sketch3d_set_tool(Some(if armed { "select" } else { tool }));
1842            return true;
1843        }
1844        if let Some(plane) = crate::workbench::sketch3d::work_plane(id) {
1845            let _ = self.docs.engine_mut().sketch3d_set_work_plane(plane);
1846            return true;
1847        }
1848        match id {
1849            // The sketch strip's one-shot action, now the row's: infer the
1850            // constraints the rough-in geometry already implies.
1851            crate::workbench::sketch::AUTOCONSTRAIN_BUTTON_ID => {
1852                self.docs.engine_mut().sketch_auto_constrain();
1853            }
1854            // Sheet Metal's flat pattern: open the export modal in its DXF /
1855            // SVG mode; the engine reports "no sheet-metal body in the part"
1856            // as a toast on export.
1857            "sheetmetal.flat_pattern" => {
1858                self.file.dispatch(FileAction::ExportFlatPattern, &mut self.docs, self.model_store.as_ref());
1859            }
1860            // Assembly's Add Component: open the insert-component modal (the
1861            // same flow as the ACOMP palette pick). ONE arm for both rows that
1862            // carry it — PCB BORROWS this very button (`workbench::BORROWED`)
1863            // rather than declaring a second id for the same action.
1864            crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID => {
1865                self.file.dispatch(FileAction::InsertComponent, &mut self.docs, self.model_store.as_ref());
1866            }
1867            // Assembly's interference check: run the engine's pairwise
1868            // intersect sweep NOW and open the results window.
1869            "assembly.interference" => {
1870                self.interference.open_and_run(self.docs.engine_mut());
1871            }
1872            // Assembly's Auto Constraints: open the inference window and scan
1873            // the current placement NOW, so it opens showing real counts.
1874            "assembly.auto_constrain" => {
1875                self.auto_constraints.open_and_scan(self.docs.engine_mut());
1876            }
1877            // Assembly's step.parts library: open the online-library browser
1878            // (search → thumbnails → import a STEP part → add as an ACOMP).
1879            "assembly.step_parts_library" => {
1880                self.step_parts.open();
1881            }
1882            // Wire harness's Declare connection point: the part's FIRST port
1883            // group, with one point at the origin. It exists because the
1884            // Qualify pane is on screen only while the part declares some — so
1885            // without it a 3D-only part could never get its first one from the
1886            // UI at all. The pane is surfaced by the dock's own ports door on
1887            // the next frame, once the visibility pass has let it back in.
1888            crate::workbench::wire_harness::DECLARE_POINT_BUTTON_ID => {
1889                let engine = self.docs.engine_mut();
1890                engine.history.set_ports_block(
1891                    Some(serde_json::json!([{
1892                        "name": crate::workbench::wire_harness::FIRST_GROUP,
1893                        "purpose": crate::workbench::wire_harness::FIRST_PURPOSE,
1894                        "points": [{
1895                            "name": crate::workbench::wire_harness::FIRST_POINT,
1896                            "transform": {
1897                                "position": [0.0, 0.0, 0.0],
1898                                "rotationEuler": [0.0, 0.0, 0.0],
1899                            },
1900                        }],
1901                    }])),
1902                    None,
1903                );
1904            }
1905            // CAM's four actions: each hands the CAM pane its request and
1906            // surfaces the pane, which performs it on the frame it draws (the
1907            // pane owns the selection the action reads — which setup an
1908            // operation goes into — so the pane performs it, not the shell).
1909            crate::workbench::cam::NEW_SETUP_BUTTON_ID => {
1910                self.cam.request(crate::panels::cam::CamRequest::NewSetup);
1911                self.show_pane(PaneKind::Cam);
1912            }
1913            crate::workbench::cam::NEW_TOOL_BUTTON_ID => {
1914                self.cam.request(crate::panels::cam::CamRequest::NewTool);
1915                self.show_pane(PaneKind::Cam);
1916            }
1917            crate::workbench::cam::NEW_OPERATION_BUTTON_ID => {
1918                self.cam.request(crate::panels::cam::CamRequest::NewOperation);
1919                self.show_pane(PaneKind::Cam);
1920            }
1921            crate::workbench::cam::GENERATE_BUTTON_ID => {
1922                self.cam.request(crate::panels::cam::CamRequest::Generate);
1923                self.show_pane(PaneKind::Cam);
1924            }
1925            // Instructions: New from assembly opens the instructions in a new
1926            // tab (the open assembly, saved, is the target); the wizard and a
1927            // new step go to the pane, which owns the selected step.
1928            crate::workbench::instructions::NEW_BUTTON_ID => {
1929                let (_, docs, store) = self.file_docs_store();
1930                match crate::instructions::new_instructions(docs, store, crate::instructions::Source::Active, true) {
1931                    Ok(_) => self.show_pane(PaneKind::Instructions),
1932                    Err(error) => self.docs.engine_mut().push_notice(format!("New instructions: {error}")),
1933                }
1934            }
1935            crate::workbench::instructions::WIZARD_BUTTON_ID => {
1936                self.instructions.request(crate::panels::instructions::InstructionsRequest::Wizard);
1937                self.show_pane(PaneKind::Instructions);
1938            }
1939            crate::workbench::instructions::NEW_STEP_BUTTON_ID => {
1940                self.instructions.request(crate::panels::instructions::InstructionsRequest::NewStep);
1941                self.show_pane(PaneKind::Instructions);
1942            }
1943            // Play starts at the first step, or plays the shown one again.
1944            crate::workbench::instructions::PLAY_BUTTON_ID => {
1945                let shown = self.docs.engine().instructions_shown_step().is_some();
1946                self.instructions_player.request(if shown { brep_render::engine_state::PlayTo::Replay } else { brep_render::engine_state::PlayTo::First });
1947                self.show_pane(PaneKind::InstructionsPlayer);
1948            }
1949            // Simulation: the pane performs them (it draws the timeline the
1950            // new study or motor lands on).
1951            crate::workbench::simulation::NEW_STUDY_BUTTON_ID => {
1952                self.simulation.request(crate::panels::simulation::SimulationRequest::NewStudy);
1953                self.show_pane(PaneKind::Simulation);
1954            }
1955            crate::workbench::simulation::ADD_MOTOR_BUTTON_ID => {
1956                self.simulation.request(crate::panels::simulation::SimulationRequest::AddMotor);
1957                self.show_pane(PaneKind::Simulation);
1958            }
1959            // PMI's Capture view: snapshot the camera + visibility into a new
1960            // active view and surface the PMI pane so its row is seen.
1961            crate::workbench::pmi::CAPTURE_BUTTON_ID => {
1962                self.docs.engine_mut().pmi_capture_view(None);
1963                self.dock.show_pane(PaneKind::Pmi);
1964            }
1965            // Drawing's Back to 3D: leave the open sheet. The paper's own
1966            // widgets go with it, and the sheet keeps its contents — the same
1967            // edit the Sheets pane's Close sheet makes.
1968            crate::workbench::drawing::SHEET_CLOSE_BUTTON_ID => {
1969                let _ = self.docs.engine_mut().sheet_set_open(None);
1970                self.viewport.forget_sheet();
1971            }
1972            // Drawing's Add sheet: a new sheet, opened on the paper, and the
1973            // Sheets pane brought forward so its row is seen.
1974            crate::workbench::drawing::ADD_SHEET_BUTTON_ID => {
1975                self.docs.engine_mut().sheet_add(None);
1976                self.dock.show_pane(PaneKind::Sheets);
1977            }
1978            // Drawing's Place view: the first saved PMI view on the open sheet;
1979            // the placement's form opens (the door surfaces the pane), and its
1980            // `view` field is where a different view is chosen.
1981            crate::workbench::drawing::PLACE_VIEW_BUTTON_ID => {
1982                let engine = self.docs.engine_mut();
1983                let placed = match engine.sheet_pmi_view_ids().first().cloned() {
1984                    Some(view) => engine.sheet_place_view(None, &view, None, None).map(|_| ()),
1985                    None => Err("capture a PMI view in the PMI workbench first".to_string()),
1986                };
1987                if let Err(error) = placed {
1988                    engine.push_notice(format!("Place view: {error}"));
1989                }
1990            }
1991            _ => return false,
1992        }
1993        true
1994    }
1995
1996}
1997
1998impl eframe::App for BrepApp {
1999    /// The browser's automation pump reaches the app between frames through
2000    /// eframe's runner (`WebRunner::app_mut`, `web_pump`). A web-only method.
2001    #[cfg(target_arch = "wasm32")]
2002    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
2003        Some(self)
2004    }
2005
2006    /// Phase 1 of the automation frame (§4.4): queued input commands become
2007    /// egui events; a screenshot that landed completes its reply. Called by
2008    /// every eframe runner before `ui`; the headless host calls it itself.
2009    #[cfg(feature = "automation")]
2010    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
2011        let view = self.viewport.last_rect();
2012        // eframe runs a pass without `ui` when the window is not visible (a
2013        // hidden tab's timer frames, an occluded or minimised window): input
2014        // given to it reaches no widget. Automation input waits for a pass
2015        // that draws (the loop's or the page pump's own, `on_demand`, which
2016        // mark theirs visible), and `activate_widget` keeps the widgets of
2017        // the last pass that drew. egui hit-tests pointer input against the
2018        // PREVIOUS pass's widgets, so the pass right after one that did not
2019        // draw only lays out again, and input waits for the one after it.
2020        //
2021        // A hidden TAB with a WebMCP host attached draws every pass instead:
2022        // eframe runs its timer passes right after the page pump's (each
2023        // repaint request schedules one), and a pass that skips `ui` between
2024        // a press and its release makes egui forget the press. eframe reads
2025        // the root viewport's visibility after this hook, so marking it
2026        // visible here is what has it run `ui`.
2027        #[cfg(target_arch = "wasm32")]
2028        if crate::automation::registry::enabled() {
2029            let root = raw_input.viewports.entry(egui::ViewportId::ROOT).or_default();
2030            if root.occluded == Some(true) {
2031                root.occluded = Some(false);
2032            }
2033        }
2034        let draws = raw_input.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.visible()).unwrap_or(true);
2035        let drew = crate::automation::actions::note_pass(ctx, draws);
2036        if !draws || !drew {
2037            return;
2038        }
2039        self.automation.drain_input(raw_input, ctx.cumulative_frame_nr(), ctx.pixels_per_point(), view, ctx);
2040    }
2041
2042
2043    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
2044        // Open this frame's timing window (banks the previous frame's phase
2045        // totals and its period) and bracket the whole body. See `perf`: on a
2046        // single-threaded shell a long frame IS a frozen UI, so what the frame
2047        // spends is the whole diagnosis.
2048        crate::perf::tick();
2049        let _ui_span = crate::perf::span(crate::perf::Phase::Ui);
2050        let _frame_trace = crate::perf::trace("frame");
2051
2052        // The native store and PLM queue (`plm::native`): every task whose I/O
2053        // answered since the last frame finishes here, on this thread, FIRST —
2054        // before any early return below (the mesh preview's replaces the whole
2055        // shell) can skip it. The browser drains its microtasks between events
2056        // the same way, whatever the page is showing.
2057        #[cfg(not(target_arch = "wasm32"))]
2058        crate::plm::native::run_pending();
2059        // A PLM save made mid-rebuild is pictured once its run lands.
2060        crate::plm::thumbnail::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
2061        // Its player data too (instructions), and a refused upload is said.
2062        crate::plm::player_data::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
2063        if let Some(notice) = crate::plm::player_data::take_notice() {
2064            self.docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, notice);
2065        }
2066        // The File menu's recent-file pictures that finished since last frame.
2067        crate::plm::thumbnail::persist_recent(self.model_store.as_ref());
2068
2069        // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
2070        // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
2071        // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
2072        // `&mut self` call).
2073        let ctx = ui.ctx().clone();
2074
2075        self.plugins.finish_installations(&mut self.docs, self.model_store.as_ref());
2076        self.plugins.sync_documents(&mut self.docs);
2077        self.javascript.poll(&ctx, &mut self.docs, self.model_store.as_ref());
2078        self.plugins.poll(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2079
2080        // Phase 2 of the automation frame (§4.4): mutations run before any
2081        // panel draws, so this frame shows their effect.
2082        #[cfg(feature = "automation")]
2083        {
2084            let queue = self.automation.clone();
2085            let frame = ctx.cumulative_frame_nr();
2086            let _t = crate::perf::trace("drain mutate");
2087            queue.drain_app(
2088                crate::automation::command::Phase::Mutate,
2089                &mut crate::automation::command::Ctx { app: self, egui: &ctx },
2090                frame,
2091            );
2092        }
2093
2094        // A measured spin (`perf_spin`) advances one pointer step per frame, here
2095        // — after the mutation drain that may have armed it, so the spin starts
2096        // on the frame it was asked for.
2097        #[cfg(feature = "automation")]
2098        self.step_spin(&ctx);
2099
2100        // --- a different document is active than the panels were drawn for ----
2101        // Checked FIRST, before anything draws: the switch itself happened late
2102        // in some earlier frame (a tab click, a close, an Open), and every
2103        // shared panel is still holding the previous document's transient state.
2104        if self.active_document != self.docs.view_id() {
2105            if !self.javascript.running_in(self.active_document) {
2106                for doc in self.docs.iter_mut() { if doc.id() == self.active_document { doc.engine.cancel_plugin_action(); } }
2107            }
2108            // The Interference window is reset below, but its overlay lives in
2109            // the document being left: take it down there, or switching back
2110            // shows the volumes with no window to clear them.
2111            for doc in self.docs.iter_mut() {
2112                if doc.id() == self.active_document {
2113                    doc.engine.clear_interference_overlay();
2114                }
2115            }
2116            self.plugins.close_action();
2117            self.active_document = self.docs.view_id();
2118            let same_tab = self.active_tab == self.docs.active_id();
2119            self.active_tab = self.docs.active_id();
2120            let bom = same_tab.then(|| std::mem::take(&mut self.bom));
2121            self.reset_document_scoped_state();
2122            if let Some(bom) = bom {
2123                self.bom = bom;
2124            }
2125        }
2126
2127        // --- the workbench decides whether placed parts show their ports -------
2128        // A workbench that shows the Wire Harness panel (Wire harness, All)
2129        // draws the ports components carry; the others — Modeling included,
2130        // though it offers the Port feature — keep the assembly clean. A no-op
2131        // when nothing changed.
2132        {
2133            // The document's name lives in the SHELL (the tab, the file), and a
2134            // drawing sheet's title block prints it. Told to the engine here,
2135            // every frame, because that is where the active tab is known; a
2136            // no-op when it has not changed. It is the DISPLAY name, the one the
2137            // tab shows: a stamp reading `bracket.nbrep` would be naming our
2138            // file format rather than the drawing.
2139            let name = self
2140                .docs
2141                .active()
2142                .name()
2143                .map(crate::store::model_display_name)
2144                .unwrap_or_default();
2145            let engine = self.docs.engine_mut();
2146            engine.set_document_name(&name);
2147            let show = crate::workbench::panel_visible(
2148                &engine.settings.workbench,
2149                crate::workbench::wire_harness::PANEL_ID,
2150                &crate::workbench::ButtonState::of(engine),
2151            );
2152            engine.set_component_ports_visible(show);
2153            // The PMI workbench IS the PMI editing mode: a workbench that shows
2154            // the PMI panel (PMI, All) enters it — the modeling camera /
2155            // visibility / wireframe are remembered — and one that hides it
2156            // leaves it, deactivating the view and restoring them.
2157            let pmi_shown = crate::workbench::panel_visible(
2158                &engine.settings.workbench,
2159                crate::workbench::pmi::PANEL_ID,
2160                &crate::workbench::ButtonState::of(engine),
2161            );
2162            // An exploded view is shown from the Assembly workbench's Constraints
2163            // pane without entering PMI (a capture still remembers the modeling
2164            // state), so that pane counts as an exploded view's home too. With
2165            // neither on screen (nor the paper), the view is put away, so no one
2166            // models on exploded displays.
2167            let exploded_shown = crate::workbench::panel_visible(
2168                &engine.settings.workbench,
2169                crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
2170                &crate::workbench::ButtonState::of(engine),
2171            ) && engine
2172                .pmi_active_view()
2173                .is_some_and(|id| engine.is_exploded_view(id));
2174            let paper_shown = crate::workbench::panel_visible(
2175                &engine.settings.workbench,
2176                crate::workbench::drawing::SHEETS_PANEL_ID,
2177                &crate::workbench::ButtonState::of(engine),
2178            );
2179            // A step of the instructions shows through its view, so the
2180            // Instructions and Player panes are a view's home too.
2181            let instructions_shown = [crate::workbench::instructions::PANEL_ID, crate::workbench::instructions::PLAYER_PANEL_ID]
2182                .into_iter()
2183                .any(|panel| crate::workbench::panel_visible(&engine.settings.workbench, panel, &crate::workbench::ButtonState::of(engine)));
2184            if pmi_shown && !engine.pmi_workbench_entered() {
2185                engine.pmi_enter_workbench();
2186            } else if !pmi_shown && !exploded_shown && !instructions_shown && engine.pmi_workbench_entered() {
2187                engine.pmi_leave_workbench();
2188            }
2189            if !pmi_shown && !exploded_shown && !paper_shown && !instructions_shown && engine.pmi_active_view().is_some() {
2190                engine.pmi_deactivate_view();
2191            }
2192            // The DRAWING workbench owns the paper: LEAVING it for a workbench
2193            // that hides the Sheets pane closes an open sheet, so the central
2194            // tile is not left drawing paper whose tools, pane and way back are
2195            // all hidden. It is a MODE change — the sheets and their contents
2196            // are untouched — and an edge, see `sheets_pane_shown`.
2197            let sheets_shown = crate::workbench::panel_visible(
2198                &engine.settings.workbench,
2199                crate::workbench::drawing::SHEETS_PANEL_ID,
2200                &crate::workbench::ButtonState::of(engine),
2201            );
2202            if self.sheets_pane_shown && !sheets_shown && engine.sheet_open().is_some() {
2203                let _ = engine.sheet_set_open(None);
2204                self.viewport.forget_sheet();
2205            }
2206            self.sheets_pane_shown = sheets_shown;
2207        }
2208
2209        // --- GUI chrome theme -------------------------------------------------
2210        // Apply the user's theme preference to the egui chrome every frame
2211        // (idempotent: `set_theme` just stores the preference). Auto follows the
2212        // OS/system theme (prefers-color-scheme on web); egui falls back to dark
2213        // when no OS signal is available. This controls panels/windows/toolbar/
2214        // text only — the 3D viewport `background` is a separate setting.
2215        ctx.set_theme(match self.docs.engine().settings.theme {
2216            ThemeMode::Auto => egui::ThemePreference::System,
2217            ThemeMode::Light => egui::ThemePreference::Light,
2218            ThemeMode::Dark => egui::ThemePreference::Dark,
2219        });
2220
2221        // --- global UI size scale --------------------------------------------
2222        // Apply the user's "UI scale" to the whole egui chrome every frame. This
2223        // is idempotent when unchanged (`set_zoom_factor` only repaints on an
2224        // actual change) and composes with the native device pixel ratio
2225        // (pixels_per_point = zoom_factor * native_pixels_per_point).
2226        //
2227        // Defer live UI rescale while the user drags the Settings "UI scale" slider:
2228        // the slider value updates continuously, but only commit it to the actual egui
2229        // zoom once the pointer is released, so the whole UI doesn't rescale under the
2230        // cursor mid-drag.
2231        let pointer_down = ctx.input(|i| i.pointer.any_down());
2232        if !pointer_down {
2233            self.applied_ui_scale = self.docs.engine().settings.ui_scale;
2234        }
2235        ctx.set_zoom_factor(self.applied_ui_scale);
2236        crate::viewport::touch::prepare_browser_input(ui);
2237
2238        // --- history-runner pump ---------------------------------------------
2239        // Apply any completed background history run BEFORE panels read the scene.
2240        // For the synchronous InlineRunner this is a no-op (`rerun_history` already
2241        // pumped its own submit), so nothing changes today; it is the seam a future
2242        // native-thread / wasm-worker runner lands its reply through. While a run is
2243        // still in flight, keep the frame loop alive so its reply gets pumped — for
2244        // Inline `run_pending()` is always false, so this never fires.
2245        //
2246        // EVERY open document is pumped, not just the active one — see
2247        // `Documents::pump_all`, which also keeps the active document's parts
2248        // library in the kernel's store when a background tab's run lands.
2249        let work_in_flight = {
2250            let _t = crate::perf::trace("pump_all");
2251            self.docs.pump_all()
2252        };
2253        if work_in_flight {
2254            ctx.request_repaint();
2255        }
2256        // The CAM simulation player, whether or not its pane is drawn.
2257        let dt = ctx.input(|i| i.stable_dt as f64).clamp(0.0, 0.25);
2258        if self.cam.sim.pump(self.docs.engine_mut(), dt) {
2259            ctx.request_repaint();
2260        }
2261        // An exploded view's Explode / Collapse animation.
2262        if self.docs.engine_mut().exploded_tick(dt) {
2263            ctx.request_repaint();
2264        }
2265        // A motion study: its bake (a few frames per tick) and its playhead,
2266        // driven only while the workbench offers the Simulation pane
2267        // (Simulation, All); the frame that leaves it puts every posed part
2268        // and the camera back, and a document edit made in another workbench
2269        // never wakes the study there (`SimulationPanel::drive_playback`).
2270        let simulation_shown = {
2271            let engine = self.docs.engine();
2272            crate::workbench::panel_visible(
2273                &engine.settings.workbench,
2274                crate::workbench::simulation::PANEL_ID,
2275                &crate::workbench::ButtonState::of(engine),
2276            )
2277        };
2278        if self.simulation.drive_playback(self.docs.engine_mut(), simulation_shown, dt) {
2279            ctx.request_repaint();
2280        }
2281        // The pane's Export button: the app owns the viewport's render core.
2282        if let Some(request) = self.simulation.take_export() {
2283            #[cfg(not(target_arch = "wasm32"))]
2284            let result = {
2285                let spec = crate::simulation_export::ExportSpec {
2286                    study: Some(request.study.clone()),
2287                    format: request.format.clone(),
2288                    width: request.width,
2289                    height: request.height,
2290                    fps: Some(request.fps),
2291                    path: Some(request.path.clone()),
2292                };
2293                crate::simulation_export::export(self, &spec).map(|(report, _)| report.summary())
2294            };
2295            #[cfg(target_arch = "wasm32")]
2296            let result = Err::<String, String>(format!("video export is native only (asked for {})", request.format));
2297            self.simulation.report(result);
2298            ctx.request_repaint();
2299        }
2300        // An instructions step's parts coming home along their arrows.
2301        if self.docs.engine_mut().instructions_insertion_tick(dt) {
2302            ctx.request_repaint();
2303        }
2304        self.docs.show_work_part_bar(ui, self.model_store.as_ref());
2305        if self.show_stl_preview(ui) {
2306            // The preview REPLACES the shell for the frame, and this early
2307            // return used to take the frame's READ drain with it: while a
2308            // preview was open every read-only automation command — `ping`,
2309            // `state_get`, `hit_rects`, a screenshot — went unanswered until
2310            // the host gave up on it, so no script could observe the preview it
2311            // had just opened. The preview publishes its own state and rects
2312            // above; this answers the reads that ask for them.
2313            #[cfg(feature = "automation")]
2314            self.drain_reads(&ctx);
2315            return;
2316        }
2317        if crate::automation::registry::enabled() {
2318            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
2319            for name in ["__brepImportPreview", "__brepImportRegions"] {
2320                crate::automation::registry::publish(name, crate::automation::state_blobs::doc(name), "null");
2321            }
2322        }
2323
2324        // The tab strip's dirty dots, refreshed once per frame (cheap — see
2325        // `Document::refresh_dirty_marker`).
2326        {
2327            let _t = crate::perf::trace("refresh_dirty_markers");
2328            self.docs.refresh_dirty_markers();
2329        }
2330
2331        // --- boot-load (?loadModel=): apply the fetched model once it lands ----
2332        // Replaces the seed with the URL-specified document (armed in `new`). The
2333        // ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
2334        // `load_model_and_fit` arms deferred framing; the pump above reframes it
2335        // next frame. `mark_clean` opens it as a non-dirty document.
2336        if self.pending_boot_load.is_some() {
2337            let received = self
2338                .pending_boot_load
2339                .as_ref()
2340                .and_then(|rx| rx.try_recv().ok());
2341            if let Some(result) = received {
2342                self.pending_boot_load = None;
2343                apply_linked_model(&mut self.docs, result);
2344            }
2345        }
2346
2347        // --- "Open in CAD" (?open=part/…/rev/…) --------------------------------
2348        if let Some(launch) = self.plm_launch.as_mut() {
2349            use crate::panels::plm_launch::Step;
2350            let step = launch.step(self.model_store.as_ref(), &mut self.docs, &mut self.file);
2351            match &step {
2352                Step::SignIn(sentence) => {
2353                    self.settings.show_plm_tab();
2354                    self.docs.engine_mut().push_notice(sentence.clone());
2355                }
2356                Step::Refused(sentence) => self.docs.engine_mut().push_notice(sentence.clone()),
2357                _ => {}
2358            }
2359            if step.settles() {
2360                crate::panels::plm_launch::forget_query();
2361            }
2362            if launch.is_done() {
2363                self.plm_launch = None;
2364            } else {
2365                ui.ctx().request_repaint();
2366            }
2367        }
2368
2369        crate::panels::ecad_parts::sync(self.docs.active_mut());
2370        // A sheet's Duplicate past the last offered occurrence: another assembly component.
2371        crate::panels::ecad_parts::answer_duplicate_requests(self.docs.active_mut());
2372        let requests = crate::panels::ecad_parts::take_open_requests(self.docs.active_mut());
2373        for key in requests {
2374            self.file.open_document(&mut self.docs, self.model_store.as_ref(), &key);
2375        }
2376        // The parts pane's Add part button presses Assembly's Add Component: one arm.
2377        if crate::panels::ecad_parts::take_add_part_request(self.docs.active_mut()) {
2378            self.dispatch_workbench_button(crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID);
2379        }
2380        // The eCAD host's Update parts button is Constraints' Update components.
2381        if std::mem::take(&mut self.docs.active_mut().ecad_update_requested) {
2382            if let Err(error) = self.update_components.run(self.docs.engine_mut(), self.model_store.as_ref()) {
2383                self.docs.engine_mut().push_notice(format!("Update components: {error}"));
2384            }
2385        }
2386
2387        // --- update-components badge freshness ---------------------------------
2388        // Keep the outdated-parts checker current BEFORE any assembly panel draws
2389        // (the structure tree renders per-node badges ahead of the constraints
2390        // header). Cheap: a real recompute happens only when an applied run or a
2391        // successful store save moved the generation key.
2392        let synced = self.docs.synced_signatures();
2393        self.update_components.ensure_current(
2394            self.docs.engine_mut(),
2395            self.model_store.as_ref(),
2396            self.file.save_generation(),
2397            &synced,
2398        );
2399        // …and the eCAD host marks the components placed from those parts.
2400        self.docs.active_mut().ecad_outdated = self.update_components.outdated().to_vec();
2401        // An Update that would discard an edit made here asks first.
2402        self.update_components.show_conflict(ui.ctx(), self.docs.engine_mut(), self.model_store.as_ref());
2403
2404        // --- async-safe first-model framing -----------------------------------
2405        // The seed run is async under a background runner (native thread / wasm
2406        // worker), so the boot `zoom_to_fit` may have run before any solid existed.
2407        // Frame the model ONCE, the first frame the seed run has fully landed (solids
2408        // present AND no run still in flight). Under the synchronous Inline runner
2409        // (tests) both hold on the very first frame, so this is identical to today.
2410        if !self.first_run_framed
2411            && !self.docs.engine().run_pending()
2412            && self.docs.engine().has_solids()
2413        {
2414            self.docs.engine_mut().zoom_to_fit();
2415            self.first_run_framed = true;
2416        }
2417
2418        self.handle_shortcuts(&ctx);
2419
2420        // A form field's reference pick on a touch or narrow window is a
2421        // FOCUSED pick: every piece of chrome — toolbar and ribbon, workbench
2422        // strip, status bar, docks and the dialog in them, the action overlay
2423        // — steps aside and the view fills the window, with the PICK BAR
2424        // (`panels::pick_bar`: what is picked, the picks, the filter button,
2425        // Finish, Cancel) its only control. Finish or Cancel brings all of it
2426        // back as it was. Decided once, here, before any of it is drawn.
2427        let pick_layout = {
2428            let engine = self.docs.engine();
2429            crate::panels::pick_bar::PickLayout::of(
2430                engine.ref_select_active(),
2431                engine.ref_select_in_form(),
2432                crate::viewport::touch::narrow(&ctx),
2433            )
2434        };
2435        let focused_pick = pick_layout == crate::panels::pick_bar::PickLayout::Focused;
2436        crate::viewport::touch::set_chrome_hidden(&ctx, focused_pick);
2437
2438        // --- top toolbar: primary actions, drawn FIRST so its top strip is
2439        // reserved above the left panel + central viewport. A clicked File button
2440        // returns an action the file dialog acts on (open its modal / save / new).
2441        self.toolbar.read_only = !self.docs.active().access().is_editable();
2442        if crate::viewport::touch::compact(&ctx) {
2443            self.toolbar.documents = document_tabs::MenuState::from_documents(&self.docs);
2444        }
2445        let toolbar_outcome = if focused_pick {
2446            self.toolbar.clear_hits();
2447            crate::panels::toolbar::ToolbarOutcome::default()
2448        } else {
2449            let doc = self.docs.view_mut();
2450            self.toolbar.show(
2451                ui,
2452                &mut doc.engine,
2453                Some(&doc.ecad),
2454                self.model_store.as_ref(),
2455                &mut self.settings.open,
2456                &mut self.part_properties.open,
2457                &mut self.info.open,
2458            )
2459        };
2460        crate::panels::set_chrome_bottom(&ctx, ui.available_rect_before_wrap().top());
2461        if toolbar_outcome.toggle_mobile_panels { self.dock.toggle_mobile_drawer(); }
2462        if crate::viewport::touch::compact(&ctx) {
2463            self.apply_document_actions(toolbar_outcome.documents.activate, toolbar_outcome.documents.close);
2464            self.document_tab_hits = toolbar_outcome.documents.hits;
2465        }
2466        if toolbar_outcome.plugins { self.plugins.open = !self.plugins.open; }
2467        if toolbar_outcome.javascript { self.javascript.open = !self.javascript.open; }
2468        if let Some(id) = toolbar_outcome.plugin_action {
2469            if let Err(error) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(error); }
2470        }
2471        self.plugins.active_document = self.docs.active_id();
2472        self.plugins.show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2473        // Run goes to the app's own script runner (its server's code mode),
2474        // started on the first run; installed once the window is opened.
2475        #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2476        if self.javascript.open && self.javascript.host.is_none() {
2477            self.javascript.host = Some(Box::new(crate::mcp::EditorHost::new(self.automation.clone(), self.diagnostics.adapter_line())));
2478        }
2479        // In the browser: the page's Web Worker runner (src/webmcp.rs).
2480        #[cfg(all(target_arch = "wasm32", feature = "mcp", feature = "scripting"))]
2481        if self.javascript.open && self.javascript.host.is_none() {
2482            self.javascript.host = Some(Box::new(crate::webmcp::PageScripts));
2483        }
2484        self.javascript.show(&ctx, &mut self.docs, self.model_store.as_ref());
2485        // The panels' exports (a plugin bundle, the script) leave through the
2486        // file dialog's export door: the Save-file chooser on desktop, a
2487        // download in the browser.
2488        let exports = self.plugins.take_save_requests().into_iter().chain(self.javascript.take_save_requests());
2489        for request in exports {
2490            if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2491                self.docs.engine_mut().push_notice(error);
2492            }
2493        }
2494        // An inbox row opens its review in the PLM pane.
2495        for event in toolbar_outcome.plm_review {
2496            self.plm.open_review(event);
2497        }
2498        if let Some(name) = toolbar_outcome.recent_document {
2499            self.file
2500                .open_document(&mut self.docs, self.model_store.as_ref(), &name);
2501        }
2502        if let Some(action) = toolbar_outcome.file {
2503            self.file
2504                .dispatch(action, &mut self.docs, self.model_store.as_ref());
2505        }
2506        // Submit Bug: begin the screenshot-capture + report flow. `request`
2507        // grabs the current frame (before its dialog exists) and the model, so
2508        // it must run THIS frame while the shot is still dialog-free.
2509        if toolbar_outcome.bug_report {
2510            self.bug_report.request(&ctx, self.docs.engine(), &self.diagnostics);
2511        }
2512        // A workbench toolbar button click surfaces its id here; the dispatch
2513        // itself is a METHOD so the `workbench_button` command runs the very
2514        // same arms (a button an agent can only reach by clicking is a button
2515        // an agent cannot reach when something covers it).
2516        if let Some(id) = toolbar_outcome.workbench_button {
2517            self.dispatch_workbench_button(id);
2518        }
2519        // Zoom-to-fit: the ONE button frames whatever the tile draws — the open
2520        // sheet's paper, or the 3D scene. See `BrepApp::zoom_to_fit`.
2521        if toolbar_outcome.zoom_to_fit {
2522            self.zoom_to_fit();
2523        }
2524
2525        // --- workbench actions toolbar: a second strip directly UNDER the
2526        // primary toolbar (egui stacks top panels in call order) listing the
2527        // active workbench's creatable features + the constraint types. Off via
2528        // the Settings checkbox, and never in sketch / reference-selection mode
2529        // (the panel decides — see `WorkbenchToolbarPanel::visible`). A feature
2530        // click adds exactly what the palette pick would, so ACOMP still routes
2531        // to the component selector; a constraint click is the context bar's
2532        // constraint offer, seeded from the current selection.
2533        // A read-only document (a PLM revision not checked out, or released)
2534        // draws the strip disabled: every button on it creates something.
2535        self.workbench_toolbar.read_only = !self.docs.active().access().is_editable();
2536        let actions = if self.docs.engine().settings.toolbar_style
2537            == brep_render::style::ToolbarStyle::Classic
2538        {
2539            self.workbench_toolbar.show(ui, self.docs.engine())
2540        } else {
2541            self.workbench_toolbar
2542                .set_ribbon_hits(self.toolbar.home_hits());
2543            toolbar_outcome.creation
2544        };
2545        if let Some(type_code) = actions.feature {
2546            self.history
2547                .add_feature_of_type(self.docs.engine_mut(), &type_code);
2548            // No `show_pane` here: `HistoryPanel::open_form` moved the target and
2549            // the dialog door surfaces History for it (`surface_open_dialogs`
2550            // below). ACOMP opens the component-selector MODAL instead of a
2551            // feature dialog, so it moves no target and surfaces no pane.
2552            if self.history.take_insert_component_request() {
2553                self.file.dispatch(
2554                    FileAction::InsertComponent,
2555                    &mut self.docs,
2556                    self.model_store.as_ref(),
2557                );
2558            }
2559        }
2560        if let Some(type_id) = actions.constraint {
2561            // The strip offers every type regardless of the selection (unlike the
2562            // context bar's gated offers), so a refusal has to be SAID: a toast,
2563            // never a silent no-op. A document with no components has no assembly
2564            // session to add to (the kernel's own message names the session, not
2565            // the cause) — say what is actually missing.
2566            let engine = self.docs.engine_mut();
2567            if !engine.history_has_assembly() {
2568                engine.push_notice(
2569                    "Add constraint: the document has no components — insert a component first"
2570                        .to_string(),
2571                );
2572            } else {
2573                // The add leaves the new constraint OPEN (the kernel mints it
2574                // that way), so the dialog door surfaces the Constraints pane.
2575                if let Err(error) =
2576                    crate::panels::context_bar::add_constraint_from_selection(engine, &type_id)
2577                {
2578                    engine.push_notice(format!("Add constraint: {error}"));
2579                }
2580            }
2581        }
2582        if let Some(type_id) = actions.annotation {
2583            // The context bar's annotation offer, from the strip: seeded from
2584            // the selection, and the new annotation's form opens through the
2585            // dialog door. The strip offers every type whatever is selected, so
2586            // a refusal — no active view to add to — is said, not swallowed.
2587            let engine = self.docs.engine_mut();
2588            if let Err(error) = crate::panels::context_bar::add_pmi_from_selection(engine, &type_id) {
2589                engine.push_notice(format!("Add annotation: {error}"));
2590            }
2591        }
2592
2593        // --- sketch mode has NO strip of its own: its draw tools are workbench
2594        // buttons in the row above (`workbench::sketch`), offered while a sketch
2595        // is being edited, so the 3D viewport is the full-width sketching
2596        // surface with nothing between it and the one toolbar.
2597
2598        // --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
2599        // chosen by context each frame. Drawn AFTER the top bars but BEFORE the
2600        // left panel(s) so it reserves the FULL bottom width and the left column
2601        // stops above it (egui resolves reserved space by call order). It is a
2602        // HOST: the branch below picks what to draw. A NEW context is added by
2603        // extending this branch (e.g. `else if engine.some_mode() { … }`) and
2604        // routing through the owning panel's `show_status_bar` for DRY styling.
2605        // Its top is the floor the toasts stack up from (`Toasts::show`).
2606        //
2607        // Its RIGHT END is the app-wide working indicator (`panels::busy`),
2608        // whatever the context: collected here from every source, drawn first
2609        // in a right-to-left row so the context content gets what is left of
2610        // the width and never runs under it.
2611        self.collect_busy(&ctx);
2612        let status_bar_top = if focused_pick {
2613            // The pick bar carries the filter button; nothing of the bar
2614            // stays published.
2615            self.busy.clear_hits();
2616            self.selection.clear_hits();
2617            ui.max_rect().bottom()
2618        } else { egui::containers::panel::Panel::bottom("brep-status-bar")
2619            .resizable(false)
2620            .min_size(30.0)
2621            .show(ui, |ui| {
2622                ui.add_space(2.0);
2623                // `Align::Min`, not `Center`: the bar's height is not known
2624                // until its content is laid out, and centring against the
2625                // unbounded space below doubled the bar with the indicator on
2626                // a row of its own.
2627                ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
2628                    self.busy.show_status_bar(ui);
2629                    ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
2630                        if self.docs.engine().sketch_mode() {
2631                            // Sketch context: the status row (title / DOF / N selected /
2632                            // undo-redo / Lock). The selection-filter row is NOT drawn
2633                            // now, so drop its stale hit-rects (the verifier must never
2634                            // click a phantom rect for an off-screen widget).
2635                            self.selection.clear_hits();
2636                            self.sketch.show_status_bar(ui, self.docs.engine_mut());
2637                        } else if self.docs.engine().sketch3d_mode() {
2638                            // The 3D sketch's status row: DOF, selection, the
2639                            // working plane in force and what the last click hit.
2640                            self.selection.clear_hits();
2641                            self.sketch3d.show_status_bar(ui, self.docs.engine_mut());
2642                        } else if crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench).is_some() {
2643                            // An eCAD editor owns the central tile, and a click there
2644                            // picks nothing 3D: the pickable-kinds filter would be a
2645                            // row of dead checkboxes. The strip carries the editor's
2646                            // own line instead: where the pointer is and what is under it.
2647                            self.selection.clear_hits();
2648                            self.viewport.show_ecad_status(ui);
2649                        } else {
2650                            // Modeling context: the selection filter (pickable kinds).
2651                            let document = self.docs.active_id();
2652                            self.selection.show_status_bar(ui, self.docs.engine_mut(), document);
2653                        }
2654                    });
2655                });
2656                ui.add_space(2.0);
2657            })
2658            .response
2659            .rect
2660            .top() };
2661        // Cancel on the working indicator: sent to the document whose run it
2662        // named, which a background tab's run need not be the active one.
2663        if let Some(activity) = self.busy.take_cancel() {
2664            if let Some(doc) = self.docs.iter_mut().find(|doc| Some(doc.id()) == activity.document) {
2665                doc.engine.cancel_run();
2666            }
2667        }
2668
2669        // --- central region: the dock tree, OR (special modes) the bare 3D view
2670        // ---------------------------------------------------------------------
2671        // Normal modeling mode: ONE egui_tiles tree fills the whole remaining
2672        // area between the top toolbar and the bottom status bar. Every side-panel
2673        // section AND the 3D viewport are tiles the user can split / tab / resize /
2674        // drag-rearrange, and the layout persists. Which side panes are visible is
2675        // filtered per-workbench inside the dock (`workbench::panel_visible`).
2676        //
2677        // Sketch mode and reference-selection are "special modes" that take over
2678        // the shell: they BYPASS the tree and draw the viewport directly, so the
2679        // modeling side panes don't appear (sketch's own entity-list panel + the
2680        // top-right mode card own those flows). Drawing the viewport HERE — before
2681        // the top-right overlay below — keeps `viewport.last_rect()` current-frame
2682        // so the overlay anchors to the live 3D-view rect with no lag.
2683        let sketch = self.docs.engine().any_sketch_mode();
2684        // Only a CARD-based pick (a connection point's seat, a spline anchor's
2685        // port) takes the shell over. A FORM FIELD's pick — a feature's, a
2686        // constraint's, an annotation's, a sheet object's — keeps the dock: the
2687        // field is drawn focused in its own pane and the picks go to it, the
2688        // way other CAD applications keep the dialog up while you click in the
2689        // view (`EngineState::ref_select_in_form`).
2690        // Except on a touch or narrow window, where the dock and the dialog
2691        // in it would cover the view and leave nothing to pick: there a form
2692        // field's pick takes the shell over too (`focused_pick`, decided
2693        // before the toolbar is drawn).
2694
2695        let compact = crate::viewport::touch::compact(&ctx);
2696        let entity_drawer_id = egui::Id::new("brep-mobile-sketch-entities");
2697        let mut entity_drawer_open = sketch && ctx.data(|d| d.get_temp::<bool>(entity_drawer_id)).unwrap_or(false);
2698        let mut mobile_sketch_hits: std::collections::HashMap<String, egui::Rect> = std::collections::HashMap::new();
2699        if sketch && compact {
2700            egui::containers::panel::Panel::top("brep-mobile-sketch-controls").resizable(false).show(ui, |ui| {
2701                ui.horizontal(|ui| {
2702                    self.mode_bar.card(ui, self.docs.engine_mut());
2703                });
2704            });
2705            self.sketch.clear_list_hits();
2706            self.sketch3d.clear_list_hits();
2707        } else if sketch {
2708            // Sketch entity lists (Curves / Points / Constraints) + solver
2709            // settings — a dedicated left panel, drawn BEFORE the viewport so it
2710            // reserves the left and the viewport fills the rest.
2711            egui::containers::panel::Panel::left("sketch-entities")
2712                .resizable(true)
2713                .default_size(300.0)
2714                .size_range(200.0..=560.0)
2715                .show(ui, |ui| {
2716                    if self.docs.engine().sketch3d_mode() {
2717                        self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
2718                    } else {
2719                        self.sketch.show_entity_lists(ui, self.docs.engine_mut());
2720                    }
2721                });
2722        }
2723
2724        self.history.sync_palette_display(self.model_store.as_ref());
2725        // The PLM panels (a PLM session only): each PLM document's access from
2726        // its revision, and the revisions the user asked to open.
2727        let plm = self.plm.sync(self.model_store.as_ref(), &mut self.docs, self.file.save_generation());
2728        for name in plm.open {
2729            self.file.open_document(&mut self.docs, self.model_store.as_ref(), &name);
2730        }
2731        for (name, folder) in plm.opened_from {
2732            if let Some(doc) = self.docs.index_of(&name).and_then(|index| self.docs.get_mut(index)) {
2733                doc.set_plm_folder(Some(folder));
2734            }
2735        }
2736        // The CAM pane's chooser and save requests (a tool import, a library
2737        // export) go through the shell's doors like the PLM pane's; the file
2738        // the chooser delivers goes back to the pane.
2739        let cam_requests = self.cam.take_shell_requests();
2740        if let Some((tag, title)) = cam_requests.pick_file {
2741            if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
2742                self.file.status = error;
2743            }
2744        }
2745        for request in cam_requests.save_files {
2746            if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2747                self.docs.engine_mut().push_notice(error);
2748            }
2749        }
2750        if let Some(file) = self.file.take_picked_file(crate::panels::cam::IMPORT_TOOLS_TAG) {
2751            self.cam.deliver_picked(self.docs.engine_mut(), &file.name, &file.bytes);
2752        }
2753        // A PLM section asked for a file (Attach a file…, Add file…): the file
2754        // chooser answers, and the file goes back to the section that asked.
2755        if let Some((tag, title)) = plm.pick_file {
2756            if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
2757                self.file.status = error;
2758            }
2759        }
2760        for tag in self.plm.pick_tags() {
2761            if let Some(file) = self.file.take_picked_file(&tag) {
2762                self.plm.deliver_picked(&tag, file);
2763            }
2764        }
2765        if plm.refresh_inbox {
2766            self.toolbar.refresh_inbox();
2767        }
2768        if let Some(why) = plm.sign_in_again {
2769            self.settings.ask_plm_sign_in(why);
2770        }
2771        // A PLM section's downloads leave through the same export door.
2772        for request in plm.save_files {
2773            if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2774                self.docs.engine_mut().push_notice(error);
2775            }
2776        }
2777        // Save As → "A new part" (D9): the PLM pane's New part form, shown.
2778        if self.file.take_plm_new_part() {
2779            self.plm.open_new_part();
2780            self.show_pane(PaneKind::Plm);
2781        }
2782        if self.plm.busy() {
2783            ui.ctx().request_repaint();
2784        }
2785        // Following other clients (`panels::plm_follow`): when the feed moved,
2786        // the inbox asks again too; and the next question gets a frame.
2787        if self.plm.take_followed() {
2788            self.toolbar.refresh_inbox();
2789        }
2790        if let Some(next) = self.plm.next_follow_in() {
2791            ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(next.max(0.1)));
2792        }
2793        if sketch || !pick_layout.shows_dock() {
2794            self.viewport.set_chrome_obscured(compact && entity_drawer_open);
2795            // Special mode: the viewport fills the remaining central area; no
2796            // dock, no modeling side panes — and therefore no DOCUMENT TAB
2797            // STRIP either, which is the guard that keeps a live sketch /
2798            // reference-pick session from having its document swapped out from
2799            // under it.
2800            self.viewport.show(ui, self.docs.engine_mut());
2801            // No dock, so no tab strip: its last-drawn rects must not stay
2802            // published (the compact strip is the toolbar's, set above).
2803            if !compact { self.document_tab_hits.clear(); }
2804            if focused_pick {
2805                let view = self.viewport.last_rect().unwrap_or(ui.max_rect());
2806                self.pick_bar.show(&ctx, view, self.docs.engine_mut(), &mut self.selection);
2807                // Settled as the dock's branch settles it: the bar claimed the
2808                // pick, so this finishes a single field on its pick.
2809                self.settle_form_pick();
2810            } else {
2811                self.pick_bar.clear_hits();
2812            }
2813            if compact && sketch {
2814                let view = self.viewport.last_rect().unwrap_or(ui.max_rect());
2815                egui::Area::new(egui::Id::new("brep-mobile-sketch-tab"))
2816                    .order(egui::Order::Middle).fixed_pos(view.left_top() + egui::vec2(2.0, 4.0)).movable(false)
2817                    .show(&ctx, |ui| {
2818                        let tab = crate::panels::dock::mobile_tab(ui, "Entities", entity_drawer_open);
2819                        mobile_sketch_hits.insert("toggle".into(), tab.rect);
2820                        if entity_drawer_open { mobile_sketch_hits.insert("close".into(), tab.rect); }
2821                        if tab.clicked() { entity_drawer_open = !entity_drawer_open; ctx.request_repaint(); }
2822                    });
2823                if entity_drawer_open {
2824                    let panel = egui::Rect::from_min_size(view.left_top() + egui::vec2(52.0, 4.0),
2825                        egui::vec2((view.width() - 60.0).min(400.0).max(100.0), (view.height() - 8.0).max(100.0)));
2826                    crate::panels::dock::mobile_panel(&ctx, entity_drawer_id, panel, |ui| {
2827                        if self.docs.engine().sketch3d_mode() {
2828                            self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
2829                        } else {
2830                            self.sketch.show_entity_lists(ui, self.docs.engine_mut());
2831                        }
2832                    });
2833                }
2834            }
2835            DockState::retract_all(DockContext {
2836                docs: &mut self.docs,
2837                plugins: &mut self.plugins,
2838                viewport: &mut self.viewport,
2839                history: &mut self.history,
2840                bom: &mut self.bom,
2841                assembly_constraints: &mut self.assembly_constraints,
2842                wire_harness: &mut self.wire_harness,
2843                pmi: &mut self.pmi,
2844                sheets: &mut self.sheets,
2845                scene: &mut self.scene,
2846                expressions: &mut self.expressions,
2847                qualify: &mut self.qualify,
2848                family_table: &mut self.family_table,
2849                update_components: &mut self.update_components,
2850                model_store: self.model_store.as_ref(),
2851                plm: &mut self.plm,
2852                cam: &mut self.cam,
2853                instructions: &mut self.instructions,
2854                instructions_player: &mut self.instructions_player,
2855                simulation: &mut self.simulation,
2856            });
2857        } else {
2858            self.pick_bar.clear_hits();
2859            // Normal mode: the dock owns the whole central area (the viewport is a
2860            // pane). Cross-panel requests the panels can't act on while their
2861            // borrows are held bubble OUT via the returned outcome — the SAME
2862            // requests the old left-panel closure produced.
2863            let outcome = self.dock.ui(
2864                ui,
2865                DockContext {
2866                    docs: &mut self.docs,
2867                    plugins: &mut self.plugins,
2868                    viewport: &mut self.viewport,
2869                    history: &mut self.history,
2870                    bom: &mut self.bom,
2871                    assembly_constraints: &mut self.assembly_constraints,
2872                    wire_harness: &mut self.wire_harness,
2873                    pmi: &mut self.pmi,
2874                    sheets: &mut self.sheets,
2875                    scene: &mut self.scene,
2876                    expressions: &mut self.expressions,
2877                    qualify: &mut self.qualify,
2878                    family_table: &mut self.family_table,
2879                    update_components: &mut self.update_components,
2880                    model_store: self.model_store.as_ref(),
2881                    plm: &mut self.plm,
2882                    cam: &mut self.cam,
2883                    instructions: &mut self.instructions,
2884                    instructions_player: &mut self.instructions_player,
2885                    simulation: &mut self.simulation,
2886                },
2887            );
2888
2889            // A form field's pick, settled for the frame: finished when its
2890            // single field has its one pick, when its form asked (an edit
2891            // elsewhere on it), or when no form drew it.
2892            self.settle_form_pick();
2893
2894            // The ACOMP palette pick must open the COMPONENT SELECTOR, never a
2895            // bare feature dialog — the file dialog is shell-owned.
2896            if outcome.insert_component_requested {
2897                self.file.dispatch(
2898                    FileAction::InsertComponent,
2899                    &mut self.docs,
2900                    self.model_store.as_ref(),
2901                );
2902            }
2903            // The DOCUMENT TAB STRIP inside the viewport tile. Activation is
2904            // immediate; a close routes through the file dialog because a dirty
2905            // document has to be confirmed first, and that prompt lives there.
2906            // A pick runs in ONE document's engine: switching or closing a
2907            // document commits a form field's pick first, so it is never left
2908            // running in a document that is no longer on screen.
2909            self.apply_document_actions(outcome.document_tabs.activate, outcome.document_tabs.close);
2910            if !compact { self.document_tab_hits = outcome.document_tabs.hits; }
2911            // A structure-tree Edit — or a BOM row's action button, which
2912            // reports through the same outcome field so there is one arm and
2913            // not two — expands its feature in the history tree.
2914            if let Some(focus) = outcome.feature_focus {
2915                self.history.focus_feature(focus);
2916                if compact { self.dock.show_pane(PaneKind::History); }
2917            }
2918            // Structure-tree interaction hooks route through the SAME dispatcher
2919            // as the context bar (one truth per action); document-level flows
2920            // (edit-in-place / open-part) come back as requests the shell runs.
2921            // A BOM row menu's document-level flow: its engine-mutating half
2922            // already ran inside the panel, through the same dispatcher.
2923            // Double-clicking empty space leaves the Work part.
2924            if self.viewport.take_empty_double_click() && self.docs.work_part_path().is_some() {
2925                if let Err(error) = self.docs.return_to_assembly() {
2926                    self.docs.engine_mut().push_notice(format!("Work part: {error}"));
2927                }
2928            }
2929            if let Some(request) = outcome.component_request.or_else(|| self.viewport.take_component_request()) {
2930                self.run_component_request(request, compact);
2931            }
2932        }
2933
2934        self.history.sync_palette_display(self.model_store.as_ref());
2935
2936        // --- file dialog: a ctx-level modal (like the command palette), drawn
2937        // after the panels so its backdrop dims the whole shell. Idempotent when
2938        // closed; also polls for a completed async import each frame.
2939        self.file
2940            .show(&ctx, &mut self.docs, self.model_store.as_ref());
2941
2942        // --- crash recovery: the boot prompt, then the debounced autosave -----
2943        // Drawn with the same ctx-level modal treatment as the file dialog. The
2944        // autosave ticks only once the prompt has resolved (or never existed).
2945        if self.recovery.is_open() {
2946            if let Some(resolution) =
2947                self.recovery.show(&ctx, &mut self.docs, self.model_store.as_ref())
2948            {
2949                self.autosave.note_cleared();
2950                if let crate::recovery::Resolution::Restored(count) = resolution {
2951                    self.docs.engine_mut().push_notice_as(NoticeSeverity::Info, format!(
2952                        "Restored {count} unsaved document{}",
2953                        if count == 1 { "" } else { "s" }
2954                    ));
2955                }
2956            }
2957        } else {
2958            self.recovery.clear_hits();
2959            let now = ctx.input(|i| i.time);
2960            if let Some(due) = self.autosave.tick(&self.docs, self.model_store.as_ref(), now) {
2961                // The frame loop idles between inputs; wake it when the write is due.
2962                ctx.request_repaint_after(std::time::Duration::from_secs_f64(due.max(0.05)));
2963            }
2964        }
2965
2966        // --- desktop update notice: an installed update closes the app through
2967        // the close path below, which keeps unsaved work for recovery.
2968        #[cfg(not(target_arch = "wasm32"))]
2969        if self.updater.show(&ctx) {
2970            self.close_asked = true;
2971        }
2972        self.close_when_written(&ctx);
2973
2974        if let Some((format, name, bytes)) = self.file.take_stl_import() {
2975            self.start_mesh_preview(format, name, bytes);
2976            ctx.request_repaint();
2977        }
2978
2979        // --- Submit Bug: the screenshot-capture state machine + report modal.
2980        // Drawn at ctx level like the file dialog; idempotent while idle. Draws
2981        // NOTHING during capture, so the screenshot it requested never contains
2982        // this dialog.
2983        if let Some(report) = crate::form_view::take_problem_report_request(&ctx) {
2984            self.bug_report.request_for_error(&ctx, self.docs.engine(), &self.diagnostics, &report.dialog, &report.error);
2985        }
2986        self.bug_report.show(&ctx, self.docs.engine_mut());
2987        if let Some((id, summary)) = self.bug_report.take_submitted() {
2988            self.report_followup.record(id, summary);
2989        }
2990        self.report_followup.tick(&ctx, self.docs.engine_mut());
2991        self.sketch.show_text_dialog(&ctx, self.docs.engine_mut());
2992
2993        // --- Info: the licences + this session's diagnostics + the MCP start
2994        // button, a floating window toggled from the toolbar's info button.
2995        // Idempotent when closed. The server's state is read here, each frame
2996        // the window is open, and a click is answered here: the shell owns the
2997        // queue the server attaches to and the adapter line its session names.
2998        #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2999        let mcp_view = if self.info.open { crate::mcp::info_view() } else { crate::panels::info::McpView::Unavailable };
3000        #[cfg(not(all(not(target_arch = "wasm32"), feature = "mcp")))]
3001        let mcp_view = crate::panels::info::McpView::Unavailable;
3002        self.info.show(&ctx, &self.diagnostics, &mcp_view);
3003        #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
3004        if self.info.take_mcp_start() {
3005            // The failure is already in `mcp::status()`, which the window
3006            // shows next frame, and on stderr in the same words.
3007            let _ = crate::mcp::start_from_window(self.automation.clone(), self.diagnostics.adapter_line());
3008        }
3009
3010        // --- Settings: a floating (movable + resizable) window, toggled from the
3011        // toolbar gear button, drawn at ctx level like Properties so it floats
3012        // over the shell. Idempotent when closed. Replaces the old sidebar section.
3013        // This preview runs every frame, including when Settings is closed.
3014        // Exact dirty checks serialize the whole assembly; use the tab's cached
3015        // marker here. finish_reconnect still checks the exact saved bytes before
3016        // switching stores, including metadata edits the marker may not see.
3017        self.settings.reconnect_blocker =
3018            self.reconnect_blocker_with(crate::document::Document::dirty_marker);
3019        self.settings
3020            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
3021        // Signed out here (Forget): the server's refusal that follows is the
3022        // person's own doing, not news to prompt about.
3023        if self.settings.take_plm_signed_out_here() {
3024            self.plm.signed_out_here();
3025        }
3026        // Signed in again after the server refused this session's token: the
3027        // live session takes the new token, for the same user, and goes on with
3028        // its open tabs (no store switch, nothing to close).
3029        if self.settings.plm_signed_out() {
3030            if let (Some((username, token)), Some(client)) = (self.settings.take_fresh_plm_token(), self.model_store.plm_client()) {
3031                if client.me().is_none_or(|me| me.username == username) {
3032                    client.replace_token(&token);
3033                    self.plm.follow_now();
3034                } else {
3035                    self.docs.engine_mut().push_notice(format!(
3036                        "Signed in as {username}, not {}: restart the app (or Connect now) to use the PLM as {username}",
3037                        client.me().map(|me| me.username).unwrap_or_default()
3038                    ));
3039                }
3040            }
3041        }
3042        // The PLM tab's Connect now: switch this session to the PLM live.
3043        #[cfg(not(target_arch = "wasm32"))]
3044        if let Some(root) = self.settings.take_reconnect_request() {
3045            if let Err(why) = self.start_reconnect(root) {
3046                self.settings.reconnected(Err(why));
3047            }
3048        }
3049        #[cfg(not(target_arch = "wasm32"))]
3050        {
3051            self.poll_reconnect();
3052            self.settings.reconnecting = self.reconnecting.is_some();
3053            if self.reconnecting.is_some() {
3054                // The phase limit is checked here: wake for it on a quiet frame.
3055                ctx.request_repaint_after(std::time::Duration::from_millis(250));
3056            }
3057        }
3058
3059        // --- Part Properties: the active document's own BOM attribute record,
3060        // in a floating window beside Settings. The title is passed in because
3061        // the panel takes only the engine, and a user with several tabs open
3062        // must be able to see WHICH part they are annotating.
3063        let part_title = self.docs.active().title();
3064        let part_document = self.docs.active().id();
3065        self.part_properties.configure_plm(self.model_store.plm_client(), self.docs.active().name());
3066        self.part_properties
3067            .show(&ctx, self.docs.engine_mut(), &part_title, part_document);
3068        if std::mem::take(&mut self.assembly_constraints.review_replace_requested) {
3069            self.review_replace();
3070        }
3071        self.replace_dialog
3072            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
3073
3074        ctx.data_mut(|d| d.insert_temp(entity_drawer_id, entity_drawer_open));
3075
3076        // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
3077        // for reference-selection / sketch mode) stacked ABOVE the selection-driven
3078        // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
3079        // top-right so they never overlap, and the context rail uses the SAME
3080        // renderer whether it is showing modeling actions or sketch actions
3081        // (`panels::action_rail`). A modeling create/edit action returns a feature
3082        // id to expand in the history tree.
3083        #[cfg(all(feature = "automation", target_arch = "wasm32"))]
3084        crate::automation::registry::publish("__brepMobileActions", "compact action menu button rectangle", "{}");
3085        if focused_pick || (compact && (entity_drawer_open || (!sketch && self.dock.mobile_drawer_open()))) {
3086            crate::panels::action_rail::close_mobile_actions(&ctx);
3087            self.mode_bar.clear_hits();
3088            self.sketch.clear_context_hits();
3089            self.sketch3d.clear_context_hits();
3090            self.context_bar.clear_view();
3091        } else {
3092            let mut focus: Option<String> = None;
3093            let mut info_targets: Vec<String> = Vec::new();
3094            let mut plugin_action = None;
3095            let mut component_request: Option<ComponentActionRequest> = None;
3096            // Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
3097            // tile), not the window — so it stays glued to the viewport wherever
3098            // docking frames it. The viewport was drawn earlier THIS frame, so its
3099            // rect is current. Before the first draw (`None`) fall back to the
3100            // window's top-right.
3101            let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
3102                .order(egui::Order::Foreground);
3103            overlay = match self.viewport.last_rect() {
3104                Some(rect) => overlay
3105                    .fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
3106                    .pivot(egui::Align2::RIGHT_TOP),
3107                None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
3108            };
3109            overlay
3110                .show(&ctx, |ui| {
3111                    // 0. The family-row preview's card: which row, and Exit.
3112                    self.family_table.preview_card(ui, self.docs.engine_mut());
3113                    // 1. Exit controls for whatever special mode is active.
3114                    if !(compact && sketch) { self.mode_bar.card(ui, self.docs.engine_mut()); }
3115                    // 2. Context actions: sketch actions in sketch mode, else the
3116                    // modeling selection actions. Same rail, mode-appropriate items.
3117                    let has_actions = if self.docs.engine().sketch_mode() { self.docs.engine().sketch_selection_count() > 0 }
3118                        else if self.docs.engine().sketch3d_mode() { self.docs.engine().sketch3d_selection_count() > 0 }
3119                        else { self.docs.engine().has_selection() || self.docs.engine().selected_constraint().is_some() };
3120                    let mut draw_actions = |ui: &mut egui::Ui| {
3121                    if self.docs.engine().sketch_mode() {
3122                        self.sketch.context_card(ui, self.docs.engine_mut());
3123                    } else if self.docs.engine().sketch3d_mode() {
3124                        self.sketch3d.context_card(ui, self.docs.engine_mut());
3125                    } else {
3126                        // Room down to the ViewCube's top, from wherever the
3127                        // cards above left the cursor.
3128                        self.context_bar.max_height = if compact { None } else { self.viewport.last_rect().and_then(|view| {
3129                            let cube: serde_json::Value =
3130                                serde_json::from_str(&self.docs.engine().viewcube_rect_json()).ok()?;
3131                            let (top, h) = (cube["y"].as_f64()? as f32, cube["h"].as_f64()? as f32);
3132                            (h > 0.0).then(|| view.top() + top - 8.0 - ui.cursor().top())
3133                        }) };
3134                        let outcome = self.context_bar.card(ui, self.docs.engine_mut());
3135                        focus = outcome.focus;
3136                        info_targets = outcome.info_targets;
3137                        plugin_action = outcome.plugin_action;
3138                        component_request = outcome.component;
3139                    }
3140                    };
3141                    if compact {
3142                        if has_actions {
3143                            ui.set_min_width(110.0);
3144                            let button = ui.add_sized([110.0, 44.0], egui::Button::new("Actions"));
3145                            if button.clicked() { crate::panels::action_rail::open_mobile_actions(ui.ctx()); }
3146                            draw_actions(ui);
3147                            #[cfg(all(feature = "automation", target_arch = "wasm32"))]
3148                            crate::automation::registry::publish("__brepMobileActions", "shared action chooser and entry rectangles",
3149                                &crate::panels::action_rail::mobile_actions_json(ui.ctx(), button.rect));
3150                        } else {
3151                            crate::panels::action_rail::close_mobile_actions(ui.ctx());
3152                            self.sketch.clear_context_hits();
3153                            self.sketch3d.clear_context_hits();
3154                            self.context_bar.clear_view();
3155                        }
3156                    } else { draw_actions(ui); }
3157                });
3158            if let Some(focus) = focus {
3159                self.history.focus_feature(focus);
3160                if compact { self.dock.show_pane(PaneKind::History); }
3161            }
3162            // The Info action returns one target per selected entity — open (or, on
3163            // dedup, keep) a pinned Info window for each. Drawn below.
3164            if let Some(id) = plugin_action {
3165                if let Err(e) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(e); }
3166            }
3167            if !info_targets.is_empty() {
3168                self.info_windows.open_for(&info_targets, self.viewport.last_rect());
3169            }
3170            // Component document-level flows (the engine-mutating component
3171            // actions already ran inside the bar).
3172            if let Some(request) = component_request {
3173                self.run_component_request(request, false);
3174            }
3175        }
3176
3177        if compact { crate::panels::action_rail::show_mobile_actions(&ctx); }
3178
3179        // --- THE DIALOG DOOR: every dynamically generated dialog that opened
3180        // this frame makes its host pane's tab current.
3181        //
3182        // ONE call, placed after everything that can open one — the dock's panes
3183        // and viewport, and the context bar's overlay above — so a form opened
3184        // anywhere is surfaced in the same frame, before `__brepDock` publishes
3185        // the active tab below. It watches the TARGETS rather than the callers
3186        // because most of them set the target inside the engine or inside a
3187        // panel's draw, with no dock in reach; `panels::dock::DialogTargets`
3188        // carries that reasoning and the openers it covers.
3189        let targets = crate::panels::dock::DialogTargets::read(
3190            &self.history,
3191            self.docs.engine(),
3192            self.docs.active_id(),
3193        );
3194        self.dock.surface_opened_dialogs(targets);
3195
3196        // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
3197        // level like the file dialog so they float over the shell. Each is pinned to
3198        // its open-time object name (selection changes never retarget them); closed
3199        // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
3200        // window opened THIS frame paints this frame.
3201        self.info_windows.show(&ctx, self.docs.engine_mut());
3202
3203        // --- interference results window: same floating idiom, owned report;
3204        // its Re-run button re-drives the engine check.
3205        self.interference.show(&ctx, self.docs.engine_mut());
3206
3207        // --- auto-constraints window: the inference scan + its Create button.
3208        self.auto_constraints.show(&ctx, self.docs.engine_mut());
3209        self.step_parts
3210            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
3211
3212        // --- transient toasts: drain the engine's queued notices (e.g. a sketch
3213        // solve that failed after an edit) and show each briefly. Drawn last so
3214        // the cards float over the whole shell.
3215        let now = ctx.input(|i| i.time);
3216        // All three sources gathered FIRST, because the automation queue must see
3217        // every one of them: `Toasts::extend` keeps only the last few on screen,
3218        // and a message dropped for space is exactly the one a host wants told.
3219        use brep_render::engine_state::NoticeSeverity;
3220        let raised = self.gather_notices();
3221        self.toasts.extend(raised, now);
3222        // An edit after a card was raised supersedes it (`Toasts::note_document`).
3223        self.toasts
3224            .note_document(self.docs.active_id(), self.docs.engine().history.undo_top(), now);
3225        self.toasts.show(&ctx, self.viewport.last_rect(), status_bar_top);
3226
3227        // The state registry (`automation::registry`): the live app + engine
3228        // state, published by name with a one-line doc so a host can read it
3229        // (and, on wasm, mirrored to `window.__brep*` for the verify scripts).
3230        // Purely additive; no render effect. Published AFTER the panels draw so
3231        // the hit-rects are for THIS frame's layout. Off unless a host enabled it.
3232        if crate::automation::registry::enabled() {
3233            let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
3234            // Every blob that is plain app state, from the one producer
3235            // `state_get` also asks at dispatch time (`state_blob_json`).
3236            self.publish_state_blobs(&ctx);
3237            // Keyboard focus, for a driver typing into a widget by its rect: the
3238            // frame counter says a click has been drawn, `rect` is the widget
3239            // egui gave the focus to (null while nothing has it). A typed
3240            // string lands only in a focused field, so a verifier waits on this
3241            // rather than on time (BREP_app/web/verify_plm_helpers.mjs).
3242            let focused = crate::column_tree::focused_rect(ui.ctx());
3243            // The focused value editor's text, taken (not left) so a frame
3244            // with no focused editor publishes null.
3245            let text: Option<String> = ui.ctx().data_mut(|d| d.remove_temp::<String>(crate::column_tree::focused_text_id())).filter(|_| focused.is_some());
3246            crate::automation::registry::publish("__brepFocus", "keyboard focus {frame, rect:[x,y,w,h]|null (the focused widget), text:string|null (a value editor's in-progress string)}", &serde_json::json!({
3247                "frame": ui.ctx().cumulative_frame_nr(),
3248                "rect": focused.map(|r| [r.min.x, r.min.y, r.width(), r.height()]),
3249                "text": text,
3250            }).to_string());
3251            crate::automation::registry::publish("__brepFileHit", "file dialog widget rects", &self.file.hits_json());
3252            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());
3253            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());
3254            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());
3255            crate::automation::registry::publish("__brepExprHit", "expressions panel widget rects", &self.expressions.hits_json());
3256            crate::automation::registry::publish("__brepToolbar", "toolbar rects: shared File menu file:* and workbench:item:<id>; Ribbon tabs/overflow ribbon:* and Home creation wbtb:*; command keys undo redo fit projection wireframe show:* help info bug workbench:btn:<id>. Classic wraps its controls; Ribbon compacts Large commands before whole-group overflow. Keys appear when their widgets are rendered; menu entries appear while open", &self.toolbar.hits_json());
3257            // The workbench actions strip's button rects (`wbtb:feature:<type>` /
3258            // `wbtb:constraint:<type>` / `wbtb:annotation:<type>`); an empty map
3259            // while the strip is hidden.
3260            crate::automation::registry::publish("__brepWorkbenchToolbar", "creation rects: wbtb:* feature:type constraint:type annotation:type, from the Classic workbench strip or Ribbon Home; empty while hidden or another Ribbon tab is selected", &self.workbench_toolbar.hits_json());
3261            // The PLM pane (a PLM session only): its sections' widget rects,
3262            // `plm/section:<id>` and `plm/<section>:<widget>`, and the active
3263            // document's access and revision.
3264            crate::automation::registry::publish("__brepPlmHit", "PLM pane rects: section:lifecycle, lifecycle:revision:<label>, lifecycle:verb:check-out, save:<yes|no|remember>, web:<summary|revisions|attachments|structure|history|parts|workspace|reviews>", &self.plm.hits_json());
3265            crate::automation::registry::publish("__brepToastsHit", "toast card rects (toast:<i> top to bottom, toast:more)", &self.toasts.hits_json());
3266            crate::automation::registry::publish("__brepBugHit", "bug report panel widget rects", &self.bug_report.hits_json());
3267            // The JavaScript editor window: its outer rect against the surface
3268            // it may fill, and its widget rects (`javascript/panel:clip`,
3269            // `javascript/source`, `javascript/output`, the buttons).
3270            crate::automation::registry::publish("__brepJavascript", "JavaScript editor window {open, rect:[x,y,w,h] or null while closed, surface:[w,h], running, output}", &self.javascript.state_json());
3271            crate::automation::registry::publish("__brepJavascriptHit", "JavaScript editor window widget rects (panel:clip, run, cancel, save, open, export, help, example, source, output); empty while the window is closed", &self.javascript.hits_json());
3272            // The wire harness: the document's connections + the last run's
3273            // routing report (engine truth), and the panel's widget rects.
3274            crate::automation::registry::publish("__brepWireHarnessHit", "wire harness panel widget rects", &self.wire_harness.hits_json());
3275            // The CAM block (setups with their operations, tools, operations,
3276            // NC programs) plus the pane's selection and last notice, and the
3277            // pane's rects (`cam:*`).
3278            self.cam.refresh_geometry(self.docs.engine());
3279            crate::automation::registry::publish("__brepCamHit", "CAM pane widget rects (cam:*)", &self.cam.hits_json());
3280            crate::automation::registry::publish("__brepInstructionsHit", "Instructions pane widget rects (instructions:*)", &self.instructions.hits_json());
3281            crate::automation::registry::publish("__brepInstructionsPlayerHit", "Player pane widget rects (player:*)", &self.instructions_player.hits_json());
3282            crate::automation::registry::publish("__brepSimulationHit", "Simulation pane widget rects (simulation:*)", &self.simulation.hits_json());
3283            // The part's declared connection points (the `ports` block) and the
3284            // ports tail's report, beside the Qualify panel's own selection and
3285            // its rects (`qualify:*`).
3286            crate::automation::registry::publish("__brepQualifyHit", "Qualify panel widget rects (qualify:*)", &self.qualify.hits_json());
3287            // The family table as the Family table pane last read it, its
3288            // problems, selection and the last Generate report; and its rects.
3289            crate::automation::registry::publish("__brepFamilyHit", "Family table pane widget rects (family:*)", &self.family_table.hits_json());
3290            // The PMI block + report + active view / open annotation, and the
3291            // PMI panel's rects (`pmi:capture`, `pmi:add`, `pmi:add:<type>`,
3292            // `pmi:row:<id>`, `pmi:cell:<id>:<column>`, `pmi:menu:<id>`, the
3293            // form's `pmi:` keys).
3294            crate::automation::registry::publish("__brepPluginsHit", "Declarative plugin panel action controls", &self.plugins.panel_hits_json());
3295            crate::automation::registry::publish("__brepPmiHit", "PMI panel widget rects (pmi:*)", &self.pmi.hits_json());
3296            // The `sheets` block, which sheet the viewport draws, and the OPEN
3297            // sheet's whole projection — the paper, the visible edge runs and
3298            // the annotations that passed the parallel rule, in millimetres.
3299            // The panel's rects (`sheets:add`, `sheets:place:<view>`,
3300            // `sheets:row:<id>`, the form's `sheets:` keys) ride beside it.
3301            crate::automation::registry::publish("__brepSheetsHit", "Sheets panel widget rects (sheets:*)", &self.sheets.hits_json());
3302            crate::automation::registry::publish("__brepInfoWindowsHit", "info window widget rects", &self.info_windows.hits_json());
3303            crate::automation::registry::publish("__brepInterferenceHit", "interference panel widget rects", &self.interference.hits_json());
3304            crate::automation::registry::publish("__brepAutoConstraintsHit", "auto-constraints widget rects", &self.auto_constraints.hits_json());
3305            crate::automation::registry::publish("__brepStepPartsHit", "STEP parts library widget rects", &self.step_parts.hits_json());
3306            crate::automation::registry::publish("__brepSelectionHit", "selection bar widget rects (filter:kind, clear, hide)", &self.selection.hits_json());
3307            crate::automation::registry::publish("__brepContextHit", "context action bar widget rects", &self.context_bar.hits_json());
3308            crate::automation::registry::publish("__brepPickBarHit", "focused pick bar widget rects (bar, finish, cancel, x<i>)", &self.pick_bar.hits_json());
3309            crate::automation::registry::publish("__brepModeBarHit", "mode bar widget rects (refsel:*, Sketch:*)", &self.mode_bar.hits_json());
3310            // The DOCUMENT TABS: the open models, which one is active, and the
3311            // strip's per-tab hit-rects, so an e2e script can switch and close
3312            // documents the way a user does.
3313            crate::automation::registry::publish(
3314                "__brepDocumentsHit",
3315                "document tab strip widget rects",
3316                &crate::automation::hit_rects::hits_json(
3317                    self.document_tab_hits.iter().map(|(key, rect)| (key, rect)),
3318                ),
3319            );
3320            // The boot-time recovery prompt: its entries and its two buttons.
3321            crate::automation::registry::publish("__brepRecoveryHit", "recovery prompt widget rects", &self.recovery.hits_json());
3322            #[cfg(target_arch = "wasm32")]
3323            crate::automation::registry::publish("__brepMobileDock", "compact dock drawer controls", &self.dock.mobile_state_json());
3324            #[cfg(target_arch = "wasm32")]
3325            crate::automation::registry::publish("__brepMobileSketch", "compact sketch entity drawer controls", &serde_json::json!({
3326                "compact": compact, "open": entity_drawer_open,
3327                "rects": serde_json::from_str::<serde_json::Value>(&crate::automation::hit_rects::hits_json(mobile_sketch_hits.iter())).unwrap_or_default(),
3328            }).to_string());
3329            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()));
3330            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()));
3331            crate::automation::registry::publish("__brepSketch3dHit", "3D sketch-mode overlay widget rects: the 3D SKETCH ACTIONS card (constraint:type fix construction delete) \u{2014} empty out of 3D sketch mode. The draw tools and the working-plane radio are workbench buttons in the toolbar row (toolbar/workbench:btn:sketch3d.tool.* and sketch3d.plane.*)", &self.sketch3d.hits_json(self.docs.engine()));
3332            crate::automation::registry::publish("__brepSketch3dListHit", "3D sketch entity-list widget rects: section:* geometry:id point:id constraint:id del:kind:id reference:name unlink:name panel:clip (empty out of 3D sketch mode)", &self.sketch3d.list_hits_json(self.docs.engine()));
3333            // Viewport origin + projected probe points (viewport-local logical
3334            // px) so the verifier can click precise spots ON the Box and ON the
3335            // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
3336            // pin; indices 1..4 are points on the Pin's cylindrical stub that
3337            // protrudes above the Box top (y=20), on the camera-facing sides — the
3338            // verifier tries them until one picks "Pin".
3339            crate::automation::registry::publish("__brepView", "the 3D viewport rect {x,y,w,h} in egui points", &self.viewport.viewport_rect_json());
3340            // Dock layout snapshot (per-pane visible / rendered) so the verifier
3341            // can see which side panels are on-screen and, once a user tabs panels
3342            // together, activate the right tab before asserting on its widgets.
3343            // `active=false` in sketch / ref-select (the dock is bypassed).
3344            crate::automation::registry::publish("__brepDock", "dock layout in tab-strip order: per-pane visible/activeTab/rendered", &self.dock.state_json(!sketch && pick_layout.shows_dock()));
3345            crate::automation::registry::publish("__brepProbe", "projected seed-model probe points (viewport-local px) for the verifier",
3346                &self
3347                    .docs
3348                    .engine()
3349                    .world_to_screen_json(
3350                        "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
3351                          [10.0,22.5,16.0],[10.0,25.0,10.0]]",
3352                    )
3353                    .unwrap_or_else(|_| "[]".to_string()),
3354            );
3355        }
3356
3357        // The viewport renders only a dirty engine. A panel drawn after it this
3358        // frame may have marked it dirty: draw that on the next frame instead of
3359        // waiting for the next input event.
3360        if self.docs.engine().dirty {
3361            ctx.request_repaint();
3362        }
3363
3364        // Phase 3 of the automation frame (§4.4): reads answer from THIS frame's
3365        // registry and layout.
3366        #[cfg(feature = "automation")]
3367        self.drain_reads(&ctx);
3368
3369        #[cfg(target_arch = "wasm32")]
3370        crate::viewport::touch::publish_editor(&ctx);
3371
3372        // NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
3373        // earlier this frame (normal mode) or drawn directly in the sketch /
3374        // ref-select branch above. Drawing it before the top-right overlay is what
3375        // keeps that overlay anchored to the live viewport rect.
3376    }
3377}
3378
3379
3380/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
3381
3382/// Whether a launch opens on [`seed_history_json`]: the caller asked for the
3383/// seed and the user has not turned it off with the "Start with an empty
3384/// document" setting (`disableStartupModel`).
3385pub(crate) fn starts_on_seed_model(seed: bool, settings: &brep_render::style::RenderSettings) -> bool {
3386    seed && !settings.disable_startup_model
3387}
3388
3389/// The seed model handed to the engine at startup: a 3-feature history so the
3390/// tree / roll / edit are real —
3391///   0. `P.CU` "Box"  — a 20 mm cube at the origin (spans `[0,20]³`).
3392///   1. `P.CY` "Pin"  — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
3393///      cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
3394///   2. `B`    "Cut"  — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
3395///      with a cylindrical through-hole (the ref-select field is visible for the
3396///      next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
3397///
3398/// This is just the INITIAL document — once handed to `EngineState`, the engine
3399/// OWNS the mutable history; the app keeps no copy.
3400pub(crate) fn seed_history_json() -> String {
3401    serde_json::json!({
3402        "expressions": "",
3403        "configurator": {},
3404        "features": [
3405            {
3406                "type": "P.CU",
3407                "inputParams": {
3408                    "id": "Box",
3409                    "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
3410                    "transform": {
3411                        "position": [0.0, 0.0, 0.0],
3412                        "rotationEuler": [0.0, 0.0, 0.0],
3413                        "scale": [1.0, 1.0, 1.0]
3414                    },
3415                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
3416                },
3417                "persistentData": {}
3418            },
3419            {
3420                "type": "P.CY",
3421                "inputParams": {
3422                    "id": "Pin",
3423                    "radius": 6.0, "height": 30.0,
3424                    "transform": {
3425                        "position": [10.0, -5.0, 10.0],
3426                        "rotationEuler": [0.0, 0.0, 0.0],
3427                        "scale": [1.0, 1.0, 1.0]
3428                    },
3429                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
3430                },
3431                "persistentData": {}
3432            },
3433            {
3434                "type": "B",
3435                "inputParams": {
3436                    "id": "Cut",
3437                    "targetSolid": "Box",
3438                    "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
3439                },
3440                "persistentData": {}
3441            }
3442        ]
3443    })
3444    .to_string()
3445}
3446
3447/// Plain-state blobs whose whole input is cheap to compare: each is rebuilt
3448/// only when that input changed, not on every frame the registry publishes
3449/// (the browser build publishes for every page, as its WebMCP host attaches at
3450/// boot). A key holds EVERY input its producer reads, so a cached blob is
3451/// never stale; a blob whose inputs have no such key is built every frame.
3452#[derive(Default)]
3453struct StateBlobCache {
3454    settings: Option<((brep_render::style::RenderSettings, bool), String)>,
3455    workbench: Option<((String, serde_json::Value), String)>,
3456    model: Option<(ModelSignatureKey, String)>,
3457}
3458
3459/// `(rollback step, [(name, triangles, bbox min, bbox max)])`.
3460type ModelSignatureKey = (usize, Vec<(String, usize, [f64; 3], [f64; 3])>);
3461
3462impl StateBlobCache {
3463    /// The cached blob when its key is `key`.
3464    fn hit<K: PartialEq>(slot: &Option<(K, String)>, key: &K) -> Option<String> {
3465        slot.as_ref().filter(|(cached, _)| cached == key).map(|(_, json)| json.clone())
3466    }
3467
3468    /// The blob for `key`: the cached one, else `build`'s, kept under `key`.
3469    fn keyed<K: PartialEq>(slot: &mut Option<(K, String)>, key: K, build: impl FnOnce() -> String) -> String {
3470        if let Some(json) = Self::hit(slot, &key) {
3471            return json;
3472        }
3473        let json = build();
3474        *slot = Some((key, json.clone()));
3475        json
3476    }
3477}
3478
3479/// Everything `BrepApp::model_signature_json` reports, as a comparable key.
3480fn model_signature_key(engine: &EngineState) -> ModelSignatureKey {
3481    let solids = engine
3482        .scene
3483        .solids()
3484        .iter()
3485        .map(|s| (s.name.clone(), s.mesh.indices.len() / 3, s.bbox.min, s.bbox.max))
3486        .collect();
3487    (engine.history_rollback(), solids)
3488}
3489
3490/// Which door a file named on the command line goes through
3491/// ([`BrepApp::open_path_from_command_line`]).
3492#[cfg(not(target_arch = "wasm32"))]
3493#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3494enum CommandLineLane {
3495    /// A model document: File > Open's door.
3496    Open,
3497    /// A file File > Import takes: its importer, into a new untitled tab.
3498    Import,
3499    /// Neither: said so, nothing opened.
3500    Refuse,
3501}
3502
3503/// The lane for a command-line path, by its extension (any case). A name with
3504/// no extension keeps today's Open (the store adds `.nbrep`).
3505#[cfg(not(target_arch = "wasm32"))]
3506fn command_line_lane(path: &str) -> CommandLineLane {
3507    if crate::panels::file::import_kind(path).is_some() {
3508        return CommandLineLane::Import;
3509    }
3510    let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
3511    match base.rsplit_once('.') {
3512        None => CommandLineLane::Open,
3513        Some((_, ext)) if crate::store::MODEL_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) => CommandLineLane::Open,
3514        Some(_) => CommandLineLane::Refuse,
3515    }
3516}
3517