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