brep_app/app.rs
1//! `BrepApp` — the THIN shell of the engine-native UI.
2//!
3//! One eframe [`App`](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::sketch3d::Sketch3dPanel;
38use crate::panels::part_properties::PartPropertiesPanel;
39use crate::panels::settings::SettingsPanel;
40use crate::panels::toasts::Toasts;
41use crate::panels::toolbar::ToolbarPanel;
42use crate::panels::workbench_toolbar::WorkbenchToolbarPanel;
43use crate::panels::update_components::UpdateComponents;
44use crate::panels::bom::BomPanel;
45use crate::panels::dock::{DockContext, DockState, PaneKind};
46use crate::panels::document_tabs;
47use crate::store::{default_model_store, ModelStore, SETTINGS_KEY};
48use crate::viewport::Viewport;
49use brep_render::engine_state::EngineState;
50use brep_render::style::ThemeMode;
51use eframe::egui;
52
53/// A measured camera spin: an orbit drag the app steps ITSELF, one pointer move
54/// per frame, from `perf_spin`. See `BrepApp::spin` for why it is not driven
55/// from outside.
56///
57/// The steps run through the same `EngineState::pointer_down/move/up` the
58/// viewport's real drag uses (see `viewport::interaction`), so a spin costs
59/// exactly what dragging the mouse costs — no shortcut past the controls.
60#[cfg(feature = "automation")]
61pub struct Spin {
62 /// Pointer steps still to make; the drag ends (pointer_up) on the last one.
63 pub left: u32,
64 /// Where the drag started, in viewport-local logical px.
65 pub origin: (f64, f64),
66 /// Pointer step, in viewport-local logical px per frame.
67 pub step: (f64, f64),
68 /// Steps already made — the drag is a straight sweep from `origin`, and
69 /// reversing at the halfway mark keeps it inside the viewport however many
70 /// frames are asked for.
71 pub made: u32,
72 /// False until the first frame has pressed the button.
73 pub pressed: bool,
74}
75
76pub struct BrepApp {
77 /// Every OPEN MODEL and which one is active. Each document owns a full
78 /// `EngineState` (the shared viewer brain `desktop.rs` / the wasm shell
79 /// wrap) plus its file identity; panels borrow the active one.
80 pub(crate) docs: Documents,
81 /// The central 3D viewport: engine render core + offscreen texture + blit +
82 /// input routing.
83 pub(crate) viewport: Viewport,
84 /// The single persistence seam for settings, layout, and model documents
85 /// (native filesystem / wasm IndexedDB + download/upload).
86 pub(crate) model_store: Box<dyn ModelStore>,
87
88 // --- one small state value per panel --------------------------------------
89 /// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
90 /// and the File-actions seam (owned by the concurrent file panel).
91 pub(crate) toolbar: ToolbarPanel,
92 /// New / Open / Save / Save As of the model document (the `.nbrep`
93 /// recipe) — a reusable modal file dialog opened from the toolbar.
94 file: FileDialog,
95 stl_import: Option<crate::panels::stl_import::StlImportPreview>,
96 /// The "Submit Bug" flow: on the toolbar bug button it screenshots the app
97 /// (UI + 3D model) BEFORE its own dialog opens, then collects a description
98 /// (+ optional email) and POSTs the model + screenshot to the public reports
99 /// endpoint. Native + wasm, one path.
100 bug_report: BugReportPanel,
101 /// The Info window: the licences and this session's diagnostics, toggled
102 /// from the toolbar's info button.
103 info: crate::panels::info::InfoPanel,
104 /// What this session is running on — captured ONCE, from the adapter eframe
105 /// actually gave us (never re-probed), and read by BOTH the Info window and
106 /// the problem report so the two cannot disagree. See
107 /// [`crate::diagnostics`].
108 diagnostics: crate::diagnostics::Diagnostics,
109 /// Workbench actions toolbar: the second top strip under the primary
110 /// toolbar — one button per feature the active workbench offers, plus the
111 /// constraint types where the Constraints panel is shown. Gated by the
112 /// `showWorkbenchToolbar` setting and hidden in the special modes. A click
113 /// flows out as the type to add; the shell adds it through the SAME paths
114 /// the palette / context bar use.
115 workbench_toolbar: WorkbenchToolbarPanel,
116 /// Display-settings + per-solid color panel (Phase 1): a FLOATING window
117 /// (movable + resizable, toggled from the toolbar gear button), no longer a
118 /// left-panel section.
119 settings: SettingsPanel,
120 pub(crate) plugins: crate::plugins::PluginsPanel,
121 pub(crate) javascript: crate::javascript::JavaScriptEditor,
122 /// The active document's OWN BOM attributes (Part Number, Material, Mass,
123 /// …) as a floating window, toggled from the toolbar's Properties button.
124 /// Document-level, not selection-level: entity inspection is the context
125 /// bar's `info_windows`.
126 part_properties: PartPropertiesPanel,
127 /// The Replace component chooser + result summary.
128 replace_dialog: crate::panels::replace_component::ReplaceDialog,
129 /// History feature-tree + schema-driven feature dialog panel (Phase 2).
130 /// NOTE: the editable history is NOT owned here — it lives in the engine core
131 /// (`EngineState.history`), the single source of truth; this panel only reads
132 /// it back to draw and calls the engine's `history_*` methods to mutate.
133 history: HistoryPanel,
134 /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
135 /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
136 /// engine scene/emphasis; owns only transient expand + hit state.
137 scene: ScenePanel,
138 /// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
139 /// the scene's component records — per-instance fixed/visibility/status
140 /// adornments, actions routed to the owning ACOMP feature.
141 /// The BOM panel (claimed by the Assembly workbench): the parts list on
142 /// the shared column-tree widget, with the editable part/occurrence
143 /// attribute columns the Settings "Assemblies" section configures.
144 bom: BomPanel,
145 /// Assembly Constraints panel (claimed by the Assembly workbench): the
146 /// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
147 assembly_constraints: AssemblyConstraintsPanel,
148 /// The wire-harness connection list (a Wire harness workbench pane): add /
149 /// edit / remove wires, read their routed length + status, hover to
150 /// highlight. Document data lives in the engine; this holds the widget's
151 /// transient state.
152 wire_harness: WireHarnessPanel,
153 /// The PMI view tree + annotation forms (the PMI workbench's pane).
154 pmi: crate::panels::pmi::PmiPanel,
155 /// The CAM browser tree (the CAM workbench's pane): setups and their
156 /// operations, NC programs and the tool library over the document's
157 /// `cam` block.
158 pub(crate) cam: crate::panels::cam::CamPanel,
159 pub(crate) instructions: crate::panels::instructions::InstructionsPanel,
160 pub(crate) instructions_player: crate::panels::instructions_player::InstructionsPlayerPanel,
161 /// The Simulation workbench's pane: the studies and the active timeline.
162 pub(crate) simulation: crate::panels::simulation::SimulationPanel,
163 /// The drawing sheets + their forms (the Drawing workbench's pane).
164 sheets: crate::panels::sheets::SheetsPanel,
165 /// Update-components checker: compares each parts-library entry's
166 /// `sourceSignature` against the model store's current content. Kept
167 /// current once per frame (cheap generation key: applied run + store
168 /// save); the constraints header reads the count + runs the batch refresh,
169 /// the structure tree reads per-part badges.
170 update_components: UpdateComponents,
171 /// Expressions / parameters panel: the variable sheet (engine-owned history
172 /// `expressions`) feature params reference. Owns only its editor buffer.
173 expressions: ExpressionsPanel,
174 /// Qualify: the part's declared CONNECTION POINTS, on whichever surface is
175 /// on screen (symbol, pads or 3D). Holds only the selected address; the
176 /// block and both consistency reports come from the engine.
177 qualify: crate::panels::qualify::QualifyPanel,
178 /// The family table editor: a `.fbrep` family seed's member table. Holds
179 /// the selection, the cell being typed and the last Generate report; the
180 /// table itself is the document's `familyTable` block.
181 family_table: crate::panels::family_table_editor::FamilyTableEditor,
182 /// Every PLM panel (`panels::plm_host`): empty in a file-based session.
183 pub(crate) plm: crate::panels::plm_host::PlmHost,
184 /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
185 /// selection-driven context bar's Info action. Each floating (movable +
186 /// resizable) window is PINNED to one object name at open time — a Metadata
187 /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
188 /// and keeps showing that entity regardless of later selection changes. Replaces
189 /// the old single Properties window.
190 info_windows: InfoWindows,
191 /// Interference results window: opened by the Assembly workbench's `∩`
192 /// toolbar button, which runs the engine's pairwise-intersect check; a
193 /// floating window like the Info windows with a row per interfering pair
194 /// (click = select both components), a green all-clear pass line, and a
195 /// Re-run button.
196 interference: crate::panels::interference::InterferenceWindow,
197 /// Auto Constraints window (Assembly workbench): opened by the `⚿` toolbar
198 /// button, it lists the constraint types the kernel's inference lane can
199 /// read out of the components' current placement, with what a scan found
200 /// for each, and creates the whole accepted set as one undo step.
201 auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow,
202 /// step.parts online model library browser (Assembly workbench): a ctx-level
203 /// window (opened by the library toolbar button) that searches the public
204 /// step.parts v1 API, shows results with thumbnails, and imports a chosen
205 /// STEP model as a new part document + adds it to the assembly as an ACOMP.
206 step_parts: crate::panels::step_parts::StepPartsPanel,
207 /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
208 /// click may select — honored by the engine's `select_top_at`). The filter +
209 /// selection live in `EngineState`; this panel only reads/writes them.
210 selection: SelectionPanel,
211 /// Context action toolbar: the selection-driven action bar (Clear / Hide /
212 /// Edit-owning-feature + the feature-from-selection actions whose primary
213 /// reference accepts the selected kind). Shown only while something is
214 /// selected; drives the engine directly and returns a feature id for the shell
215 /// to expand in the history tree.
216 context_bar: ContextBarPanel,
217 /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
218 /// + circle to the `set_overlay` channel colored by solver mobility, and shows
219 /// the DOF status readout. The engine-native sketcher's foundation surface.
220 sketch: SketchPanel,
221 sketch3d: Sketch3dPanel,
222 /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
223 /// corner — a card-based reference pick, sketch mode, and any future
224 /// special mode. A form field's pick draws no card: its field is the UI.
225 mode_bar: ModeBar,
226 pick_bar: crate::panels::pick_bar::PickBar,
227 /// Transient toast overlay: drains the engine's queued notices each frame
228 /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
229 pub(crate) toasts: Toasts,
230 /// The status bar's app-wide working indicator (`panels::busy`): what is
231 /// in flight, collected from every source each frame.
232 busy: crate::panels::busy::BusyIndicator,
233 /// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
234 /// tree that hosts every side-panel section AND the 3D viewport as tiles the
235 /// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
236 /// each panel + the engine per frame through [`DockContext`]. Drawn in normal
237 /// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
238 dock: DockState,
239
240 /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
241 /// under a background runner (native thread / wasm worker), so the boot
242 /// `zoom_to_fit` can run before the first solids exist → an unframed first
243 /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
244 /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
245 /// (tests) the scene is already populated, so this fires on the very first frame.
246 first_run_framed: bool,
247
248 /// A model fetch kicked off at boot from a `?loadModel=<url>` query param
249 /// (wasm only). When the fetch lands it REPLACES the seed model. `None` on
250 /// native and once applied.
251 pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
252
253 /// The PLM tab's Connect now, on its way: the sign-in and index load run on
254 /// the frame loop's executor and the frame switches stores when they end.
255 #[cfg(not(target_arch = "wasm32"))]
256 reconnecting: Option<crate::store::PendingBoot>,
257
258 /// "Open in CAD" from the PLM's page: the `?open=part/…/rev/…` this page
259 /// was started with (wasm only), driven once a frame until it settles.
260 plm_launch: Option<crate::panels::plm_launch::Launch>,
261
262 /// The document handle the shared panels were last reset for. Compared to
263 /// `docs.view_id()` at the top of every frame: ONE check catches a switch
264 /// from any source (a tab click, a close, New, Open, Edit Part, a Work
265 /// part entered or left in place) instead of a hook per call site, and it
266 /// runs BEFORE any panel draws this frame.
267 active_document: u64,
268 /// The TAB the panels were last drawn for. A Work part entered or left in
269 /// place changes the view but not the tab, and the BOM — the assembly's
270 /// product structure in both — keeps its packed/expanded state.
271 active_tab: u64,
272
273 /// Whether the active workbench showed the Sheets pane LAST frame — the
274 /// edge the paper is closed on. Closing is a transition, not a condition:
275 /// a sheet opened while another workbench is active (a command, a script)
276 /// stays open until the Drawing workbench is LEFT, exactly as PMI's view
277 /// deactivates on leaving rather than on every frame outside it.
278 sheets_pane_shown: bool,
279
280 /// The DOCUMENT TAB STRIP's per-tab hit-rects from the last dock frame,
281 /// published for the headed verifier. The strip is drawn inside the dock's
282 /// viewport pane, so its rects have to ride back out through the outcome.
283 document_tab_hits: Vec<(String, egui::Rect)>,
284
285 /// The `__brepParams` blob and the document state it was built from:
286 /// `(document, rolled-to step, history revision, json)`.
287 ///
288 /// The blob is the rolled-to feature's whole `inputParams`, and for an
289 /// `IMPORT3D` those params carry the embedded model text — 681 KB on the
290 /// heaviest corpus model. It was rebuilt EVERY frame, which on the browser
291 /// build (where the state registry is always on) also transcoded it into a
292 /// fresh JS string every frame, on the one thread that also has to answer
293 /// the pointer. Nothing reads it per frame; it only has to be correct when
294 /// someone asks. The revision is a mutation counter the document hands out
295 /// (`EngineState::history_revision`), so this cannot go stale without the
296 /// borrow checker having let a mutation past the door that bumps it.
297 params_blob: (u64, usize, u64, String),
298 /// Plain-state blobs rebuilt only when their whole input changed (see
299 /// [`StateBlobCache`]).
300 state_blob_cache: StateBlobCache,
301
302 /// A measured camera spin in flight (`perf_spin`): the orbit drag the app
303 /// drives ITSELF, one pointer step per frame, so the frames run back to back.
304 /// Driving it from a host instead would put a round trip between every pair
305 /// of frames, and `dt` — the frame period, the number a user feels as a
306 /// freeze — would measure the host's latency rather than the app's.
307 #[cfg(feature = "automation")]
308 spin: Option<Spin>,
309
310 /// The debounced autosave of every dirty document (`crate::recovery`), and
311 /// the boot-time **Recover unsaved work?** prompt its blob feeds. The
312 /// autosave is held while the prompt is open: the clean seed tab would
313 /// otherwise remove the very blob being offered.
314 autosave: crate::recovery::Autosave,
315 /// Closing (the window's close button, or `app_close`): when it was asked
316 /// (egui clock). The close waits until the store's writes have landed.
317 closing: Option<f64>,
318 /// A close the automation layer asked for, taken by the next frame.
319 pub(crate) close_asked: bool,
320 /// The app let the window close: nothing was left on its way.
321 closed: bool,
322 /// Where a close stands once the store's writes have settled: `0` not
323 /// yet, `1` one frame given to the failure drain (a write that failed
324 /// makes its tab unsaved), `2` the recovery blob written again.
325 close_stage: u8,
326 recovery: crate::recovery::RecoveryPanel,
327 /// The desktop update check and its notice. Inert until the desktop
328 /// binary's `main` starts it, so a harness or a test never checks.
329 #[cfg(not(target_arch = "wasm32"))]
330 updater: crate::update::Updater,
331 /// The reports this user submitted, checked for resolution
332 /// (`crate::report_followup`). Inert until the entry point starts it.
333 report_followup: crate::report_followup::ReportFollowup,
334
335 /// The UI zoom scale CURRENTLY applied to the egui context. Tracks
336 /// `settings.ui_scale` but is only synced to it while the pointer is up, so
337 /// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
338 /// the cursor mid-drag — the settled value is committed on release. See the
339 /// zoom-apply block in `ui`.
340 applied_ui_scale: f32,
341 /// The automation channel (hosts submit commands; the frame drains them at
342 /// three fixed points — see `automation::queue`).
343 #[cfg(feature = "automation")]
344 pub(crate) automation: std::sync::Arc<crate::automation::queue::AutomationQueue>,
345}
346
347/// The caption text size, points (egui's own `Small` is 9). See `app_style`.
348pub const SMALL_TEXT_SIZE: f32 = 10.5;
349
350/// The app's tweaks over egui's style, applied to both themes at startup.
351pub(crate) fn app_style(style: &mut egui::Style) {
352 // Keep overflow discoverable without hovering or scrolling first, in
353 // both themes and every child UI (including eCAD and plugin panels).
354 // ScrollArea's default VisibleWhenNeeded still hides bars that aren't needed.
355 style.spacing.scroll = egui::style::ScrollStyle::solid();
356 // Captions (`ui.small`, `RichText::small`): egui's 9 pt was too small to
357 // read beside 12.5 pt body text in the Info window, the eCAD inspectors and
358 // the Selection actions summary (lane U audit item 9). 10.5 keeps them
359 // visibly secondary. The toolbar/Ribbon group captions stay at 9
360 // (`ribbon::CHROME_CAPTION_SIZE`) so the chrome keeps its height.
361 style.text_styles.insert(egui::TextStyle::Small, egui::FontId::new(SMALL_TEXT_SIZE, egui::FontFamily::Proportional));
362}
363
364
365/// How long a close waits for writes still on their way to the store (a PLM
366/// server that stopped answering must not keep the app open forever).
367const CLOSE_WAIT_SECS: f64 = 30.0;
368
369impl BrepApp {
370 /// Closing the window. What is unsaved is written for recovery at once
371 /// (the next start offers it, as after a crash), and the window stays open
372 /// until every write the store issued (a Save, that recovery blob) has
373 /// landed: a write-behind store would otherwise lose a Save made just
374 /// before. After [`CLOSE_WAIT_SECS`] it closes anyway.
375 fn close_when_written(&mut self, ctx: &egui::Context) {
376 let asked = ctx.input(|i| i.viewport().close_requested()) || std::mem::take(&mut self.close_asked);
377 if self.closed {
378 return;
379 }
380 let now = ctx.input(|i| i.time);
381 if asked && self.closing.is_none() {
382 if !self.recovery.is_open() {
383 self.autosave.flush(&self.docs, self.model_store.as_ref());
384 }
385 self.closing = Some(now);
386 }
387 let Some(since) = self.closing else { return };
388 let pending = self.model_store.pending_writes();
389 let timed_out = now - since > CLOSE_WAIT_SECS;
390 if pending == 0 || timed_out {
391 match self.close_stage {
392 // The writes settled. A failed one (the server down) makes its
393 // tab unsaved when the failures are drained, later in this
394 // frame: give that a frame…
395 0 => self.close_stage = 1,
396 // …then write the recovery blob again, so a Save that never
397 // reached the server is offered at the next start.
398 1 => {
399 if !self.recovery.is_open() {
400 self.autosave.flush(&self.docs, self.model_store.as_ref());
401 }
402 self.close_stage = 2;
403 }
404 _ if self.model_store.pending_writes() == 0 || timed_out => {
405 self.closed = true;
406 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
407 return;
408 }
409 _ => {}
410 }
411 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
412 ctx.request_repaint();
413 return;
414 }
415 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
416 egui::Area::new(egui::Id::new("brep-closing"))
417 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
418 .order(egui::Order::Foreground)
419 .show(ctx, |ui| {
420 egui::Frame::popup(ui.style()).show(ui, |ui| {
421 ui.horizontal(|ui| {
422 ui.spinner();
423 ui.label(format!("Finishing {pending} save(s) before closing…"));
424 });
425 });
426 });
427 ctx.request_repaint_after(std::time::Duration::from_millis(50));
428 }
429
430 /// The close state, for automation (`__brepClosing`).
431 pub(crate) fn closing_json(&self) -> String {
432 serde_json::json!({
433 "closing": self.closing.is_some(),
434 "closed": self.closed,
435 "pending": self.model_store.pending_writes(),
436 })
437 .to_string()
438 }
439
440 /// The automation queue a host submits commands to.
441 #[cfg(feature = "automation")]
442 pub fn automation(&self) -> &std::sync::Arc<crate::automation::queue::AutomationQueue> {
443 &self.automation
444 }
445
446 /// Open a `.nbrep` file named on the command line — `brep-app model.nbrep`,
447 /// which is also what a desktop file association hands us (`Exec=… %f`).
448 ///
449 /// The path goes through the same `FileDialog::open_document` door as
450 /// File>Open, so the document keeps the full path as its identity and a
451 /// later plain Save writes back to the file the user double-clicked, rather
452 /// than to a copy in the app's own models directory. A path that does not
453 /// parse leaves the status line saying so and the seed document in place —
454 /// a bad argument must not cost the user a window.
455 ///
456 /// Native only: called once, before the first frame.
457 #[cfg(not(target_arch = "wasm32"))]
458 pub fn open_path_from_command_line(&mut self, path: &std::path::Path) {
459 // Absolute, so the store resolves it as a real file rather than as a
460 // name inside the models directory (`store::resolve` routes anything
461 // with a separator straight to the filesystem).
462 let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
463 let Some(name) = path.to_str() else {
464 log::warn!("brep-app: {} is not valid UTF-8; not opening it", path.display());
465 return;
466 };
467 // Field-by-field, because `open_document` borrows the documents and the
468 // store while `self.file` is borrowed mutably.
469 let Self { file, docs, model_store, .. } = self;
470 file.open_document(docs, model_store.as_ref(), name);
471 }
472
473 /// The 3D viewport rect of the last frame, in egui points.
474 pub fn view_rect(&self) -> Option<egui::Rect> {
475 self.viewport.last_rect()
476 }
477
478 /// The session's store, read-only (the automation layer's `frame_info`
479 /// counts its writes still on their way).
480 pub(crate) fn model_store(&self) -> &dyn ModelStore {
481 self.model_store.as_ref()
482 }
483
484 /// Why this session cannot switch stores live, or `None` when it can: every
485 /// open document untitled and clean, the state a fresh start has. A named
486 /// document's identity belongs to the store it came from, and a dirty one's
487 /// work has nowhere to go in a new store.
488 pub(crate) fn reconnect_blocker(&self) -> Option<String> {
489 self.reconnect_blocker_with(crate::document::Document::is_dirty)
490 }
491
492 fn reconnect_blocker_with(&self, is_dirty: impl Fn(&crate::document::Document) -> bool) -> Option<String> {
493 let named = self.docs.iter().filter(|d| d.name().is_some()).count();
494 let dirty = self.docs.iter().filter(|d| is_dirty(d)).count();
495 match (named, dirty) {
496 (0, 0) => None,
497 (_, 0) => Some(format!("{named} document(s) from this store are open: close them, then Connect now (or restart)")),
498 _ => Some(format!("{dirty} document(s) have unsaved work: save or close them, then Connect now (or restart)")),
499 }
500 }
501
502 /// Start switching this session to the PLM configured at `root`, live
503 /// (S1's named limit): the native boot S2 runs at start, without blocking
504 /// the frame; [`Self::finish_reconnect`] takes the store when it ends.
505 /// Only while [`Self::reconnect_blocker`] is `None`.
506 #[cfg(not(target_arch = "wasm32"))]
507 pub(crate) fn start_reconnect(&mut self, root: std::path::PathBuf) -> Result<(), String> {
508 if self.reconnecting.is_some() {
509 return Err("already connecting".into());
510 }
511 if let Some(why) = self.reconnect_blocker() {
512 return Err(why);
513 }
514 let config = crate::plm::config::resolve(&root, &|_| None, &Default::default())?
515 .ok_or_else(|| format!("no PLM is configured in {}", root.display()))?;
516 self.reconnecting = Some(crate::store::start_native_boot(root, config));
517 Ok(())
518 }
519
520 /// A Connect now is on its way (settle waits for it).
521 #[cfg(not(target_arch = "wasm32"))]
522 pub(crate) fn reconnecting(&self) -> bool {
523 self.reconnecting.is_some()
524 }
525
526 /// The Connect now under way, once its boot has ended: switch stores (or
527 /// say why not) and tell the PLM tab.
528 #[cfg(not(target_arch = "wasm32"))]
529 fn poll_reconnect(&mut self) {
530 let Some(store) = self.reconnecting.as_mut().and_then(crate::store::PendingBoot::poll) else { return };
531 self.reconnecting = None;
532 let outcome = self.finish_reconnect(store);
533 self.settings.reconnected(outcome);
534 }
535
536 /// Switch this session to `store`, the boot a Connect now started: then
537 /// everything the app read from the store when it was built is read again
538 /// from the new one. A refused boot, or a session that took a named or
539 /// dirty document meanwhile, changes nothing and says why.
540 #[cfg(not(target_arch = "wasm32"))]
541 fn finish_reconnect(&mut self, store: Box<dyn ModelStore>) -> Result<String, String> {
542 use crate::plm::connection::Status;
543 if let Some(why) = self.reconnect_blocker() {
544 return Err(why);
545 }
546 let Some(Status::Connected { url, username }) = store.plm_session().map(|s| s.status) else {
547 let why = store.take_persistence_errors().join("; ");
548 return Err(if why.is_empty() { "the PLM did not take this session".into() } else { why });
549 };
550 self.model_store = store;
551 self.docs.set_canonicalizer(self.model_store.canonicalizer());
552 // Settings: every open engine, and every later tab, starts from the
553 // defaults with the new store's settings over them, as a fresh boot
554 // would; nothing of the old store's carries over.
555 let saved = self.model_store.read(SETTINGS_KEY);
556 let fresh = move |mut state: EngineState| {
557 state.settings = brep_render::style::RenderSettings::default();
558 let defaults = state.settings.to_json();
559 let _ = state.apply_settings_json(&defaults);
560 if let Some(saved) = &saved {
561 let _ = state.apply_settings_json(saved);
562 }
563 state
564 };
565 for doc in self.docs.iter_mut() {
566 let engine = std::mem::replace(&mut doc.engine, EngineState::new());
567 doc.engine = fresh(engine);
568 }
569 self.docs.wrap_engine_factory(fresh);
570 self.dock = DockState::new(self.model_store.as_ref());
571 self.recovery.arm_from_store(
572 self.model_store.as_ref(),
573 self.docs.engine().settings.disable_recovery_prompt,
574 );
575 self.file = FileDialog::new();
576 self.update_components = UpdateComponents::new();
577 Ok(format!("connected to {url} as {username}"))
578 }
579
580 /// Keep automation waits active until the File dialog has delivered the PDF.
581 pub(crate) fn instructions_pdf_pending(&self) -> bool {
582 self.file.instructions_pdf_pending()
583 }
584
585 /// Keep automation waits active until a PLM Save As has landed or failed.
586 pub(crate) fn plm_save_as_pending(&self) -> bool {
587 self.file.plm_save_as_pending()
588 }
589
590 /// The file dialog, the documents and the store together — for the
591 /// automation commands that drive a document-class flow (Generate, a
592 /// store read-back) exactly as the panels do.
593 pub(crate) fn file_docs_store(&mut self) -> (&mut FileDialog, &mut Documents, &dyn ModelStore) {
594 (&mut self.file, &mut self.docs, self.model_store.as_ref())
595 }
596
597 /// The active document's engine.
598 pub fn docs_engine(&self) -> &EngineState {
599 self.docs.engine()
600 }
601
602 /// Open the mesh import preview for the file `name` (`.stl` or `.3mf`)
603 /// against the active document — what activating it in the Import explorer
604 /// does. The preview replaces the shell until it is accepted or cancelled.
605 pub fn open_mesh_preview(&mut self, name: String, bytes: Vec<u8>) -> Result<(), String> {
606 use brep_render::runner::MeshImportFormat;
607 let format = match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
608 Some("stl") => MeshImportFormat::Stl,
609 Some("3mf") => MeshImportFormat::ThreeMf,
610 _ => return Err(format!("{name} is not an STL or 3MF file")),
611 };
612 self.start_mesh_preview(format, name, bytes);
613 Ok(())
614 }
615
616 fn start_mesh_preview(&mut self, format: brep_render::runner::MeshImportFormat, name: String, bytes: Vec<u8>) {
617 self.stl_import = Some(crate::panels::stl_import::StlImportPreview::new(
618 self.docs.active_id(), format, name, bytes, self.docs.spawn_engine(),
619 ));
620 self.viewport.forget_document();
621 }
622
623 /// Whether the mesh import preview is open.
624 pub fn mesh_preview_open(&self) -> bool {
625 self.stl_import.is_some()
626 }
627
628 /// The engine the 3D viewport is drawing: the mesh import preview's own
629 /// while it is open (it replaces the shell), else the active document's.
630 /// What a pointer script's `locate` must project through.
631 pub fn viewport_engine(&self) -> &EngineState {
632 match &self.stl_import {
633 Some(preview) => &preview.engine,
634 None => self.docs.engine(),
635 }
636 }
637
638 pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
639 Self::new_with(cc, crate::automation::AppOptions::default())
640 }
641
642 /// Build the app with host-supplied options: an isolated store (a host
643 /// MUST pass one) and whether to start on the seed model.
644 pub fn new_with(cc: &eframe::CreationContext<'_>, opts: crate::automation::AppOptions) -> Result<Self, String> {
645 // Solid scroll bars and readable captions, in both themes.
646 cc.egui_ctx.all_styles_mut(app_style);
647 // The action registry's view of each pass (`activate_widget`).
648 #[cfg(feature = "automation")]
649 crate::automation::actions::install(&cc.egui_ctx);
650 let crate::automation::AppOptions { store: opt_store, seed } = opts;
651 let render_state = cc
652 .wgpu_render_state
653 .as_ref()
654 .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
655
656 // The viewport owns the render core + blit pipeline, built from eframe's
657 // SHARED device/queue/format.
658 let viewport = Viewport::new(render_state);
659
660 // The session's diagnostics, taken from that same render state: the
661 // adapter about to draw every frame is the one a report must name. The
662 // WebGPU-vs-WebGL2 decision has already been made by the time we get
663 // here (eframe drops `BROWSER_WEBGPU` from the backend set when the
664 // browser offers no WebGPU adapter), so this READS the outcome — asking
665 // again later would be a second question with its own answer.
666 let diagnostics = crate::diagnostics::Diagnostics::from_render_state(render_state);
667
668 // --- storage seam: load the persisted settings ------------------------
669 let model_store = opt_store.unwrap_or_else(default_model_store);
670 // wasm: hand the store the egui context so an async file-upload load
671 // callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
672 #[cfg(target_arch = "wasm32")]
673 crate::store::set_repaint_ctx(cc.egui_ctx.clone());
674 let saved_settings = model_store.read(SETTINGS_KEY);
675
676 // --- how a document's engine is built --------------------------------
677 // Every tab gets its OWN engine, and therefore its own history runner —
678 // a runner owns the resident kernel state of the document it executes,
679 // so one shared between documents would apply a background run against
680 // the wrong registry. See `crate::document`.
681 let plugins = crate::plugins::PluginsPanel::new(model_store.as_ref());
682 let javascript = crate::javascript::JavaScriptEditor::new(model_store.as_ref());
683 let plugin_packages = plugins.packages.clone();
684 let engine_factory: EngineFactory = Box::new(move || {
685 let mut state = EngineState::new();
686 // Native: run the whole history — and per-object measurement queries — on a
687 // persistent background thread so the UI never freezes during a run or a
688 // selection (M2b). Installed BEFORE anything loads so it builds through it.
689 #[cfg(not(target_arch = "wasm32"))]
690 state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
691 // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
692 // single-threaded wasm UI stays responsive during a run. Same seam. Tests
693 // (which never hit this wasm path) keep the default synchronous InlineRunner.
694 #[cfg(target_arch = "wasm32")]
695 state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
696 if !plugin_packages.borrow().is_empty() {
697 if let Err(error) = crate::plugins::restore(&mut state, &plugin_packages.borrow()) {
698 log::error!("Plugin restore failed: {error}");
699 }
700 }
701 state.set_viewcube_enabled(true);
702 // Partial-override apply: unknown/absent keys keep their defaults.
703 if let Some(saved) = &saved_settings {
704 let _ = state.apply_settings_json(saved);
705 }
706 state
707 });
708
709 // Start with the seed model on every launch (7626103c2). Reopening the
710 // previous session can immediately rerun a problematic document and
711 // prevent the user from recovering by restarting the app, so which
712 // documents were open is not persisted at all: saved models come back
713 // through the file dialog, and work that was never saved comes back as
714 // the crash-recovery OFFER below — a prompt, never an automatic load.
715 let mut docs = Documents::new(engine_factory);
716 docs.set_canonicalizer(model_store.canonicalizer());
717 if starts_on_seed_model(seed, &docs.engine().settings) {
718 let _ = docs.engine_mut().set_history_json(&seed_history_json());
719 }
720 docs.engine_mut().zoom_to_fit();
721 docs.active_mut().mark_clean();
722
723 // The autosave blob from a session that ended with unsaved work: offer
724 // it back (a prompt, never an automatic restore — see `crate::recovery`).
725 let mut recovery = crate::recovery::RecoveryPanel::new();
726 recovery.arm_from_store(model_store.as_ref(), docs.engine().settings.disable_recovery_prompt);
727
728 // The settings panel seeds its working JSON from the (post-load) engine
729 // settings so the widgets reflect the persisted state on first paint.
730 let settings = SettingsPanel::new();
731 let part_properties = PartPropertiesPanel::new();
732
733 // New / Open / Save / Save As. Holds no document identity — that lives
734 // on each `Document`.
735 let file = FileDialog::new();
736
737 // Boot at the saved UI scale.
738 let applied_ui_scale = docs.engine().settings.ui_scale;
739 let active_document = docs.active_id();
740
741 // The dock layout (loads the persisted tree, or the default). Built before
742 // `model_store` is moved into `Self`.
743 let dock = DockState::new(model_store.as_ref());
744
745 // Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
746 // fetching that model NOW; the seed still loads this frame and the fetched
747 // model REPLACES it when it lands (drained in `ui`). See the drain block.
748 crate::offsite::prefetch();
749 #[cfg(target_arch = "wasm32")]
750 let pending_boot_load = web_sys::window()
751 .and_then(|w| w.location().search().ok())
752 .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
753 .and_then(|params| params.get("loadModel"))
754 .filter(|url| !url.is_empty())
755 .map(|url| crate::offsite::fetch_text(&cc.egui_ctx, "Opening a model from a link", url));
756 #[cfg(not(target_arch = "wasm32"))]
757 let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
758 #[cfg(target_arch = "wasm32")]
759 let plm_launch = crate::panels::plm_launch::Launch::from_page();
760 #[cfg(not(target_arch = "wasm32"))]
761 let plm_launch = None;
762
763 Ok(Self {
764 docs,
765 viewport,
766 toolbar: ToolbarPanel::new(),
767 workbench_toolbar: WorkbenchToolbarPanel::new(),
768 model_store,
769 file,
770 stl_import: None,
771 bug_report: BugReportPanel::new(),
772 info: crate::panels::info::InfoPanel::new(),
773 diagnostics,
774 settings,
775 plugins,
776 javascript,
777 part_properties,
778 replace_dialog: Default::default(),
779 history: HistoryPanel::new(),
780 scene: ScenePanel::new(),
781 bom: BomPanel::new(),
782 assembly_constraints: AssemblyConstraintsPanel::new(),
783 wire_harness: WireHarnessPanel::new(),
784 pmi: crate::panels::pmi::PmiPanel::new(),
785 cam: crate::panels::cam::CamPanel::new(),
786 instructions: crate::panels::instructions::InstructionsPanel::new(),
787 instructions_player: crate::panels::instructions_player::InstructionsPlayerPanel::new(),
788 simulation: crate::panels::simulation::SimulationPanel::new(),
789 sheets: crate::panels::sheets::SheetsPanel::new(),
790 update_components: UpdateComponents::new(),
791 expressions: ExpressionsPanel::new(),
792 qualify: crate::panels::qualify::QualifyPanel::new(),
793 family_table: crate::panels::family_table_editor::FamilyTableEditor::new(),
794 plm: crate::panels::plm_host::PlmHost::new(),
795 info_windows: InfoWindows::new(),
796 interference: crate::panels::interference::InterferenceWindow::new(),
797 auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow::new(),
798 step_parts: crate::panels::step_parts::StepPartsPanel::new(),
799 selection: SelectionPanel::new(),
800 context_bar: ContextBarPanel::new(),
801 sketch: SketchPanel::new(),
802 sketch3d: Sketch3dPanel::new(),
803 mode_bar: ModeBar::new(),
804 pick_bar: crate::panels::pick_bar::PickBar::new(),
805 toasts: Toasts::new(),
806 busy: crate::panels::busy::BusyIndicator::new(),
807 dock,
808 first_run_framed: false,
809 pending_boot_load,
810 #[cfg(not(target_arch = "wasm32"))]
811 reconnecting: None,
812 plm_launch,
813 active_document,
814 active_tab: active_document,
815 sheets_pane_shown: false,
816 document_tab_hits: Vec::new(),
817 params_blob: (u64::MAX, usize::MAX, u64::MAX, String::new()),
818 state_blob_cache: StateBlobCache::default(),
819 #[cfg(feature = "automation")]
820 spin: None,
821 autosave: crate::recovery::Autosave::new(),
822 closing: None,
823 close_asked: false,
824 closed: false,
825 close_stage: 0,
826 recovery,
827 #[cfg(not(target_arch = "wasm32"))]
828 updater: crate::update::Updater::inert(),
829 report_followup: crate::report_followup::ReportFollowup::new(),
830 applied_ui_scale,
831 #[cfg(feature = "automation")]
832 automation: {
833 let q = crate::automation::queue::AutomationQueue::new();
834 q.attach(&cc.egui_ctx);
835 q
836 },
837 })
838 }
839
840 /// Start the desktop update check (`crate::update`). Called by the
841 /// desktop binary's `main` only.
842 #[cfg(not(target_arch = "wasm32"))]
843 pub fn start_update_check(&mut self, ctx: &egui::Context) {
844 self.updater = crate::update::Updater::start(ctx);
845 }
846
847 /// Remember submitted problem reports and check them for resolution
848 /// (`crate::report_followup`). Called by the desktop `main` and the web
849 /// entry point only.
850 pub fn start_report_followup(&mut self, ctx: &egui::Context) {
851 self.report_followup.start(ctx);
852 }
853
854 /// Arm a measured camera spin: `frames` pointer steps of `step` logical px
855 /// each, starting at the centre of the 3D viewport. Replaces any spin in
856 /// flight. See [`Spin`].
857 #[cfg(feature = "automation")]
858 pub fn start_spin(&mut self, frames: u32, step: (f64, f64)) {
859 let rect = self.viewport.last_rect();
860 let origin = rect
861 .map(|r| (r.width() as f64 * 0.5, r.height() as f64 * 0.5))
862 .unwrap_or((320.0, 240.0));
863 self.spin = Some(Spin { left: frames.max(1), origin, step, made: 0, pressed: false });
864 }
865
866 /// Is a measured spin still running?
867 #[cfg(feature = "automation")]
868 pub fn spinning(&self) -> bool {
869 self.spin.is_some()
870 }
871
872 /// Advance an in-flight spin by ONE pointer step and keep the frame loop
873 /// awake. The drag sweeps out and back over a fixed span so it never leaves
874 /// the viewport however many frames are asked for; the camera keeps turning
875 /// either way, which is all the measurement needs.
876 #[cfg(feature = "automation")]
877 fn step_spin(&mut self, ctx: &egui::Context) {
878 let Some(mut spin) = self.spin.take() else { return };
879 {
880 let state = self.docs.engine_mut();
881 if !spin.pressed {
882 state.pointer_down(spin.origin.0, spin.origin.1, brep_render::controls::BUTTON_LEFT);
883 spin.pressed = true;
884 }
885 const SPAN: f64 = 60.0;
886 let phase = f64::from(spin.made % (2 * SPAN as u32));
887 let t = if phase <= SPAN { phase } else { 2.0 * SPAN - phase };
888 state.pointer_move(spin.origin.0 + spin.step.0 * t, spin.origin.1 + spin.step.1 * t);
889 spin.made += 1;
890 spin.left -= 1;
891 if spin.left == 0 {
892 state.pointer_up();
893 }
894 }
895 if spin.left > 0 {
896 self.spin = Some(spin);
897 }
898 ctx.request_repaint();
899 }
900
901 /// The `__brepParams` JSON, rebuilt only when the document it describes can
902 /// have changed. The `params_blob` FIELD carries the reason this is not
903 /// simply built afresh each frame.
904 fn params_blob(&mut self) -> &str {
905 let key = (
906 self.docs.active_id(),
907 self.docs.engine().history_rollback(),
908 self.docs.engine().history_revision(),
909 );
910 if (self.params_blob.0, self.params_blob.1, self.params_blob.2) != key {
911 self.params_blob = (
912 key.0,
913 key.1,
914 key.2,
915 self.docs.engine().feature_params_json(key.1),
916 );
917 }
918 &self.params_blob.3
919 }
920
921 /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
922 /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection
923 /// — unless a popup or menu is up, which the Escape closes instead.
924 ///
925 /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
926 /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
927 /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
928 /// consumed BEFORE undo because egui's `consume_key` matches modifiers
929 /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
930 fn handle_shortcuts(&mut self, ctx: &egui::Context) {
931 // A modal owns every key: its Escape closes it, and nothing under it —
932 // undo, a Delete, the selection — is the key's to change.
933 if ctx.text_edit_focused() || crate::viewport::modal_open_last_pass(ctx) {
934 return;
935 }
936 use egui::{Key, Modifiers};
937 // A popup or menu that was up last frame owns this Escape: it closes on
938 // `key_pressed(Escape)`, which a consumed key never is, so the key is
939 // left in the input for it and nothing below acts on it — not the
940 // sketch tool, not a pick, and not the selection its menu is about.
941 let popup_open = crate::viewport::popup_open_last_pass(ctx);
942 // On an eCAD workbench, Delete and Escape are the editor's keys, and
943 // whether they reach it is the editors' ONE rule, `keys_reach_editor`
944 // (a text field, a menu or combo box, a modal), not a copy of it here.
945 // The watch above is the host's own addition: egui's popup memory does
946 // not see the host's `open_bool` menus (a tree row's), and those own
947 // their Escape on every workbench.
948 let ecad_target = crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench);
949 let keys_free = match ecad_target {
950 Some(_) => !popup_open && brep_ecad_egui::keys_reach_editor(ctx),
951 None => !popup_open,
952 };
953 if crate::javascript::JavaScriptEditor::editing_source(ctx) {
954 return;
955 }
956 let sketching_3d = self.docs.engine().sketch3d_mode();
957 let (redo, undo, esc, tab) = ctx.input_mut(|i| {
958 let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
959 || i.consume_key(Modifiers::COMMAND, Key::Y);
960 let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
961 let esc = keys_free && i.consume_key(Modifiers::NONE, Key::Escape);
962 // Tab cycles the 3D sketch's working plane; outside that mode it
963 // stays egui's focus key.
964 let tab = sketching_3d && keys_free && i.consume_key(Modifiers::NONE, Key::Tab);
965 (redo, undo, esc, tab)
966 });
967 // Delete / Backspace: THE delete-selection command
968 // (`crate::delete_selection`), whatever the selection is — sketch,
969 // 3D sketch, eCAD, sheet object, PMI annotation, constraint, component
970 // or feature — before the sketch modes return below.
971 if keys_free {
972 self.delete_selection_key(ctx);
973 }
974 // The 3D sketch editor: the same per-session undo routing as the
975 // planar one; Esc drops the armed tool's pending clicks first and the
976 // tool itself second.
977 if sketching_3d {
978 if redo {
979 self.docs.engine_mut().sketch3d_redo();
980 }
981 if undo {
982 self.docs.engine_mut().sketch3d_undo();
983 }
984 if esc {
985 let engine = self.docs.engine_mut();
986 if engine.sketch3d_pending_len() > 0 {
987 engine.sketch3d_tool_cancel();
988 } else {
989 let _ = engine.sketch3d_set_tool(Some("select"));
990 }
991 }
992 if tab {
993 self.docs.engine_mut().sketch3d_cycle_work_plane();
994 }
995 return;
996 }
997 // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
998 // history (S6a), not the model-level undo — this global router consumes the
999 // keys first (before the viewport), so it must intercept here. Esc drops the
1000 // active draw/trim/pick tool back to Select/drag (clearing any in-progress
1001 // placement): this is the ONLY reliable capture point, since `consume_key`
1002 // above already swallowed the Escape before the viewport can see it.
1003 if self.docs.engine().sketch_mode() {
1004 if redo {
1005 self.docs.engine_mut().sketch_redo();
1006 }
1007 if undo {
1008 self.docs.engine_mut().sketch_undo();
1009 }
1010 if esc {
1011 self.docs.engine_mut().sketch_set_tool(Some("select"));
1012 }
1013 return;
1014 }
1015 if redo {
1016 self.docs.engine_mut().redo();
1017 }
1018 if undo {
1019 self.docs.engine_mut().undo();
1020 }
1021 if esc {
1022 // An open pick-list popup owns the first Escape: close it WITHOUT
1023 // clearing the selection (a popup-built multi-selection must survive
1024 // dismissing the list); the next Escape clears as before.
1025 if self.viewport.close_candidate_popup() {
1026 return;
1027 }
1028 // An open Replace component chooser closes on Escape (its own
1029 // keys never see it: this handler takes Escape first). A repair
1030 // pick in its result window is cancelled the same way.
1031 if self.replace_dialog.escape() {
1032 return;
1033 }
1034 // A REFERENCE PICK owns the next one — a 3D pick or anchors on a
1035 // drawing sheet alike — and Escape has to CANCEL it
1036 // rather than merely clear the selection. Beginning a pick ROLLS THE
1037 // MODEL BACK to the step before the edited feature so the picker sees
1038 // the geometry that feature is about to consume; Finish and Cancel
1039 // both restore that roll through `end_ref_select`. Escape used to do
1040 // neither: it cleared the selection, left the picker open and left the
1041 // history PARKED before the feature, so the feature stopped running
1042 // and the viewport quietly showed the model without it. Measured on
1043 // the retired `rotate-face.json`, which read the seed volume back at its
1044 // closed-form step with the feature never re-run.
1045 if self.docs.engine().ref_select_active() {
1046 self.docs.engine_mut().cancel_ref_select();
1047 return;
1048 }
1049 // A family-row preview owns the next one: Escape leaves the
1050 // preview (the family's own values again) and keeps the selection.
1051 if self.family_table.escape_preview(self.docs.engine_mut()) {
1052 return;
1053 }
1054 // The global router owns Escape before the viewport runs its keys.
1055 if let Some(target) = ecad_target {
1056 self.docs.active_mut().ecad.selection_key(target, false);
1057 return;
1058 }
1059 self.docs.engine_mut().clear_selection();
1060 }
1061 }
1062
1063 /// A FORM FIELD's reference pick, settled at the end of the frame (after
1064 /// the dock has drawn every pane, the viewport's click included):
1065 ///
1066 /// * a SINGLE-valued field finishes on its first pick — there is nothing
1067 /// left to wait for, and the feature's result appears at once;
1068 /// * a form that asked for the pick to end (`ref_select_request_finish`:
1069 /// an edit elsewhere on a feature's form) gets it now, after the frame's
1070 /// viewport click has landed in the pick;
1071 /// * a pick NO form claimed this frame (`ref_select_claim`) is finished:
1072 /// its pane was tabbed away, closed, or its subject is gone, and a pick
1073 /// with no field on screen has nothing to end it.
1074 ///
1075 /// Card-based picks are not this method's: their card is always on screen.
1076 fn settle_form_pick(&mut self) {
1077 let engine = self.docs.engine_mut();
1078 let Some(field) = engine.ref_select_field() else {
1079 return;
1080 };
1081 if !engine.ref_select_in_form() {
1082 return;
1083 }
1084 let claimed = engine.ref_select_take_claim();
1085 let single_done = !field.multiple && field.picks > 0;
1086 if single_done || engine.ref_select_finish_requested() || !claimed {
1087 engine.finish_ref_select();
1088 }
1089 }
1090
1091 /// DELETE (or Backspace) runs THE delete-selection command
1092 /// ([`crate::delete_selection`]) on whatever is selected: the same call as
1093 /// that object's row ✕ / menu Delete, so the key and the menu do the same
1094 /// thing (one undo step, no extra confirmation). The key is consumed only
1095 /// when there IS something to delete, and left to the family-table grid
1096 /// when it owns the keyboard. Never a SHEET, PMI VIEW or study: each takes
1097 /// everything placed on it with it and stays behind its menu's Delete.
1098 /// Called after the text-edit / modal / popup guards, so a Backspace typed
1099 /// into a field never gets here.
1100 fn delete_selection_key(&mut self, ctx: &egui::Context) {
1101 use egui::{Key, Modifiers};
1102 if self.family_table.owns_delete_key(ctx) {
1103 return;
1104 }
1105 let Some(target) = crate::delete_selection::target(&self.docs) else {
1106 return;
1107 };
1108 let pressed = ctx.input_mut(|i| {
1109 i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)
1110 });
1111 if !pressed {
1112 return;
1113 }
1114 if let Err(error) = crate::delete_selection::apply(&mut self.docs, &target) {
1115 self.docs.engine_mut().push_notice(format!("Delete: {error}"));
1116 }
1117 }
1118
1119 /// Open the component source, offering to recover a missing file from the
1120 /// assembly's embedded part definition.
1121 /// Open the Replace component chooser on `component_id` (the component
1122 /// menus' Replace…).
1123 pub(crate) fn open_replace(&mut self, component_id: &str) {
1124 if let Err(error) = crate::panels::replace_component::finish_work_part_for_replace(&mut self.docs) {
1125 self.docs.engine_mut().push_notice(format!("Replace: {error}"));
1126 return;
1127 }
1128 let document = self.docs.active().name().map(str::to_string);
1129 self.replace_dialog.open(self.docs.engine(), self.model_store.as_ref(), component_id, document.as_deref());
1130 }
1131
1132 /// Open the Replace summary alone, for mates an earlier replace holds.
1133 pub(crate) fn review_replace(&mut self) {
1134 self.replace_dialog.review();
1135 }
1136
1137 /// A component menu's document-level flow (`component_actions`): every
1138 /// menu — the context bar, the viewport's right-click, the BOM row — hands
1139 /// its request here, so each flow has one implementation.
1140 fn run_component_request(&mut self, request: ComponentActionRequest, compact: bool) {
1141 match request {
1142 ComponentActionRequest::SavePartAs { component_id } => self.edit_part(&component_id),
1143 ComponentActionRequest::Replace { component_id } => self.open_replace(&component_id),
1144 ComponentActionRequest::WorkPart { component_path, in_place } => {
1145 let mode = if in_place {
1146 crate::document::work_part::WorkPartMode::InPlace
1147 } else {
1148 crate::document::work_part::WorkPartMode::NewTab
1149 };
1150 if let Err(error) = self.docs.begin_work_part_as(&component_path, mode) {
1151 self.docs.engine_mut().push_notice(format!("Work part: {error}"));
1152 }
1153 }
1154 ComponentActionRequest::Info { targets } => {
1155 self.info_windows.open_for(&targets, self.viewport.last_rect());
1156 }
1157 ComponentActionRequest::EditFeature { feature_id } => {
1158 self.history.focus_feature(feature_id);
1159 if compact {
1160 self.dock.show_pane(PaneKind::History);
1161 }
1162 }
1163 }
1164 }
1165
1166 fn edit_part(&mut self, component_id: &str) {
1167 self.file.edit_component_part(
1168 &mut self.docs,
1169 self.model_store.as_ref(),
1170 component_id,
1171 );
1172 }
1173
1174 /// Draw an isolated preview while the destination document remains untouched.
1175 /// Hand the working indicator this frame's work, most important first:
1176 /// the active document's runner queues, a boot load, the STEP parts
1177 /// library's downloads, then every background document's queues.
1178 fn collect_busy(&mut self, ctx: &egui::Context) {
1179 use crate::panels::busy::{engine_activities, Activity};
1180 let active = self.docs.active_index();
1181 let mut activities = engine_activities(self.docs.engine(), self.docs.active_id(), None);
1182 if self.pending_boot_load.is_some() {
1183 activities.push(Activity::new("boot", "boot", "Loading document"));
1184 }
1185 #[cfg(not(target_arch = "wasm32"))]
1186 if self.reconnecting.is_some() {
1187 activities.push(Activity::new("plmConnect", "plmConnect", "Connecting to the PLM"));
1188 }
1189 activities.extend(self.step_parts.busy_activities());
1190 activities.extend(self.file.busy_activities());
1191 // A write-behind store still settling saves: the browser's IndexedDB
1192 // or a PLM server (not the desktop's own settings/layout files).
1193 let saving = self.model_store.pending_saves();
1194 if saving > 0 {
1195 #[cfg(target_arch = "wasm32")]
1196 let label = match saving {
1197 1 => "Saving to browser storage".to_string(),
1198 n => format!("Saving to browser storage ({n} writes)"),
1199 };
1200 #[cfg(not(target_arch = "wasm32"))]
1201 let label = match saving {
1202 1 => "Saving".to_string(),
1203 n => format!("Saving ({n} writes)"),
1204 };
1205 activities.push(Activity::new("storeWrite", "storeWrite", label));
1206 }
1207 for (index, doc) in self.docs.iter().enumerate() {
1208 if index != active {
1209 activities.extend(engine_activities(&doc.engine, doc.id(), Some(&doc.title())));
1210 }
1211 }
1212 self.busy.update(activities, ctx.input(|i| i.time));
1213 self.busy.request_repaint(ctx);
1214 }
1215
1216 fn show_stl_preview(&mut self, ui: &mut egui::Ui) -> bool {
1217 use crate::panels::stl_import::PreviewAction;
1218 let Some(preview) = self.stl_import.as_mut() else {
1219 return false;
1220 };
1221 let action = if preview.destination != self.docs.active_id() {
1222 PreviewAction::Cancel
1223 } else {
1224 preview.show(ui, &mut self.viewport)
1225 };
1226 if crate::automation::registry::enabled() {
1227 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
1228 crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", &preview.state_json());
1229 crate::automation::registry::publish("__brepImportRegions", "interactive mesh import: phase, region list (proposed kind, status, residual, refits), selected region and its form; null when no preview is open", &preview.regions_json());
1230 crate::automation::registry::publish("__brepImportPreviewHit", "import preview dialog widget rects", &preview.hits_json());
1231 crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &preview.engine.camera_state_json());
1232 crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{}", ui.ctx().pixels_per_point()));
1233 crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
1234 crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
1235 }
1236 let close = match action {
1237 PreviewAction::Accept => {
1238 match preview.accept_into(self.docs.active_id(), self.docs.engine_mut()) {
1239 Ok(()) => true,
1240 Err(error) => {
1241 self.docs.engine_mut().push_notice(error);
1242 false
1243 }
1244 }
1245 }
1246 PreviewAction::Cancel => true,
1247 PreviewAction::None => false,
1248 };
1249 if close {
1250 self.stl_import = None;
1251 self.viewport.forget_document();
1252 ui.ctx().request_repaint();
1253 }
1254 true
1255 }
1256
1257 /// Phase 3 of the automation frame (§4.4): read-only commands answer from
1258 /// THIS frame's registry and layout. A method rather than a block at the
1259 /// end of `ui` because the import preview returns from that function early
1260 /// and still owes the frame its reads.
1261 #[cfg(feature = "automation")]
1262 fn drain_reads(&mut self, ctx: &egui::Context) {
1263 let _t = crate::perf::trace("drain reads + publish_view");
1264 let queue = self.automation.clone();
1265 let frame = ctx.cumulative_frame_nr();
1266 queue.drain_app(
1267 crate::automation::command::Phase::Read,
1268 &mut crate::automation::command::Ctx { app: self, egui: ctx },
1269 frame,
1270 );
1271 // The frame drawn now shows this document: a capture landing next
1272 // frame is of it.
1273 let view = self.docs.view();
1274 queue.publish_view(view.handle(), view.committed_revision());
1275 // This pass's layout is the current one for a layout read that comes
1276 // before anything changes it (`automation::queue::Need::Layout`).
1277 queue.note_pass();
1278 }
1279
1280 /// Every notice raised since the last gather — the engine's, storage
1281 /// failures found after a write returned, refused edits — mirrored to the
1282 /// automation queue's `notices` drain. Called by the frame (which then
1283 /// shows them as toasts) and by the frame-free service step, so a hidden
1284 /// window still reports what the user would have seen.
1285 fn gather_notices(&mut self) -> Vec<(brep_render::engine_state::NoticeSeverity, String)> {
1286 use brep_render::engine_state::NoticeSeverity;
1287 let mut raised = self.docs.engine_mut().take_graded_notices();
1288 // Same lane for STORAGE failures the store could only discover after its
1289 // synchronous `write` returned `Ok` (the browser backend writes behind an
1290 // in-memory mirror). A save that did not persist must never be silent.
1291 let errors = self.model_store.take_persistence_errors().into_iter().chain(self.autosave.take_errors());
1292 let failures: Vec<String> = errors.filter_map(|text| crate::panels::plm_host::plain_write_failure(&text)).collect();
1293 if let Some(last) = failures.last() {
1294 // The status line said "saved …" when the write was issued.
1295 self.file.status = last.clone();
1296 }
1297 if failures.iter().any(|text| text.contains("the PLM server is not reachable")) {
1298 self.plm.note_unreachable();
1299 }
1300 raised.extend(failures.into_iter().map(|text| (NoticeSeverity::Error, text)));
1301 // ...and the tab whose Save that was is unsaved again: it was marked
1302 // clean when the write was issued, and a clean tab closes without a
1303 // question.
1304 let failed = self.model_store.take_failed_writes();
1305 for name in &failed {
1306 self.docs.mark_write_failed(self.model_store.as_ref(), name);
1307 }
1308 // A part an open assembly holds a copy of (saved with it): the copy
1309 // keeps the change, marked, so the next Save writes it again.
1310 for name in &failed {
1311 self.docs.mark_failed_copies(self.model_store.as_ref(), name);
1312 }
1313 // An edit a read-only document refused (a PLM revision not checked out,
1314 // or released): its history kept the saved bytes, and this says why.
1315 for doc in self.docs.iter_mut() {
1316 if let Some(text) = doc.take_refused() {
1317 raised.push((NoticeSeverity::Warning, text));
1318 }
1319 }
1320 // The automation channel's `notices` drain: what the user would have SEEN.
1321 // Without this the drain has no producers at all and every script's
1322 // `{"notices": []}` asserts nothing.
1323 #[cfg(feature = "automation")]
1324 for (_, text) in &raised {
1325 self.automation.push_notice(
1326 crate::automation::command::NoticeKind::Toast,
1327 text.clone(),
1328 );
1329 }
1330 raised
1331 }
1332
1333 /// Application-command dispatch without a frame (the desktop loop's
1334 /// service step; see `automation::queue`): the PLM/store tasks that
1335 /// answered, the queue's frame-free head, the background runs it started,
1336 /// and the notices they raised. Draws nothing. Returns whether work it
1337 /// must keep servicing is still in flight.
1338 #[cfg(feature = "automation")]
1339 pub fn service_automation(&mut self, ctx: &egui::Context) -> bool {
1340 #[cfg(not(target_arch = "wasm32"))]
1341 crate::plm::native::run_pending();
1342 let queue = self.automation.clone();
1343 let polled = queue.drain_frameless(&mut crate::automation::command::Ctx { app: self, egui: ctx });
1344 let running = self.docs.pump_all();
1345 let raised = self.gather_notices();
1346 if polled || running || !raised.is_empty() {
1347 // Work in flight or landing moves what a pass would show.
1348 queue.mark_layout_dirty();
1349 }
1350 if !raised.is_empty() {
1351 let now = ctx.input(|i| i.time);
1352 self.toasts.extend(raised, now);
1353 }
1354 // A window that IS drawn shows the effect on its next frame.
1355 ctx.request_repaint();
1356 polled || running || queue.has_frameless_work()
1357 }
1358
1359 /// Reset everything the shared panels and the viewport hold ABOUT ONE
1360 /// DOCUMENT, run at the top of the first frame that sees a different active
1361 /// document.
1362 ///
1363 /// Panel state is deliberately NOT per-document (one History panel, one
1364 /// Scene tree, …): a second copy per tab would double every panel's state
1365 /// for a benefit — remembering which feature form was open in a background
1366 /// tab — nobody asked for. The price is that the transient state has to be
1367 /// dropped on a switch, because every bit of it (expansion sets, hit maps,
1368 /// an open feature form, a pinned Info window's object name) refers to the
1369 /// document that just went away.
1370 fn reset_document_scoped_state(&mut self) {
1371 self.history = HistoryPanel::new();
1372 self.scene = ScenePanel::new();
1373 self.bom = BomPanel::new();
1374 self.assembly_constraints = AssemblyConstraintsPanel::new();
1375 self.wire_harness = WireHarnessPanel::new();
1376 self.pmi = crate::panels::pmi::PmiPanel::new();
1377 self.cam = crate::panels::cam::CamPanel::new();
1378 self.sheets = crate::panels::sheets::SheetsPanel::new();
1379 self.expressions = ExpressionsPanel::new();
1380 self.qualify = crate::panels::qualify::QualifyPanel::new();
1381 self.family_table = crate::panels::family_table_editor::FamilyTableEditor::new();
1382 // A family-row preview left on in a document the user comes back to
1383 // would have no pane state to show or end it: switching ends it.
1384 self.docs.engine_mut().set_expression_preview(None);
1385 // Pinned to object NAMES of the old document ("Box" exists in most of
1386 // them), so these would silently retarget rather than go blank.
1387 self.info_windows = InfoWindows::new();
1388 self.interference = crate::panels::interference::InterferenceWindow::new();
1389 self.auto_constraints = crate::panels::auto_constraints::AutoConstraintsWindow::new();
1390 // The outdated-parts cache keys on `(applied_generation, save_generation)`,
1391 // and two documents' generations are unrelated — a switch can land on the
1392 // same key with an entirely different parts library.
1393 self.update_components.invalidate();
1394 self.viewport.forget_document();
1395 }
1396
1397 /// Publish every plain-state blob ([`state_blobs::STATE_BLOBS`]) from its
1398 /// producer. The frame calls this; `state_get` asks
1399 /// [`state_blob_json`](Self::state_blob_json) itself, so a read of one of
1400 /// these never waits for a frame.
1401 ///
1402 /// [`state_blobs::STATE_BLOBS`]: crate::automation::state_blobs::STATE_BLOBS
1403 fn publish_state_blobs(&mut self, ctx: &egui::Context) {
1404 for (name, doc) in crate::automation::state_blobs::STATE_BLOBS {
1405 if let Some(json) = self.state_blob_json(name, ctx) {
1406 crate::automation::registry::publish(name, doc, &json);
1407 }
1408 }
1409 }
1410
1411 /// One plain-state blob by name, built from the app's data now. `None`
1412 /// for a name that is not one of [`STATE_BLOBS`] (a layout blob, which
1413 /// only a drawn pass publishes).
1414 ///
1415 /// [`STATE_BLOBS`]: crate::automation::state_blobs::STATE_BLOBS
1416 pub fn state_blob_json(&mut self, name: &str, ctx: &egui::Context) -> Option<String> {
1417 if name == "__brepSelectionInspection" {
1418 let document = self.docs.active_id();
1419 return Some(self.selection.inspection_json(self.docs.engine_mut(), document));
1420 }
1421 let engine = self.docs.engine();
1422 Some(match name {
1423 // The import preview draws its own engine while it is open.
1424 "__brepCamera" => match &self.stl_import {
1425 Some(preview) => preview.engine.camera_state_json(),
1426 None => engine.camera_state_json(),
1427 },
1428 "__brepImportPreview" => self.stl_import.as_ref().map_or_else(|| "null".to_string(), |p| p.state_json()),
1429 "__brepImportRegions" => self.stl_import.as_ref().map_or_else(|| "null".to_string(), |p| p.regions_json()),
1430 "__brepPpp" => format!("{}", ctx.pixels_per_point()),
1431 // Keyed on the settings themselves and the projection, the only
1432 // inputs `settings_json` reads.
1433 "__brepSettings" => {
1434 let orthographic = matches!(engine.camera.projection, brep_render::view::Projection::Orthographic { .. });
1435 let key = (engine.settings.clone(), orthographic);
1436 StateBlobCache::keyed(&mut self.state_blob_cache.settings, key, || engine.settings_json())
1437 }
1438 "__brepSolidColors" => engine.solid_color_overrides_json(),
1439 "__brepHistory" => engine.history_listing_json(),
1440 "__brepGizmo" => engine.gizmo_state_json(),
1441 "__brepFile" => self.file.file_state_json(&self.docs, self.model_store.as_ref()),
1442 // Keyed on exactly what the signature reports (rollback step, each
1443 // solid's name, triangle count and bounds), so building the key
1444 // skips only the JSON, never a change.
1445 "__brepModel" => {
1446 let key = model_signature_key(engine);
1447 match StateBlobCache::hit(&self.state_blob_cache.model, &key) {
1448 Some(json) => json,
1449 None => {
1450 let json = self.model_signature_json();
1451 self.state_blob_cache.model = Some((key, json.clone()));
1452 json
1453 }
1454 }
1455 }
1456 "__brepReport" => engine.history_report_json(),
1457 // The in-flight run: whether one is pending, the feature the runner
1458 // says it is executing, and the feature a cancelled run was stuck on.
1459 "__brepRun" => serde_json::json!({
1460 "pending": engine.run_pending(),
1461 "progress": engine.run_progress().map(|p| serde_json::json!({
1462 "generation": p.generation,
1463 "index": p.index,
1464 "total": p.total,
1465 "featureId": p.feature_id,
1466 "featureType": p.feature_type,
1467 })),
1468 "cancelled": engine.cancelled_run(),
1469 })
1470 .to_string(),
1471 "__brepBusy" => self.busy.state_json(),
1472 "__brepExpr" => serde_json::json!({
1473 "expressions": engine.expressions_json(),
1474 "variables": serde_json::from_str::<serde_json::Value>(&engine.expression_variables_json())
1475 .unwrap_or(serde_json::Value::Null),
1476 "configurator": serde_json::from_str::<serde_json::Value>(&engine.configurator_json())
1477 .unwrap_or(serde_json::Value::Null),
1478 })
1479 .to_string(),
1480 "__brepPlmDoc" => self.plm.state_json(&self.docs),
1481 // The queued toast texts — the only trace of a refusal the app shows
1482 // as a transient card (e.g. a constraint the strip could not add).
1483 "__brepNotices" => self.toasts.texts_json(),
1484 "__brepToasts" => self.toasts.state_json(),
1485 "__brepBug" => self.bug_report.state_json(),
1486 "__brepDiagnostics" => self.diagnostics.json().to_string(),
1487 // Where the last ~120 frames' milliseconds went. `__brepDiagnostics`
1488 // says WHICH renderer is drawing; this says what it costs, in the
1489 // same split the Info window's Performance rows show.
1490 "__brepPerf" => crate::perf::json(),
1491 "__brepWireHarness" => engine.wire_harness_state_json(),
1492 "__brepCam" => {
1493 self.cam.refresh_geometry(self.docs.engine());
1494 crate::panels::cam::cam_state_json(self.docs.engine(), &self.cam)
1495 }
1496 "__brepInstructions" => self.instructions.state_json(),
1497 "__brepInstructionsPlayer" => self.instructions_player.state_json(engine),
1498 "__brepSimulation" => crate::panels::simulation::state_json(engine, &self.simulation),
1499 "__brepCamSim" => self.cam.sim.state_json().to_string(),
1500 "__brepPorts" => engine.ports_state_json(),
1501 "__brepQualify" => self.qualify.state_json(),
1502 "__brepFamily" => self.family_table.state_json(),
1503 "__brepPmi" => engine.pmi_state_json(),
1504 "__brepSheets" => self.docs.engine_mut().sheet_state_json(),
1505 // The workbench logical state (resolved current id + available
1506 // ids); the dropdown's rects ride in `__brepToolbar`.
1507 "__brepWorkbench" => {
1508 // Its inputs: the stored workbench and the plugins' workbenches.
1509 let key = (engine.settings.workbench.clone(), engine.plugin_workbenches());
1510 StateBlobCache::keyed(&mut self.state_blob_cache.workbench, key, || {
1511 crate::workbench::workbench_state_scoped(engine).to_string()
1512 })
1513 }
1514 "__brepSelection" => engine.selection_json(),
1515 "__brepInfoWindows" => self.info_windows.published_json(self.docs.engine_mut()),
1516 "__brepInterference" => self.interference.state_json_in(engine),
1517 "__brepAutoConstraints" => self.auto_constraints.state_json(),
1518 "__brepStepParts" => self.step_parts.state_json(),
1519 "__brepSelectionFilter" => engine.selection_filter_json(),
1520 "__brepFilterButton" => self.selection.button_json(),
1521 "__brepContext" => self.context_bar.state_json(),
1522 "__brepPickBar" => self.pick_bar.state_json(),
1523 "__brepDocuments" => document_tabs::state_json(&self.docs),
1524 "__brepRecovery" => self.recovery.state_json(),
1525 "__brepClosing" => self.closing_json(),
1526 "__brepComponentMove" => engine.component_move_json(),
1527 "__brepSketch" => self.sketch.published_json(engine),
1528 "__brepSketch3d" => self.sketch3d.published_json(engine),
1529 "__brepWireframe" => format!("{}", engine.settings.wireframe),
1530 // The pick's `subject` and `path` name the FORM FIELD it runs in
1531 // (`<panel>/field:<path>#activate` is that field's button, Done
1532 // while the pick runs); `inForm` is false for the card-based
1533 // picks, whose controls are `modebar/refsel:*`.
1534 "__brepRefSelect" => {
1535 let field = engine.ref_select_field();
1536 serde_json::json!({
1537 "active": engine.ref_select_active(),
1538 "prompt": engine.ref_select_prompt(),
1539 "names": engine.ref_select_names(),
1540 "subject": field.as_ref().map(|f| f.subject.clone()),
1541 "path": field.as_ref().map(|f| f.path.join(".")),
1542 "multiple": field.as_ref().map(|f| f.multiple),
1543 "picks": field.as_ref().map(|f| f.picks),
1544 "inForm": engine.ref_select_in_form(),
1545 "ghostsLit": engine.ref_select_ghosts_lit(),
1546 })
1547 .to_string()
1548 }
1549 "__brepStep" => format!("{}", engine.history_rollback()),
1550 "__brepParams" => self.params_blob().to_string(),
1551 _ => return None,
1552 })
1553 }
1554
1555 /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
1556 /// per-solid triangle count + bbox, and total triangles. Published to JS so
1557 /// the headed verifier can prove each roll / edit produced different geometry
1558 /// (names alone don't: a SUBTRACT reuses the target's name).
1559 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1560 pub(crate) fn model_signature_json(&self) -> String {
1561 let solids: Vec<serde_json::Value> = self
1562 .docs
1563 .engine()
1564 .scene
1565 .solids()
1566 .iter()
1567 .map(|s| {
1568 serde_json::json!({
1569 "name": s.name,
1570 "tris": s.mesh.indices.len() / 3,
1571 "min": s.bbox.min,
1572 "max": s.bbox.max,
1573 })
1574 })
1575 .collect();
1576 let total_tris: usize = self
1577 .docs
1578 .engine()
1579 .scene
1580 .solids()
1581 .iter()
1582 .map(|s| s.mesh.indices.len() / 3)
1583 .sum();
1584 serde_json::json!({
1585 "step": self.docs.engine().history_rollback(),
1586 "solidCount": solids.len(),
1587 "totalTris": total_tris,
1588 "solids": solids,
1589 })
1590 .to_string()
1591 }
1592
1593 /// Open or close the floating Settings window — the toolbar gear's flag,
1594 /// reachable from the automation layer (`settings_window`).
1595 pub fn set_settings_window_open(&mut self, open: bool) {
1596 self.settings.open = open;
1597 }
1598
1599 /// Open or close the floating Part Properties window — the toolbar tag
1600 /// button's flag, reachable from the automation layer
1601 /// (`part_properties_window`).
1602 pub fn set_part_properties_window_open(&mut self, open: bool) {
1603 self.part_properties.open = open;
1604 }
1605
1606 /// Open or close the floating Info window — the toolbar info button's flag,
1607 /// reachable from the automation layer (`info_window`).
1608 pub fn set_info_window_open(&mut self, open: bool) {
1609 self.info.open = open;
1610 }
1611
1612 /// What this session is running on. The app's ONE record of it: the Info
1613 /// window draws it, a problem report carries it, the `diagnostics` command
1614 /// returns it and the MCP banner names its adapter — all from here.
1615 /// Where the JavaScript editor's Run sends a program (a host's own
1616 /// script runner; the desktop app installs its server's on its own).
1617 pub fn set_script_host(&mut self, host: Box<dyn crate::javascript::ScriptHost>) {
1618 self.javascript.host = Some(host);
1619 }
1620
1621 pub fn diagnostics(&self) -> &crate::diagnostics::Diagnostics {
1622 &self.diagnostics
1623 }
1624
1625 /// Begin the in-app problem report: the same call the Submit Bug button
1626 /// makes, and for the same reason it makes it THIS frame — `request`
1627 /// captures the current frame before its own dialog exists.
1628 pub fn begin_bug_report(&mut self, ctx: &egui::Context) {
1629 self.bug_report.request(ctx, self.docs.engine(), &self.diagnostics);
1630 }
1631
1632 /// Activate an available declarative panel using its stable package ID.
1633 pub(crate) fn show_plugin_panel(&mut self, id: &str) -> Result<(), String> {
1634 self.dock.show_plugin_panel(self.docs.engine(), id)
1635 }
1636
1637 /// Bring a built-in dock pane to the front (`show_pane`).
1638 pub fn show_pane(&mut self, kind: PaneKind) {
1639 self.dock.show_pane(kind);
1640 }
1641
1642 /// Shared document activation / unsaved-close handling for both presentations.
1643 fn apply_document_actions(&mut self, activate: Option<usize>, close: Option<usize>) {
1644 if (activate.is_some() || close.is_some()) && self.docs.engine().ref_select_in_form() {
1645 self.docs.engine_mut().finish_ref_select();
1646 }
1647 if let Some(index) = activate { self.docs.activate(index); }
1648 if let Some(index) = close { self.file.request_close(&mut self.docs, index); }
1649 }
1650
1651 /// The parts-library staleness check and its refresh — the Constraints
1652 /// header's "Update components (N)" button, reachable as the
1653 /// `component_update` command. Returns `(outdated, missing, refreshed)`;
1654 /// `run` is skipped when nothing is outdated.
1655 pub fn update_components(&mut self, run: bool) -> Result<(usize, Vec<String>, usize), String> {
1656 let store = self.model_store.as_ref();
1657 let synced = self.docs.synced_signatures();
1658 self.update_components.ensure_current(self.docs.engine_mut(), store, self.file.save_generation(), &synced);
1659 let outdated = self.update_components.outdated_count();
1660 let missing = self.update_components.missing().to_vec();
1661 let refreshed = if run { self.update_components.run(self.docs.engine_mut(), store)? } else { 0 };
1662 Ok((outdated, missing, refreshed))
1663 }
1664
1665 /// Frame what the central tile is actually DRAWING: the OPEN SHEET's paper
1666 /// when a sheet is open, the 3D scene otherwise.
1667 ///
1668 /// This is where the fit branch lives, and it is here rather than in the
1669 /// toolbar because the two halves sit in different places — the 3D fit is an
1670 /// engine camera op (`EngineState::zoom_to_fit`) while the sheet's pan/zoom
1671 /// is viewport-transient state, refitted by clearing the marker the sheet
1672 /// paint compares against. One method, so the toolbar's Zoom-to-fit button
1673 /// and the `zoom_to_fit` command can never mean different things — and so
1674 /// the paper needs no Fit button of its own.
1675 ///
1676 /// The toolbar draws BEFORE the dock in `ui`, so a fit requested from the
1677 /// button lands on this very frame.
1678 pub fn zoom_to_fit(&mut self) {
1679 if self.docs.engine().sheet_open().is_some() {
1680 self.viewport.request_sheet_fit();
1681 } else {
1682 self.docs.engine_mut().zoom_to_fit();
1683 }
1684 }
1685
1686 /// Run what a WORKBENCH TOOLBAR button does, by its `WorkbenchButton::id`.
1687 ///
1688 /// Split out of `ui` so a toolbar click and the `workbench_button` command
1689 /// run the SAME arms: the workbench registry declares a button, this
1690 /// dispatches it, and the automation surface owns no second copy of the
1691 /// list. Returns whether the id was known.
1692 pub fn dispatch_workbench_button(&mut self, id: &str) -> bool {
1693 // The eCAD workbenches' buttons (Diagram, PCB, Symbol, Pads) are
1694 // eCAD's own actions: run on the active document's editors with
1695 // eCAD's `run_action`, so a press does exactly what eCAD's toolbar
1696 // button does. `None` is any other id, which falls through to the
1697 // arms below.
1698 if crate::workbench::ecad::dispatch(&mut self.docs.active_mut().ecad, id).is_some() {
1699 return true;
1700 }
1701 // The SHEET's constructions are the Drawing workbench's two tables plus
1702 // the section and the detail, and each press is the PMI annotation
1703 // buttons' workflow: the object is created on the open sheet with
1704 // nothing picked yet, and its dialog opens (through the dialog door,
1705 // which surfaces the Sheets pane) — where its reference rows are picked
1706 // on the paper with the reference picker.
1707 if let Some((kind, alignment)) = crate::workbench::drawing::dim_tool(id) {
1708 let engine = self.docs.engine_mut();
1709 if let Err(error) = engine.sheet_add_dimension(None, kind, Some(alignment), Vec::new()) {
1710 engine.push_notice(format!("Sheet dimension: {error}"));
1711 }
1712 return true;
1713 }
1714 if let Some(axis) = crate::workbench::drawing::ord_tool(id) {
1715 let engine = self.docs.engine_mut();
1716 if let Err(error) = engine.sheet_add_ordinate(None, axis, None, Vec::new()) {
1717 engine.push_notice(format!("Ordinate set: {error}"));
1718 }
1719 return true;
1720 }
1721 if id == crate::workbench::drawing::BOM_BUTTON_ID {
1722 let engine = self.docs.engine_mut();
1723 if let Err(error) = engine.sheet_insert_bom() { engine.push_notice(format!("BOM table: {error}")); }
1724 return true;
1725 }
1726 if id == crate::workbench::drawing::SECTION_BUTTON_ID {
1727 let engine = self.docs.engine_mut();
1728 if let Err(error) = engine.sheet_new_section(None) {
1729 engine.push_notice(format!("Section view: {error}"));
1730 }
1731 return true;
1732 }
1733 if id == crate::workbench::drawing::DETAIL_BUTTON_ID {
1734 let engine = self.docs.engine_mut();
1735 if let Err(error) = engine.sheet_new_detail(None) {
1736 engine.push_notice(format!("Detail view: {error}"));
1737 }
1738 return true;
1739 }
1740 // The SKETCH mode's draw tools are one table too, and they are SHARED —
1741 // every workbench's row carries them while a sketch is being edited, so
1742 // this arm answers for all of them whatever workbench is active.
1743 // Pressing the LIT tool puts it down (back to Select), matching the
1744 // sheet constructions above; pressing the lit Select is already Select.
1745 if let Some(tool) = crate::workbench::sketch::draw_tool(id) {
1746 let engine = self.docs.engine_mut();
1747 let armed = engine.sketch_active_tool().unwrap_or("select") == tool;
1748 engine.sketch_set_tool(Some(if armed { "select" } else { tool }));
1749 return true;
1750 }
1751 // The 3D sketch's tools and working-plane radio, shared the same way.
1752 if let Some(tool) = crate::workbench::sketch3d::draw_tool(id) {
1753 let engine = self.docs.engine_mut();
1754 let armed = engine.sketch3d_active_tool().unwrap_or("select") == tool;
1755 let _ = engine.sketch3d_set_tool(Some(if armed { "select" } else { tool }));
1756 return true;
1757 }
1758 if let Some(plane) = crate::workbench::sketch3d::work_plane(id) {
1759 let _ = self.docs.engine_mut().sketch3d_set_work_plane(plane);
1760 return true;
1761 }
1762 match id {
1763 // The sketch strip's one-shot action, now the row's: infer the
1764 // constraints the rough-in geometry already implies.
1765 crate::workbench::sketch::AUTOCONSTRAIN_BUTTON_ID => {
1766 self.docs.engine_mut().sketch_auto_constrain();
1767 }
1768 // Sheet Metal's flat pattern: open the export modal in its DXF /
1769 // SVG mode; the engine reports "no sheet-metal body in the part"
1770 // as a toast on export.
1771 "sheetmetal.flat_pattern" => {
1772 self.file.dispatch(FileAction::ExportFlatPattern, &mut self.docs, self.model_store.as_ref());
1773 }
1774 // Assembly's Add Component: open the insert-component modal (the
1775 // same flow as the ACOMP palette pick). ONE arm for both rows that
1776 // carry it — PCB BORROWS this very button (`workbench::BORROWED`)
1777 // rather than declaring a second id for the same action.
1778 crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID => {
1779 self.file.dispatch(FileAction::InsertComponent, &mut self.docs, self.model_store.as_ref());
1780 }
1781 // Assembly's interference check: run the engine's pairwise
1782 // intersect sweep NOW and open the results window.
1783 "assembly.interference" => {
1784 self.interference.open_and_run(self.docs.engine_mut());
1785 }
1786 // Assembly's Auto Constraints: open the inference window and scan
1787 // the current placement NOW, so it opens showing real counts.
1788 "assembly.auto_constrain" => {
1789 self.auto_constraints.open_and_scan(self.docs.engine_mut());
1790 }
1791 // Assembly's step.parts library: open the online-library browser
1792 // (search → thumbnails → import a STEP part → add as an ACOMP).
1793 "assembly.step_parts_library" => {
1794 self.step_parts.open();
1795 }
1796 // Wire harness's Declare connection point: the part's FIRST port
1797 // group, with one point at the origin. It exists because the
1798 // Qualify pane is on screen only while the part declares some — so
1799 // without it a 3D-only part could never get its first one from the
1800 // UI at all. The pane is surfaced by the dock's own ports door on
1801 // the next frame, once the visibility pass has let it back in.
1802 crate::workbench::wire_harness::DECLARE_POINT_BUTTON_ID => {
1803 let engine = self.docs.engine_mut();
1804 engine.history.set_ports_block(
1805 Some(serde_json::json!([{
1806 "name": crate::workbench::wire_harness::FIRST_GROUP,
1807 "purpose": crate::workbench::wire_harness::FIRST_PURPOSE,
1808 "points": [{
1809 "name": crate::workbench::wire_harness::FIRST_POINT,
1810 "transform": {
1811 "position": [0.0, 0.0, 0.0],
1812 "rotationEuler": [0.0, 0.0, 0.0],
1813 },
1814 }],
1815 }])),
1816 None,
1817 );
1818 }
1819 // CAM's four actions: each hands the CAM pane its request and
1820 // surfaces the pane, which performs it on the frame it draws (the
1821 // pane owns the selection the action reads — which setup an
1822 // operation goes into — so the pane performs it, not the shell).
1823 crate::workbench::cam::NEW_SETUP_BUTTON_ID => {
1824 self.cam.request(crate::panels::cam::CamRequest::NewSetup);
1825 self.show_pane(PaneKind::Cam);
1826 }
1827 crate::workbench::cam::NEW_TOOL_BUTTON_ID => {
1828 self.cam.request(crate::panels::cam::CamRequest::NewTool);
1829 self.show_pane(PaneKind::Cam);
1830 }
1831 crate::workbench::cam::NEW_OPERATION_BUTTON_ID => {
1832 self.cam.request(crate::panels::cam::CamRequest::NewOperation);
1833 self.show_pane(PaneKind::Cam);
1834 }
1835 crate::workbench::cam::GENERATE_BUTTON_ID => {
1836 self.cam.request(crate::panels::cam::CamRequest::Generate);
1837 self.show_pane(PaneKind::Cam);
1838 }
1839 // Instructions: New from assembly opens the instructions in a new
1840 // tab (the open assembly, saved, is the target); the wizard and a
1841 // new step go to the pane, which owns the selected step.
1842 crate::workbench::instructions::NEW_BUTTON_ID => {
1843 let (_, docs, store) = self.file_docs_store();
1844 match crate::instructions::new_instructions(docs, store, crate::instructions::Source::Active, true) {
1845 Ok(_) => self.show_pane(PaneKind::Instructions),
1846 Err(error) => self.docs.engine_mut().push_notice(format!("New instructions: {error}")),
1847 }
1848 }
1849 crate::workbench::instructions::WIZARD_BUTTON_ID => {
1850 self.instructions.request(crate::panels::instructions::InstructionsRequest::Wizard);
1851 self.show_pane(PaneKind::Instructions);
1852 }
1853 crate::workbench::instructions::NEW_STEP_BUTTON_ID => {
1854 self.instructions.request(crate::panels::instructions::InstructionsRequest::NewStep);
1855 self.show_pane(PaneKind::Instructions);
1856 }
1857 // Play starts at the first step, or plays the shown one again.
1858 crate::workbench::instructions::PLAY_BUTTON_ID => {
1859 let shown = self.docs.engine().instructions_shown_step().is_some();
1860 self.instructions_player.request(if shown { brep_render::engine_state::PlayTo::Replay } else { brep_render::engine_state::PlayTo::First });
1861 self.show_pane(PaneKind::InstructionsPlayer);
1862 }
1863 // Simulation: the pane performs them (it draws the timeline the
1864 // new study or motor lands on).
1865 crate::workbench::simulation::NEW_STUDY_BUTTON_ID => {
1866 self.simulation.request(crate::panels::simulation::SimulationRequest::NewStudy);
1867 self.show_pane(PaneKind::Simulation);
1868 }
1869 crate::workbench::simulation::ADD_MOTOR_BUTTON_ID => {
1870 self.simulation.request(crate::panels::simulation::SimulationRequest::AddMotor);
1871 self.show_pane(PaneKind::Simulation);
1872 }
1873 // PMI's Capture view: snapshot the camera + visibility into a new
1874 // active view and surface the PMI pane so its row is seen.
1875 crate::workbench::pmi::CAPTURE_BUTTON_ID => {
1876 self.docs.engine_mut().pmi_capture_view(None);
1877 self.dock.show_pane(PaneKind::Pmi);
1878 }
1879 // Drawing's Back to 3D: leave the open sheet. The paper's own
1880 // widgets go with it, and the sheet keeps its contents — the same
1881 // edit the Sheets pane's Close sheet makes.
1882 crate::workbench::drawing::SHEET_CLOSE_BUTTON_ID => {
1883 let _ = self.docs.engine_mut().sheet_set_open(None);
1884 self.viewport.forget_sheet();
1885 }
1886 // Drawing's Add sheet: a new sheet, opened on the paper, and the
1887 // Sheets pane brought forward so its row is seen.
1888 crate::workbench::drawing::ADD_SHEET_BUTTON_ID => {
1889 self.docs.engine_mut().sheet_add(None);
1890 self.dock.show_pane(PaneKind::Sheets);
1891 }
1892 // Drawing's Place view: the first saved PMI view on the open sheet;
1893 // the placement's form opens (the door surfaces the pane), and its
1894 // `view` field is where a different view is chosen.
1895 crate::workbench::drawing::PLACE_VIEW_BUTTON_ID => {
1896 let engine = self.docs.engine_mut();
1897 let placed = match engine.sheet_pmi_view_ids().first().cloned() {
1898 Some(view) => engine.sheet_place_view(None, &view, None, None).map(|_| ()),
1899 None => Err("capture a PMI view in the PMI workbench first".to_string()),
1900 };
1901 if let Err(error) = placed {
1902 engine.push_notice(format!("Place view: {error}"));
1903 }
1904 }
1905 _ => return false,
1906 }
1907 true
1908 }
1909
1910}
1911
1912impl eframe::App for BrepApp {
1913 /// The browser's automation pump reaches the app between frames through
1914 /// eframe's runner (`WebRunner::app_mut`, `web_pump`). A web-only method.
1915 #[cfg(target_arch = "wasm32")]
1916 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1917 Some(self)
1918 }
1919
1920 /// Phase 1 of the automation frame (§4.4): queued input commands become
1921 /// egui events; a screenshot that landed completes its reply. Called by
1922 /// every eframe runner before `ui`; the headless host calls it itself.
1923 #[cfg(feature = "automation")]
1924 fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
1925 let view = self.viewport.last_rect();
1926 // eframe runs a pass without `ui` when the window is not visible (a
1927 // hidden tab's timer frames, an occluded or minimised window): input
1928 // given to it reaches no widget. Automation input waits for a pass
1929 // that draws (the loop's or the page pump's own, `on_demand`, which
1930 // mark theirs visible), and `activate_widget` keeps the widgets of
1931 // the last pass that drew. egui hit-tests pointer input against the
1932 // PREVIOUS pass's widgets, so the pass right after one that did not
1933 // draw only lays out again, and input waits for the one after it.
1934 //
1935 // A hidden TAB with a WebMCP host attached draws every pass instead:
1936 // eframe runs its timer passes right after the page pump's (each
1937 // repaint request schedules one), and a pass that skips `ui` between
1938 // a press and its release makes egui forget the press. eframe reads
1939 // the root viewport's visibility after this hook, so marking it
1940 // visible here is what has it run `ui`.
1941 #[cfg(target_arch = "wasm32")]
1942 if crate::automation::registry::enabled() {
1943 let root = raw_input.viewports.entry(egui::ViewportId::ROOT).or_default();
1944 if root.occluded == Some(true) {
1945 root.occluded = Some(false);
1946 }
1947 }
1948 let draws = raw_input.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.visible()).unwrap_or(true);
1949 let drew = crate::automation::actions::note_pass(ctx, draws);
1950 if !draws || !drew {
1951 return;
1952 }
1953 self.automation.drain_input(raw_input, ctx.cumulative_frame_nr(), ctx.pixels_per_point(), view, ctx);
1954 }
1955
1956
1957 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
1958 // Open this frame's timing window (banks the previous frame's phase
1959 // totals and its period) and bracket the whole body. See `perf`: on a
1960 // single-threaded shell a long frame IS a frozen UI, so what the frame
1961 // spends is the whole diagnosis.
1962 crate::perf::tick();
1963 let _ui_span = crate::perf::span(crate::perf::Phase::Ui);
1964 let _frame_trace = crate::perf::trace("frame");
1965
1966 // The native store and PLM queue (`plm::native`): every task whose I/O
1967 // answered since the last frame finishes here, on this thread, FIRST —
1968 // before any early return below (the mesh preview's replaces the whole
1969 // shell) can skip it. The browser drains its microtasks between events
1970 // the same way, whatever the page is showing.
1971 #[cfg(not(target_arch = "wasm32"))]
1972 crate::plm::native::run_pending();
1973 // A PLM save made mid-rebuild is pictured once its run lands.
1974 crate::plm::thumbnail::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
1975 // Its player data too (instructions), and a refused upload is said.
1976 crate::plm::player_data::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
1977 if let Some(notice) = crate::plm::player_data::take_notice() {
1978 self.docs.engine_mut().push_notice_as(brep_render::engine_state::NoticeSeverity::Warning, notice);
1979 }
1980 // The File menu's recent-file pictures that finished since last frame.
1981 crate::plm::thumbnail::persist_recent(self.model_store.as_ref());
1982
1983 // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
1984 // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
1985 // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
1986 // `&mut self` call).
1987 let ctx = ui.ctx().clone();
1988
1989 self.plugins.finish_installations(&mut self.docs, self.model_store.as_ref());
1990 self.plugins.sync_documents(&mut self.docs);
1991 self.javascript.poll(&ctx, &mut self.docs, self.model_store.as_ref());
1992 self.plugins.poll(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
1993
1994 // Phase 2 of the automation frame (§4.4): mutations run before any
1995 // panel draws, so this frame shows their effect.
1996 #[cfg(feature = "automation")]
1997 {
1998 let queue = self.automation.clone();
1999 let frame = ctx.cumulative_frame_nr();
2000 let _t = crate::perf::trace("drain mutate");
2001 queue.drain_app(
2002 crate::automation::command::Phase::Mutate,
2003 &mut crate::automation::command::Ctx { app: self, egui: &ctx },
2004 frame,
2005 );
2006 }
2007
2008 // A measured spin (`perf_spin`) advances one pointer step per frame, here
2009 // — after the mutation drain that may have armed it, so the spin starts
2010 // on the frame it was asked for.
2011 #[cfg(feature = "automation")]
2012 self.step_spin(&ctx);
2013
2014 // --- a different document is active than the panels were drawn for ----
2015 // Checked FIRST, before anything draws: the switch itself happened late
2016 // in some earlier frame (a tab click, a close, an Open), and every
2017 // shared panel is still holding the previous document's transient state.
2018 if self.active_document != self.docs.view_id() {
2019 if !self.javascript.running_in(self.active_document) {
2020 for doc in self.docs.iter_mut() { if doc.id() == self.active_document { doc.engine.cancel_plugin_action(); } }
2021 }
2022 // The Interference window is reset below, but its overlay lives in
2023 // the document being left: take it down there, or switching back
2024 // shows the volumes with no window to clear them.
2025 for doc in self.docs.iter_mut() {
2026 if doc.id() == self.active_document {
2027 doc.engine.clear_interference_overlay();
2028 }
2029 }
2030 self.plugins.close_action();
2031 self.active_document = self.docs.view_id();
2032 let same_tab = self.active_tab == self.docs.active_id();
2033 self.active_tab = self.docs.active_id();
2034 let bom = same_tab.then(|| std::mem::take(&mut self.bom));
2035 self.reset_document_scoped_state();
2036 if let Some(bom) = bom {
2037 self.bom = bom;
2038 }
2039 }
2040
2041 // --- the workbench decides whether placed parts show their ports -------
2042 // A workbench that shows the Wire Harness panel (Wire harness, All)
2043 // draws the ports components carry; the others — Modeling included,
2044 // though it offers the Port feature — keep the assembly clean. A no-op
2045 // when nothing changed.
2046 {
2047 // The document's name lives in the SHELL (the tab, the file), and a
2048 // drawing sheet's title block prints it. Told to the engine here,
2049 // every frame, because that is where the active tab is known; a
2050 // no-op when it has not changed. It is the DISPLAY name, the one the
2051 // tab shows: a stamp reading `bracket.nbrep` would be naming our
2052 // file format rather than the drawing.
2053 let name = self
2054 .docs
2055 .active()
2056 .name()
2057 .map(crate::store::model_display_name)
2058 .unwrap_or_default();
2059 let engine = self.docs.engine_mut();
2060 engine.set_document_name(&name);
2061 let show = crate::workbench::panel_visible(
2062 &engine.settings.workbench,
2063 crate::workbench::wire_harness::PANEL_ID,
2064 &crate::workbench::ButtonState::of(engine),
2065 );
2066 engine.set_component_ports_visible(show);
2067 // The PMI workbench IS the PMI editing mode: a workbench that shows
2068 // the PMI panel (PMI, All) enters it — the modeling camera /
2069 // visibility / wireframe are remembered — and one that hides it
2070 // leaves it, deactivating the view and restoring them.
2071 let pmi_shown = crate::workbench::panel_visible(
2072 &engine.settings.workbench,
2073 crate::workbench::pmi::PANEL_ID,
2074 &crate::workbench::ButtonState::of(engine),
2075 );
2076 // An exploded view is shown from the Assembly workbench's Constraints
2077 // pane without entering PMI (a capture still remembers the modeling
2078 // state), so that pane counts as an exploded view's home too. With
2079 // neither on screen (nor the paper), the view is put away, so no one
2080 // models on exploded displays.
2081 let exploded_shown = crate::workbench::panel_visible(
2082 &engine.settings.workbench,
2083 crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
2084 &crate::workbench::ButtonState::of(engine),
2085 ) && engine
2086 .pmi_active_view()
2087 .is_some_and(|id| engine.is_exploded_view(id));
2088 let paper_shown = crate::workbench::panel_visible(
2089 &engine.settings.workbench,
2090 crate::workbench::drawing::SHEETS_PANEL_ID,
2091 &crate::workbench::ButtonState::of(engine),
2092 );
2093 // A step of the instructions shows through its view, so the
2094 // Instructions and Player panes are a view's home too.
2095 let instructions_shown = [crate::workbench::instructions::PANEL_ID, crate::workbench::instructions::PLAYER_PANEL_ID]
2096 .into_iter()
2097 .any(|panel| crate::workbench::panel_visible(&engine.settings.workbench, panel, &crate::workbench::ButtonState::of(engine)));
2098 if pmi_shown && !engine.pmi_workbench_entered() {
2099 engine.pmi_enter_workbench();
2100 } else if !pmi_shown && !exploded_shown && !instructions_shown && engine.pmi_workbench_entered() {
2101 engine.pmi_leave_workbench();
2102 }
2103 if !pmi_shown && !exploded_shown && !paper_shown && !instructions_shown && engine.pmi_active_view().is_some() {
2104 engine.pmi_deactivate_view();
2105 }
2106 // The DRAWING workbench owns the paper: LEAVING it for a workbench
2107 // that hides the Sheets pane closes an open sheet, so the central
2108 // tile is not left drawing paper whose tools, pane and way back are
2109 // all hidden. It is a MODE change — the sheets and their contents
2110 // are untouched — and an edge, see `sheets_pane_shown`.
2111 let sheets_shown = crate::workbench::panel_visible(
2112 &engine.settings.workbench,
2113 crate::workbench::drawing::SHEETS_PANEL_ID,
2114 &crate::workbench::ButtonState::of(engine),
2115 );
2116 if self.sheets_pane_shown && !sheets_shown && engine.sheet_open().is_some() {
2117 let _ = engine.sheet_set_open(None);
2118 self.viewport.forget_sheet();
2119 }
2120 self.sheets_pane_shown = sheets_shown;
2121 }
2122
2123 // --- GUI chrome theme -------------------------------------------------
2124 // Apply the user's theme preference to the egui chrome every frame
2125 // (idempotent: `set_theme` just stores the preference). Auto follows the
2126 // OS/system theme (prefers-color-scheme on web); egui falls back to dark
2127 // when no OS signal is available. This controls panels/windows/toolbar/
2128 // text only — the 3D viewport `background` is a separate setting.
2129 ctx.set_theme(match self.docs.engine().settings.theme {
2130 ThemeMode::Auto => egui::ThemePreference::System,
2131 ThemeMode::Light => egui::ThemePreference::Light,
2132 ThemeMode::Dark => egui::ThemePreference::Dark,
2133 });
2134
2135 // --- global UI size scale --------------------------------------------
2136 // Apply the user's "UI scale" to the whole egui chrome every frame. This
2137 // is idempotent when unchanged (`set_zoom_factor` only repaints on an
2138 // actual change) and composes with the native device pixel ratio
2139 // (pixels_per_point = zoom_factor * native_pixels_per_point).
2140 //
2141 // Defer live UI rescale while the user drags the Settings "UI scale" slider:
2142 // the slider value updates continuously, but only commit it to the actual egui
2143 // zoom once the pointer is released, so the whole UI doesn't rescale under the
2144 // cursor mid-drag.
2145 let pointer_down = ctx.input(|i| i.pointer.any_down());
2146 if !pointer_down {
2147 self.applied_ui_scale = self.docs.engine().settings.ui_scale;
2148 }
2149 ctx.set_zoom_factor(self.applied_ui_scale);
2150 crate::viewport::touch::prepare_browser_input(ui);
2151
2152 // --- history-runner pump ---------------------------------------------
2153 // Apply any completed background history run BEFORE panels read the scene.
2154 // For the synchronous InlineRunner this is a no-op (`rerun_history` already
2155 // pumped its own submit), so nothing changes today; it is the seam a future
2156 // native-thread / wasm-worker runner lands its reply through. While a run is
2157 // still in flight, keep the frame loop alive so its reply gets pumped — for
2158 // Inline `run_pending()` is always false, so this never fires.
2159 //
2160 // EVERY open document is pumped, not just the active one — see
2161 // `Documents::pump_all`, which also keeps the active document's parts
2162 // library in the kernel's store when a background tab's run lands.
2163 let work_in_flight = {
2164 let _t = crate::perf::trace("pump_all");
2165 self.docs.pump_all()
2166 };
2167 if work_in_flight {
2168 ctx.request_repaint();
2169 }
2170 // The CAM simulation player, whether or not its pane is drawn.
2171 let dt = ctx.input(|i| i.stable_dt as f64).clamp(0.0, 0.25);
2172 if self.cam.sim.pump(self.docs.engine_mut(), dt) {
2173 ctx.request_repaint();
2174 }
2175 // An exploded view's Explode / Collapse animation.
2176 if self.docs.engine_mut().exploded_tick(dt) {
2177 ctx.request_repaint();
2178 }
2179 // A motion study: its bake (a few frames per tick) and its playhead,
2180 // driven only while the workbench offers the Simulation pane
2181 // (Simulation, All); the frame that leaves it puts every posed part
2182 // and the camera back, and a document edit made in another workbench
2183 // never wakes the study there (`SimulationPanel::drive_playback`).
2184 let simulation_shown = {
2185 let engine = self.docs.engine();
2186 crate::workbench::panel_visible(
2187 &engine.settings.workbench,
2188 crate::workbench::simulation::PANEL_ID,
2189 &crate::workbench::ButtonState::of(engine),
2190 )
2191 };
2192 if self.simulation.drive_playback(self.docs.engine_mut(), simulation_shown, dt) {
2193 ctx.request_repaint();
2194 }
2195 // The pane's Export button: the app owns the viewport's render core.
2196 if let Some(request) = self.simulation.take_export() {
2197 #[cfg(not(target_arch = "wasm32"))]
2198 let result = {
2199 let spec = crate::simulation_export::ExportSpec {
2200 study: Some(request.study.clone()),
2201 format: request.format.clone(),
2202 width: request.width,
2203 height: request.height,
2204 fps: Some(request.fps),
2205 path: Some(request.path.clone()),
2206 };
2207 crate::simulation_export::export(self, &spec).map(|(report, _)| report.summary())
2208 };
2209 #[cfg(target_arch = "wasm32")]
2210 let result = Err::<String, String>(format!("video export is native only (asked for {})", request.format));
2211 self.simulation.report(result);
2212 ctx.request_repaint();
2213 }
2214 // An instructions step's parts coming home along their arrows.
2215 if self.docs.engine_mut().instructions_insertion_tick(dt) {
2216 ctx.request_repaint();
2217 }
2218 self.docs.show_work_part_bar(ui, self.model_store.as_ref());
2219 if self.show_stl_preview(ui) {
2220 // The preview REPLACES the shell for the frame, and this early
2221 // return used to take the frame's READ drain with it: while a
2222 // preview was open every read-only automation command — `ping`,
2223 // `state_get`, `hit_rects`, a screenshot — went unanswered until
2224 // the host gave up on it, so no script could observe the preview it
2225 // had just opened. The preview publishes its own state and rects
2226 // above; this answers the reads that ask for them.
2227 #[cfg(feature = "automation")]
2228 self.drain_reads(&ctx);
2229 return;
2230 }
2231 if crate::automation::registry::enabled() {
2232 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
2233 for name in ["__brepImportPreview", "__brepImportRegions"] {
2234 crate::automation::registry::publish(name, crate::automation::state_blobs::doc(name), "null");
2235 }
2236 }
2237
2238 // The tab strip's dirty dots, refreshed once per frame (cheap — see
2239 // `Document::refresh_dirty_marker`).
2240 {
2241 let _t = crate::perf::trace("refresh_dirty_markers");
2242 self.docs.refresh_dirty_markers();
2243 }
2244
2245 // --- boot-load (?loadModel=): apply the fetched model once it lands ----
2246 // Replaces the seed with the URL-specified document (armed in `new`). The
2247 // ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
2248 // `load_model_and_fit` arms deferred framing; the pump above reframes it
2249 // next frame. `mark_clean` opens it as a non-dirty document.
2250 if self.pending_boot_load.is_some() {
2251 let received = self
2252 .pending_boot_load
2253 .as_ref()
2254 .and_then(|rx| rx.try_recv().ok());
2255 if let Some(result) = received {
2256 self.pending_boot_load = None;
2257 match result {
2258 Ok(json) => {
2259 // REPLACES the seed in place rather than adding a tab.
2260 // It lands on the document that is already active,
2261 // whatever the session restored.
2262 let _ = self.docs.engine_mut().load_model_and_fit(&json);
2263 self.docs.active_mut().mark_clean();
2264 }
2265 Err(e) => self
2266 .docs
2267 .engine_mut()
2268 .push_notice(format!("Could not load model from URL: {e}")),
2269 }
2270 }
2271 }
2272
2273 // --- "Open in CAD" (?open=part/…/rev/…) --------------------------------
2274 if let Some(launch) = self.plm_launch.as_mut() {
2275 use crate::panels::plm_launch::Step;
2276 let step = launch.step(self.model_store.as_ref(), &mut self.docs, &mut self.file);
2277 match &step {
2278 Step::SignIn(sentence) => {
2279 self.settings.show_plm_tab();
2280 self.docs.engine_mut().push_notice(sentence.clone());
2281 }
2282 Step::Refused(sentence) => self.docs.engine_mut().push_notice(sentence.clone()),
2283 _ => {}
2284 }
2285 if step.settles() {
2286 crate::panels::plm_launch::forget_query();
2287 }
2288 if launch.is_done() {
2289 self.plm_launch = None;
2290 } else {
2291 ui.ctx().request_repaint();
2292 }
2293 }
2294
2295 crate::panels::ecad_parts::sync(self.docs.active_mut());
2296 // A sheet's Duplicate past the last offered occurrence: another assembly component.
2297 crate::panels::ecad_parts::answer_duplicate_requests(self.docs.active_mut());
2298 let requests = crate::panels::ecad_parts::take_open_requests(self.docs.active_mut());
2299 for key in requests {
2300 self.file.open_document(&mut self.docs, self.model_store.as_ref(), &key);
2301 }
2302 // The parts pane's Add part button presses Assembly's Add Component: one arm.
2303 if crate::panels::ecad_parts::take_add_part_request(self.docs.active_mut()) {
2304 self.dispatch_workbench_button(crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID);
2305 }
2306 // The eCAD host's Update parts button is Constraints' Update components.
2307 if std::mem::take(&mut self.docs.active_mut().ecad_update_requested) {
2308 if let Err(error) = self.update_components.run(self.docs.engine_mut(), self.model_store.as_ref()) {
2309 self.docs.engine_mut().push_notice(format!("Update components: {error}"));
2310 }
2311 }
2312
2313 // --- update-components badge freshness ---------------------------------
2314 // Keep the outdated-parts checker current BEFORE any assembly panel draws
2315 // (the structure tree renders per-node badges ahead of the constraints
2316 // header). Cheap: a real recompute happens only when an applied run or a
2317 // successful store save moved the generation key.
2318 let synced = self.docs.synced_signatures();
2319 self.update_components.ensure_current(
2320 self.docs.engine_mut(),
2321 self.model_store.as_ref(),
2322 self.file.save_generation(),
2323 &synced,
2324 );
2325 // …and the eCAD host marks the components placed from those parts.
2326 self.docs.active_mut().ecad_outdated = self.update_components.outdated().to_vec();
2327 // An Update that would discard an edit made here asks first.
2328 self.update_components.show_conflict(ui.ctx(), self.docs.engine_mut(), self.model_store.as_ref());
2329
2330 // --- async-safe first-model framing -----------------------------------
2331 // The seed run is async under a background runner (native thread / wasm
2332 // worker), so the boot `zoom_to_fit` may have run before any solid existed.
2333 // Frame the model ONCE, the first frame the seed run has fully landed (solids
2334 // present AND no run still in flight). Under the synchronous Inline runner
2335 // (tests) both hold on the very first frame, so this is identical to today.
2336 if !self.first_run_framed
2337 && !self.docs.engine().run_pending()
2338 && self.docs.engine().has_solids()
2339 {
2340 self.docs.engine_mut().zoom_to_fit();
2341 self.first_run_framed = true;
2342 }
2343
2344 self.handle_shortcuts(&ctx);
2345
2346 // A form field's reference pick on a touch or narrow window is a
2347 // FOCUSED pick: every piece of chrome — toolbar and ribbon, workbench
2348 // strip, status bar, docks and the dialog in them, the action overlay
2349 // — steps aside and the view fills the window, with the PICK BAR
2350 // (`panels::pick_bar`: what is picked, the picks, the filter button,
2351 // Finish, Cancel) its only control. Finish or Cancel brings all of it
2352 // back as it was. Decided once, here, before any of it is drawn.
2353 let pick_layout = {
2354 let engine = self.docs.engine();
2355 crate::panels::pick_bar::PickLayout::of(
2356 engine.ref_select_active(),
2357 engine.ref_select_in_form(),
2358 crate::viewport::touch::narrow(&ctx),
2359 )
2360 };
2361 let focused_pick = pick_layout == crate::panels::pick_bar::PickLayout::Focused;
2362 crate::viewport::touch::set_chrome_hidden(&ctx, focused_pick);
2363
2364 // --- top toolbar: primary actions, drawn FIRST so its top strip is
2365 // reserved above the left panel + central viewport. A clicked File button
2366 // returns an action the file dialog acts on (open its modal / save / new).
2367 self.toolbar.read_only = !self.docs.active().access().is_editable();
2368 if crate::viewport::touch::compact(&ctx) {
2369 self.toolbar.documents = document_tabs::MenuState::from_documents(&self.docs);
2370 }
2371 let toolbar_outcome = if focused_pick {
2372 self.toolbar.clear_hits();
2373 crate::panels::toolbar::ToolbarOutcome::default()
2374 } else {
2375 let doc = self.docs.view_mut();
2376 self.toolbar.show(
2377 ui,
2378 &mut doc.engine,
2379 Some(&doc.ecad),
2380 self.model_store.as_ref(),
2381 &mut self.settings.open,
2382 &mut self.part_properties.open,
2383 &mut self.info.open,
2384 )
2385 };
2386 crate::panels::set_chrome_bottom(&ctx, ui.available_rect_before_wrap().top());
2387 if toolbar_outcome.toggle_mobile_panels { self.dock.toggle_mobile_drawer(); }
2388 if crate::viewport::touch::compact(&ctx) {
2389 self.apply_document_actions(toolbar_outcome.documents.activate, toolbar_outcome.documents.close);
2390 self.document_tab_hits = toolbar_outcome.documents.hits;
2391 }
2392 if toolbar_outcome.plugins { self.plugins.open = !self.plugins.open; }
2393 if toolbar_outcome.javascript { self.javascript.open = !self.javascript.open; }
2394 if let Some(id) = toolbar_outcome.plugin_action {
2395 if let Err(error) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(error); }
2396 }
2397 self.plugins.active_document = self.docs.active_id();
2398 self.plugins.show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2399 // Run goes to the app's own script runner (its server's code mode),
2400 // started on the first run; installed once the window is opened.
2401 #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2402 if self.javascript.open && self.javascript.host.is_none() {
2403 self.javascript.host = Some(Box::new(crate::mcp::EditorHost::new(self.automation.clone(), self.diagnostics.adapter_line())));
2404 }
2405 // In the browser: the page's Web Worker runner (src/webmcp.rs).
2406 #[cfg(all(target_arch = "wasm32", feature = "mcp", feature = "scripting"))]
2407 if self.javascript.open && self.javascript.host.is_none() {
2408 self.javascript.host = Some(Box::new(crate::webmcp::PageScripts));
2409 }
2410 self.javascript.show(&ctx, &mut self.docs, self.model_store.as_ref());
2411 // The panels' exports (a plugin bundle, the script) leave through the
2412 // file dialog's export door: the Save-file chooser on desktop, a
2413 // download in the browser.
2414 let exports = self.plugins.take_save_requests().into_iter().chain(self.javascript.take_save_requests());
2415 for request in exports {
2416 if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2417 self.docs.engine_mut().push_notice(error);
2418 }
2419 }
2420 // An inbox row opens its review in the PLM pane.
2421 for event in toolbar_outcome.plm_review {
2422 self.plm.open_review(event);
2423 }
2424 if let Some(name) = toolbar_outcome.recent_document {
2425 self.file
2426 .open_document(&mut self.docs, self.model_store.as_ref(), &name);
2427 }
2428 if let Some(action) = toolbar_outcome.file {
2429 self.file
2430 .dispatch(action, &mut self.docs, self.model_store.as_ref());
2431 }
2432 // Submit Bug: begin the screenshot-capture + report flow. `request`
2433 // grabs the current frame (before its dialog exists) and the model, so
2434 // it must run THIS frame while the shot is still dialog-free.
2435 if toolbar_outcome.bug_report {
2436 self.bug_report.request(&ctx, self.docs.engine(), &self.diagnostics);
2437 }
2438 // A workbench toolbar button click surfaces its id here; the dispatch
2439 // itself is a METHOD so the `workbench_button` command runs the very
2440 // same arms (a button an agent can only reach by clicking is a button
2441 // an agent cannot reach when something covers it).
2442 if let Some(id) = toolbar_outcome.workbench_button {
2443 self.dispatch_workbench_button(id);
2444 }
2445 // Zoom-to-fit: the ONE button frames whatever the tile draws — the open
2446 // sheet's paper, or the 3D scene. See `BrepApp::zoom_to_fit`.
2447 if toolbar_outcome.zoom_to_fit {
2448 self.zoom_to_fit();
2449 }
2450
2451 // --- workbench actions toolbar: a second strip directly UNDER the
2452 // primary toolbar (egui stacks top panels in call order) listing the
2453 // active workbench's creatable features + the constraint types. Off via
2454 // the Settings checkbox, and never in sketch / reference-selection mode
2455 // (the panel decides — see `WorkbenchToolbarPanel::visible`). A feature
2456 // click adds exactly what the palette pick would, so ACOMP still routes
2457 // to the component selector; a constraint click is the context bar's
2458 // constraint offer, seeded from the current selection.
2459 // A read-only document (a PLM revision not checked out, or released)
2460 // draws the strip disabled: every button on it creates something.
2461 self.workbench_toolbar.read_only = !self.docs.active().access().is_editable();
2462 let actions = if self.docs.engine().settings.toolbar_style
2463 == brep_render::style::ToolbarStyle::Classic
2464 {
2465 self.workbench_toolbar.show(ui, self.docs.engine())
2466 } else {
2467 self.workbench_toolbar
2468 .set_ribbon_hits(self.toolbar.home_hits());
2469 toolbar_outcome.creation
2470 };
2471 if let Some(type_code) = actions.feature {
2472 self.history
2473 .add_feature_of_type(self.docs.engine_mut(), &type_code);
2474 // No `show_pane` here: `HistoryPanel::open_form` moved the target and
2475 // the dialog door surfaces History for it (`surface_open_dialogs`
2476 // below). ACOMP opens the component-selector MODAL instead of a
2477 // feature dialog, so it moves no target and surfaces no pane.
2478 if self.history.take_insert_component_request() {
2479 self.file.dispatch(
2480 FileAction::InsertComponent,
2481 &mut self.docs,
2482 self.model_store.as_ref(),
2483 );
2484 }
2485 }
2486 if let Some(type_id) = actions.constraint {
2487 // The strip offers every type regardless of the selection (unlike the
2488 // context bar's gated offers), so a refusal has to be SAID: a toast,
2489 // never a silent no-op. A document with no components has no assembly
2490 // session to add to (the kernel's own message names the session, not
2491 // the cause) — say what is actually missing.
2492 let engine = self.docs.engine_mut();
2493 if !engine.history_has_assembly() {
2494 engine.push_notice(
2495 "Add constraint: the document has no components — insert a component first"
2496 .to_string(),
2497 );
2498 } else {
2499 // The add leaves the new constraint OPEN (the kernel mints it
2500 // that way), so the dialog door surfaces the Constraints pane.
2501 if let Err(error) =
2502 crate::panels::context_bar::add_constraint_from_selection(engine, &type_id)
2503 {
2504 engine.push_notice(format!("Add constraint: {error}"));
2505 }
2506 }
2507 }
2508 if let Some(type_id) = actions.annotation {
2509 // The context bar's annotation offer, from the strip: seeded from
2510 // the selection, and the new annotation's form opens through the
2511 // dialog door. The strip offers every type whatever is selected, so
2512 // a refusal — no active view to add to — is said, not swallowed.
2513 let engine = self.docs.engine_mut();
2514 if let Err(error) = crate::panels::context_bar::add_pmi_from_selection(engine, &type_id) {
2515 engine.push_notice(format!("Add annotation: {error}"));
2516 }
2517 }
2518
2519 // --- sketch mode has NO strip of its own: its draw tools are workbench
2520 // buttons in the row above (`workbench::sketch`), offered while a sketch
2521 // is being edited, so the 3D viewport is the full-width sketching
2522 // surface with nothing between it and the one toolbar.
2523
2524 // --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
2525 // chosen by context each frame. Drawn AFTER the top bars but BEFORE the
2526 // left panel(s) so it reserves the FULL bottom width and the left column
2527 // stops above it (egui resolves reserved space by call order). It is a
2528 // HOST: the branch below picks what to draw. A NEW context is added by
2529 // extending this branch (e.g. `else if engine.some_mode() { … }`) and
2530 // routing through the owning panel's `show_status_bar` for DRY styling.
2531 // Its top is the floor the toasts stack up from (`Toasts::show`).
2532 //
2533 // Its RIGHT END is the app-wide working indicator (`panels::busy`),
2534 // whatever the context: collected here from every source, drawn first
2535 // in a right-to-left row so the context content gets what is left of
2536 // the width and never runs under it.
2537 self.collect_busy(&ctx);
2538 let status_bar_top = if focused_pick {
2539 // The pick bar carries the filter button; nothing of the bar
2540 // stays published.
2541 self.busy.clear_hits();
2542 self.selection.clear_hits();
2543 ui.max_rect().bottom()
2544 } else { egui::containers::panel::Panel::bottom("brep-status-bar")
2545 .resizable(false)
2546 .min_size(30.0)
2547 .show(ui, |ui| {
2548 ui.add_space(2.0);
2549 // `Align::Min`, not `Center`: the bar's height is not known
2550 // until its content is laid out, and centring against the
2551 // unbounded space below doubled the bar with the indicator on
2552 // a row of its own.
2553 ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
2554 self.busy.show_status_bar(ui);
2555 ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
2556 if self.docs.engine().sketch_mode() {
2557 // Sketch context: the status row (title / DOF / N selected /
2558 // undo-redo / Lock). The selection-filter row is NOT drawn
2559 // now, so drop its stale hit-rects (the verifier must never
2560 // click a phantom rect for an off-screen widget).
2561 self.selection.clear_hits();
2562 self.sketch.show_status_bar(ui, self.docs.engine_mut());
2563 } else if self.docs.engine().sketch3d_mode() {
2564 // The 3D sketch's status row: DOF, selection, the
2565 // working plane in force and what the last click hit.
2566 self.selection.clear_hits();
2567 self.sketch3d.show_status_bar(ui, self.docs.engine_mut());
2568 } else if crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench).is_some() {
2569 // An eCAD editor owns the central tile, and a click there
2570 // picks nothing 3D: the pickable-kinds filter would be a
2571 // row of dead checkboxes. The strip carries the editor's
2572 // own line instead: where the pointer is and what is under it.
2573 self.selection.clear_hits();
2574 self.viewport.show_ecad_status(ui);
2575 } else {
2576 // Modeling context: the selection filter (pickable kinds).
2577 let document = self.docs.active_id();
2578 self.selection.show_status_bar(ui, self.docs.engine_mut(), document);
2579 }
2580 });
2581 });
2582 ui.add_space(2.0);
2583 })
2584 .response
2585 .rect
2586 .top() };
2587 // Cancel on the working indicator: sent to the document whose run it
2588 // named, which a background tab's run need not be the active one.
2589 if let Some(activity) = self.busy.take_cancel() {
2590 if let Some(doc) = self.docs.iter_mut().find(|doc| Some(doc.id()) == activity.document) {
2591 doc.engine.cancel_run();
2592 }
2593 }
2594
2595 // --- central region: the dock tree, OR (special modes) the bare 3D view
2596 // ---------------------------------------------------------------------
2597 // Normal modeling mode: ONE egui_tiles tree fills the whole remaining
2598 // area between the top toolbar and the bottom status bar. Every side-panel
2599 // section AND the 3D viewport are tiles the user can split / tab / resize /
2600 // drag-rearrange, and the layout persists. Which side panes are visible is
2601 // filtered per-workbench inside the dock (`workbench::panel_visible`).
2602 //
2603 // Sketch mode and reference-selection are "special modes" that take over
2604 // the shell: they BYPASS the tree and draw the viewport directly, so the
2605 // modeling side panes don't appear (sketch's own entity-list panel + the
2606 // top-right mode card own those flows). Drawing the viewport HERE — before
2607 // the top-right overlay below — keeps `viewport.last_rect()` current-frame
2608 // so the overlay anchors to the live 3D-view rect with no lag.
2609 let sketch = self.docs.engine().any_sketch_mode();
2610 // Only a CARD-based pick (a connection point's seat, a spline anchor's
2611 // port) takes the shell over. A FORM FIELD's pick — a feature's, a
2612 // constraint's, an annotation's, a sheet object's — keeps the dock: the
2613 // field is drawn focused in its own pane and the picks go to it, the
2614 // way other CAD applications keep the dialog up while you click in the
2615 // view (`EngineState::ref_select_in_form`).
2616 // Except on a touch or narrow window, where the dock and the dialog
2617 // in it would cover the view and leave nothing to pick: there a form
2618 // field's pick takes the shell over too (`focused_pick`, decided
2619 // before the toolbar is drawn).
2620
2621 let compact = crate::viewport::touch::compact(&ctx);
2622 let entity_drawer_id = egui::Id::new("brep-mobile-sketch-entities");
2623 let mut entity_drawer_open = sketch && ctx.data(|d| d.get_temp::<bool>(entity_drawer_id)).unwrap_or(false);
2624 let mut mobile_sketch_hits: std::collections::HashMap<String, egui::Rect> = std::collections::HashMap::new();
2625 if sketch && compact {
2626 egui::containers::panel::Panel::top("brep-mobile-sketch-controls").resizable(false).show(ui, |ui| {
2627 ui.horizontal(|ui| {
2628 self.mode_bar.card(ui, self.docs.engine_mut());
2629 });
2630 });
2631 self.sketch.clear_list_hits();
2632 self.sketch3d.clear_list_hits();
2633 } else if sketch {
2634 // Sketch entity lists (Curves / Points / Constraints) + solver
2635 // settings — a dedicated left panel, drawn BEFORE the viewport so it
2636 // reserves the left and the viewport fills the rest.
2637 egui::containers::panel::Panel::left("sketch-entities")
2638 .resizable(true)
2639 .default_size(300.0)
2640 .size_range(200.0..=560.0)
2641 .show(ui, |ui| {
2642 if self.docs.engine().sketch3d_mode() {
2643 self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
2644 } else {
2645 self.sketch.show_entity_lists(ui, self.docs.engine_mut());
2646 }
2647 });
2648 }
2649
2650 self.history.sync_palette_display(self.model_store.as_ref());
2651 // The PLM panels (a PLM session only): each PLM document's access from
2652 // its revision, and the revisions the user asked to open.
2653 let plm = self.plm.sync(self.model_store.as_ref(), &mut self.docs, self.file.save_generation());
2654 for name in plm.open {
2655 self.file.open_document(&mut self.docs, self.model_store.as_ref(), &name);
2656 }
2657 for (name, folder) in plm.opened_from {
2658 if let Some(doc) = self.docs.index_of(&name).and_then(|index| self.docs.get_mut(index)) {
2659 doc.set_plm_folder(Some(folder));
2660 }
2661 }
2662 // The CAM pane's chooser and save requests (a tool import, a library
2663 // export) go through the shell's doors like the PLM pane's; the file
2664 // the chooser delivers goes back to the pane.
2665 let cam_requests = self.cam.take_shell_requests();
2666 if let Some((tag, title)) = cam_requests.pick_file {
2667 if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
2668 self.file.status = error;
2669 }
2670 }
2671 for request in cam_requests.save_files {
2672 if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2673 self.docs.engine_mut().push_notice(error);
2674 }
2675 }
2676 if let Some(file) = self.file.take_picked_file(crate::panels::cam::IMPORT_TOOLS_TAG) {
2677 self.cam.deliver_picked(self.docs.engine_mut(), &file.name, &file.bytes);
2678 }
2679 // A PLM section asked for a file (Attach a file…, Add file…): the file
2680 // chooser answers, and the file goes back to the section that asked.
2681 if let Some((tag, title)) = plm.pick_file {
2682 if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
2683 self.file.status = error;
2684 }
2685 }
2686 for tag in self.plm.pick_tags() {
2687 if let Some(file) = self.file.take_picked_file(&tag) {
2688 self.plm.deliver_picked(&tag, file);
2689 }
2690 }
2691 if plm.refresh_inbox {
2692 self.toolbar.refresh_inbox();
2693 }
2694 if let Some(why) = plm.sign_in_again {
2695 self.settings.ask_plm_sign_in(why);
2696 }
2697 // A PLM section's downloads leave through the same export door.
2698 for request in plm.save_files {
2699 if let Err(error) = self.file.request_save_file(&mut self.docs, self.model_store.as_ref(), request) {
2700 self.docs.engine_mut().push_notice(error);
2701 }
2702 }
2703 // Save As → "A new part" (D9): the PLM pane's New part form, shown.
2704 if self.file.take_plm_new_part() {
2705 self.plm.open_new_part();
2706 self.show_pane(PaneKind::Plm);
2707 }
2708 if self.plm.busy() {
2709 ui.ctx().request_repaint();
2710 }
2711 // Following other clients (`panels::plm_follow`): when the feed moved,
2712 // the inbox asks again too; and the next question gets a frame.
2713 if self.plm.take_followed() {
2714 self.toolbar.refresh_inbox();
2715 }
2716 if let Some(next) = self.plm.next_follow_in() {
2717 ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(next.max(0.1)));
2718 }
2719 if sketch || !pick_layout.shows_dock() {
2720 self.viewport.set_chrome_obscured(compact && entity_drawer_open);
2721 // Special mode: the viewport fills the remaining central area; no
2722 // dock, no modeling side panes — and therefore no DOCUMENT TAB
2723 // STRIP either, which is the guard that keeps a live sketch /
2724 // reference-pick session from having its document swapped out from
2725 // under it.
2726 self.viewport.show(ui, self.docs.engine_mut());
2727 // No dock, so no tab strip: its last-drawn rects must not stay
2728 // published (the compact strip is the toolbar's, set above).
2729 if !compact { self.document_tab_hits.clear(); }
2730 if focused_pick {
2731 let view = self.viewport.last_rect().unwrap_or(ui.max_rect());
2732 self.pick_bar.show(&ctx, view, self.docs.engine_mut(), &mut self.selection);
2733 // Settled as the dock's branch settles it: the bar claimed the
2734 // pick, so this finishes a single field on its pick.
2735 self.settle_form_pick();
2736 } else {
2737 self.pick_bar.clear_hits();
2738 }
2739 if compact && sketch {
2740 let view = self.viewport.last_rect().unwrap_or(ui.max_rect());
2741 egui::Area::new(egui::Id::new("brep-mobile-sketch-tab"))
2742 .order(egui::Order::Middle).fixed_pos(view.left_top() + egui::vec2(2.0, 4.0)).movable(false)
2743 .show(&ctx, |ui| {
2744 let tab = crate::panels::dock::mobile_tab(ui, "Entities", entity_drawer_open);
2745 mobile_sketch_hits.insert("toggle".into(), tab.rect);
2746 if entity_drawer_open { mobile_sketch_hits.insert("close".into(), tab.rect); }
2747 if tab.clicked() { entity_drawer_open = !entity_drawer_open; ctx.request_repaint(); }
2748 });
2749 if entity_drawer_open {
2750 let panel = egui::Rect::from_min_size(view.left_top() + egui::vec2(52.0, 4.0),
2751 egui::vec2((view.width() - 60.0).min(400.0).max(100.0), (view.height() - 8.0).max(100.0)));
2752 crate::panels::dock::mobile_panel(&ctx, entity_drawer_id, panel, |ui| {
2753 if self.docs.engine().sketch3d_mode() {
2754 self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
2755 } else {
2756 self.sketch.show_entity_lists(ui, self.docs.engine_mut());
2757 }
2758 });
2759 }
2760 }
2761 DockState::retract_all(DockContext {
2762 docs: &mut self.docs,
2763 plugins: &mut self.plugins,
2764 viewport: &mut self.viewport,
2765 history: &mut self.history,
2766 bom: &mut self.bom,
2767 assembly_constraints: &mut self.assembly_constraints,
2768 wire_harness: &mut self.wire_harness,
2769 pmi: &mut self.pmi,
2770 sheets: &mut self.sheets,
2771 scene: &mut self.scene,
2772 expressions: &mut self.expressions,
2773 qualify: &mut self.qualify,
2774 family_table: &mut self.family_table,
2775 update_components: &mut self.update_components,
2776 model_store: self.model_store.as_ref(),
2777 plm: &mut self.plm,
2778 cam: &mut self.cam,
2779 instructions: &mut self.instructions,
2780 instructions_player: &mut self.instructions_player,
2781 simulation: &mut self.simulation,
2782 });
2783 } else {
2784 self.pick_bar.clear_hits();
2785 // Normal mode: the dock owns the whole central area (the viewport is a
2786 // pane). Cross-panel requests the panels can't act on while their
2787 // borrows are held bubble OUT via the returned outcome — the SAME
2788 // requests the old left-panel closure produced.
2789 let outcome = self.dock.ui(
2790 ui,
2791 DockContext {
2792 docs: &mut self.docs,
2793 plugins: &mut self.plugins,
2794 viewport: &mut self.viewport,
2795 history: &mut self.history,
2796 bom: &mut self.bom,
2797 assembly_constraints: &mut self.assembly_constraints,
2798 wire_harness: &mut self.wire_harness,
2799 pmi: &mut self.pmi,
2800 sheets: &mut self.sheets,
2801 scene: &mut self.scene,
2802 expressions: &mut self.expressions,
2803 qualify: &mut self.qualify,
2804 family_table: &mut self.family_table,
2805 update_components: &mut self.update_components,
2806 model_store: self.model_store.as_ref(),
2807 plm: &mut self.plm,
2808 cam: &mut self.cam,
2809 instructions: &mut self.instructions,
2810 instructions_player: &mut self.instructions_player,
2811 simulation: &mut self.simulation,
2812 },
2813 );
2814
2815 // A form field's pick, settled for the frame: finished when its
2816 // single field has its one pick, when its form asked (an edit
2817 // elsewhere on it), or when no form drew it.
2818 self.settle_form_pick();
2819
2820 // The ACOMP palette pick must open the COMPONENT SELECTOR, never a
2821 // bare feature dialog — the file dialog is shell-owned.
2822 if outcome.insert_component_requested {
2823 self.file.dispatch(
2824 FileAction::InsertComponent,
2825 &mut self.docs,
2826 self.model_store.as_ref(),
2827 );
2828 }
2829 // The DOCUMENT TAB STRIP inside the viewport tile. Activation is
2830 // immediate; a close routes through the file dialog because a dirty
2831 // document has to be confirmed first, and that prompt lives there.
2832 // A pick runs in ONE document's engine: switching or closing a
2833 // document commits a form field's pick first, so it is never left
2834 // running in a document that is no longer on screen.
2835 self.apply_document_actions(outcome.document_tabs.activate, outcome.document_tabs.close);
2836 if !compact { self.document_tab_hits = outcome.document_tabs.hits; }
2837 // A structure-tree Edit — or a BOM row's action button, which
2838 // reports through the same outcome field so there is one arm and
2839 // not two — expands its feature in the history tree.
2840 if let Some(focus) = outcome.feature_focus {
2841 self.history.focus_feature(focus);
2842 if compact { self.dock.show_pane(PaneKind::History); }
2843 }
2844 // Structure-tree interaction hooks route through the SAME dispatcher
2845 // as the context bar (one truth per action); document-level flows
2846 // (edit-in-place / open-part) come back as requests the shell runs.
2847 // A BOM row menu's document-level flow: its engine-mutating half
2848 // already ran inside the panel, through the same dispatcher.
2849 // Double-clicking empty space leaves the Work part.
2850 if self.viewport.take_empty_double_click() && self.docs.work_part_path().is_some() {
2851 if let Err(error) = self.docs.return_to_assembly() {
2852 self.docs.engine_mut().push_notice(format!("Work part: {error}"));
2853 }
2854 }
2855 if let Some(request) = outcome.component_request.or_else(|| self.viewport.take_component_request()) {
2856 self.run_component_request(request, compact);
2857 }
2858 }
2859
2860 self.history.sync_palette_display(self.model_store.as_ref());
2861
2862 // --- file dialog: a ctx-level modal (like the command palette), drawn
2863 // after the panels so its backdrop dims the whole shell. Idempotent when
2864 // closed; also polls for a completed async import each frame.
2865 self.file
2866 .show(&ctx, &mut self.docs, self.model_store.as_ref());
2867
2868 // --- crash recovery: the boot prompt, then the debounced autosave -----
2869 // Drawn with the same ctx-level modal treatment as the file dialog. The
2870 // autosave ticks only once the prompt has resolved (or never existed).
2871 if self.recovery.is_open() {
2872 if let Some(resolution) =
2873 self.recovery.show(&ctx, &mut self.docs, self.model_store.as_ref())
2874 {
2875 self.autosave.note_cleared();
2876 if let crate::recovery::Resolution::Restored(count) = resolution {
2877 self.docs.engine_mut().push_notice_as(NoticeSeverity::Info, format!(
2878 "Restored {count} unsaved document{}",
2879 if count == 1 { "" } else { "s" }
2880 ));
2881 }
2882 }
2883 } else {
2884 self.recovery.clear_hits();
2885 let now = ctx.input(|i| i.time);
2886 if let Some(due) = self.autosave.tick(&self.docs, self.model_store.as_ref(), now) {
2887 // The frame loop idles between inputs; wake it when the write is due.
2888 ctx.request_repaint_after(std::time::Duration::from_secs_f64(due.max(0.05)));
2889 }
2890 }
2891
2892 // --- desktop update notice: an installed update closes the app through
2893 // the close path below, which keeps unsaved work for recovery.
2894 #[cfg(not(target_arch = "wasm32"))]
2895 if self.updater.show(&ctx) {
2896 self.close_asked = true;
2897 }
2898 self.close_when_written(&ctx);
2899
2900 if let Some((format, name, bytes)) = self.file.take_stl_import() {
2901 self.start_mesh_preview(format, name, bytes);
2902 ctx.request_repaint();
2903 }
2904
2905 // --- Submit Bug: the screenshot-capture state machine + report modal.
2906 // Drawn at ctx level like the file dialog; idempotent while idle. Draws
2907 // NOTHING during capture, so the screenshot it requested never contains
2908 // this dialog.
2909 if let Some(report) = crate::form_view::take_problem_report_request(&ctx) {
2910 self.bug_report.request_for_error(&ctx, self.docs.engine(), &self.diagnostics, &report.dialog, &report.error);
2911 }
2912 self.bug_report.show(&ctx, self.docs.engine_mut());
2913 if let Some((id, summary)) = self.bug_report.take_submitted() {
2914 self.report_followup.record(id, summary);
2915 }
2916 self.report_followup.tick(&ctx, self.docs.engine_mut());
2917 self.sketch.show_text_dialog(&ctx, self.docs.engine_mut());
2918
2919 // --- Info: the licences + this session's diagnostics + the MCP start
2920 // button, a floating window toggled from the toolbar's info button.
2921 // Idempotent when closed. The server's state is read here, each frame
2922 // the window is open, and a click is answered here: the shell owns the
2923 // queue the server attaches to and the adapter line its session names.
2924 #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2925 let mcp_view = if self.info.open { crate::mcp::info_view() } else { crate::panels::info::McpView::Unavailable };
2926 #[cfg(not(all(not(target_arch = "wasm32"), feature = "mcp")))]
2927 let mcp_view = crate::panels::info::McpView::Unavailable;
2928 self.info.show(&ctx, &self.diagnostics, &mcp_view);
2929 #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2930 if self.info.take_mcp_start() {
2931 // The failure is already in `mcp::status()`, which the window
2932 // shows next frame, and on stderr in the same words.
2933 let _ = crate::mcp::start_from_window(self.automation.clone(), self.diagnostics.adapter_line());
2934 }
2935
2936 // --- Settings: a floating (movable + resizable) window, toggled from the
2937 // toolbar gear button, drawn at ctx level like Properties so it floats
2938 // over the shell. Idempotent when closed. Replaces the old sidebar section.
2939 // This preview runs every frame, including when Settings is closed.
2940 // Exact dirty checks serialize the whole assembly; use the tab's cached
2941 // marker here. finish_reconnect still checks the exact saved bytes before
2942 // switching stores, including metadata edits the marker may not see.
2943 self.settings.reconnect_blocker =
2944 self.reconnect_blocker_with(crate::document::Document::dirty_marker);
2945 self.settings
2946 .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2947 // Signed out here (Forget): the server's refusal that follows is the
2948 // person's own doing, not news to prompt about.
2949 if self.settings.take_plm_signed_out_here() {
2950 self.plm.signed_out_here();
2951 }
2952 // Signed in again after the server refused this session's token: the
2953 // live session takes the new token, for the same user, and goes on with
2954 // its open tabs (no store switch, nothing to close).
2955 if self.settings.plm_signed_out() {
2956 if let (Some((username, token)), Some(client)) = (self.settings.take_fresh_plm_token(), self.model_store.plm_client()) {
2957 if client.me().is_none_or(|me| me.username == username) {
2958 client.replace_token(&token);
2959 self.plm.follow_now();
2960 } else {
2961 self.docs.engine_mut().push_notice(format!(
2962 "Signed in as {username}, not {}: restart the app (or Connect now) to use the PLM as {username}",
2963 client.me().map(|me| me.username).unwrap_or_default()
2964 ));
2965 }
2966 }
2967 }
2968 // The PLM tab's Connect now: switch this session to the PLM live.
2969 #[cfg(not(target_arch = "wasm32"))]
2970 if let Some(root) = self.settings.take_reconnect_request() {
2971 if let Err(why) = self.start_reconnect(root) {
2972 self.settings.reconnected(Err(why));
2973 }
2974 }
2975 #[cfg(not(target_arch = "wasm32"))]
2976 {
2977 self.poll_reconnect();
2978 self.settings.reconnecting = self.reconnecting.is_some();
2979 if self.reconnecting.is_some() {
2980 // The phase limit is checked here: wake for it on a quiet frame.
2981 ctx.request_repaint_after(std::time::Duration::from_millis(250));
2982 }
2983 }
2984
2985 // --- Part Properties: the active document's own BOM attribute record,
2986 // in a floating window beside Settings. The title is passed in because
2987 // the panel takes only the engine, and a user with several tabs open
2988 // must be able to see WHICH part they are annotating.
2989 let part_title = self.docs.active().title();
2990 let part_document = self.docs.active().id();
2991 self.part_properties.configure_plm(self.model_store.plm_client(), self.docs.active().name());
2992 self.part_properties
2993 .show(&ctx, self.docs.engine_mut(), &part_title, part_document);
2994 if std::mem::take(&mut self.assembly_constraints.review_replace_requested) {
2995 self.review_replace();
2996 }
2997 self.replace_dialog
2998 .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2999
3000 ctx.data_mut(|d| d.insert_temp(entity_drawer_id, entity_drawer_open));
3001
3002 // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
3003 // for reference-selection / sketch mode) stacked ABOVE the selection-driven
3004 // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
3005 // top-right so they never overlap, and the context rail uses the SAME
3006 // renderer whether it is showing modeling actions or sketch actions
3007 // (`panels::action_rail`). A modeling create/edit action returns a feature
3008 // id to expand in the history tree.
3009 #[cfg(all(feature = "automation", target_arch = "wasm32"))]
3010 crate::automation::registry::publish("__brepMobileActions", "compact action menu button rectangle", "{}");
3011 if focused_pick || (compact && (entity_drawer_open || (!sketch && self.dock.mobile_drawer_open()))) {
3012 crate::panels::action_rail::close_mobile_actions(&ctx);
3013 self.mode_bar.clear_hits();
3014 self.sketch.clear_context_hits();
3015 self.sketch3d.clear_context_hits();
3016 self.context_bar.clear_view();
3017 } else {
3018 let mut focus: Option<String> = None;
3019 let mut info_targets: Vec<String> = Vec::new();
3020 let mut plugin_action = None;
3021 let mut component_request: Option<ComponentActionRequest> = None;
3022 // Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
3023 // tile), not the window — so it stays glued to the viewport wherever
3024 // docking frames it. The viewport was drawn earlier THIS frame, so its
3025 // rect is current. Before the first draw (`None`) fall back to the
3026 // window's top-right.
3027 let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
3028 .order(egui::Order::Foreground);
3029 overlay = match self.viewport.last_rect() {
3030 Some(rect) => overlay
3031 .fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
3032 .pivot(egui::Align2::RIGHT_TOP),
3033 None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
3034 };
3035 overlay
3036 .show(&ctx, |ui| {
3037 // 0. The family-row preview's card: which row, and Exit.
3038 self.family_table.preview_card(ui, self.docs.engine_mut());
3039 // 1. Exit controls for whatever special mode is active.
3040 if !(compact && sketch) { self.mode_bar.card(ui, self.docs.engine_mut()); }
3041 // 2. Context actions: sketch actions in sketch mode, else the
3042 // modeling selection actions. Same rail, mode-appropriate items.
3043 let has_actions = if self.docs.engine().sketch_mode() { self.docs.engine().sketch_selection_count() > 0 }
3044 else if self.docs.engine().sketch3d_mode() { self.docs.engine().sketch3d_selection_count() > 0 }
3045 else { self.docs.engine().has_selection() || self.docs.engine().selected_constraint().is_some() };
3046 let mut draw_actions = |ui: &mut egui::Ui| {
3047 if self.docs.engine().sketch_mode() {
3048 self.sketch.context_card(ui, self.docs.engine_mut());
3049 } else if self.docs.engine().sketch3d_mode() {
3050 self.sketch3d.context_card(ui, self.docs.engine_mut());
3051 } else {
3052 // Room down to the ViewCube's top, from wherever the
3053 // cards above left the cursor.
3054 self.context_bar.max_height = if compact { None } else { self.viewport.last_rect().and_then(|view| {
3055 let cube: serde_json::Value =
3056 serde_json::from_str(&self.docs.engine().viewcube_rect_json()).ok()?;
3057 let (top, h) = (cube["y"].as_f64()? as f32, cube["h"].as_f64()? as f32);
3058 (h > 0.0).then(|| view.top() + top - 8.0 - ui.cursor().top())
3059 }) };
3060 let outcome = self.context_bar.card(ui, self.docs.engine_mut());
3061 focus = outcome.focus;
3062 info_targets = outcome.info_targets;
3063 plugin_action = outcome.plugin_action;
3064 component_request = outcome.component;
3065 }
3066 };
3067 if compact {
3068 if has_actions {
3069 ui.set_min_width(110.0);
3070 let button = ui.add_sized([110.0, 44.0], egui::Button::new("Actions"));
3071 if button.clicked() { crate::panels::action_rail::open_mobile_actions(ui.ctx()); }
3072 draw_actions(ui);
3073 #[cfg(all(feature = "automation", target_arch = "wasm32"))]
3074 crate::automation::registry::publish("__brepMobileActions", "shared action chooser and entry rectangles",
3075 &crate::panels::action_rail::mobile_actions_json(ui.ctx(), button.rect));
3076 } else {
3077 crate::panels::action_rail::close_mobile_actions(ui.ctx());
3078 self.sketch.clear_context_hits();
3079 self.sketch3d.clear_context_hits();
3080 self.context_bar.clear_view();
3081 }
3082 } else { draw_actions(ui); }
3083 });
3084 if let Some(focus) = focus {
3085 self.history.focus_feature(focus);
3086 if compact { self.dock.show_pane(PaneKind::History); }
3087 }
3088 // The Info action returns one target per selected entity — open (or, on
3089 // dedup, keep) a pinned Info window for each. Drawn below.
3090 if let Some(id) = plugin_action {
3091 if let Err(e) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(e); }
3092 }
3093 if !info_targets.is_empty() {
3094 self.info_windows.open_for(&info_targets, self.viewport.last_rect());
3095 }
3096 // Component document-level flows (the engine-mutating component
3097 // actions already ran inside the bar).
3098 if let Some(request) = component_request {
3099 self.run_component_request(request, false);
3100 }
3101 }
3102
3103 if compact { crate::panels::action_rail::show_mobile_actions(&ctx); }
3104
3105 // --- THE DIALOG DOOR: every dynamically generated dialog that opened
3106 // this frame makes its host pane's tab current.
3107 //
3108 // ONE call, placed after everything that can open one — the dock's panes
3109 // and viewport, and the context bar's overlay above — so a form opened
3110 // anywhere is surfaced in the same frame, before `__brepDock` publishes
3111 // the active tab below. It watches the TARGETS rather than the callers
3112 // because most of them set the target inside the engine or inside a
3113 // panel's draw, with no dock in reach; `panels::dock::DialogTargets`
3114 // carries that reasoning and the openers it covers.
3115 let targets = crate::panels::dock::DialogTargets::read(
3116 &self.history,
3117 self.docs.engine(),
3118 self.docs.active_id(),
3119 );
3120 self.dock.surface_opened_dialogs(targets);
3121
3122 // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
3123 // level like the file dialog so they float over the shell. Each is pinned to
3124 // its open-time object name (selection changes never retarget them); closed
3125 // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
3126 // window opened THIS frame paints this frame.
3127 self.info_windows.show(&ctx, self.docs.engine_mut());
3128
3129 // --- interference results window: same floating idiom, owned report;
3130 // its Re-run button re-drives the engine check.
3131 self.interference.show(&ctx, self.docs.engine_mut());
3132
3133 // --- auto-constraints window: the inference scan + its Create button.
3134 self.auto_constraints.show(&ctx, self.docs.engine_mut());
3135 self.step_parts
3136 .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
3137
3138 // --- transient toasts: drain the engine's queued notices (e.g. a sketch
3139 // solve that failed after an edit) and show each briefly. Drawn last so
3140 // the cards float over the whole shell.
3141 let now = ctx.input(|i| i.time);
3142 // All three sources gathered FIRST, because the automation queue must see
3143 // every one of them: `Toasts::extend` keeps only the last few on screen,
3144 // and a message dropped for space is exactly the one a host wants told.
3145 use brep_render::engine_state::NoticeSeverity;
3146 let raised = self.gather_notices();
3147 self.toasts.extend(raised, now);
3148 // An edit after a card was raised supersedes it (`Toasts::note_document`).
3149 self.toasts
3150 .note_document(self.docs.active_id(), self.docs.engine().history.undo_top(), now);
3151 self.toasts.show(&ctx, self.viewport.last_rect(), status_bar_top);
3152
3153 // The state registry (`automation::registry`): the live app + engine
3154 // state, published by name with a one-line doc so a host can read it
3155 // (and, on wasm, mirrored to `window.__brep*` for the verify scripts).
3156 // Purely additive; no render effect. Published AFTER the panels draw so
3157 // the hit-rects are for THIS frame's layout. Off unless a host enabled it.
3158 if crate::automation::registry::enabled() {
3159 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
3160 // Every blob that is plain app state, from the one producer
3161 // `state_get` also asks at dispatch time (`state_blob_json`).
3162 self.publish_state_blobs(&ctx);
3163 // Keyboard focus, for a driver typing into a widget by its rect: the
3164 // frame counter says a click has been drawn, `rect` is the widget
3165 // egui gave the focus to (null while nothing has it). A typed
3166 // string lands only in a focused field, so a verifier waits on this
3167 // rather than on time (BREP_app/web/verify_plm_helpers.mjs).
3168 let focused = crate::column_tree::focused_rect(ui.ctx());
3169 // The focused value editor's text, taken (not left) so a frame
3170 // with no focused editor publishes null.
3171 let text: Option<String> = ui.ctx().data_mut(|d| d.remove_temp::<String>(crate::column_tree::focused_text_id())).filter(|_| focused.is_some());
3172 crate::automation::registry::publish("__brepFocus", "keyboard focus {frame, rect:[x,y,w,h]|null (the focused widget), text:string|null (a value editor's in-progress string)}", &serde_json::json!({
3173 "frame": ui.ctx().cumulative_frame_nr(),
3174 "rect": focused.map(|r| [r.min.x, r.min.y, r.width(), r.height()]),
3175 "text": text,
3176 }).to_string());
3177 crate::automation::registry::publish("__brepFileHit", "file dialog widget rects", &self.file.hits_json());
3178 crate::automation::registry::publish("__brepStatusHit", "status bar widget rects: busy, busy:more, busy:cancel (present only while the working indicator shows)", &self.busy.hits_json());
3179 crate::automation::registry::publish("__brepHit", "history panel widget rects: step:i edit:i del:i box:i add:menu form:* field:* panel:clip", &self.history.hits_json());
3180 crate::automation::registry::publish("__brepPaletteHit", "add-feature palette widget rects in screen points: input top item:type display display#mode (empty while closed)", &self.history.palette_hits_json());
3181 crate::automation::registry::publish("__brepExprHit", "expressions panel widget rects", &self.expressions.hits_json());
3182 crate::automation::registry::publish("__brepToolbar", "toolbar rects: shared File menu file:* and workbench:item:<id>; Ribbon tabs/overflow ribbon:* and Home creation wbtb:*; command keys undo redo fit projection wireframe show:* help info bug workbench:btn:<id>. Classic wraps its controls; Ribbon compacts Large commands before whole-group overflow. Keys appear when their widgets are rendered; menu entries appear while open", &self.toolbar.hits_json());
3183 // The workbench actions strip's button rects (`wbtb:feature:<type>` /
3184 // `wbtb:constraint:<type>` / `wbtb:annotation:<type>`); an empty map
3185 // while the strip is hidden.
3186 crate::automation::registry::publish("__brepWorkbenchToolbar", "creation rects: wbtb:* feature:type constraint:type annotation:type, from the Classic workbench strip or Ribbon Home; empty while hidden or another Ribbon tab is selected", &self.workbench_toolbar.hits_json());
3187 // The PLM pane (a PLM session only): its sections' widget rects,
3188 // `plm/section:<id>` and `plm/<section>:<widget>`, and the active
3189 // document's access and revision.
3190 crate::automation::registry::publish("__brepPlmHit", "PLM pane rects: section:lifecycle, lifecycle:revision:<label>, lifecycle:verb:check-out, save:<yes|no|remember>, web:<summary|revisions|attachments|structure|history|parts|workspace|reviews>", &self.plm.hits_json());
3191 crate::automation::registry::publish("__brepToastsHit", "toast card rects (toast:<i> top to bottom, toast:more)", &self.toasts.hits_json());
3192 crate::automation::registry::publish("__brepBugHit", "bug report panel widget rects", &self.bug_report.hits_json());
3193 // The JavaScript editor window: its outer rect against the surface
3194 // it may fill, and its widget rects (`javascript/panel:clip`,
3195 // `javascript/source`, `javascript/output`, the buttons).
3196 crate::automation::registry::publish("__brepJavascript", "JavaScript editor window {open, rect:[x,y,w,h] or null while closed, surface:[w,h], running, output}", &self.javascript.state_json());
3197 crate::automation::registry::publish("__brepJavascriptHit", "JavaScript editor window widget rects (panel:clip, run, cancel, save, open, export, help, example, source, output); empty while the window is closed", &self.javascript.hits_json());
3198 // The wire harness: the document's connections + the last run's
3199 // routing report (engine truth), and the panel's widget rects.
3200 crate::automation::registry::publish("__brepWireHarnessHit", "wire harness panel widget rects", &self.wire_harness.hits_json());
3201 // The CAM block (setups with their operations, tools, operations,
3202 // NC programs) plus the pane's selection and last notice, and the
3203 // pane's rects (`cam:*`).
3204 self.cam.refresh_geometry(self.docs.engine());
3205 crate::automation::registry::publish("__brepCamHit", "CAM pane widget rects (cam:*)", &self.cam.hits_json());
3206 crate::automation::registry::publish("__brepInstructionsHit", "Instructions pane widget rects (instructions:*)", &self.instructions.hits_json());
3207 crate::automation::registry::publish("__brepInstructionsPlayerHit", "Player pane widget rects (player:*)", &self.instructions_player.hits_json());
3208 crate::automation::registry::publish("__brepSimulationHit", "Simulation pane widget rects (simulation:*)", &self.simulation.hits_json());
3209 // The part's declared connection points (the `ports` block) and the
3210 // ports tail's report, beside the Qualify panel's own selection and
3211 // its rects (`qualify:*`).
3212 crate::automation::registry::publish("__brepQualifyHit", "Qualify panel widget rects (qualify:*)", &self.qualify.hits_json());
3213 // The family table as the Family table pane last read it, its
3214 // problems, selection and the last Generate report; and its rects.
3215 crate::automation::registry::publish("__brepFamilyHit", "Family table pane widget rects (family:*)", &self.family_table.hits_json());
3216 // The PMI block + report + active view / open annotation, and the
3217 // PMI panel's rects (`pmi:capture`, `pmi:add`, `pmi:add:<type>`,
3218 // `pmi:row:<id>`, `pmi:cell:<id>:<column>`, `pmi:menu:<id>`, the
3219 // form's `pmi:` keys).
3220 crate::automation::registry::publish("__brepPluginsHit", "Declarative plugin panel action controls", &self.plugins.panel_hits_json());
3221 crate::automation::registry::publish("__brepPmiHit", "PMI panel widget rects (pmi:*)", &self.pmi.hits_json());
3222 // The `sheets` block, which sheet the viewport draws, and the OPEN
3223 // sheet's whole projection — the paper, the visible edge runs and
3224 // the annotations that passed the parallel rule, in millimetres.
3225 // The panel's rects (`sheets:add`, `sheets:place:<view>`,
3226 // `sheets:row:<id>`, the form's `sheets:` keys) ride beside it.
3227 crate::automation::registry::publish("__brepSheetsHit", "Sheets panel widget rects (sheets:*)", &self.sheets.hits_json());
3228 crate::automation::registry::publish("__brepInfoWindowsHit", "info window widget rects", &self.info_windows.hits_json());
3229 crate::automation::registry::publish("__brepInterferenceHit", "interference panel widget rects", &self.interference.hits_json());
3230 crate::automation::registry::publish("__brepAutoConstraintsHit", "auto-constraints widget rects", &self.auto_constraints.hits_json());
3231 crate::automation::registry::publish("__brepStepPartsHit", "STEP parts library widget rects", &self.step_parts.hits_json());
3232 crate::automation::registry::publish("__brepSelectionHit", "selection bar widget rects (filter:kind, clear, hide)", &self.selection.hits_json());
3233 crate::automation::registry::publish("__brepContextHit", "context action bar widget rects", &self.context_bar.hits_json());
3234 crate::automation::registry::publish("__brepPickBarHit", "focused pick bar widget rects (bar, finish, cancel, x<i>)", &self.pick_bar.hits_json());
3235 crate::automation::registry::publish("__brepModeBarHit", "mode bar widget rects (refsel:*, Sketch:*)", &self.mode_bar.hits_json());
3236 // The DOCUMENT TABS: the open models, which one is active, and the
3237 // strip's per-tab hit-rects, so an e2e script can switch and close
3238 // documents the way a user does.
3239 crate::automation::registry::publish(
3240 "__brepDocumentsHit",
3241 "document tab strip widget rects",
3242 &crate::automation::hit_rects::hits_json(
3243 self.document_tab_hits.iter().map(|(key, rect)| (key, rect)),
3244 ),
3245 );
3246 // The boot-time recovery prompt: its entries and its two buttons.
3247 crate::automation::registry::publish("__brepRecoveryHit", "recovery prompt widget rects", &self.recovery.hits_json());
3248 #[cfg(target_arch = "wasm32")]
3249 crate::automation::registry::publish("__brepMobileDock", "compact dock drawer controls", &self.dock.mobile_state_json());
3250 #[cfg(target_arch = "wasm32")]
3251 crate::automation::registry::publish("__brepMobileSketch", "compact sketch entity drawer controls", &serde_json::json!({
3252 "compact": compact, "open": entity_drawer_open,
3253 "rects": serde_json::from_str::<serde_json::Value>(&crate::automation::hit_rects::hits_json(mobile_sketch_hits.iter())).unwrap_or_default(),
3254 }).to_string());
3255 crate::automation::registry::publish("__brepSketchHit", "sketch-mode overlay widget rects: the SKETCH ACTIONS card (constraint:glyph fix construction cleanup delete) \u{2014} empty out of sketch mode. The draw tools are NOT here: they are workbench buttons in the toolbar row (toolbar/workbench:btn:sketch.tool.* and sketch.autoconstrain)", &self.sketch.hits_json(self.docs.engine()));
3256 crate::automation::registry::publish("__brepSketchListHit", "sketch entity-list widget rects: section:* geometry:id point:id constraint:id del:kind:id panel:clip (empty out of sketch mode)", &self.sketch.list_hits_json(self.docs.engine()));
3257 crate::automation::registry::publish("__brepSketch3dHit", "3D sketch-mode overlay widget rects: the 3D SKETCH ACTIONS card (constraint:type fix construction delete) \u{2014} empty out of 3D sketch mode. The draw tools and the working-plane radio are workbench buttons in the toolbar row (toolbar/workbench:btn:sketch3d.tool.* and sketch3d.plane.*)", &self.sketch3d.hits_json(self.docs.engine()));
3258 crate::automation::registry::publish("__brepSketch3dListHit", "3D sketch entity-list widget rects: section:* geometry:id point:id constraint:id del:kind:id reference:name unlink:name panel:clip (empty out of 3D sketch mode)", &self.sketch3d.list_hits_json(self.docs.engine()));
3259 // Viewport origin + projected probe points (viewport-local logical
3260 // px) so the verifier can click precise spots ON the Box and ON the
3261 // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
3262 // pin; indices 1..4 are points on the Pin's cylindrical stub that
3263 // protrudes above the Box top (y=20), on the camera-facing sides — the
3264 // verifier tries them until one picks "Pin".
3265 crate::automation::registry::publish("__brepView", "the 3D viewport rect {x,y,w,h} in egui points", &self.viewport.viewport_rect_json());
3266 // Dock layout snapshot (per-pane visible / rendered) so the verifier
3267 // can see which side panels are on-screen and, once a user tabs panels
3268 // together, activate the right tab before asserting on its widgets.
3269 // `active=false` in sketch / ref-select (the dock is bypassed).
3270 crate::automation::registry::publish("__brepDock", "dock layout in tab-strip order: per-pane visible/activeTab/rendered", &self.dock.state_json(!sketch && pick_layout.shows_dock()));
3271 crate::automation::registry::publish("__brepProbe", "projected seed-model probe points (viewport-local px) for the verifier",
3272 &self
3273 .docs
3274 .engine()
3275 .world_to_screen_json(
3276 "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
3277 [10.0,22.5,16.0],[10.0,25.0,10.0]]",
3278 )
3279 .unwrap_or_else(|_| "[]".to_string()),
3280 );
3281 }
3282
3283 // The viewport renders only a dirty engine. A panel drawn after it this
3284 // frame may have marked it dirty: draw that on the next frame instead of
3285 // waiting for the next input event.
3286 if self.docs.engine().dirty {
3287 ctx.request_repaint();
3288 }
3289
3290 // Phase 3 of the automation frame (§4.4): reads answer from THIS frame's
3291 // registry and layout.
3292 #[cfg(feature = "automation")]
3293 self.drain_reads(&ctx);
3294
3295 #[cfg(target_arch = "wasm32")]
3296 crate::viewport::touch::publish_editor(&ctx);
3297
3298 // NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
3299 // earlier this frame (normal mode) or drawn directly in the sketch /
3300 // ref-select branch above. Drawing it before the top-right overlay is what
3301 // keeps that overlay anchored to the live viewport rect.
3302 }
3303}
3304
3305
3306/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
3307
3308/// Whether a launch opens on [`seed_history_json`]: the caller asked for the
3309/// seed and the user has not turned it off with the "Start with an empty
3310/// document" setting (`disableStartupModel`).
3311pub(crate) fn starts_on_seed_model(seed: bool, settings: &brep_render::style::RenderSettings) -> bool {
3312 seed && !settings.disable_startup_model
3313}
3314
3315/// The seed model handed to the engine at startup: a 3-feature history so the
3316/// tree / roll / edit are real —
3317/// 0. `P.CU` "Box" — a 20 mm cube at the origin (spans `[0,20]³`).
3318/// 1. `P.CY` "Pin" — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
3319/// cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
3320/// 2. `B` "Cut" — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
3321/// with a cylindrical through-hole (the ref-select field is visible for the
3322/// next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
3323///
3324/// This is just the INITIAL document — once handed to `EngineState`, the engine
3325/// OWNS the mutable history; the app keeps no copy.
3326pub(crate) fn seed_history_json() -> String {
3327 serde_json::json!({
3328 "expressions": "",
3329 "configurator": {},
3330 "features": [
3331 {
3332 "type": "P.CU",
3333 "inputParams": {
3334 "id": "Box",
3335 "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
3336 "transform": {
3337 "position": [0.0, 0.0, 0.0],
3338 "rotationEuler": [0.0, 0.0, 0.0],
3339 "scale": [1.0, 1.0, 1.0]
3340 },
3341 "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
3342 },
3343 "persistentData": {}
3344 },
3345 {
3346 "type": "P.CY",
3347 "inputParams": {
3348 "id": "Pin",
3349 "radius": 6.0, "height": 30.0,
3350 "transform": {
3351 "position": [10.0, -5.0, 10.0],
3352 "rotationEuler": [0.0, 0.0, 0.0],
3353 "scale": [1.0, 1.0, 1.0]
3354 },
3355 "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
3356 },
3357 "persistentData": {}
3358 },
3359 {
3360 "type": "B",
3361 "inputParams": {
3362 "id": "Cut",
3363 "targetSolid": "Box",
3364 "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
3365 },
3366 "persistentData": {}
3367 }
3368 ]
3369 })
3370 .to_string()
3371}
3372
3373/// Plain-state blobs whose whole input is cheap to compare: each is rebuilt
3374/// only when that input changed, not on every frame the registry publishes
3375/// (the browser build publishes for every page, as its WebMCP host attaches at
3376/// boot). A key holds EVERY input its producer reads, so a cached blob is
3377/// never stale; a blob whose inputs have no such key is built every frame.
3378#[derive(Default)]
3379struct StateBlobCache {
3380 settings: Option<((brep_render::style::RenderSettings, bool), String)>,
3381 workbench: Option<((String, serde_json::Value), String)>,
3382 model: Option<(ModelSignatureKey, String)>,
3383}
3384
3385/// `(rollback step, [(name, triangles, bbox min, bbox max)])`.
3386type ModelSignatureKey = (usize, Vec<(String, usize, [f64; 3], [f64; 3])>);
3387
3388impl StateBlobCache {
3389 /// The cached blob when its key is `key`.
3390 fn hit<K: PartialEq>(slot: &Option<(K, String)>, key: &K) -> Option<String> {
3391 slot.as_ref().filter(|(cached, _)| cached == key).map(|(_, json)| json.clone())
3392 }
3393
3394 /// The blob for `key`: the cached one, else `build`'s, kept under `key`.
3395 fn keyed<K: PartialEq>(slot: &mut Option<(K, String)>, key: K, build: impl FnOnce() -> String) -> String {
3396 if let Some(json) = Self::hit(slot, &key) {
3397 return json;
3398 }
3399 let json = build();
3400 *slot = Some((key, json.clone()));
3401 json
3402 }
3403}
3404
3405/// Everything `BrepApp::model_signature_json` reports, as a comparable key.
3406fn model_signature_key(engine: &EngineState) -> ModelSignatureKey {
3407 let solids = engine
3408 .scene
3409 .solids()
3410 .iter()
3411 .map(|s| (s.name.clone(), s.mesh.indices.len() / 3, s.bbox.min, s.bbox.max))
3412 .collect();
3413 (engine.history_rollback(), solids)
3414}