BREP_app 0.1.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
//! `BrepApp` — the THIN shell of the engine-native UI.
//!
//! One eframe [`App`] that hosts the EXISTING `brep-render` engine and lays out
//! the panels. The heavy lifting lives in focused modules; this file only owns
//! the shell:
//!
//! * [`EngineState`] (`brep-render`) stays the single windowing-agnostic BRAIN
//!   (scene / camera / controls / settings / widgets + pointer/wheel/viewcube/
//!   pick). We do NOT fork it — panels borrow `&mut EngineState`.
//! * [`crate::viewport::Viewport`] draws + drives the central 3D viewport (the
//!   offscreen texture, the `egui_wgpu` blit callback, input routing).
//! * [`crate::panels`] — one module per left-panel section, each a small state
//!   struct + a `show(&mut self, ui, state, …)` method. Adding a panel = add
//!   `panels/<name>.rs`, one field here, one `self.<name>.show(…)` call in
//!   [`eframe::App::ui`] below (see `README.md` → "Adding a panel").
//!
//! Native (`run_native`) and wasm (`WebRunner`) run this SAME code.

use crate::panels::context_bar::ContextBarPanel;
use crate::panels::mode_bar::ModeBar;
use crate::panels::expressions::ExpressionsPanel;
use crate::panels::file::FileDialog;
use crate::panels::history::HistoryPanel;
use crate::panels::info_windows::InfoWindows;
use crate::panels::scene::ScenePanel;
use crate::panels::selection::SelectionPanel;
use crate::panels::sketch::SketchPanel;
use crate::panels::settings::SettingsPanel;
use crate::panels::toasts::Toasts;
use crate::panels::toolbar::ToolbarPanel;
use crate::store::{default_model_store, default_store, ModelStore, Store};
use crate::viewport::Viewport;
use brep_render::engine_state::EngineState;
use eframe::egui;

pub struct BrepApp {
    /// The shared viewer brain — identical to what `desktop.rs` / the wasm shell
    /// wrap. Never forked; panels borrow it.
    state: EngineState,
    /// The central 3D viewport: engine render core + offscreen texture + blit +
    /// input routing.
    viewport: Viewport,
    /// The persistence seam (native config file / wasm localStorage).
    store: Box<dyn Store>,
    /// The MODEL-document half of the storage seam (native models dir / wasm
    /// localStorage + download/upload) — used by the file panel.
    model_store: Box<dyn ModelStore>,

    // --- one small state value per panel --------------------------------------
    /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
    /// and the File-actions seam (owned by the concurrent file panel).
    toolbar: ToolbarPanel,
    /// New / Open / Save / Save As of the model document (the `.BREP.json`
    /// recipe) — a reusable modal file dialog opened from the toolbar.
    file: FileDialog,
    /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
    /// (movable + resizable, toggled from the toolbar gear button), no longer a
    /// left-panel section.
    settings: SettingsPanel,
    /// History feature-tree + schema-driven feature dialog panel (Phase 2).
    /// NOTE: the editable history is NOT owned here — it lives in the engine core
    /// (`EngineState.history`), the single source of truth; this panel only reads
    /// it back to draw and calls the engine's `history_*` methods to mutate.
    history: HistoryPanel,
    /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
    /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
    /// engine scene/emphasis; owns only transient expand + hit state.
    scene: ScenePanel,
    /// Expressions / parameters panel: the variable sheet (engine-owned history
    /// `expressions`) feature params reference. Owns only its editor buffer.
    expressions: ExpressionsPanel,
    /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
    /// selection-driven context bar's Info action. Each floating (movable +
    /// resizable) window is PINNED to one object name at open time — a Metadata
    /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
    /// and keeps showing that entity regardless of later selection changes. Replaces
    /// the old single Properties window.
    info_windows: InfoWindows,
    /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
    /// click may select — honored by the engine's `select_top_at`). The filter +
    /// selection live in `EngineState`; this panel only reads/writes them.
    selection: SelectionPanel,
    /// Context action toolbar: the selection-driven action bar (Clear / Hide /
    /// Edit-owning-feature + the feature-from-selection actions whose primary
    /// reference accepts the selected kind). Shown only while something is
    /// selected; drives the engine directly and returns a feature id for the shell
    /// to expand in the history tree.
    context_bar: ContextBarPanel,
    /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
    /// + circle to the `set_overlay` channel colored by solver mobility, and shows
    /// the DOF status readout. The engine-native sketcher's foundation surface.
    sketch: SketchPanel,
    /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
    /// corner — reference-selection, sketch mode, and any future special mode.
    mode_bar: ModeBar,
    /// Transient toast overlay: drains the engine's queued notices each frame
    /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
    toasts: Toasts,

