Skip to main content

brep_app/
app.rs

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