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