    /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
    /// under a background runner (native thread / wasm worker), so the boot
    /// `zoom_to_fit` can run before the first solids exist → an unframed first
    /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
    /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
    /// (tests) the scene is already populated, so this fires on the very first frame.
    first_run_framed: bool,
}

impl BrepApp {
    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
        let render_state = cc
            .wgpu_render_state
            .as_ref()
            .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;

        // The viewport owns the render core + blit pipeline, built from eframe's
        // SHARED device/queue/format.
        let viewport = Viewport::new(render_state);

        // --- seed the ENGINE-owned mutable history + roll to the last step ----
        // The engine now owns the recipe; we only hand it the initial document.
        let mut state = EngineState::new();
        // Native: run the whole history — and per-object measurement queries — on a
        // persistent background thread so the UI never freezes during a run or a
        // selection (M2b). Installed BEFORE the seed so the seed builds through it.
        #[cfg(not(target_arch = "wasm32"))]
        state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
        // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
        // single-threaded wasm UI stays responsive during a run. Same seam; installed
        // BEFORE the seed so the (now async) seed run builds through the worker. Tests
        // (which never hit this wasm path) keep the default synchronous InlineRunner.
        #[cfg(target_arch = "wasm32")]
        state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
        let _ = state.set_history_json(&seed_history_json());
        state.set_viewcube_enabled(true);
        state.zoom_to_fit();

        // --- storage seam: load + apply any persisted settings ----------------
        let store = default_store();
        if let Some(saved) = store.load("settings") {
            // Partial-override apply: unknown/absent keys keep their defaults.
            let _ = state.apply_settings_json(&saved);
        }

        // The settings panel seeds its working JSON from the (post-load) engine
        // settings so the widgets reflect the persisted state on first paint.
        let settings = SettingsPanel::new();

        // The model-document store + the file panel (seeded clean from the seed
        // model, so the first edit marks it dirty).
        let model_store = default_model_store();
        let file = FileDialog::new(&state);

        Ok(Self {
            state,
            viewport,
            store,
            toolbar: ToolbarPanel::new(),
            model_store,
            file,
            settings,
            history: HistoryPanel::new(),
            scene: ScenePanel::new(),
            expressions: ExpressionsPanel::new(),
            info_windows: InfoWindows::new(),
            selection: SelectionPanel::new(),
            context_bar: ContextBarPanel::new(),
            sketch: SketchPanel::new(),
            mode_bar: ModeBar::new(),
            toasts: Toasts::new(),
            first_run_framed: false,
        })
    }

    /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
    /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection.
    ///
    /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
    /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
    /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
    /// consumed BEFORE undo because egui's `consume_key` matches modifiers
    /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
    fn handle_shortcuts(&mut self, ctx: &egui::Context) {
        if ctx.text_edit_focused() {
            return;
        }
        use egui::{Key, Modifiers};
        let (redo, undo, esc) = ctx.input_mut(|i| {
            let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
                || i.consume_key(Modifiers::COMMAND, Key::Y);
            let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
            let esc = i.consume_key(Modifiers::NONE, Key::Escape);
            (redo, undo, esc)
        });
        // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
        // history (S6a), not the model-level undo — this global router consumes the
        // keys first (before the viewport), so it must intercept here. Esc drops the
        // active draw/trim/pick tool back to Select/drag (clearing any in-progress
        // placement): this is the ONLY reliable capture point, since `consume_key`
        // above already swallowed the Escape before the viewport can see it.
        if self.state.sketch_mode() {
            if redo {
                self.state.sketch_redo();
            }
            if undo {
                self.state.sketch_undo();
            }
            if esc {
                self.state.sketch_set_tool(Some("select"));
            }
            return;
        }
        if redo {
            self.state.redo();
        }
        if undo {
            self.state.undo();
        }
        if esc {
            self.state.clear_selection();
        }
    }

    /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
    /// per-solid triangle count + bbox, and total triangles. Published to JS so
    /// the headed verifier can prove each roll / edit produced different geometry
    /// (names alone don't: a SUBTRACT reuses the target's name).
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    fn model_signature_json(&self) -> String {
        let solids: Vec<serde_json::Value> = self
            .state
            .scene
            .solids()
            .iter()
            .map(|s| {
                serde_json::json!({
                    "name": s.name,
                    "tris": s.mesh.indices.len() / 3,
                    "min": s.bbox.min,
                    "max": s.bbox.max,
                })
            })
            .collect();
        let total_tris: usize = self
            .state
            .scene
            .solids()
            .iter()
            .map(|s| s.mesh.indices.len() / 3)
            .sum();
        serde_json::json!({
            "step": self.state.history_rollback(),
            "solidCount": solids.len(),
            "totalTris": total_tris,
            "solids": solids,
        })
        .to_string()
    }
}

