Skip to main content

brep_app/
app.rs

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