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::component_actions::ComponentActionRequest;
27use crate::panels::context_bar::ContextBarPanel;
28use crate::panels::mode_bar::ModeBar;
29use crate::panels::expressions::ExpressionsPanel;
30use crate::panels::file::{FileAction, FileDialog};
31use crate::panels::history::HistoryPanel;
32use crate::panels::info_windows::InfoWindows;
33use crate::panels::scene::ScenePanel;
34use crate::panels::selection::SelectionPanel;
35use crate::panels::sketch::SketchPanel;
36use crate::panels::settings::SettingsPanel;
37use crate::panels::toasts::Toasts;
38use crate::panels::toolbar::ToolbarPanel;
39use crate::panels::workbench_toolbar::WorkbenchToolbarPanel;
40use crate::panels::update_components::UpdateComponents;
41use crate::panels::bom::BomPanel;
42use crate::panels::dock::{DockContext, DockState, PaneKind};
43#[cfg(target_arch = "wasm32")]
44use crate::panels::document_tabs;
45use crate::store::{default_model_store, ModelStore, SESSION_KEY, SETTINGS_KEY};
46use crate::viewport::Viewport;
47use brep_render::engine_state::EngineState;
48use brep_render::style::ThemeMode;
49use eframe::egui;
50
51pub struct BrepApp {
52    /// Every OPEN MODEL and which one is active. Each document owns a full
53    /// `EngineState` (the shared viewer brain `desktop.rs` / the wasm shell
54    /// wrap) plus its file identity; panels borrow the active one.
55    docs: Documents,
56    /// The central 3D viewport: engine render core + offscreen texture + blit +
57    /// input routing.
58    viewport: Viewport,
59    /// The single persistence seam for settings, layout, and model documents
60    /// (native filesystem / wasm IndexedDB + download/upload).
61    model_store: Box<dyn ModelStore>,
62
63    // --- one small state value per panel --------------------------------------
64    /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
65    /// and the File-actions seam (owned by the concurrent file panel).
66    toolbar: ToolbarPanel,
67    /// New / Open / Save / Save As of the model document (the `.BREP.json`
68    /// recipe) — a reusable modal file dialog opened from the toolbar.
69    file: FileDialog,
70    stl_import: Option<crate::panels::stl_import::StlImportPreview>,
71    /// The "Submit Bug" flow: on the toolbar bug button it screenshots the app
72    /// (UI + 3D model) BEFORE its own dialog opens, then collects a description
73    /// (+ optional email) and POSTs the model + screenshot to the public reports
74    /// endpoint. Native + wasm, one path.
75    bug_report: BugReportPanel,
76    /// Workbench actions toolbar: the second top strip under the primary
77    /// toolbar — one button per feature the active workbench offers, plus the
78    /// constraint types where the Constraints panel is shown. Gated by the
79    /// `showWorkbenchToolbar` setting and hidden in the special modes. A click
80    /// flows out as the type to add; the shell adds it through the SAME paths
81    /// the palette / context bar use.
82    workbench_toolbar: WorkbenchToolbarPanel,
83    /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
84    /// (movable + resizable, toggled from the toolbar gear button), no longer a
85    /// left-panel section.
86    settings: SettingsPanel,
87    /// History feature-tree + schema-driven feature dialog panel (Phase 2).
88    /// NOTE: the editable history is NOT owned here — it lives in the engine core
89    /// (`EngineState.history`), the single source of truth; this panel only reads
90    /// it back to draw and calls the engine's `history_*` methods to mutate.
91    history: HistoryPanel,
92    /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
93    /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
94    /// engine scene/emphasis; owns only transient expand + hit state.
95    scene: ScenePanel,
96    /// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
97    /// the scene's component records — per-instance fixed/visibility/status
98    /// adornments, actions routed to the owning ACOMP feature.
99    /// The BOM panel (claimed by the Assembly workbench): the parts list on
100    /// the shared column-tree widget, with the editable part/occurrence
101    /// attribute columns the Settings "Assemblies" section configures.
102    bom: BomPanel,
103    /// Assembly Constraints panel (claimed by the Assembly workbench): the
104    /// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
105    assembly_constraints: AssemblyConstraintsPanel,
106    /// Update-components checker (build-spec §8.6): compares each parts-library
107    /// entry's `sourceSignature` against the model store's current content.
108    /// Kept current once per frame (cheap generation key: applied run + store
109    /// save); the constraints header reads the count + runs the batch refresh,
110    /// the structure tree reads per-part badges.
111    update_components: UpdateComponents,
112    /// Expressions / parameters panel: the variable sheet (engine-owned history
113    /// `expressions`) feature params reference. Owns only its editor buffer.
114    expressions: ExpressionsPanel,
115    /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
116    /// selection-driven context bar's Info action. Each floating (movable +
117    /// resizable) window is PINNED to one object name at open time — a Metadata
118    /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
119    /// and keeps showing that entity regardless of later selection changes. Replaces
120    /// the old single Properties window.
121    info_windows: InfoWindows,
122    /// Interference results window (assemblies build-spec §9): opened by the
123    /// Assembly workbench's `∩` toolbar button, which runs the engine's
124    /// pairwise-intersect check; a floating window like the Info windows with a
125    /// row per interfering pair (click = select both components), a green
126    /// all-clear pass line, and a Re-run button.
127    interference: crate::panels::interference::InterferenceWindow,
128    /// step.parts online model library browser (Assembly workbench): a ctx-level
129    /// window (opened by the library toolbar button) that searches the public
130    /// step.parts v1 API, shows results with thumbnails, and imports a chosen
131    /// STEP model as a new part document + adds it to the assembly as an ACOMP.
132    step_parts: crate::panels::step_parts::StepPartsPanel,
133    /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
134    /// click may select — honored by the engine's `select_top_at`). The filter +
135    /// selection live in `EngineState`; this panel only reads/writes them.
136    selection: SelectionPanel,
137    /// Context action toolbar: the selection-driven action bar (Clear / Hide /
138    /// Edit-owning-feature + the feature-from-selection actions whose primary
139    /// reference accepts the selected kind). Shown only while something is
140    /// selected; drives the engine directly and returns a feature id for the shell
141    /// to expand in the history tree.
142    context_bar: ContextBarPanel,
143    /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
144    /// + circle to the `set_overlay` channel colored by solver mobility, and shows
145    /// the DOF status readout. The engine-native sketcher's foundation surface.
146    sketch: SketchPanel,
147    /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
148    /// corner — reference-selection, sketch mode, and any future special mode.
149    mode_bar: ModeBar,
150    /// Transient toast overlay: drains the engine's queued notices each frame
151    /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
152    toasts: Toasts,
153    /// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
154    /// tree that hosts every side-panel section AND the 3D viewport as tiles the
155    /// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
156    /// each panel + the engine per frame through [`DockContext`]. Drawn in normal
157    /// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
158    dock: DockState,
159
160    /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
161    /// under a background runner (native thread / wasm worker), so the boot
162    /// `zoom_to_fit` can run before the first solids exist → an unframed first
163    /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
164    /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
165    /// (tests) the scene is already populated, so this fires on the very first frame.
166    first_run_framed: bool,
167
168    /// A model fetch kicked off at boot from a `?loadModel=<url>` query param
169    /// (wasm only — the cadDev admin "Launch model in CAD app" opens the app with
170    /// a report's model URL). When the fetch lands it REPLACES the seed model.
171    /// `None` on native and once applied.
172    pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
173
174    /// The document handle the shared panels were last reset for. Compared to
175    /// `docs.active_id()` at the top of every frame: ONE check catches a switch
176    /// from any source (a tab click, a close, New, Open, Edit Part) instead of a
177    /// hook per call site, and it runs BEFORE any panel draws this frame.
178    active_document: u64,
179
180    /// The DOCUMENT TAB STRIP's per-tab hit-rects from the last dock frame,
181    /// published for the headed verifier. The strip is drawn inside the dock's
182    /// viewport pane, so its rects have to ride back out through the outcome.
183    #[cfg(target_arch = "wasm32")]
184    document_tab_hits: Vec<(String, egui::Rect)>,
185
186    /// The last session blob written through the store — the change detector for
187    /// the open-document list, so persisting cannot be forgotten at a mutation
188    /// site (there is no "session dirty" flag to set).
189    session_saved: String,
190
191    /// The debounced autosave of every dirty document (`crate::recovery`), and
192    /// the boot-time **Recover unsaved work?** prompt its blob feeds. The
193    /// autosave is held while the prompt is open: the clean seed tab would
194    /// otherwise remove the very blob being offered.
195    autosave: crate::recovery::Autosave,
196    recovery: crate::recovery::RecoveryPanel,
197
198    /// The UI zoom scale CURRENTLY applied to the egui context. Tracks
199    /// `settings.ui_scale` but is only synced to it while the pointer is up, so
200    /// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
201    /// the cursor mid-drag — the settled value is committed on release. See the
202    /// zoom-apply block in `ui`.
203    applied_ui_scale: f32,
204}
205
206impl BrepApp {
207    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
208        let render_state = cc
209            .wgpu_render_state
210            .as_ref()
211            .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
212
213        // The viewport owns the render core + blit pipeline, built from eframe's
214        // SHARED device/queue/format.
215        let viewport = Viewport::new(render_state);
216
217        // --- storage seam: load the persisted settings ------------------------
218        let model_store = default_model_store();
219        // wasm: hand the store the egui context so an async file-upload load
220        // callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
221        #[cfg(target_arch = "wasm32")]
222        crate::store::set_repaint_ctx(cc.egui_ctx.clone());
223        let saved_settings = model_store.read(SETTINGS_KEY);
224
225        // --- how a document's engine is built --------------------------------
226        // Every tab gets its OWN engine, and therefore its own history runner —
227        // a runner owns the resident kernel state of the document it executes,
228        // so one shared between documents would apply a background run against
229        // the wrong registry. See `crate::document`.
230        let engine_factory: EngineFactory = Box::new(move || {
231            let mut state = EngineState::new();
232            // Native: run the whole history — and per-object measurement queries — on a
233            // persistent background thread so the UI never freezes during a run or a
234            // selection (M2b). Installed BEFORE anything loads so it builds through it.
235            #[cfg(not(target_arch = "wasm32"))]
236            state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
237            // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
238            // single-threaded wasm UI stays responsive during a run. Same seam. Tests
239            // (which never hit this wasm path) keep the default synchronous InlineRunner.
240            #[cfg(target_arch = "wasm32")]
241            state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
242            state.set_viewcube_enabled(true);
243            // Partial-override apply: unknown/absent keys keep their defaults.
244            if let Some(saved) = &saved_settings {
245                let _ = state.apply_settings_json(saved);
246            }
247            state
248        });
249
250        // Start with the seed model on every launch. Reopening the previous
251        // session can immediately rerun a problematic document and prevent the
252        // user from recovering by restarting the app. Saved models are opened
253        // explicitly through the file dialog instead.
254        let mut docs = Documents::new(engine_factory);
255        let _ = docs.engine_mut().set_history_json(&seed_history_json());
256        docs.engine_mut().zoom_to_fit();
257        docs.active_mut().mark_clean();
258        let session_saved = docs.session_json();
259
260        // The autosave blob from a session that ended with unsaved work: offer
261        // it back (a prompt, never an automatic restore — see `crate::recovery`).
262        let mut recovery = crate::recovery::RecoveryPanel::new();
263        recovery.arm(crate::recovery::read_entries(model_store.as_ref()));
264
265        // The settings panel seeds its working JSON from the (post-load) engine
266        // settings so the widgets reflect the persisted state on first paint.
267        let settings = SettingsPanel::new();
268
269        // New / Open / Save / Save As. Holds no document identity — that lives
270        // on each `Document`.
271        let file = FileDialog::new();
272
273        // Boot at the saved UI scale.
274        let applied_ui_scale = docs.engine().settings.ui_scale;
275        let active_document = docs.active_id();
276
277        // The dock layout (loads the persisted tree, or the default). Built before
278        // `model_store` is moved into `Self`.
279        let dock = DockState::new(model_store.as_ref());
280
281        // Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
282        // fetching that model NOW; the seed still loads this frame and the fetched
283        // model REPLACES it when it lands (drained in `ui`). See the drain block.
284        #[cfg(target_arch = "wasm32")]
285        let pending_boot_load = web_sys::window()
286            .and_then(|w| w.location().search().ok())
287            .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
288            .and_then(|params| params.get("loadModel"))
289            .filter(|url| !url.is_empty())
290            .map(|url| fetch_model(&cc.egui_ctx, url));
291        #[cfg(not(target_arch = "wasm32"))]
292        let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
293
294        Ok(Self {
295            docs,
296            viewport,
297            toolbar: ToolbarPanel::new(),
298            workbench_toolbar: WorkbenchToolbarPanel::new(),
299            model_store,
300            file,
301            stl_import: None,
302            bug_report: BugReportPanel::new(),
303            settings,
304            history: HistoryPanel::new(),
305            scene: ScenePanel::new(),
306            bom: BomPanel::new(),
307            assembly_constraints: AssemblyConstraintsPanel::new(),
308            update_components: UpdateComponents::new(),
309            expressions: ExpressionsPanel::new(),
310            info_windows: InfoWindows::new(),
311            interference: crate::panels::interference::InterferenceWindow::new(),
312            step_parts: crate::panels::step_parts::StepPartsPanel::new(),
313            selection: SelectionPanel::new(),
314            context_bar: ContextBarPanel::new(),
315            sketch: SketchPanel::new(),
316            mode_bar: ModeBar::new(),
317            toasts: Toasts::new(),
318            dock,
319            first_run_framed: false,
320            pending_boot_load,
321            active_document,
322            #[cfg(target_arch = "wasm32")]
323            document_tab_hits: Vec::new(),
324            session_saved,
325            autosave: crate::recovery::Autosave::new(),
326            recovery,
327            applied_ui_scale,
328        })
329    }
330
331    /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
332    /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection.
333    ///
334    /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
335    /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
336    /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
337    /// consumed BEFORE undo because egui's `consume_key` matches modifiers
338    /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
339    fn handle_shortcuts(&mut self, ctx: &egui::Context) {
340        if ctx.text_edit_focused() {
341            return;
342        }
343        use egui::{Key, Modifiers};
344        let (redo, undo, esc) = ctx.input_mut(|i| {
345            let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
346                || i.consume_key(Modifiers::COMMAND, Key::Y);
347            let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
348            let esc = i.consume_key(Modifiers::NONE, Key::Escape);
349            (redo, undo, esc)
350        });
351        // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
352        // history (S6a), not the model-level undo — this global router consumes the
353        // keys first (before the viewport), so it must intercept here. Esc drops the
354        // active draw/trim/pick tool back to Select/drag (clearing any in-progress
355        // placement): this is the ONLY reliable capture point, since `consume_key`
356        // above already swallowed the Escape before the viewport can see it.
357        if self.docs.engine().sketch_mode() {
358            if redo {
359                self.docs.engine_mut().sketch_redo();
360            }
361            if undo {
362                self.docs.engine_mut().sketch_undo();
363            }
364            if esc {
365                self.docs.engine_mut().sketch_set_tool(Some("select"));
366            }
367            return;
368        }
369        if redo {
370            self.docs.engine_mut().redo();
371        }
372        if undo {
373            self.docs.engine_mut().undo();
374        }
375        if esc {
376            // An open pick-list popup owns the first Escape: close it WITHOUT
377            // clearing the selection (a popup-built multi-selection must survive
378            // dismissing the list); the next Escape clears as before.
379            if !self.viewport.close_candidate_popup() {
380                self.docs.engine_mut().clear_selection();
381            }
382        }
383    }
384
385    /// EDIT PART (assemblies §8.5): open the component's SOURCE document in its
386    /// own tab — or focus the tab already holding it. Editing a component IS
387    /// opening its part now; the assembly picks the change up through the
388    /// outdated badge / Update Components once the part is saved, so there is no
389    /// session to finish and nothing to stash.
390    ///
391    /// A part with no store document under its `sourceKey` (an embedded-only
392    /// part — a headless STEP import, or one whose write failed) has no file to
393    /// open, and says so rather than doing nothing.
394    fn edit_part(&mut self, component_id: &str) {
395        let source =
396            crate::panels::component_actions::part_source_key(self.docs.engine(), component_id);
397        match source {
398            Some(key) if self.model_store.read(&key).is_some() => {
399                self.file
400                    .open_document(&mut self.docs, self.model_store.as_ref(), &key);
401            }
402            Some(key) => self.docs.engine_mut().push_notice(format!(
403                "This part's source document '{key}' is no longer in storage — nothing to open"
404            )),
405            None => self.docs.engine_mut().push_notice(
406                "This part is embedded in the assembly — it has no source document to open"
407                    .to_string(),
408            ),
409        }
410    }
411
412    /// Draw an isolated preview while the destination document remains untouched.
413    fn show_stl_preview(&mut self, ui: &mut egui::Ui) -> bool {
414        use crate::panels::stl_import::PreviewAction;
415        let Some(preview) = self.stl_import.as_mut() else {
416            return false;
417        };
418        let action = if preview.destination != self.docs.active_id() {
419            PreviewAction::Cancel
420        } else {
421            preview.show(ui, &mut self.viewport)
422        };
423        #[cfg(target_arch = "wasm32")]
424        {
425            publish_to_js("__brepImportPreview", &preview.state_json());
426            publish_to_js("__brepImportPreviewHit", &preview.hits_json());
427            publish_to_js("__brepCamera", &preview.engine.camera_state_json());
428            publish_to_js("__brepPpp", &format!("{}", ui.ctx().pixels_per_point()));
429            publish_to_js("__brepHistory", &self.docs.engine().history_listing_json());
430            publish_to_js("__brepDocuments", &document_tabs::state_json(&self.docs));
431        }
432        let close = match action {
433            PreviewAction::Accept => {
434                match preview.accept_into(self.docs.active_id(), self.docs.engine_mut()) {
435                    Ok(()) => true,
436                    Err(error) => {
437                        self.docs.engine_mut().push_notice(error);
438                        false
439                    }
440                }
441            }
442            PreviewAction::Cancel => true,
443            PreviewAction::None => false,
444        };
445        if close {
446            self.stl_import = None;
447            self.viewport.forget_document();
448            ui.ctx().request_repaint();
449        }
450        true
451    }
452
453    /// Reset everything the shared panels and the viewport hold ABOUT ONE
454    /// DOCUMENT, run at the top of the first frame that sees a different active
455    /// document.
456    ///
457    /// Panel state is deliberately NOT per-document (one History panel, one
458    /// Scene tree, …): a second copy per tab would double every panel's state
459    /// for a benefit — remembering which feature form was open in a background
460    /// tab — nobody asked for. The price is that the transient state has to be
461    /// dropped on a switch, because every bit of it (expansion sets, hit maps,
462    /// an open feature form, a pinned Info window's object name) refers to the
463    /// document that just went away.
464    fn reset_document_scoped_state(&mut self) {
465        self.history = HistoryPanel::new();
466        self.scene = ScenePanel::new();
467        self.bom = BomPanel::new();
468        self.assembly_constraints = AssemblyConstraintsPanel::new();
469        self.expressions = ExpressionsPanel::new();
470        // Pinned to object NAMES of the old document ("Box" exists in most of
471        // them), so these would silently retarget rather than go blank.
472        self.info_windows = InfoWindows::new();
473        self.interference = crate::panels::interference::InterferenceWindow::new();
474        // The outdated-parts cache keys on `(applied_generation, save_generation)`,
475        // and two documents' generations are unrelated — a switch can land on the
476        // same key with an entirely different parts library.
477        self.update_components.invalidate();
478        self.viewport.forget_document();
479    }
480
481    /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
482    /// per-solid triangle count + bbox, and total triangles. Published to JS so
483    /// the headed verifier can prove each roll / edit produced different geometry
484    /// (names alone don't: a SUBTRACT reuses the target's name).
485    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
486    fn model_signature_json(&self) -> String {
487        let solids: Vec<serde_json::Value> = self
488            .docs
489            .engine()
490            .scene
491            .solids()
492            .iter()
493            .map(|s| {
494                serde_json::json!({
495                    "name": s.name,
496                    "tris": s.mesh.indices.len() / 3,
497                    "min": s.bbox.min,
498                    "max": s.bbox.max,
499                })
500            })
501            .collect();
502        let total_tris: usize = self
503            .docs
504            .engine()
505            .scene
506            .solids()
507            .iter()
508            .map(|s| s.mesh.indices.len() / 3)
509            .sum();
510        serde_json::json!({
511            "step": self.docs.engine().history_rollback(),
512            "solidCount": solids.len(),
513            "totalTris": total_tris,
514            "solids": solids,
515        })
516        .to_string()
517    }
518}
519
520impl eframe::App for BrepApp {
521    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
522        // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
523        // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
524        // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
525        // `&mut self` call).
526        let ctx = ui.ctx().clone();
527
528        // --- a different document is active than the panels were drawn for ----
529        // Checked FIRST, before anything draws: the switch itself happened late
530        // in some earlier frame (a tab click, a close, an Open), and every
531        // shared panel is still holding the previous document's transient state.
532        if self.active_document != self.docs.active_id() {
533            self.active_document = self.docs.active_id();
534            self.reset_document_scoped_state();
535        }
536
537        // --- GUI chrome theme -------------------------------------------------
538        // Apply the user's theme preference to the egui chrome every frame
539        // (idempotent: `set_theme` just stores the preference). Auto follows the
540        // OS/system theme (prefers-color-scheme on web); egui falls back to dark
541        // when no OS signal is available. This controls panels/windows/toolbar/
542        // text only — the 3D viewport `background` is a separate setting.
543        ctx.set_theme(match self.docs.engine().settings.theme {
544            ThemeMode::Auto => egui::ThemePreference::System,
545            ThemeMode::Light => egui::ThemePreference::Light,
546            ThemeMode::Dark => egui::ThemePreference::Dark,
547        });
548
549        // --- global UI size scale --------------------------------------------
550        // Apply the user's "UI scale" to the whole egui chrome every frame. This
551        // is idempotent when unchanged (`set_zoom_factor` only repaints on an
552        // actual change) and composes with the native device pixel ratio
553        // (pixels_per_point = zoom_factor * native_pixels_per_point).
554        //
555        // Defer live UI rescale while the user drags the Settings "UI scale" slider:
556        // the slider value updates continuously, but only commit it to the actual egui
557        // zoom once the pointer is released, so the whole UI doesn't rescale under the
558        // cursor mid-drag.
559        let pointer_down = ctx.input(|i| i.pointer.any_down());
560        if !pointer_down {
561            self.applied_ui_scale = self.docs.engine().settings.ui_scale;
562        }
563        ctx.set_zoom_factor(self.applied_ui_scale);
564
565        // --- history-runner pump ---------------------------------------------
566        // Apply any completed background history run BEFORE panels read the scene.
567        // For the synchronous InlineRunner this is a no-op (`rerun_history` already
568        // pumped its own submit), so nothing changes today; it is the seam a future
569        // native-thread / wasm-worker runner lands its reply through. While a run is
570        // still in flight, keep the frame loop alive so its reply gets pumped — for
571        // Inline `run_pending()` is always false, so this never fires.
572        //
573        // EVERY open document is pumped, not just the active one: a run belongs
574        // to the engine that submitted it (each document owns its own runner —
575        // see `crate::document`), so a run still in flight when the user switches
576        // tabs must land in ITS document rather than be dropped or, worse,
577        // applied to whatever is on screen. An idle document's pump is a couple
578        // of empty `try_recv`s.
579        let mut work_in_flight = false;
580        for doc in self.docs.iter_mut() {
581            doc.engine.pump();
582            work_in_flight |= doc.engine.run_pending()
583                || doc.engine.queries_pending()
584                || doc.engine.mesh_imports_pending()
585                || doc.engine.step_probes_pending();
586        }
587        if work_in_flight {
588            ctx.request_repaint();
589        }
590        if self.show_stl_preview(ui) {
591            return;
592        }
593        #[cfg(target_arch = "wasm32")]
594        publish_to_js("__brepImportPreview", "null");
595
596        // The tab strip's dirty dots, refreshed once per frame (cheap — see
597        // `Document::refresh_dirty_marker`).
598        self.docs.refresh_dirty_markers();
599
600        // --- boot-load (?loadModel=): apply the fetched model once it lands ----
601        // Replaces the seed with the URL-specified document (armed in `new`). The
602        // ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
603        // `load_model_and_fit` arms deferred framing; the pump above reframes it
604        // next frame. `mark_clean` opens it as a non-dirty document.
605        if self.pending_boot_load.is_some() {
606            let received = self
607                .pending_boot_load
608                .as_ref()
609                .and_then(|rx| rx.try_recv().ok());
610            if let Some(result) = received {
611                self.pending_boot_load = None;
612                match result {
613                    Ok(json) => {
614                        // REPLACES the seed in place rather than adding a tab:
615                        // the cadDev "Launch model in CAD app" link means "show
616                        // me this model", and a boot with the demo cube sitting
617                        // in tab 1 beside it would be noise. It lands on the
618                        // document that is already active, whatever the session
619                        // restored.
620                        let _ = self.docs.engine_mut().load_model_and_fit(&json);
621                        self.docs.active_mut().mark_clean();
622                    }
623                    Err(e) => self
624                        .docs
625                        .engine_mut()
626                        .push_notice(format!("Could not load model from URL: {e}")),
627                }
628            }
629        }
630
631        // --- update-components badge freshness ---------------------------------
632        // Keep the outdated-parts checker current BEFORE any assembly panel draws
633        // (the structure tree renders per-node badges ahead of the constraints
634        // header). Cheap: a real recompute happens only when an applied run or a
635        // successful store save moved the generation key.
636        self.update_components.ensure_current(
637            self.docs.engine_mut(),
638            self.model_store.as_ref(),
639            self.file.save_generation(),
640        );
641
642        // --- async-safe first-model framing -----------------------------------
643        // The seed run is async under a background runner (native thread / wasm
644        // worker), so the boot `zoom_to_fit` may have run before any solid existed.
645        // Frame the model ONCE, the first frame the seed run has fully landed (solids
646        // present AND no run still in flight). Under the synchronous Inline runner
647        // (tests) both hold on the very first frame, so this is identical to today.
648        if !self.first_run_framed
649            && !self.docs.engine().run_pending()
650            && self.docs.engine().has_solids()
651        {
652            self.docs.engine_mut().zoom_to_fit();
653            self.first_run_framed = true;
654        }
655
656        self.handle_shortcuts(&ctx);
657
658        // --- top toolbar: primary actions, drawn FIRST so its top strip is
659        // reserved above the left panel + central viewport. A clicked File button
660        // returns an action the file dialog acts on (open its modal / save / new).
661        let toolbar_outcome = self.toolbar.show(
662            ui,
663            self.docs.engine_mut(),
664            self.model_store.as_ref(),
665            &mut self.settings.open,
666        );
667        if let Some(action) = toolbar_outcome.file {
668            self.file
669                .dispatch(action, &mut self.docs, self.model_store.as_ref());
670        }
671        // Submit Bug: begin the screenshot-capture + report flow. `request`
672        // grabs the current frame (before its dialog exists) and the model, so
673        // it must run THIS frame while the shot is still dialog-free.
674        if toolbar_outcome.bug_report {
675            self.bug_report.request(&ctx, self.docs.engine());
676        }
677        // A workbench toolbar button click surfaces its id here. Sheet Metal's
678        // flat-pattern button opens the export modal in its DXF / SVG mode; the
679        // engine reports "no sheet-metal body in the part" as a toast on export.
680        match toolbar_outcome.workbench_button {
681            Some("sheetmetal.flat_pattern") => {
682                self.file.dispatch(
683                    FileAction::ExportFlatPattern,
684                    &mut self.docs,
685                    self.model_store.as_ref(),
686                );
687            }
688            // Assembly's Add Component: open the insert-component modal (the
689            // same flow as the ACOMP palette pick).
690            Some("assembly.add_component") => {
691                self.file.dispatch(
692                    FileAction::InsertComponent,
693                    &mut self.docs,
694                    self.model_store.as_ref(),
695                );
696            }
697            // Assembly's interference check: run the engine's pairwise
698            // intersect sweep NOW and open the results window (drawn below,
699            // next to the Info windows).
700            Some("assembly.interference") => {
701                self.interference.open_and_run(self.docs.engine_mut());
702            }
703            // Assembly's step.parts library: open the online-library browser
704            // (search → thumbnails → import a STEP part → add as an ACOMP).
705            Some("assembly.step_parts_library") => {
706                self.step_parts.open();
707            }
708            Some(_) | None => {}
709        }
710
711        // --- workbench actions toolbar: a second strip directly UNDER the
712        // primary toolbar (egui stacks top panels in call order) listing the
713        // active workbench's creatable features + the constraint types. Off via
714        // the Settings checkbox, and never in sketch / reference-selection mode
715        // (the panel decides — see `WorkbenchToolbarPanel::visible`). A feature
716        // click adds exactly what the palette pick would, so ACOMP still routes
717        // to the component selector; a constraint click is the context bar's
718        // constraint offer, seeded from the current selection.
719        let actions = self.workbench_toolbar.show(ui, self.docs.engine());
720        if let Some(type_code) = actions.feature {
721            self.history
722                .add_feature_of_type(self.docs.engine_mut(), &type_code);
723            if self.history.take_insert_component_request() {
724                self.file.dispatch(
725                    FileAction::InsertComponent,
726                    &mut self.docs,
727                    self.model_store.as_ref(),
728                );
729            } else {
730                // Surface History so the new feature's form is actually visible.
731                self.dock.show_pane(PaneKind::History);
732            }
733        }
734        if let Some(type_id) = actions.constraint {
735            // The strip offers every type regardless of the selection (unlike the
736            // context bar's gated offers), so a refusal has to be SAID: a toast,
737            // never a silent no-op. A document with no components has no assembly
738            // session to add to (the kernel's own message names the session, not
739            // the cause) — say what is actually missing.
740            let engine = self.docs.engine_mut();
741            if !engine.history_has_assembly() {
742                engine.push_notice(
743                    "Add constraint: the document has no components — insert a component first"
744                        .to_string(),
745                );
746            } else {
747                match crate::panels::context_bar::add_constraint_from_selection(engine, &type_id) {
748                    Ok(_) => self.dock.show_pane(PaneKind::AssemblyConstraints),
749                    Err(error) => engine.push_notice(format!("Add constraint: {error}")),
750                }
751            }
752        }
753
754        // --- sketch mode: a slim top bar (the draw tools) drawn just below the
755        // toolbar while editing a sketch. The normal side panel is hidden (below)
756        // so the 3D viewport is the full-width sketching surface.
757        if self.docs.engine().sketch_mode() {
758            self.sketch.show_mode_bar(ui, self.docs.engine_mut());
759        }
760
761        // --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
762        // chosen by context each frame. Drawn AFTER the top bars but BEFORE the
763        // left panel(s) so it reserves the FULL bottom width and the left column
764        // stops above it (egui resolves reserved space by call order). It is a
765        // HOST: the branch below picks what to draw. A NEW context is added by
766        // extending this branch (e.g. `else if engine.some_mode() { … }`) and
767        // routing through the owning panel's `show_status_bar` for DRY styling.
768        egui::containers::panel::Panel::bottom("brep-status-bar")
769            .resizable(false)
770            .min_size(30.0)
771            .show(ui, |ui| {
772                ui.add_space(2.0);
773                if self.docs.engine().sketch_mode() {
774                    // Sketch context: the status row (title / DOF / N selected /
775                    // undo-redo / Lock). The selection-filter row is NOT drawn
776                    // now, so drop its stale hit-rects (the verifier must never
777                    // click a phantom rect for an off-screen widget).
778                    self.selection.clear_hits();
779                    self.sketch.show_status_bar(ui, self.docs.engine_mut());
780                } else {
781                    // Modeling context: the selection filter (pickable kinds).
782                    self.selection.show_status_bar(ui, self.docs.engine_mut());
783                }
784                ui.add_space(2.0);
785            });
786
787        // --- central region: the dock tree, OR (special modes) the bare 3D view
788        // ---------------------------------------------------------------------
789        // Normal modeling mode: ONE egui_tiles tree fills the whole remaining
790        // area between the top toolbar and the bottom status bar. Every side-panel
791        // section AND the 3D viewport are tiles the user can split / tab / resize /
792        // drag-rearrange, and the layout persists. Which side panes are visible is
793        // filtered per-workbench inside the dock (`workbench::panel_visible`).
794        //
795        // Sketch mode and reference-selection are "special modes" that take over
796        // the shell: they BYPASS the tree and draw the viewport directly, so the
797        // modeling side panes don't appear (sketch's own entity-list panel + the
798        // top-right mode card own those flows). Drawing the viewport HERE — before
799        // the top-right overlay below — keeps `viewport.last_rect()` current-frame
800        // so the overlay anchors to the live 3D-view rect with no lag.
801        let sketch = self.docs.engine().sketch_mode();
802        let ref_select = self.docs.engine().ref_select_active();
803
804        if sketch {
805            // Sketch entity lists (Curves / Points / Constraints) + solver
806            // settings — a dedicated left panel, drawn BEFORE the viewport so it
807            // reserves the left and the viewport fills the rest.
808            egui::containers::panel::Panel::left("sketch-entities")
809                .resizable(true)
810                .default_size(300.0)
811                .size_range(200.0..=560.0)
812                .show(ui, |ui| {
813                    self.sketch.show_entity_lists(ui, self.docs.engine_mut());
814                });
815        }
816
817        self.history.sync_palette_display(self.model_store.as_ref());
818        if sketch || ref_select {
819            // Special mode: the viewport fills the remaining central area; no
820            // dock, no modeling side panes — and therefore no DOCUMENT TAB
821            // STRIP either, which is the guard that keeps a live sketch /
822            // reference-pick session from having its document swapped out from
823            // under it.
824            self.viewport.show(ui, self.docs.engine_mut());
825        } else {
826            // Normal mode: the dock owns the whole central area (the viewport is a
827            // pane). Cross-panel requests the panels can't act on while their
828            // borrows are held bubble OUT via the returned outcome — the SAME
829            // requests the old left-panel closure produced.
830            let outcome = self.dock.ui(
831                ui,
832                DockContext {
833                    docs: &mut self.docs,
834                    viewport: &mut self.viewport,
835                    history: &mut self.history,
836                    bom: &mut self.bom,
837                    assembly_constraints: &mut self.assembly_constraints,
838                    scene: &mut self.scene,
839                    expressions: &mut self.expressions,
840                    update_components: &mut self.update_components,
841                    model_store: self.model_store.as_ref(),
842                },
843            );
844
845            // The ACOMP palette pick must open the COMPONENT SELECTOR, never a
846            // bare feature dialog — the file dialog is shell-owned.
847            if outcome.insert_component_requested {
848                self.file.dispatch(
849                    FileAction::InsertComponent,
850                    &mut self.docs,
851                    self.model_store.as_ref(),
852                );
853            }
854            // The DOCUMENT TAB STRIP inside the viewport tile. Activation is
855            // immediate; a close routes through the file dialog because a dirty
856            // document has to be confirmed first, and that prompt lives there.
857            if let Some(index) = outcome.document_tabs.activate {
858                self.docs.activate(index);
859            }
860            if let Some(index) = outcome.document_tabs.close {
861                self.file.request_close(&mut self.docs, index);
862            }
863            #[cfg(target_arch = "wasm32")]
864            {
865                self.document_tab_hits = outcome.document_tabs.hits;
866            }
867            // A structure-tree Edit — or a BOM row's action button, which
868            // reports through the same outcome field so there is one arm and
869            // not two — expands its feature in the history tree.
870            if let Some(focus) = outcome.feature_focus {
871                self.history.focus_feature(focus);
872                // Surface History so the expanded feature is actually visible.
873                self.dock.show_pane(PaneKind::History);
874            }
875            // Structure-tree interaction hooks route through the SAME dispatcher
876            // as the context bar (one truth per action); document-level flows
877            // (edit-in-place / open-part) come back as requests the shell runs.
878            // A BOM row menu's document-level flow: its engine-mutating half
879            // already ran inside the panel, through the same dispatcher.
880            match outcome.component_request {
881                Some(ComponentActionRequest::OpenPart { component_id }) => {
882                    self.edit_part(&component_id);
883                }
884                None => {}
885            }
886        }
887
888        self.history.sync_palette_display(self.model_store.as_ref());
889
890        // --- file dialog: a ctx-level modal (like the command palette), drawn
891        // after the panels so its backdrop dims the whole shell. Idempotent when
892        // closed; also polls for a completed async import each frame.
893        self.file
894            .show(&ctx, &mut self.docs, self.model_store.as_ref());
895
896        // --- crash recovery: the boot prompt, then the debounced autosave -----
897        // Drawn with the same ctx-level modal treatment as the file dialog. The
898        // autosave ticks only once the prompt has resolved (or never existed).
899        if self.recovery.is_open() {
900            if let Some(resolution) =
901                self.recovery.show(&ctx, &mut self.docs, self.model_store.as_ref())
902            {
903                self.autosave.note_cleared();
904                if let crate::recovery::Resolution::Restored(count) = resolution {
905                    self.docs.engine_mut().push_notice(format!(
906                        "Restored {count} unsaved document{}",
907                        if count == 1 { "" } else { "s" }
908                    ));
909                }
910            }
911        } else {
912            self.recovery.clear_hits();
913            let now = ctx.input(|i| i.time);
914            if let Some(due) = self.autosave.tick(&self.docs, self.model_store.as_ref(), now) {
915                // The frame loop idles between inputs; wake it when the write is due.
916                ctx.request_repaint_after(std::time::Duration::from_secs_f64(due.max(0.05)));
917            }
918        }
919
920        if let Some((name, bytes)) = self.file.take_stl_import() {
921            self.stl_import = Some(crate::panels::stl_import::StlImportPreview::new(
922                self.docs.active_id(), name, bytes, self.docs.spawn_engine(),
923            ));
924            self.viewport.forget_document();
925            ctx.request_repaint();
926        }
927
928        // --- Submit Bug: the screenshot-capture state machine + report modal.
929        // Drawn at ctx level like the file dialog; idempotent while idle. Draws
930        // NOTHING during capture, so the screenshot it requested never contains
931        // this dialog.
932        self.bug_report.show(&ctx, self.docs.engine_mut());
933
934        // --- Settings: a floating (movable + resizable) window, toggled from the
935        // toolbar gear button, drawn at ctx level like Properties so it floats
936        // over the shell. Idempotent when closed. Replaces the old sidebar section.
937        self.settings
938            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
939
940        // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
941        // for reference-selection / sketch mode) stacked ABOVE the selection-driven
942        // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
943        // top-right so they never overlap, and the context rail uses the SAME
944        // renderer whether it is showing modeling actions or sketch actions
945        // (`panels::action_rail`). A modeling create/edit action returns a feature
946        // id to expand in the history tree.
947        {
948            let mut focus: Option<String> = None;
949            let mut info_targets: Vec<String> = Vec::new();
950            let mut component_request: Option<ComponentActionRequest> = None;
951            // Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
952            // tile), not the window — so it stays glued to the viewport wherever
953            // docking frames it. The viewport was drawn earlier THIS frame, so its
954            // rect is current. Before the first draw (`None`) fall back to the
955            // window's top-right.
956            let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
957                .order(egui::Order::Foreground);
958            overlay = match self.viewport.last_rect() {
959                Some(rect) => overlay
960                    .fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
961                    .pivot(egui::Align2::RIGHT_TOP),
962                None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
963            };
964            overlay
965                .show(&ctx, |ui| {
966                    // 1. Exit controls for whatever special mode is active.
967                    self.mode_bar.card(ui, self.docs.engine_mut());
968                    // 2. Context actions: sketch actions in sketch mode, else the
969                    // modeling selection actions. Same rail, mode-appropriate items.
970                    if self.docs.engine().sketch_mode() {
971                        self.sketch.context_card(ui, self.docs.engine_mut());
972                    } else {
973                        let outcome = self.context_bar.card(ui, self.docs.engine_mut());
974                        focus = outcome.focus;
975                        info_targets = outcome.info_targets;
976                        component_request = outcome.component;
977                    }
978                });
979            if let Some(focus) = focus {
980                self.history.focus_feature(focus);
981                // Adding a feature from the context bar can happen while another
982                // side tab is active — bring History forward so the new row shows.
983                self.dock.show_pane(PaneKind::History);
984            }
985            // The Info action returns one target per selected entity — open (or, on
986            // dedup, keep) a pinned Info window for each. Drawn below.
987            if !info_targets.is_empty() {
988                self.info_windows.open_for(&info_targets);
989            }
990            // Component document-level flows (the engine-mutating component
991            // actions already ran inside the bar).
992            match component_request {
993                Some(ComponentActionRequest::OpenPart { component_id }) => {
994                    self.edit_part(&component_id);
995                }
996                None => {}
997            }
998        }
999
1000        // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
1001        // level like the file dialog so they float over the shell. Each is pinned to
1002        // its open-time object name (selection changes never retarget them); closed
1003        // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
1004        // window opened THIS frame paints this frame.
1005        self.info_windows.show(&ctx, self.docs.engine_mut());
1006
1007        // --- interference results window: same floating idiom, owned report;
1008        // its Re-run button re-drives the engine check.
1009        self.interference.show(&ctx, self.docs.engine_mut());
1010        self.step_parts
1011            .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
1012
1013        // --- transient toasts: drain the engine's queued notices (e.g. a sketch
1014        // solve that failed after an edit) and show each briefly. Drawn last so
1015        // the cards float over the whole shell.
1016        let now = ctx.input(|i| i.time);
1017        let notices = self.docs.engine_mut().take_notices();
1018        self.toasts.extend(notices, now);
1019        // Same lane for STORAGE failures the store could only discover after its
1020        // synchronous `write` returned `Ok` (the browser backend writes behind an
1021        // in-memory mirror). A save that did not persist must never be silent.
1022        self.toasts
1023            .extend(self.model_store.take_persistence_errors(), now);
1024        self.toasts.extend(self.autosave.take_errors(), now);
1025        self.toasts.show(&ctx);
1026
1027        // --- persist the open-document session --------------------------------
1028        // Compared against what was last WRITTEN rather than flagged at each
1029        // mutation site: New / Open / close / activate / Save As (a rename) all
1030        // move it, and a change detector cannot forget one of them. The blob is
1031        // a short name list, so the per-frame compare is free.
1032        let session = self.docs.session_json();
1033        if session != self.session_saved {
1034            let _ = self.model_store.write(SESSION_KEY, &session);
1035            self.session_saved = session;
1036        }
1037
1038        // Verification hook (wasm only): mirror the live app + engine state to JS
1039        // globals so the headed verifier can assert roll-to-step / edit-re-run /
1040        // add / delete took effect, and locate the real egui widgets to click.
1041        // Purely additive; no render effect. Published AFTER the panel draws so
1042        // the hit-rects are for THIS frame's layout.
1043        #[cfg(target_arch = "wasm32")]
1044        {
1045            let ppp = ui.ctx().pixels_per_point();
1046            publish_to_js("__brepCamera", &self.docs.engine().camera_state_json());
1047            publish_to_js("__brepSettings", &self.docs.engine().settings_json());
1048            publish_to_js("__brepSolidColors", &self.docs.engine().solid_color_overrides_json());
1049            publish_to_js("__brepHistory", &self.docs.engine().history_listing_json());
1050            publish_to_js(
1051                "__brepFile",
1052                &self.file.file_state_json(&self.docs, self.model_store.as_ref()),
1053            );
1054            publish_to_js("__brepFileHit", &self.file.hits_json());
1055            publish_to_js("__brepModel", &self.model_signature_json());
1056            publish_to_js("__brepReport", &self.docs.engine().history_report_json());
1057            // The in-flight run: whether one is pending, the feature the runner
1058            // says it is executing, and the feature a cancelled run was stuck on.
1059            publish_to_js(
1060                "__brepRun",
1061                &serde_json::json!({
1062                    "pending": self.docs.engine().run_pending(),
1063                    "progress": self.docs.engine().run_progress().map(|p| serde_json::json!({
1064                        "generation": p.generation,
1065                        "index": p.index,
1066                        "total": p.total,
1067                        "featureId": p.feature_id,
1068                        "featureType": p.feature_type,
1069                    })),
1070                    "cancelled": self.docs.engine().cancelled_run(),
1071                })
1072                .to_string(),
1073            );
1074            publish_to_js("__brepHit", &self.history.hits_json());
1075            publish_to_js("__brepExprHit", &self.expressions.hits_json());
1076            publish_to_js(
1077                "__brepExpr",
1078                &serde_json::json!({
1079                    "expressions": self.docs.engine().expressions_json(),
1080                    "variables": serde_json::from_str::<serde_json::Value>(
1081                        &self.docs.engine().expression_variables_json()
1082                    )
1083                    .unwrap_or(serde_json::Value::Null),
1084                    "configurator": serde_json::from_str::<serde_json::Value>(
1085                        &self.docs.engine().configurator_json()
1086                    )
1087                    .unwrap_or(serde_json::Value::Null),
1088                })
1089                .to_string(),
1090            );
1091            publish_to_js("__brepToolbar", &self.toolbar.hits_json());
1092            // The workbench actions strip's button rects (`wbtb:feature:<type>` /
1093            // `wbtb:constraint:<type>`); an empty map while the strip is hidden.
1094            publish_to_js("__brepWorkbenchToolbar", &self.workbench_toolbar.hits_json());
1095            // The queued toast texts — the only trace of a refusal the app shows
1096            // as a transient card (e.g. a constraint the strip could not add).
1097            publish_to_js("__brepNotices", &self.toasts.texts_json());
1098            publish_to_js("__brepBug", &self.bug_report.state_json());
1099            publish_to_js("__brepBugHit", &self.bug_report.hits_json());
1100            // The workbench logical state (resolved current id + available ids) so
1101            // the verifier can drive the dropdown and confirm the active workbench.
1102            // Hit-rects for the dropdown ride in `__brepToolbar` (self.toolbar.hits).
1103            publish_to_js(
1104                "__brepWorkbench",
1105                &crate::workbench::workbench_state_json(&self.docs.engine().settings.workbench),
1106            );
1107            publish_to_js("__brepSelection", &self.docs.engine().selection_json());
1108            publish_to_js(
1109                "__brepInfoWindows",
1110                &self.info_windows.published_json(self.docs.engine_mut()),
1111            );
1112            publish_to_js("__brepInfoWindowsHit", &self.info_windows.hits_json());
1113            publish_to_js("__brepInterference", &self.interference.state_json());
1114            publish_to_js("__brepInterferenceHit", &self.interference.hits_json());
1115            publish_to_js("__brepStepParts", &self.step_parts.state_json());
1116            publish_to_js("__brepStepPartsHit", &self.step_parts.hits_json());
1117            publish_to_js("__brepSelectionFilter", &self.docs.engine().selection_filter_json());
1118            publish_to_js("__brepSelectionHit", &self.selection.hits_json());
1119            publish_to_js("__brepContext", &self.context_bar.state_json());
1120            publish_to_js("__brepContextHit", &self.context_bar.hits_json());
1121            publish_to_js("__brepModeBarHit", &self.mode_bar.hits_json());
1122            // The DOCUMENT TABS: the open models, which one is active, and the
1123            // strip's per-tab hit-rects, so an e2e script can switch and close
1124            // documents the way a user does.
1125            publish_to_js("__brepDocuments", &document_tabs::state_json(&self.docs));
1126            publish_to_js("__brepDocumentsHit", &hits_json(&self.document_tab_hits));
1127            // The boot-time recovery prompt: its entries and its two buttons.
1128            publish_to_js("__brepRecovery", &self.recovery.state_json());
1129            publish_to_js("__brepRecoveryHit", &self.recovery.hits_json());
1130            publish_to_js("__brepComponentMove", &self.docs.engine().component_move_json());
1131            publish_to_js("__brepSketch", &self.sketch.published_json(self.docs.engine()));
1132            publish_to_js("__brepWireframe", &format!("{}", self.docs.engine().settings.wireframe));
1133            publish_to_js(
1134                "__brepRefSelect",
1135                &serde_json::json!({
1136                    "active": self.docs.engine().ref_select_active(),
1137                    "prompt": self.docs.engine().ref_select_prompt(),
1138                    "names": self.docs.engine().ref_select_names(),
1139                })
1140                .to_string(),
1141            );
1142            // Viewport origin + projected probe points (viewport-local logical
1143            // px) so the verifier can click precise spots ON the Box and ON the
1144            // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
1145            // pin; indices 1..4 are points on the Pin's cylindrical stub that
1146            // protrudes above the Box top (y=20), on the camera-facing sides — the
1147            // verifier tries them until one picks "Pin".
1148            publish_to_js("__brepView", &self.viewport.viewport_rect_json());
1149            // Dock layout snapshot (per-pane visible / rendered) so the verifier
1150            // can see which side panels are on-screen and, once a user tabs panels
1151            // together, activate the right tab before asserting on its widgets.
1152            // `active=false` in sketch / ref-select (the dock is bypassed).
1153            publish_to_js("__brepDock", &self.dock.state_json(!sketch && !ref_select));
1154            publish_to_js(
1155                "__brepProbe",
1156                &self
1157                    .docs
1158                    .engine()
1159                    .world_to_screen_json(
1160                        "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
1161                          [10.0,22.5,16.0],[10.0,25.0,10.0]]",
1162                    )
1163                    .unwrap_or_else(|_| "[]".to_string()),
1164            );
1165            publish_to_js("__brepPpp", &format!("{ppp}"));
1166            publish_to_js("__brepStep", &format!("{}", self.docs.engine().history_rollback()));
1167            publish_to_js(
1168                "__brepParams",
1169                &self.docs.engine().feature_params_json(self.docs.engine().history_rollback()),
1170            );
1171        }
1172
1173        // NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
1174        // earlier this frame (normal mode) or drawn directly in the sketch /
1175        // ref-select branch above. Drawing it before the top-right overlay is what
1176        // keeps that overlay anchored to the live viewport rect.
1177    }
1178}
1179
1180/// Fetch a `.BREP.json` document over HTTP for the `?loadModel=` boot path; the
1181/// reply (or a human error) arrives on the returned channel and `ctx` is
1182/// repainted so the frame loop drains it. Mirrors `step_parts::fetch_text`.
1183#[cfg(target_arch = "wasm32")]
1184fn fetch_model(
1185    ctx: &egui::Context,
1186    url: String,
1187) -> std::sync::mpsc::Receiver<Result<String, String>> {
1188    let (tx, rx) = std::sync::mpsc::channel();
1189    let ctx = ctx.clone();
1190    ehttp::fetch(ehttp::Request::get(url), move |result| {
1191        let out = match result {
1192            Ok(resp) if resp.ok => Ok(resp
1193                .text()
1194                .map(str::to_owned)
1195                .unwrap_or_else(|| String::from_utf8_lossy(&resp.bytes).into_owned())),
1196            Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
1197            Err(err) => Err(err),
1198        };
1199        let _ = tx.send(out);
1200        ctx.request_repaint();
1201    });
1202    rx
1203}
1204
1205/// A `(key, rect)` list as the verifier's `{key: [x, y, w, h]}` map — the same
1206/// shape every panel's own `hits_json` publishes.
1207#[cfg(target_arch = "wasm32")]
1208fn hits_json(hits: &[(String, egui::Rect)]) -> String {
1209    let map: serde_json::Map<String, serde_json::Value> = hits
1210        .iter()
1211        .map(|(key, rect)| {
1212            (
1213                key.clone(),
1214                serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
1215            )
1216        })
1217        .collect();
1218    serde_json::Value::Object(map).to_string()
1219}
1220
1221/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
1222#[cfg(target_arch = "wasm32")]
1223fn publish_to_js(name: &str, json: &str) {
1224    if let Some(win) = web_sys::window() {
1225        let _ = js_sys::Reflect::set(
1226            &win,
1227            &wasm_bindgen::JsValue::from_str(name),
1228            &wasm_bindgen::JsValue::from_str(json),
1229        );
1230    }
1231}
1232
1233/// The seed model handed to the engine at startup: a 3-feature history so the
1234/// tree / roll / edit are real —
1235///   0. `P.CU` "Box"  — a 20 mm cube at the origin (spans `[0,20]³`).
1236///   1. `P.CY` "Pin"  — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
1237///      cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
1238///   2. `B`    "Cut"  — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
1239///      with a cylindrical through-hole (the ref-select field is visible for the
1240///      next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
1241///
1242/// This is just the INITIAL document — once handed to `EngineState`, the engine
1243/// OWNS the mutable history; the app keeps no copy.
1244pub(crate) fn seed_history_json() -> String {
1245    serde_json::json!({
1246        "expressions": "",
1247        "configurator": {},
1248        "features": [
1249            {
1250                "type": "P.CU",
1251                "inputParams": {
1252                    "id": "Box",
1253                    "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
1254                    "transform": {
1255                        "position": [0.0, 0.0, 0.0],
1256                        "rotationEuler": [0.0, 0.0, 0.0],
1257                        "scale": [1.0, 1.0, 1.0]
1258                    },
1259                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
1260                },
1261                "persistentData": {}
1262            },
1263            {
1264                "type": "P.CY",
1265                "inputParams": {
1266                    "id": "Pin",
1267                    "radius": 6.0, "height": 30.0,
1268                    "transform": {
1269                        "position": [10.0, -5.0, 10.0],
1270                        "rotationEuler": [0.0, 0.0, 0.0],
1271                        "scale": [1.0, 1.0, 1.0]
1272                    },
1273                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
1274                },
1275                "persistentData": {}
1276            },
1277            {
1278                "type": "B",
1279                "inputParams": {
1280                    "id": "Cut",
1281                    "targetSolid": "Box",
1282                    "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
1283                },
1284                "persistentData": {}
1285            }
1286        ]
1287    })
1288    .to_string()
1289}