impl eframe::App for BrepApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
        // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
        // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
        // `&mut self` call).
        let ctx = ui.ctx().clone();

        // --- history-runner pump ---------------------------------------------
        // Apply any completed background history run BEFORE panels read the scene.
        // For the synchronous InlineRunner this is a no-op (`rerun_history` already
        // pumped its own submit), so nothing changes today; it is the seam a future
        // native-thread / wasm-worker runner lands its reply through. While a run is
        // still in flight, keep the frame loop alive so its reply gets pumped — for
        // Inline `run_pending()` is always false, so this never fires.
        self.state.pump();
        if self.state.run_pending() || self.state.queries_pending() {
            ctx.request_repaint();
        }

        // --- async-safe first-model framing -----------------------------------
        // The seed run is async under a background runner (native thread / wasm
        // worker), so the boot `zoom_to_fit` may have run before any solid existed.
        // Frame the model ONCE, the first frame the seed run has fully landed (solids
        // present AND no run still in flight). Under the synchronous Inline runner
        // (tests) both hold on the very first frame, so this is identical to today.
        if !self.first_run_framed && !self.state.run_pending() && self.state.has_solids() {
            self.state.zoom_to_fit();
            self.first_run_framed = true;
        }

        self.handle_shortcuts(&ctx);

        // --- top toolbar: primary actions, drawn FIRST so its top strip is
        // reserved above the left panel + central viewport. A clicked File button
        // returns an action the file dialog acts on (open its modal / save / new).
        if let Some(action) = self.toolbar.show(
            ui,
            &mut self.state,
            self.store.as_ref(),
            &mut self.settings.open,
        ) {
            self.file
                .dispatch(action, &mut self.state, self.model_store.as_ref());
        }

        // --- sketch mode: a slim top bar (title + DOF + Finish/Cancel) drawn
        // just below the toolbar while editing a sketch. The normal side panel is
        // hidden (below) so the 3D viewport is the full-width sketching surface.
        if self.state.sketch_mode() {
            self.sketch.show_mode_bar(ui, &mut self.state);
        }

        // --- side panel: the left control column, one call per panel ----------
        // egui 0.35 unified SidePanel/TopBottomPanel into `Panel`. Hidden while a
        // sketch is being edited (the sketch-mode bar above owns the controls) OR
        // while a reference-selection picker is active (the top-right mode card
        // owns that flow) — both are "special modes" that take over the shell.
        if !self.state.sketch_mode() && !self.state.ref_select_active() {
            egui::containers::panel::Panel::left("brep-controls")
                // User-resizable by dragging the right edge. egui persists the
                // chosen width under this Panel's stable `Id` ("brep-controls")
                // across frames, so a drag sticks for the session. `default_size`
                // matches the old fixed 320px so nothing jumps on launch, and
                // `size_range` clamps the drag (readable min, can't be collapsed
                // to nothing nor dragged past a sane cap).
                .resizable(true)
                .default_size(320.0)
                .size_range(220.0..=720.0)
                .show(ui, |ui| {
                    egui::ScrollArea::vertical().show(ui, |ui| {
                        ui.add_space(6.0);

                        // History feature-tree + schema-driven feature dialog. When
                        // a reference-selection picker is active this draws ONLY the
                        // picker's modal (list + Finish/Cancel) — see below.
                        self.history.show(ui, &mut self.state);

                        // Ref-select is a MODAL: hide the rest of the UI (the design
                        // doc's "show only this widget's list + Finish + Cancel").
                        if !self.state.ref_select_active() {
                            ui.separator();

                            // Scene tree: the display scene as a file-tree (per-solid
                            // visibility + Faces/Edges/Vertices, two-way selection sync).
                            self.scene.show(ui, &mut self.state);

                            ui.separator();

                            // Expressions / parameters: the variable sheet feature
                            // params reference (edits set the history's `expressions`
                            // and re-run, so var-referencing params update live).
                            self.expressions.show(ui, &mut self.state);

                            ui.separator();

                            // Selection filter (pickable kinds). The selection-driven
                            // ACTION toolbar is a floating bar drawn at ctx level below
                            // (so its buttons never scroll out of reach).
                            self.selection.show(ui, &mut self.state);
                        }
                    });
                });
        }

        // --- sketch-mode left panel: the entity lists (Curves / Points /
        // Constraints) + solver settings, the port of the previous app's sketch sidebar. Drawn
        // only while editing a sketch (the modeling control column above is hidden
        // then); the mode bar owns the top strip, this owns the left column.
        if self.state.sketch_mode() {
            egui::containers::panel::Panel::left("sketch-entities")
                .resizable(true)
                .default_size(300.0)
                .size_range(200.0..=560.0)
                .show(ui, |ui| {
                    self.sketch.show_entity_lists(ui, &mut self.state);
                });
        }

        // --- file dialog: a ctx-level modal (like the command palette), drawn
        // after the panels so its backdrop dims the whole shell. Idempotent when
        // closed; also polls for a completed async import each frame.
        self.file
            .show(&ctx, &mut self.state, self.model_store.as_ref());

        // --- Settings: a floating (movable + resizable) window, toggled from the
        // toolbar gear button, drawn at ctx level like Properties so it floats
        // over the shell. Idempotent when closed. Replaces the old sidebar section.
        self.settings
            .show(&ctx, &mut self.state, self.store.as_ref());

        // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
        // for reference-selection / sketch mode) stacked ABOVE the selection-driven
        // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
        // top-right so they never overlap, and the context rail uses the SAME
        // renderer whether it is showing modeling actions or sketch actions
        // (`panels::action_rail`). A modeling create/edit action returns a feature
        // id to expand in the history tree.
        {
            let mut focus: Option<String> = None;
            let mut info_targets: Vec<String> = Vec::new();
            egui::Area::new(egui::Id::new("brep-top-right-overlay"))
                .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0))
                .order(egui::Order::Foreground)
                .show(&ctx, |ui| {
                    // 1. Exit controls for whatever special mode is active.
                    self.mode_bar.card(ui, &mut self.state);
                    // 2. Context actions: sketch actions in sketch mode, else the
                    // modeling selection actions. Same rail, mode-appropriate items.
                    if self.state.sketch_mode() {
                        self.sketch.context_card(ui, &mut self.state);
                    } else {
                        let outcome = self.context_bar.card(ui, &mut self.state);
                        focus = outcome.focus;
                        info_targets = outcome.info_targets;
                    }
                });
            if let Some(focus) = focus {
                self.history.focus_feature(focus);
            }
            // The Info action returns one target per selected entity — open (or, on
            // dedup, keep) a pinned Info window for each. Drawn below.
            if !info_targets.is_empty() {
                self.info_windows.open_for(&info_targets);
            }
        }

        // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
        // level like the file dialog so they float over the shell. Each is pinned to
        // its open-time object name (selection changes never retarget them); closed
        // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
        // window opened THIS frame paints this frame.
        self.info_windows.show(&ctx, &mut self.state);

        // --- transient toasts: drain the engine's queued notices (e.g. a sketch
        // solve that failed after an edit) and show each briefly. Drawn last so
        // the cards float over the whole shell.
        let now = ctx.input(|i| i.time);
        self.toasts.extend(self.state.take_notices(), now);
        self.toasts.show(&ctx);

        // Verification hook (wasm only): mirror the live app + engine state to JS
        // globals so the headed verifier can assert roll-to-step / edit-re-run /
        // add / delete took effect, and locate the real egui widgets to click.
        // Purely additive; no render effect. Published AFTER the panel draws so
        // the hit-rects are for THIS frame's layout.
        #[cfg(target_arch = "wasm32")]
        {
            let ppp = ui.ctx().pixels_per_point();
            publish_to_js("__brepCamera", &self.state.camera_state_json());
            publish_to_js("__brepSettings", &self.state.settings_json());
            publish_to_js("__brepSolidColors", &self.state.solid_color_overrides_json());
            publish_to_js("__brepHistory", &self.state.history_listing_json());
            publish_to_js(
                "__brepFile",
                &self.file.file_state_json(&self.state, self.model_store.as_ref()),
            );
            publish_to_js("__brepFileHit", &self.file.hits_json());
            publish_to_js("__brepModel", &self.model_signature_json());
            publish_to_js("__brepReport", &self.state.history_report_json());
            publish_to_js("__brepHit", &self.history.hits_json());
            publish_to_js("__brepExprHit", &self.expressions.hits_json());
            publish_to_js(
                "__brepExpr",
                &serde_json::json!({
                    "expressions": self.state.expressions_json(),
                    "variables": serde_json::from_str::<serde_json::Value>(
                        &self.state.expression_variables_json()
                    )
                    .unwrap_or(serde_json::Value::Null),
                    "configurator": serde_json::from_str::<serde_json::Value>(
                        &self.state.configurator_json()
                    )
                    .unwrap_or(serde_json::Value::Null),
                })
                .to_string(),
            );
            publish_to_js("__brepToolbar", &self.toolbar.hits_json());
            publish_to_js("__brepSelection", &self.state.selection_json());
            publish_to_js(
                "__brepInfoWindows",
                &self.info_windows.published_json(&mut self.state),
            );
            publish_to_js("__brepInfoWindowsHit", &self.info_windows.hits_json());
            publish_to_js("__brepSelectionFilter", &self.state.selection_filter_json());
            publish_to_js("__brepSelectionHit", &self.selection.hits_json());
            publish_to_js("__brepContext", &self.context_bar.state_json());
            publish_to_js("__brepContextHit", &self.context_bar.hits_json());
            publish_to_js("__brepModeBarHit", &self.mode_bar.hits_json());
            publish_to_js("__brepSketch", &self.sketch.published_json(&self.state));
            publish_to_js("__brepWireframe", &format!("{}", self.state.settings.wireframe));
            publish_to_js(
                "__brepRefSelect",
                &serde_json::json!({
                    "active": self.state.ref_select_active(),
                    "prompt": self.state.ref_select_prompt(),
                    "names": self.state.ref_select_names(),
                })
                .to_string(),
            );
            // Viewport origin + projected probe points (viewport-local logical
            // px) so the verifier can click precise spots ON the Box and ON the
            // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
            // pin; indices 1..4 are points on the Pin's cylindrical stub that
            // protrudes above the Box top (y=20), on the camera-facing sides — the
            // verifier tries them until one picks "Pin".
            publish_to_js("__brepView", &self.viewport.viewport_rect_json());
            publish_to_js(
                "__brepProbe",
                &self
                    .state
                    .world_to_screen_json(
                        "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
                          [10.0,22.5,16.0],[10.0,25.0,10.0]]",
                    )
                    .unwrap_or_else(|_| "[]".to_string()),
            );
            publish_to_js("__brepPpp", &format!("{ppp}"));
            publish_to_js("__brepStep", &format!("{}", self.state.history_rollback()));
            publish_to_js(
                "__brepParams",
                &self.state.feature_params_json(self.state.history_rollback()),
            );
        }

        // --- central panel: the 3D viewport -----------------------------------
        self.viewport.show(ui, &mut self.state);
    }
}

