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