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