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