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}