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//! * [`EngineState`] (`brep-render`) stays the single windowing-agnostic BRAIN
8//!   (scene / camera / controls / settings / widgets + pointer/wheel/viewcube/
9//!   pick). We do NOT fork it — panels borrow `&mut EngineState`.
10//! * [`crate::viewport::Viewport`] draws + drives the central 3D viewport (the
11//!   offscreen texture, the `egui_wgpu` blit callback, input routing).
12//! * [`crate::panels`] — one module per left-panel section, each a small state
13//!   struct + a `show(&mut self, ui, state, …)` method. Adding a panel = add
14//!   `panels/<name>.rs`, one field here, one `self.<name>.show(…)` call in
15//!   [`eframe::App::ui`] below (see `README.md` → "Adding a panel").
16//!
17//! Native (`run_native`) and wasm (`WebRunner`) run this SAME code.
18
19use crate::panels::context_bar::ContextBarPanel;
20use crate::panels::mode_bar::ModeBar;
21use crate::panels::expressions::ExpressionsPanel;
22use crate::panels::file::FileDialog;
23use crate::panels::history::HistoryPanel;
24use crate::panels::info_windows::InfoWindows;
25use crate::panels::scene::ScenePanel;
26use crate::panels::selection::SelectionPanel;
27use crate::panels::sketch::SketchPanel;
28use crate::panels::settings::SettingsPanel;
29use crate::panels::toasts::Toasts;
30use crate::panels::toolbar::ToolbarPanel;
31use crate::store::{default_model_store, default_store, ModelStore, Store};
32use crate::viewport::Viewport;
33use brep_render::engine_state::EngineState;
34use eframe::egui;
35
36pub struct BrepApp {
37    /// The shared viewer brain — identical to what `desktop.rs` / the wasm shell
38    /// wrap. Never forked; panels borrow it.
39    state: EngineState,
40    /// The central 3D viewport: engine render core + offscreen texture + blit +
41    /// input routing.
42    viewport: Viewport,
43    /// The persistence seam (native config file / wasm localStorage).
44    store: Box<dyn Store>,
45    /// The MODEL-document half of the storage seam (native models dir / wasm
46    /// localStorage + download/upload) — used by the file panel.
47    model_store: Box<dyn ModelStore>,
48
49    // --- one small state value per panel --------------------------------------
50    /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
51    /// and the File-actions seam (owned by the concurrent file panel).
52    toolbar: ToolbarPanel,
53    /// New / Open / Save / Save As of the model document (the `.BREP.json`
54    /// recipe) — a reusable modal file dialog opened from the toolbar.
55    file: FileDialog,
56    /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
57    /// (movable + resizable, toggled from the toolbar gear button), no longer a
58    /// left-panel section.
59    settings: SettingsPanel,
60    /// History feature-tree + schema-driven feature dialog panel (Phase 2).
61    /// NOTE: the editable history is NOT owned here — it lives in the engine core
62    /// (`EngineState.history`), the single source of truth; this panel only reads
63    /// it back to draw and calls the engine's `history_*` methods to mutate.
64    history: HistoryPanel,
65    /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
66    /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
67    /// engine scene/emphasis; owns only transient expand + hit state.
68    scene: ScenePanel,
69    /// Expressions / parameters panel: the variable sheet (engine-owned history
70    /// `expressions`) feature params reference. Owns only its editor buffer.
71    expressions: ExpressionsPanel,
72    /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
73    /// selection-driven context bar's Info action. Each floating (movable +
74    /// resizable) window is PINNED to one object name at open time — a Metadata
75    /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
76    /// and keeps showing that entity regardless of later selection changes. Replaces
77    /// the old single Properties window.
78    info_windows: InfoWindows,
79    /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
80    /// click may select — honored by the engine's `select_top_at`). The filter +
81    /// selection live in `EngineState`; this panel only reads/writes them.
82    selection: SelectionPanel,
83    /// Context action toolbar: the selection-driven action bar (Clear / Hide /
84    /// Edit-owning-feature + the feature-from-selection actions whose primary
85    /// reference accepts the selected kind). Shown only while something is
86    /// selected; drives the engine directly and returns a feature id for the shell
87    /// to expand in the history tree.
88    context_bar: ContextBarPanel,
89    /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
90    /// + circle to the `set_overlay` channel colored by solver mobility, and shows
91    /// the DOF status readout. The engine-native sketcher's foundation surface.
92    sketch: SketchPanel,
93    /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
94    /// corner — reference-selection, sketch mode, and any future special mode.
95    mode_bar: ModeBar,
96    /// Transient toast overlay: drains the engine's queued notices each frame
97    /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
98    toasts: Toasts,
99
100    /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
101    /// under a background runner (native thread / wasm worker), so the boot
102    /// `zoom_to_fit` can run before the first solids exist → an unframed first
103    /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
104    /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
105    /// (tests) the scene is already populated, so this fires on the very first frame.
106    first_run_framed: bool,
107}
108
109impl BrepApp {
110    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
111        let render_state = cc
112            .wgpu_render_state
113            .as_ref()
114            .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
115
116        // The viewport owns the render core + blit pipeline, built from eframe's
117        // SHARED device/queue/format.
118        let viewport = Viewport::new(render_state);
119
120        // --- seed the ENGINE-owned mutable history + roll to the last step ----
121        // The engine now owns the recipe; we only hand it the initial document.
122        let mut state = EngineState::new();
123        // Native: run the whole history — and per-object measurement queries — on a
124        // persistent background thread so the UI never freezes during a run or a
125        // selection (M2b). Installed BEFORE the seed so the seed builds through it.
126        #[cfg(not(target_arch = "wasm32"))]
127        state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
128        // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
129        // single-threaded wasm UI stays responsive during a run. Same seam; installed
130        // BEFORE the seed so the (now async) seed run builds through the worker. Tests
131        // (which never hit this wasm path) keep the default synchronous InlineRunner.
132        #[cfg(target_arch = "wasm32")]
133        state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
134        let _ = state.set_history_json(&seed_history_json());
135        state.set_viewcube_enabled(true);
136        state.zoom_to_fit();
137
138        // --- storage seam: load + apply any persisted settings ----------------
139        let store = default_store();
140        if let Some(saved) = store.load("settings") {
141            // Partial-override apply: unknown/absent keys keep their defaults.
142            let _ = state.apply_settings_json(&saved);
143        }
144
145        // The settings panel seeds its working JSON from the (post-load) engine
146        // settings so the widgets reflect the persisted state on first paint.
147        let settings = SettingsPanel::new();
148
149        // The model-document store + the file panel (seeded clean from the seed
150        // model, so the first edit marks it dirty).
151        let model_store = default_model_store();
152        let file = FileDialog::new(&state);
153
154        Ok(Self {
155            state,
156            viewport,
157            store,
158            toolbar: ToolbarPanel::new(),
159            model_store,
160            file,
161            settings,
162            history: HistoryPanel::new(),
163            scene: ScenePanel::new(),
164            expressions: ExpressionsPanel::new(),
165            info_windows: InfoWindows::new(),
166            selection: SelectionPanel::new(),
167            context_bar: ContextBarPanel::new(),
168            sketch: SketchPanel::new(),
169            mode_bar: ModeBar::new(),
170            toasts: Toasts::new(),
171            first_run_framed: false,
172        })
173    }
174
175    /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
176    /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection.
177    ///
178    /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
179    /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
180    /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
181    /// consumed BEFORE undo because egui's `consume_key` matches modifiers
182    /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
183    fn handle_shortcuts(&mut self, ctx: &egui::Context) {
184        if ctx.text_edit_focused() {
185            return;
186        }
187        use egui::{Key, Modifiers};
188        let (redo, undo, esc) = ctx.input_mut(|i| {
189            let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
190                || i.consume_key(Modifiers::COMMAND, Key::Y);
191            let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
192            let esc = i.consume_key(Modifiers::NONE, Key::Escape);
193            (redo, undo, esc)
194        });
195        // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
196        // history (S6a), not the model-level undo — this global router consumes the
197        // keys first (before the viewport), so it must intercept here. Esc drops the
198        // active draw/trim/pick tool back to Select/drag (clearing any in-progress
199        // placement): this is the ONLY reliable capture point, since `consume_key`
200        // above already swallowed the Escape before the viewport can see it.
201        if self.state.sketch_mode() {
202            if redo {
203                self.state.sketch_redo();
204            }
205            if undo {
206                self.state.sketch_undo();
207            }
208            if esc {
209                self.state.sketch_set_tool(Some("select"));
210            }
211            return;
212        }
213        if redo {
214            self.state.redo();
215        }
216        if undo {
217            self.state.undo();
218        }
219        if esc {
220            self.state.clear_selection();
221        }
222    }
223
224    /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
225    /// per-solid triangle count + bbox, and total triangles. Published to JS so
226    /// the headed verifier can prove each roll / edit produced different geometry
227    /// (names alone don't: a SUBTRACT reuses the target's name).
228    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
229    fn model_signature_json(&self) -> String {
230        let solids: Vec<serde_json::Value> = self
231            .state
232            .scene
233            .solids()
234            .iter()
235            .map(|s| {
236                serde_json::json!({
237                    "name": s.name,
238                    "tris": s.mesh.indices.len() / 3,
239                    "min": s.bbox.min,
240                    "max": s.bbox.max,
241                })
242            })
243            .collect();
244        let total_tris: usize = self
245            .state
246            .scene
247            .solids()
248            .iter()
249            .map(|s| s.mesh.indices.len() / 3)
250            .sum();
251        serde_json::json!({
252            "step": self.state.history_rollback(),
253            "solidCount": solids.len(),
254            "totalTris": total_tris,
255            "solids": solids,
256        })
257        .to_string()
258    }
259}
260
261impl eframe::App for BrepApp {
262    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
263        // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
264        // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
265        // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
266        // `&mut self` call).
267        let ctx = ui.ctx().clone();
268
269        // --- history-runner pump ---------------------------------------------
270        // Apply any completed background history run BEFORE panels read the scene.
271        // For the synchronous InlineRunner this is a no-op (`rerun_history` already
272        // pumped its own submit), so nothing changes today; it is the seam a future
273        // native-thread / wasm-worker runner lands its reply through. While a run is
274        // still in flight, keep the frame loop alive so its reply gets pumped — for
275        // Inline `run_pending()` is always false, so this never fires.
276        self.state.pump();
277        if self.state.run_pending() || self.state.queries_pending() {
278            ctx.request_repaint();
279        }
280
281        // --- async-safe first-model framing -----------------------------------
282        // The seed run is async under a background runner (native thread / wasm
283        // worker), so the boot `zoom_to_fit` may have run before any solid existed.
284        // Frame the model ONCE, the first frame the seed run has fully landed (solids
285        // present AND no run still in flight). Under the synchronous Inline runner
286        // (tests) both hold on the very first frame, so this is identical to today.
287        if !self.first_run_framed && !self.state.run_pending() && self.state.has_solids() {
288            self.state.zoom_to_fit();
289            self.first_run_framed = true;
290        }
291
292        self.handle_shortcuts(&ctx);
293
294        // --- top toolbar: primary actions, drawn FIRST so its top strip is
295        // reserved above the left panel + central viewport. A clicked File button
296        // returns an action the file dialog acts on (open its modal / save / new).
297        if let Some(action) = self.toolbar.show(
298            ui,
299            &mut self.state,
300            self.store.as_ref(),
301            &mut self.settings.open,
302        ) {
303            self.file
304                .dispatch(action, &mut self.state, self.model_store.as_ref());
305        }
306
307        // --- sketch mode: a slim top bar (title + DOF + Finish/Cancel) drawn
308        // just below the toolbar while editing a sketch. The normal side panel is
309        // hidden (below) so the 3D viewport is the full-width sketching surface.
310        if self.state.sketch_mode() {
311            self.sketch.show_mode_bar(ui, &mut self.state);
312        }
313
314        // --- side panel: the left control column, one call per panel ----------
315        // egui 0.35 unified SidePanel/TopBottomPanel into `Panel`. Hidden while a
316        // sketch is being edited (the sketch-mode bar above owns the controls) OR
317        // while a reference-selection picker is active (the top-right mode card
318        // owns that flow) — both are "special modes" that take over the shell.
319        if !self.state.sketch_mode() && !self.state.ref_select_active() {
320            egui::containers::panel::Panel::left("brep-controls")
321                // User-resizable by dragging the right edge. egui persists the
322                // chosen width under this Panel's stable `Id` ("brep-controls")
323                // across frames, so a drag sticks for the session. `default_size`
324                // matches the old fixed 320px so nothing jumps on launch, and
325                // `size_range` clamps the drag (readable min, can't be collapsed
326                // to nothing nor dragged past a sane cap).
327                .resizable(true)
328                .default_size(320.0)
329                .size_range(220.0..=720.0)
330                .show(ui, |ui| {
331                    egui::ScrollArea::vertical().show(ui, |ui| {
332                        ui.add_space(6.0);
333
334                        // History feature-tree + schema-driven feature dialog. When
335                        // a reference-selection picker is active this draws ONLY the
336                        // picker's modal (list + Finish/Cancel) — see below.
337                        self.history.show(ui, &mut self.state);
338
339                        // Ref-select is a MODAL: hide the rest of the UI (the design
340                        // doc's "show only this widget's list + Finish + Cancel").
341                        if !self.state.ref_select_active() {
342                            ui.separator();
343
344                            // Scene tree: the display scene as a file-tree (per-solid
345                            // visibility + Faces/Edges/Vertices, two-way selection sync).
346                            self.scene.show(ui, &mut self.state);
347
348                            ui.separator();
349
350                            // Expressions / parameters: the variable sheet feature
351                            // params reference (edits set the history's `expressions`
352                            // and re-run, so var-referencing params update live).
353                            self.expressions.show(ui, &mut self.state);
354
355                            ui.separator();
356
357                            // Selection filter (pickable kinds). The selection-driven
358                            // ACTION toolbar is a floating bar drawn at ctx level below
359                            // (so its buttons never scroll out of reach).
360                            self.selection.show(ui, &mut self.state);
361                        }
362                    });
363                });
364        }
365
366        // --- sketch-mode left panel: the entity lists (Curves / Points /
367        // Constraints) + solver settings, the port of the previous app's sketch sidebar. Drawn
368        // only while editing a sketch (the modeling control column above is hidden
369        // then); the mode bar owns the top strip, this owns the left column.
370        if self.state.sketch_mode() {
371            egui::containers::panel::Panel::left("sketch-entities")
372                .resizable(true)
373                .default_size(300.0)
374                .size_range(200.0..=560.0)
375                .show(ui, |ui| {
376                    self.sketch.show_entity_lists(ui, &mut self.state);
377                });
378        }
379
380        // --- file dialog: a ctx-level modal (like the command palette), drawn
381        // after the panels so its backdrop dims the whole shell. Idempotent when
382        // closed; also polls for a completed async import each frame.
383        self.file
384            .show(&ctx, &mut self.state, self.model_store.as_ref());
385
386        // --- Settings: a floating (movable + resizable) window, toggled from the
387        // toolbar gear button, drawn at ctx level like Properties so it floats
388        // over the shell. Idempotent when closed. Replaces the old sidebar section.
389        self.settings
390            .show(&ctx, &mut self.state, self.store.as_ref());
391
392        // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
393        // for reference-selection / sketch mode) stacked ABOVE the selection-driven
394        // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
395        // top-right so they never overlap, and the context rail uses the SAME
396        // renderer whether it is showing modeling actions or sketch actions
397        // (`panels::action_rail`). A modeling create/edit action returns a feature
398        // id to expand in the history tree.
399        {
400            let mut focus: Option<String> = None;
401            let mut info_targets: Vec<String> = Vec::new();
402            egui::Area::new(egui::Id::new("brep-top-right-overlay"))
403                .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0))
404                .order(egui::Order::Foreground)
405                .show(&ctx, |ui| {
406                    // 1. Exit controls for whatever special mode is active.
407                    self.mode_bar.card(ui, &mut self.state);
408                    // 2. Context actions: sketch actions in sketch mode, else the
409                    // modeling selection actions. Same rail, mode-appropriate items.
410                    if self.state.sketch_mode() {
411                        self.sketch.context_card(ui, &mut self.state);
412                    } else {
413                        let outcome = self.context_bar.card(ui, &mut self.state);
414                        focus = outcome.focus;
415                        info_targets = outcome.info_targets;
416                    }
417                });
418            if let Some(focus) = focus {
419                self.history.focus_feature(focus);
420            }
421            // The Info action returns one target per selected entity — open (or, on
422            // dedup, keep) a pinned Info window for each. Drawn below.
423            if !info_targets.is_empty() {
424                self.info_windows.open_for(&info_targets);
425            }
426        }
427
428        // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
429        // level like the file dialog so they float over the shell. Each is pinned to
430        // its open-time object name (selection changes never retarget them); closed
431        // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
432        // window opened THIS frame paints this frame.
433        self.info_windows.show(&ctx, &mut self.state);
434
435        // --- transient toasts: drain the engine's queued notices (e.g. a sketch
436        // solve that failed after an edit) and show each briefly. Drawn last so
437        // the cards float over the whole shell.
438        let now = ctx.input(|i| i.time);
439        self.toasts.extend(self.state.take_notices(), now);
440        self.toasts.show(&ctx);
441
442        // Verification hook (wasm only): mirror the live app + engine state to JS
443        // globals so the headed verifier can assert roll-to-step / edit-re-run /
444        // add / delete took effect, and locate the real egui widgets to click.
445        // Purely additive; no render effect. Published AFTER the panel draws so
446        // the hit-rects are for THIS frame's layout.
447        #[cfg(target_arch = "wasm32")]
448        {
449            let ppp = ui.ctx().pixels_per_point();
450            publish_to_js("__brepCamera", &self.state.camera_state_json());
451            publish_to_js("__brepSettings", &self.state.settings_json());
452            publish_to_js("__brepSolidColors", &self.state.solid_color_overrides_json());
453            publish_to_js("__brepHistory", &self.state.history_listing_json());
454            publish_to_js(
455                "__brepFile",
456                &self.file.file_state_json(&self.state, self.model_store.as_ref()),
457            );
458            publish_to_js("__brepFileHit", &self.file.hits_json());
459            publish_to_js("__brepModel", &self.model_signature_json());
460            publish_to_js("__brepReport", &self.state.history_report_json());
461            publish_to_js("__brepHit", &self.history.hits_json());
462            publish_to_js("__brepExprHit", &self.expressions.hits_json());
463            publish_to_js(
464                "__brepExpr",
465                &serde_json::json!({
466                    "expressions": self.state.expressions_json(),
467                    "variables": serde_json::from_str::<serde_json::Value>(
468                        &self.state.expression_variables_json()
469                    )
470                    .unwrap_or(serde_json::Value::Null),
471                    "configurator": serde_json::from_str::<serde_json::Value>(
472                        &self.state.configurator_json()
473                    )
474                    .unwrap_or(serde_json::Value::Null),
475                })
476                .to_string(),
477            );
478            publish_to_js("__brepToolbar", &self.toolbar.hits_json());
479            publish_to_js("__brepSelection", &self.state.selection_json());
480            publish_to_js(
481                "__brepInfoWindows",
482                &self.info_windows.published_json(&mut self.state),
483            );
484            publish_to_js("__brepInfoWindowsHit", &self.info_windows.hits_json());
485            publish_to_js("__brepSelectionFilter", &self.state.selection_filter_json());
486            publish_to_js("__brepSelectionHit", &self.selection.hits_json());
487            publish_to_js("__brepContext", &self.context_bar.state_json());
488            publish_to_js("__brepContextHit", &self.context_bar.hits_json());
489            publish_to_js("__brepModeBarHit", &self.mode_bar.hits_json());
490            publish_to_js("__brepSketch", &self.sketch.published_json(&self.state));
491            publish_to_js("__brepWireframe", &format!("{}", self.state.settings.wireframe));
492            publish_to_js(
493                "__brepRefSelect",
494                &serde_json::json!({
495                    "active": self.state.ref_select_active(),
496                    "prompt": self.state.ref_select_prompt(),
497                    "names": self.state.ref_select_names(),
498                })
499                .to_string(),
500            );
501            // Viewport origin + projected probe points (viewport-local logical
502            // px) so the verifier can click precise spots ON the Box and ON the
503            // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
504            // pin; indices 1..4 are points on the Pin's cylindrical stub that
505            // protrudes above the Box top (y=20), on the camera-facing sides — the
506            // verifier tries them until one picks "Pin".
507            publish_to_js("__brepView", &self.viewport.viewport_rect_json());
508            publish_to_js(
509                "__brepProbe",
510                &self
511                    .state
512                    .world_to_screen_json(
513                        "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
514                          [10.0,22.5,16.0],[10.0,25.0,10.0]]",
515                    )
516                    .unwrap_or_else(|_| "[]".to_string()),
517            );
518            publish_to_js("__brepPpp", &format!("{ppp}"));
519            publish_to_js("__brepStep", &format!("{}", self.state.history_rollback()));
520            publish_to_js(
521                "__brepParams",
522                &self.state.feature_params_json(self.state.history_rollback()),
523            );
524        }
525
526        // --- central panel: the 3D viewport -----------------------------------
527        self.viewport.show(ui, &mut self.state);
528    }
529}
530
531/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
532#[cfg(target_arch = "wasm32")]
533fn publish_to_js(name: &str, json: &str) {
534    if let Some(win) = web_sys::window() {
535        let _ = js_sys::Reflect::set(
536            &win,
537            &wasm_bindgen::JsValue::from_str(name),
538            &wasm_bindgen::JsValue::from_str(json),
539        );
540    }
541}
542
543/// The seed model handed to the engine at startup: a 3-feature history so the
544/// tree / roll / edit are real —
545///   0. `P.CU` "Box"  — a 20 mm cube at the origin (spans `[0,20]³`).
546///   1. `P.CY` "Pin"  — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
547///      cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
548///   2. `B`    "Cut"  — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
549///      with a cylindrical through-hole (the ref-select field is visible for the
550///      next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
551///
552/// This is just the INITIAL document — once handed to `EngineState`, the engine
553/// OWNS the mutable history; the app keeps no copy.
554fn seed_history_json() -> String {
555    serde_json::json!({
556        "expressions": "",
557        "configurator": {},
558        "features": [
559            {
560                "type": "P.CU",
561                "inputParams": {
562                    "id": "Box",
563                    "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
564                    "transform": {
565                        "position": [0.0, 0.0, 0.0],
566                        "rotationEuler": [0.0, 0.0, 0.0],
567                        "scale": [1.0, 1.0, 1.0]
568                    },
569                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
570                },
571                "persistentData": {}
572            },
573            {
574                "type": "P.CY",
575                "inputParams": {
576                    "id": "Pin",
577                    "radius": 6.0, "height": 30.0,
578                    "transform": {
579                        "position": [10.0, -5.0, 10.0],
580                        "rotationEuler": [0.0, 0.0, 0.0],
581                        "scale": [1.0, 1.0, 1.0]
582                    },
583                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
584                },
585                "persistentData": {}
586            },
587            {
588                "type": "B",
589                "inputParams": {
590                    "id": "Cut",
591                    "targetSolid": "Box",
592                    "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
593                },
594                "persistentData": {}
595            }
596        ]
597    })
598    .to_string()
599}