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