/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
#[cfg(target_arch = "wasm32")]
fn publish_to_js(name: &str, json: &str) {
    if let Some(win) = web_sys::window() {
        let _ = js_sys::Reflect::set(
            &win,
            &wasm_bindgen::JsValue::from_str(name),
            &wasm_bindgen::JsValue::from_str(json),
        );
    }
}

/// The seed model handed to the engine at startup: a 3-feature history so the
/// tree / roll / edit are real —
///   0. `P.CU` "Box"  — a 20 mm cube at the origin (spans `[0,20]³`).
///   1. `P.CY` "Pin"  — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
///      cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
///   2. `B`    "Cut"  — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
///      with a cylindrical through-hole (the ref-select field is visible for the
///      next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
///
/// This is just the INITIAL document — once handed to `EngineState`, the engine
/// OWNS the mutable history; the app keeps no copy.
fn seed_history_json() -> String {
    serde_json::json!({
        "expressions": "",
        "configurator": {},
        "features": [
            {
                "type": "P.CU",
                "inputParams": {
                    "id": "Box",
                    "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
                    "transform": {
                        "position": [0.0, 0.0, 0.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
                },
                "persistentData": {}
            },
            {
                "type": "P.CY",
                "inputParams": {
                    "id": "Pin",
                    "radius": 6.0, "height": 30.0,
                    "transform": {
                        "position": [10.0, -5.0, 10.0],
                        "rotationEuler": [0.0, 0.0, 0.0],
                        "scale": [1.0, 1.0, 1.0]
                    },
                    "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
                },
                "persistentData": {}
            },
            {
                "type": "B",
                "inputParams": {
                    "id": "Cut",
                    "targetSolid": "Box",
                    "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
                },
                "persistentData": {}
            }
        ]
    })
    .to_string()
}