brep_app/app.rs
1//! `BrepApp` — the THIN shell of the engine-native UI.
2//!
3//! One eframe [`App`] that hosts the EXISTING `brep-render` engine and lays out
4//! the panels. The heavy lifting lives in focused modules; this file only owns
5//! the shell:
6//!
7//! * [`Documents`] (`crate::document`) holds the OPEN MODELS — one
8//! [`EngineState`] per document plus its identity, exactly one active. Panels
9//! borrow the active engine through `self.docs.engine_mut()`, which borrows
10//! only that FIELD and so still composes with the disjoint panel borrows
11//! beside it. [`EngineState`] (`brep-render`) is still the single
12//! windowing-agnostic BRAIN per document (scene / camera / controls / settings
13//! / widgets + pointer/wheel/viewcube/pick); we do NOT fork it.
14//! * [`crate::viewport::Viewport`] draws + drives the central 3D viewport (the
15//! offscreen texture, the `egui_wgpu` blit callback, input routing).
16//! * [`crate::panels`] — one module per left-panel section, each a small state
17//! struct + a `show(&mut self, ui, state, …)` method. Adding a panel = add
18//! `panels/<name>.rs`, one field here, one `self.<name>.show(…)` call in
19//! [`eframe::App::ui`] below (see `README.md` → "Adding a panel").
20//!
21//! Native (`run_native`) and wasm (`WebRunner`) run this SAME code.
22
23use crate::document::{Documents, EngineFactory};
24use crate::panels::assembly_constraints::AssemblyConstraintsPanel;
25use crate::panels::bug_report::BugReportPanel;
26use crate::panels::wire_harness::WireHarnessPanel;
27use crate::panels::component_actions::ComponentActionRequest;
28use crate::panels::context_bar::ContextBarPanel;
29use crate::panels::mode_bar::ModeBar;
30use crate::panels::expressions::ExpressionsPanel;
31use crate::panels::file::{FileAction, FileDialog};
32use crate::panels::history::HistoryPanel;
33use crate::panels::info_windows::InfoWindows;
34use crate::panels::scene::ScenePanel;
35use crate::panels::selection::SelectionPanel;
36use crate::panels::sketch::SketchPanel;
37use crate::panels::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 /// History feature-tree + schema-driven feature dialog panel (Phase 2).
128 /// NOTE: the editable history is NOT owned here — it lives in the engine core
129 /// (`EngineState.history`), the single source of truth; this panel only reads
130 /// it back to draw and calls the engine's `history_*` methods to mutate.
131 history: HistoryPanel,
132 /// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
133 /// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
134 /// engine scene/emphasis; owns only transient expand + hit state.
135 scene: ScenePanel,
136 /// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
137 /// the scene's component records — per-instance fixed/visibility/status
138 /// adornments, actions routed to the owning ACOMP feature.
139 /// The BOM panel (claimed by the Assembly workbench): the parts list on
140 /// the shared column-tree widget, with the editable part/occurrence
141 /// attribute columns the Settings "Assemblies" section configures.
142 bom: BomPanel,
143 /// Assembly Constraints panel (claimed by the Assembly workbench): the
144 /// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
145 assembly_constraints: AssemblyConstraintsPanel,
146 /// The wire-harness connection list (a Wire harness workbench pane): add /
147 /// edit / remove wires, read their routed length + status, hover to
148 /// highlight. Document data lives in the engine; this holds the widget's
149 /// transient state.
150 wire_harness: WireHarnessPanel,
151 /// The PMI view tree + annotation forms (the PMI workbench's pane).
152 pmi: crate::panels::pmi::PmiPanel,
153 /// The drawing sheets + their forms (the Drawing workbench's pane).
154 sheets: crate::panels::sheets::SheetsPanel,
155 /// Update-components checker: compares each parts-library entry's
156 /// `sourceSignature` against the model store's current content. Kept
157 /// current once per frame (cheap generation key: applied run + store
158 /// save); the constraints header reads the count + runs the batch refresh,
159 /// the structure tree reads per-part badges.
160 update_components: UpdateComponents,
161 /// Expressions / parameters panel: the variable sheet (engine-owned history
162 /// `expressions`) feature params reference. Owns only its editor buffer.
163 expressions: ExpressionsPanel,
164 /// Qualify: the part's declared CONNECTION POINTS, on whichever surface is
165 /// on screen (symbol, pads or 3D). Holds only the selected address; the
166 /// block and both consistency reports come from the engine.
167 qualify: crate::panels::qualify::QualifyPanel,
168 /// The family table editor: a `.fbrep` family seed's member table. Holds
169 /// the selection, the cell being typed and the last Generate report; the
170 /// table itself is the document's `familyTable` block.
171 family_table: crate::panels::family_table_editor::FamilyTableEditor,
172 /// Every PLM panel (`panels::plm_host`): empty in a file-based session.
173 pub(crate) plm: crate::panels::plm_host::PlmHost,
174 /// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
175 /// selection-driven context bar's Info action. Each floating (movable +
176 /// resizable) window is PINNED to one object name at open time — a Metadata
177 /// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
178 /// and keeps showing that entity regardless of later selection changes. Replaces
179 /// the old single Properties window.
180 info_windows: InfoWindows,
181 /// Interference results window: opened by the Assembly workbench's `∩`
182 /// toolbar button, which runs the engine's pairwise-intersect check; a
183 /// floating window like the Info windows with a row per interfering pair
184 /// (click = select both components), a green all-clear pass line, and a
185 /// Re-run button.
186 interference: crate::panels::interference::InterferenceWindow,
187 /// Auto Constraints window (Assembly workbench): opened by the `⚿` toolbar
188 /// button, it lists the constraint types the kernel's inference lane can
189 /// read out of the components' current placement, with what a scan found
190 /// for each, and creates the whole accepted set as one undo step.
191 auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow,
192 /// step.parts online model library browser (Assembly workbench): a ctx-level
193 /// window (opened by the library toolbar button) that searches the public
194 /// step.parts v1 API, shows results with thumbnails, and imports a chosen
195 /// STEP model as a new part document + adds it to the assembly as an ACOMP.
196 step_parts: crate::panels::step_parts::StepPartsPanel,
197 /// Selection panel: the pickable-kinds filter (which entity kinds a viewport
198 /// click may select — honored by the engine's `select_top_at`). The filter +
199 /// selection live in `EngineState`; this panel only reads/writes them.
200 selection: SelectionPanel,
201 /// Context action toolbar: the selection-driven action bar (Clear / Hide /
202 /// Edit-owning-feature + the feature-from-selection actions whose primary
203 /// reference accepts the selected kind). Shown only while something is
204 /// selected; drives the engine directly and returns a feature id for the shell
205 /// to expand in the history tree.
206 context_bar: ContextBarPanel,
207 /// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
208 /// + circle to the `set_overlay` channel colored by solver mobility, and shows
209 /// the DOF status readout. The engine-native sketcher's foundation surface.
210 sketch: SketchPanel,
211 sketch3d: Sketch3dPanel,
212 /// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
213 /// corner — a card-based reference pick, sketch mode, and any future
214 /// special mode. A form field's pick draws no card: its field is the UI.
215 mode_bar: ModeBar,
216 /// Transient toast overlay: drains the engine's queued notices each frame
217 /// (e.g. a sketch solve that failed after an edit) and shows each briefly.
218 pub(crate) toasts: Toasts,
219 /// The status bar's app-wide working indicator (`panels::busy`): what is
220 /// in flight, collected from every source each frame.
221 busy: crate::panels::busy::BusyIndicator,
222 /// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
223 /// tree that hosts every side-panel section AND the 3D viewport as tiles the
224 /// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
225 /// each panel + the engine per frame through [`DockContext`]. Drawn in normal
226 /// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
227 dock: DockState,
228
229 /// Whether the ONE-SHOT first-model framing has fired. The seed run is async
230 /// under a background runner (native thread / wasm worker), so the boot
231 /// `zoom_to_fit` can run before the first solids exist → an unframed first
232 /// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
233 /// `ui` loop frames it once and sets this. Under the synchronous Inline runner
234 /// (tests) the scene is already populated, so this fires on the very first frame.
235 first_run_framed: bool,
236
237 /// A model fetch kicked off at boot from a `?loadModel=<url>` query param
238 /// (wasm only). When the fetch lands it REPLACES the seed model. `None` on
239 /// native and once applied.
240 pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
241
242 /// "Open in CAD" from the PLM's page: the `?open=part/…/rev/…` this page
243 /// was started with (wasm only), driven once a frame until it settles.
244 plm_launch: Option<crate::panels::plm_launch::Launch>,
245
246 /// The document handle the shared panels were last reset for. Compared to
247 /// `docs.active_id()` at the top of every frame: ONE check catches a switch
248 /// from any source (a tab click, a close, New, Open, Edit Part) instead of a
249 /// hook per call site, and it runs BEFORE any panel draws this frame.
250 active_document: u64,
251
252 /// Whether the active workbench showed the Sheets pane LAST frame — the
253 /// edge the paper is closed on. Closing is a transition, not a condition:
254 /// a sheet opened while another workbench is active (a command, a script)
255 /// stays open until the Drawing workbench is LEFT, exactly as PMI's view
256 /// deactivates on leaving rather than on every frame outside it.
257 sheets_pane_shown: bool,
258
259 /// The DOCUMENT TAB STRIP's per-tab hit-rects from the last dock frame,
260 /// published for the headed verifier. The strip is drawn inside the dock's
261 /// viewport pane, so its rects have to ride back out through the outcome.
262 document_tab_hits: Vec<(String, egui::Rect)>,
263
264 /// The `__brepParams` blob and the document state it was built from:
265 /// `(document, rolled-to step, history revision, json)`.
266 ///
267 /// The blob is the rolled-to feature's whole `inputParams`, and for an
268 /// `IMPORT3D` those params carry the embedded model text — 681 KB on the
269 /// heaviest corpus model. It was rebuilt EVERY frame, which on the browser
270 /// build (where the state registry is always on) also transcoded it into a
271 /// fresh JS string every frame, on the one thread that also has to answer
272 /// the pointer. Nothing reads it per frame; it only has to be correct when
273 /// someone asks. The revision is a mutation counter the document hands out
274 /// (`EngineState::history_revision`), so this cannot go stale without the
275 /// borrow checker having let a mutation past the door that bumps it.
276 params_blob: (u64, usize, u64, String),
277
278 /// A measured camera spin in flight (`perf_spin`): the orbit drag the app
279 /// drives ITSELF, one pointer step per frame, so the frames run back to back.
280 /// Driving it from a host instead would put a round trip between every pair
281 /// of frames, and `dt` — the frame period, the number a user feels as a
282 /// freeze — would measure the host's latency rather than the app's.
283 #[cfg(feature = "automation")]
284 spin: Option<Spin>,
285
286 /// The debounced autosave of every dirty document (`crate::recovery`), and
287 /// the boot-time **Recover unsaved work?** prompt its blob feeds. The
288 /// autosave is held while the prompt is open: the clean seed tab would
289 /// otherwise remove the very blob being offered.
290 autosave: crate::recovery::Autosave,
291 recovery: crate::recovery::RecoveryPanel,
292
293 /// The UI zoom scale CURRENTLY applied to the egui context. Tracks
294 /// `settings.ui_scale` but is only synced to it while the pointer is up, so
295 /// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
296 /// the cursor mid-drag — the settled value is committed on release. See the
297 /// zoom-apply block in `ui`.
298 applied_ui_scale: f32,
299 /// The automation channel (hosts submit commands; the frame drains them at
300 /// three fixed points — see `automation::queue`).
301 #[cfg(feature = "automation")]
302 pub(crate) automation: std::sync::Arc<crate::automation::queue::AutomationQueue>,
303}
304
305/// The caption text size, points (egui's own `Small` is 9). See [`app_style`].
306pub const SMALL_TEXT_SIZE: f32 = 10.5;
307
308/// The app's tweaks over egui's style, applied to both themes at startup.
309pub(crate) fn app_style(style: &mut egui::Style) {
310 // Keep overflow discoverable without hovering or scrolling first, in
311 // both themes and every child UI (including eCAD and plugin panels).
312 // ScrollArea's default VisibleWhenNeeded still hides bars that aren't needed.
313 style.spacing.scroll = egui::style::ScrollStyle::solid();
314 // Captions (`ui.small`, `RichText::small`): egui's 9 pt was too small to
315 // read beside 12.5 pt body text in the Info window, the eCAD inspectors and
316 // the Selection actions summary (lane U audit item 9). 10.5 keeps them
317 // visibly secondary. The toolbar/Ribbon group captions stay at 9
318 // (`ribbon::CHROME_CAPTION_SIZE`) so the chrome keeps its height.
319 style.text_styles.insert(egui::TextStyle::Small, egui::FontId::new(SMALL_TEXT_SIZE, egui::FontFamily::Proportional));
320}
321
322
323impl BrepApp {
324 /// The automation queue a host submits commands to.
325 #[cfg(feature = "automation")]
326 pub fn automation(&self) -> &std::sync::Arc<crate::automation::queue::AutomationQueue> {
327 &self.automation
328 }
329
330 /// Open a `.nbrep` file named on the command line — `brep-app model.nbrep`,
331 /// which is also what a desktop file association hands us (`Exec=… %f`).
332 ///
333 /// The path goes through the same `FileDialog::open_document` door as
334 /// File>Open, so the document keeps the full path as its identity and a
335 /// later plain Save writes back to the file the user double-clicked, rather
336 /// than to a copy in the app's own models directory. A path that does not
337 /// parse leaves the status line saying so and the seed document in place —
338 /// a bad argument must not cost the user a window.
339 ///
340 /// Native only: called once, before the first frame.
341 #[cfg(not(target_arch = "wasm32"))]
342 pub fn open_path_from_command_line(&mut self, path: &std::path::Path) {
343 // Absolute, so the store resolves it as a real file rather than as a
344 // name inside the models directory (`store::resolve` routes anything
345 // with a separator straight to the filesystem).
346 let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
347 let Some(name) = path.to_str() else {
348 log::warn!("brep-app: {} is not valid UTF-8; not opening it", path.display());
349 return;
350 };
351 // Field-by-field, because `open_document` borrows the documents and the
352 // store while `self.file` is borrowed mutably.
353 let Self { file, docs, model_store, .. } = self;
354 file.open_document(docs, model_store.as_ref(), name);
355 }
356
357 /// The 3D viewport rect of the last frame, in egui points.
358 pub fn view_rect(&self) -> Option<egui::Rect> {
359 self.viewport.last_rect()
360 }
361
362 /// The session's store, read-only (the automation layer's `frame_info`
363 /// counts its writes still on their way).
364 pub(crate) fn model_store(&self) -> &dyn ModelStore {
365 self.model_store.as_ref()
366 }
367
368 /// Why this session cannot switch stores live, or `None` when it can: every
369 /// open document untitled and clean, the state a fresh start has. A named
370 /// document's identity belongs to the store it came from, and a dirty one's
371 /// work has nowhere to go in a new store.
372 pub(crate) fn reconnect_blocker(&self) -> Option<String> {
373 self.reconnect_blocker_with(crate::document::Document::is_dirty)
374 }
375
376 fn reconnect_blocker_with(&self, is_dirty: impl Fn(&crate::document::Document) -> bool) -> Option<String> {
377 let named = self.docs.iter().filter(|d| d.name().is_some()).count();
378 let dirty = self.docs.iter().filter(|d| is_dirty(d)).count();
379 match (named, dirty) {
380 (0, 0) => None,
381 (_, 0) => Some(format!("{named} document(s) from this store are open: close them, then Connect now (or restart)")),
382 _ => Some(format!("{dirty} document(s) have unsaved work: save or close them, then Connect now (or restart)")),
383 }
384 }
385
386 /// Switch this session to the PLM configured at `root`, live (S1's named
387 /// limit): the native boot S2 runs at start, then everything the app read
388 /// from the store when it was built is read again from the new one. Only
389 /// while [`Self::reconnect_blocker`] is `None`. A refused boot changes
390 /// nothing and says why.
391 #[cfg(not(target_arch = "wasm32"))]
392 pub(crate) fn reconnect_plm(&mut self, root: std::path::PathBuf) -> Result<String, String> {
393 use crate::plm::connection::Status;
394 if let Some(why) = self.reconnect_blocker() {
395 return Err(why);
396 }
397 let config = crate::plm::config::resolve(&root, &|_| None, &Default::default())?
398 .ok_or_else(|| format!("no PLM is configured in {}", root.display()))?;
399 let store = crate::store::boot_native_store(root, config);
400 let Some(Status::Connected { url, username }) = store.plm_session().map(|s| s.status) else {
401 let why = store.take_persistence_errors().join("; ");
402 return Err(if why.is_empty() { "the PLM did not take this session".into() } else { why });
403 };
404 self.model_store = store;
405 // Settings: every open engine, and every later tab, starts from the
406 // defaults with the new store's settings over them, as a fresh boot
407 // would; nothing of the old store's carries over.
408 let saved = self.model_store.read(SETTINGS_KEY);
409 let fresh = move |mut state: EngineState| {
410 state.settings = brep_render::style::RenderSettings::default();
411 let defaults = state.settings.to_json();
412 let _ = state.apply_settings_json(&defaults);
413 if let Some(saved) = &saved {
414 let _ = state.apply_settings_json(saved);
415 }
416 state
417 };
418 for doc in self.docs.iter_mut() {
419 let engine = std::mem::replace(&mut doc.engine, EngineState::new());
420 doc.engine = fresh(engine);
421 }
422 self.docs.wrap_engine_factory(fresh);
423 self.dock = DockState::new(self.model_store.as_ref());
424 self.recovery.arm_from_store(
425 self.model_store.as_ref(),
426 self.docs.engine().settings.disable_recovery_prompt,
427 );
428 self.file = FileDialog::new();
429 self.update_components = UpdateComponents::new();
430 Ok(format!("connected to {url} as {username}"))
431 }
432
433 /// The file dialog, the documents and the store together — for the
434 /// automation commands that drive a document-class flow (Generate, a
435 /// store read-back) exactly as the panels do.
436 pub(crate) fn file_docs_store(&mut self) -> (&mut FileDialog, &mut Documents, &dyn ModelStore) {
437 (&mut self.file, &mut self.docs, self.model_store.as_ref())
438 }
439
440 /// The active document's engine.
441 pub fn docs_engine(&self) -> &EngineState {
442 self.docs.engine()
443 }
444
445 /// Open the mesh import preview for the file `name` (`.stl` or `.3mf`)
446 /// against the active document — what activating it in the Import explorer
447 /// does. The preview replaces the shell until it is accepted or cancelled.
448 pub fn open_mesh_preview(&mut self, name: String, bytes: Vec<u8>) -> Result<(), String> {
449 use brep_render::runner::MeshImportFormat;
450 let format = match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
451 Some("stl") => MeshImportFormat::Stl,
452 Some("3mf") => MeshImportFormat::ThreeMf,
453 _ => return Err(format!("{name} is not an STL or 3MF file")),
454 };
455 self.start_mesh_preview(format, name, bytes);
456 Ok(())
457 }
458
459 fn start_mesh_preview(&mut self, format: brep_render::runner::MeshImportFormat, name: String, bytes: Vec<u8>) {
460 self.stl_import = Some(crate::panels::stl_import::StlImportPreview::new(
461 self.docs.active_id(), format, name, bytes, self.docs.spawn_engine(),
462 ));
463 self.viewport.forget_document();
464 }
465
466 /// Whether the mesh import preview is open.
467 pub fn mesh_preview_open(&self) -> bool {
468 self.stl_import.is_some()
469 }
470
471 pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
472 Self::new_with(cc, crate::automation::AppOptions::default())
473 }
474
475 /// Build the app with host-supplied options: an isolated store (a host
476 /// MUST pass one) and whether to start on the seed model.
477 pub fn new_with(cc: &eframe::CreationContext<'_>, opts: crate::automation::AppOptions) -> Result<Self, String> {
478 // Solid scroll bars and readable captions, in both themes.
479 cc.egui_ctx.all_styles_mut(app_style);
480 let crate::automation::AppOptions { store: opt_store, seed } = opts;
481 let render_state = cc
482 .wgpu_render_state
483 .as_ref()
484 .ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
485
486 // The viewport owns the render core + blit pipeline, built from eframe's
487 // SHARED device/queue/format.
488 let viewport = Viewport::new(render_state);
489
490 // The session's diagnostics, taken from that same render state: the
491 // adapter about to draw every frame is the one a report must name. The
492 // WebGPU-vs-WebGL2 decision has already been made by the time we get
493 // here (eframe drops `BROWSER_WEBGPU` from the backend set when the
494 // browser offers no WebGPU adapter), so this READS the outcome — asking
495 // again later would be a second question with its own answer.
496 let diagnostics = crate::diagnostics::Diagnostics::from_render_state(render_state);
497
498 // --- storage seam: load the persisted settings ------------------------
499 let model_store = opt_store.unwrap_or_else(default_model_store);
500 // wasm: hand the store the egui context so an async file-upload load
501 // callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
502 #[cfg(target_arch = "wasm32")]
503 crate::store::set_repaint_ctx(cc.egui_ctx.clone());
504 let saved_settings = model_store.read(SETTINGS_KEY);
505
506 // --- how a document's engine is built --------------------------------
507 // Every tab gets its OWN engine, and therefore its own history runner —
508 // a runner owns the resident kernel state of the document it executes,
509 // so one shared between documents would apply a background run against
510 // the wrong registry. See `crate::document`.
511 let plugins = crate::plugins::PluginsPanel::new(model_store.as_ref());
512 let javascript = crate::javascript::JavaScriptEditor::new(model_store.as_ref());
513 let plugin_packages = plugins.packages.clone();
514 let engine_factory: EngineFactory = Box::new(move || {
515 let mut state = EngineState::new();
516 // Native: run the whole history — and per-object measurement queries — on a
517 // persistent background thread so the UI never freezes during a run or a
518 // selection (M2b). Installed BEFORE anything loads so it builds through it.
519 #[cfg(not(target_arch = "wasm32"))]
520 state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
521 // wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
522 // single-threaded wasm UI stays responsive during a run. Same seam. Tests
523 // (which never hit this wasm path) keep the default synchronous InlineRunner.
524 #[cfg(target_arch = "wasm32")]
525 state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
526 if !plugin_packages.borrow().is_empty() {
527 if let Err(error) = crate::plugins::restore(&mut state, &plugin_packages.borrow()) {
528 log::error!("Plugin restore failed: {error}");
529 }
530 }
531 state.set_viewcube_enabled(true);
532 // Partial-override apply: unknown/absent keys keep their defaults.
533 if let Some(saved) = &saved_settings {
534 let _ = state.apply_settings_json(saved);
535 }
536 state
537 });
538
539 // Start with the seed model on every launch (7626103c2). Reopening the
540 // previous session can immediately rerun a problematic document and
541 // prevent the user from recovering by restarting the app, so which
542 // documents were open is not persisted at all: saved models come back
543 // through the file dialog, and work that was never saved comes back as
544 // the crash-recovery OFFER below — a prompt, never an automatic load.
545 let mut docs = Documents::new(engine_factory);
546 if seed {
547 let _ = docs.engine_mut().set_history_json(&seed_history_json());
548 }
549 docs.engine_mut().zoom_to_fit();
550 docs.active_mut().mark_clean();
551
552 // The autosave blob from a session that ended with unsaved work: offer
553 // it back (a prompt, never an automatic restore — see `crate::recovery`).
554 let mut recovery = crate::recovery::RecoveryPanel::new();
555 recovery.arm_from_store(model_store.as_ref(), docs.engine().settings.disable_recovery_prompt);
556
557 // The settings panel seeds its working JSON from the (post-load) engine
558 // settings so the widgets reflect the persisted state on first paint.
559 let settings = SettingsPanel::new();
560 let part_properties = PartPropertiesPanel::new();
561
562 // New / Open / Save / Save As. Holds no document identity — that lives
563 // on each `Document`.
564 let file = FileDialog::new();
565
566 // Boot at the saved UI scale.
567 let applied_ui_scale = docs.engine().settings.ui_scale;
568 let active_document = docs.active_id();
569
570 // The dock layout (loads the persisted tree, or the default). Built before
571 // `model_store` is moved into `Self`.
572 let dock = DockState::new(model_store.as_ref());
573
574 // Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
575 // fetching that model NOW; the seed still loads this frame and the fetched
576 // model REPLACES it when it lands (drained in `ui`). See the drain block.
577 crate::offsite::prefetch();
578 #[cfg(target_arch = "wasm32")]
579 let pending_boot_load = web_sys::window()
580 .and_then(|w| w.location().search().ok())
581 .and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
582 .and_then(|params| params.get("loadModel"))
583 .filter(|url| !url.is_empty())
584 .map(|url| crate::offsite::fetch_text(&cc.egui_ctx, "Opening a model from a link", url));
585 #[cfg(not(target_arch = "wasm32"))]
586 let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
587 #[cfg(target_arch = "wasm32")]
588 let plm_launch = crate::panels::plm_launch::Launch::from_page();
589 #[cfg(not(target_arch = "wasm32"))]
590 let plm_launch = None;
591
592 Ok(Self {
593 docs,
594 viewport,
595 toolbar: ToolbarPanel::new(),
596 workbench_toolbar: WorkbenchToolbarPanel::new(),
597 model_store,
598 file,
599 stl_import: None,
600 bug_report: BugReportPanel::new(),
601 info: crate::panels::info::InfoPanel::new(),
602 diagnostics,
603 settings,
604 plugins,
605 javascript,
606 part_properties,
607 history: HistoryPanel::new(),
608 scene: ScenePanel::new(),
609 bom: BomPanel::new(),
610 assembly_constraints: AssemblyConstraintsPanel::new(),
611 wire_harness: WireHarnessPanel::new(),
612 pmi: crate::panels::pmi::PmiPanel::new(),
613 sheets: crate::panels::sheets::SheetsPanel::new(),
614 update_components: UpdateComponents::new(),
615 expressions: ExpressionsPanel::new(),
616 qualify: crate::panels::qualify::QualifyPanel::new(),
617 family_table: crate::panels::family_table_editor::FamilyTableEditor::new(),
618 plm: crate::panels::plm_host::PlmHost::new(),
619 info_windows: InfoWindows::new(),
620 interference: crate::panels::interference::InterferenceWindow::new(),
621 auto_constraints: crate::panels::auto_constraints::AutoConstraintsWindow::new(),
622 step_parts: crate::panels::step_parts::StepPartsPanel::new(),
623 selection: SelectionPanel::new(),
624 context_bar: ContextBarPanel::new(),
625 sketch: SketchPanel::new(),
626 sketch3d: Sketch3dPanel::new(),
627 mode_bar: ModeBar::new(),
628 toasts: Toasts::new(),
629 busy: crate::panels::busy::BusyIndicator::new(),
630 dock,
631 first_run_framed: false,
632 pending_boot_load,
633 plm_launch,
634 active_document,
635 sheets_pane_shown: false,
636 document_tab_hits: Vec::new(),
637 params_blob: (u64::MAX, usize::MAX, u64::MAX, String::new()),
638 #[cfg(feature = "automation")]
639 spin: None,
640 autosave: crate::recovery::Autosave::new(),
641 recovery,
642 applied_ui_scale,
643 #[cfg(feature = "automation")]
644 automation: {
645 let q = crate::automation::queue::AutomationQueue::new();
646 q.attach(&cc.egui_ctx);
647 q
648 },
649 })
650 }
651
652 /// Arm a measured camera spin: `frames` pointer steps of `step` logical px
653 /// each, starting at the centre of the 3D viewport. Replaces any spin in
654 /// flight. See [`Spin`].
655 #[cfg(feature = "automation")]
656 pub fn start_spin(&mut self, frames: u32, step: (f64, f64)) {
657 let rect = self.viewport.last_rect();
658 let origin = rect
659 .map(|r| (r.width() as f64 * 0.5, r.height() as f64 * 0.5))
660 .unwrap_or((320.0, 240.0));
661 self.spin = Some(Spin { left: frames.max(1), origin, step, made: 0, pressed: false });
662 }
663
664 /// Is a measured spin still running?
665 #[cfg(feature = "automation")]
666 pub fn spinning(&self) -> bool {
667 self.spin.is_some()
668 }
669
670 /// Advance an in-flight spin by ONE pointer step and keep the frame loop
671 /// awake. The drag sweeps out and back over a fixed span so it never leaves
672 /// the viewport however many frames are asked for; the camera keeps turning
673 /// either way, which is all the measurement needs.
674 #[cfg(feature = "automation")]
675 fn step_spin(&mut self, ctx: &egui::Context) {
676 let Some(mut spin) = self.spin.take() else { return };
677 {
678 let state = self.docs.engine_mut();
679 if !spin.pressed {
680 state.pointer_down(spin.origin.0, spin.origin.1, brep_render::controls::BUTTON_LEFT);
681 spin.pressed = true;
682 }
683 const SPAN: f64 = 60.0;
684 let phase = f64::from(spin.made % (2 * SPAN as u32));
685 let t = if phase <= SPAN { phase } else { 2.0 * SPAN - phase };
686 state.pointer_move(spin.origin.0 + spin.step.0 * t, spin.origin.1 + spin.step.1 * t);
687 spin.made += 1;
688 spin.left -= 1;
689 if spin.left == 0 {
690 state.pointer_up();
691 }
692 }
693 if spin.left > 0 {
694 self.spin = Some(spin);
695 }
696 ctx.request_repaint();
697 }
698
699 /// The `__brepParams` JSON, rebuilt only when the document it describes can
700 /// have changed. The `params_blob` FIELD carries the reason this is not
701 /// simply built afresh each frame.
702 fn params_blob(&mut self) -> &str {
703 let key = (
704 self.docs.active_id(),
705 self.docs.engine().history_rollback(),
706 self.docs.engine().history_revision(),
707 );
708 if (self.params_blob.0, self.params_blob.1, self.params_blob.2) != key {
709 self.params_blob = (
710 key.0,
711 key.1,
712 key.2,
713 self.docs.engine().feature_params_json(key.1),
714 );
715 }
716 &self.params_blob.3
717 }
718
719 /// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
720 /// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection
721 /// — unless a popup or menu is up, which the Escape closes instead.
722 ///
723 /// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
724 /// covers both. Skipped entirely while an egui TEXT edit is focused so typing
725 /// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
726 /// consumed BEFORE undo because egui's `consume_key` matches modifiers
727 /// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
728 fn handle_shortcuts(&mut self, ctx: &egui::Context) {
729 // A modal owns every key: its Escape closes it, and nothing under it —
730 // undo, a Delete, the selection — is the key's to change.
731 if ctx.text_edit_focused() || crate::viewport::modal_open_last_pass(ctx) {
732 return;
733 }
734 use egui::{Key, Modifiers};
735 // A popup or menu that was up last frame owns this Escape: it closes on
736 // `key_pressed(Escape)`, which a consumed key never is, so the key is
737 // left in the input for it and nothing below acts on it — not the
738 // sketch tool, not a pick, and not the selection its menu is about.
739 let popup_open = crate::viewport::popup_open_last_pass(ctx);
740 // On an eCAD workbench, Delete and Escape are the editor's keys, and
741 // whether they reach it is the editors' ONE rule, `keys_reach_editor`
742 // (a text field, a menu or combo box, a modal), not a copy of it here.
743 // The watch above is the host's own addition: egui's popup memory does
744 // not see the host's `open_bool` menus (a tree row's), and those own
745 // their Escape on every workbench.
746 let ecad_target = crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench);
747 let keys_free = match ecad_target {
748 Some(_) => !popup_open && brep_ecad_egui::keys_reach_editor(ctx),
749 None => !popup_open,
750 };
751 if crate::javascript::JavaScriptEditor::editing_source(ctx) {
752 return;
753 }
754 let sketching_3d = self.docs.engine().sketch3d_mode();
755 let (redo, undo, esc, tab) = ctx.input_mut(|i| {
756 let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
757 || i.consume_key(Modifiers::COMMAND, Key::Y);
758 let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
759 let esc = keys_free && i.consume_key(Modifiers::NONE, Key::Escape);
760 // Tab cycles the 3D sketch's working plane; outside that mode it
761 // stays egui's focus key.
762 let tab = sketching_3d && keys_free && i.consume_key(Modifiers::NONE, Key::Tab);
763 (redo, undo, esc, tab)
764 });
765 // The 3D sketch editor: the same per-session undo routing as the
766 // planar one; Esc drops the armed tool's pending clicks first and the
767 // tool itself second.
768 if sketching_3d {
769 if redo {
770 self.docs.engine_mut().sketch3d_redo();
771 }
772 if undo {
773 self.docs.engine_mut().sketch3d_undo();
774 }
775 if esc {
776 let engine = self.docs.engine_mut();
777 if engine.sketch3d_pending_len() > 0 {
778 engine.sketch3d_tool_cancel();
779 } else {
780 let _ = engine.sketch3d_set_tool(Some("select"));
781 }
782 }
783 if tab {
784 self.docs.engine_mut().sketch3d_cycle_work_plane();
785 }
786 return;
787 }
788 // While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
789 // history (S6a), not the model-level undo — this global router consumes the
790 // keys first (before the viewport), so it must intercept here. Esc drops the
791 // active draw/trim/pick tool back to Select/drag (clearing any in-progress
792 // placement): this is the ONLY reliable capture point, since `consume_key`
793 // above already swallowed the Escape before the viewport can see it.
794 if self.docs.engine().sketch_mode() {
795 if redo {
796 self.docs.engine_mut().sketch_redo();
797 }
798 if undo {
799 self.docs.engine_mut().sketch_undo();
800 }
801 if esc {
802 self.docs.engine_mut().sketch_set_tool(Some("select"));
803 }
804 return;
805 }
806 if redo {
807 self.docs.engine_mut().redo();
808 }
809 if undo {
810 self.docs.engine_mut().undo();
811 }
812 if let Some(target) = ecad_target {
813 if keys_free && !self.docs.engine().ref_select_active() {
814 let delete = ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete)
815 || i.consume_key(Modifiers::NONE, Key::Backspace));
816 if delete {
817 self.docs.active_mut().ecad.selection_key(target, true);
818 }
819 }
820 } else {
821 self.delete_selected_annotation(ctx);
822 }
823 if esc {
824 // An open pick-list popup owns the first Escape: close it WITHOUT
825 // clearing the selection (a popup-built multi-selection must survive
826 // dismissing the list); the next Escape clears as before.
827 if self.viewport.close_candidate_popup() {
828 return;
829 }
830 // A REFERENCE PICK owns the next one — a 3D pick or anchors on a
831 // drawing sheet alike — and Escape has to CANCEL it
832 // rather than merely clear the selection. Beginning a pick ROLLS THE
833 // MODEL BACK to the step before the edited feature so the picker sees
834 // the geometry that feature is about to consume; Finish and Cancel
835 // both restore that roll through `end_ref_select`. Escape used to do
836 // neither: it cleared the selection, left the picker open and left the
837 // history PARKED before the feature, so the feature stopped running
838 // and the viewport quietly showed the model without it. Measured on
839 // the retired `rotate-face.json`, which read the seed volume back at its
840 // closed-form step with the feature never re-run.
841 if self.docs.engine().ref_select_active() {
842 self.docs.engine_mut().cancel_ref_select();
843 return;
844 }
845 // A family-row preview owns the next one: Escape leaves the
846 // preview (the family's own values again) and keeps the selection.
847 if self.family_table.escape_preview(self.docs.engine_mut()) {
848 return;
849 }
850 // The global router owns Escape before the viewport runs its keys.
851 if let Some(target) = ecad_target {
852 self.docs.active_mut().ecad.selection_key(target, false);
853 return;
854 }
855 self.docs.engine_mut().clear_selection();
856 }
857 }
858
859 /// A FORM FIELD's reference pick, settled at the end of the frame (after
860 /// the dock has drawn every pane, the viewport's click included):
861 ///
862 /// * a SINGLE-valued field finishes on its first pick — there is nothing
863 /// left to wait for, and the feature's result appears at once;
864 /// * a form that asked for the pick to end (`ref_select_request_finish`:
865 /// an edit elsewhere on a feature's form) gets it now, after the frame's
866 /// viewport click has landed in the pick;
867 /// * a pick NO form claimed this frame (`ref_select_claim`) is finished:
868 /// its pane was tabbed away, closed, or its subject is gone, and a pick
869 /// with no field on screen has nothing to end it.
870 ///
871 /// Card-based picks are not this method's: their card is always on screen.
872 fn settle_form_pick(&mut self) {
873 let engine = self.docs.engine_mut();
874 let Some(field) = engine.ref_select_field() else {
875 return;
876 };
877 if !engine.ref_select_in_form() {
878 return;
879 }
880 let claimed = engine.ref_select_take_claim();
881 let single_done = !field.multiple && field.picks > 0;
882 if single_done || engine.ref_select_finish_requested() || !claimed {
883 engine.finish_ref_select();
884 }
885 }
886
887 /// DELETE (or Backspace) removes the selected object of the OPEN drawing
888 /// sheet — a placed view, a sheet dimension or an ordinate set, picked on
889 /// the paper or in the Sheets tree — or, with no sheet open, the selected
890 /// PMI annotation while the PMI workbench is up. Either goes through the
891 /// destructive entry of the object's own row menu, so the key and the menu
892 /// do the same thing (and it is one undo step, like the menu's).
893 ///
894 /// The key is consumed only when there IS something to delete. Never a
895 /// SHEET or a PMI VIEW: each takes everything placed on it with it, and
896 /// both stay behind their menus' Delete. Nothing while a reference pick is
897 /// up — deleting the object whose references are being picked would
898 /// strand the picker. (Called after the text-edit and sketch-mode returns,
899 /// so a Backspace typed into a field, or a sketch's own Delete, never gets
900 /// here.)
901 fn delete_selected_annotation(&mut self, ctx: &egui::Context) {
902 use egui::{Key, Modifiers};
903 let engine = self.docs.engine();
904 if engine.ref_select_active() {
905 return;
906 }
907 let target = if engine.sheet_open().is_some() {
908 engine.sheet_selected_object().map(|id| (true, id.to_string()))
909 } else if engine.pmi_workbench_entered() {
910 engine.pmi_selected_annotation().map(|id| (false, id.to_string()))
911 } else {
912 None
913 };
914 let Some((on_sheet, id)) = target else {
915 return;
916 };
917 let pressed = ctx.input_mut(|i| {
918 i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)
919 });
920 if !pressed {
921 return;
922 }
923 let engine = self.docs.engine_mut();
924 let result = if on_sheet {
925 crate::panels::sheets::delete_held_object(engine, &id)
926 } else {
927 crate::panels::pmi::delete_annotation(engine, &id)
928 };
929 if let Err(error) = result {
930 engine.push_notice(format!("{}: {error}", if on_sheet { "Sheets" } else { "PMI" }));
931 }
932 }
933
934 /// Open the component source, offering to recover a missing file from the
935 /// assembly's embedded part definition.
936 fn edit_part(&mut self, component_id: &str) {
937 self.file.edit_component_part(
938 &mut self.docs,
939 self.model_store.as_ref(),
940 component_id,
941 );
942 }
943
944 /// Draw an isolated preview while the destination document remains untouched.
945 /// Hand the working indicator this frame's work, most important first:
946 /// the active document's runner queues, a boot load, the STEP parts
947 /// library's downloads, then every background document's queues.
948 fn collect_busy(&mut self, ctx: &egui::Context) {
949 use crate::panels::busy::{engine_activities, Activity};
950 let active = self.docs.active_index();
951 let mut activities = engine_activities(self.docs.engine(), self.docs.active_id(), None);
952 if self.pending_boot_load.is_some() {
953 activities.push(Activity::new("boot", "boot", "Loading document"));
954 }
955 activities.extend(self.step_parts.busy_activities());
956 activities.extend(self.file.busy_activities());
957 // A write-behind store (the browser's IndexedDB) still settling saves.
958 let saving = self.model_store.pending_writes();
959 if saving > 0 {
960 let label = match saving {
961 1 => "Saving to browser storage".to_string(),
962 n => format!("Saving to browser storage ({n} writes)"),
963 };
964 activities.push(Activity::new("storeWrite", "storeWrite", label));
965 }
966 for (index, doc) in self.docs.iter().enumerate() {
967 if index != active {
968 activities.extend(engine_activities(&doc.engine, doc.id(), Some(&doc.title())));
969 }
970 }
971 self.busy.update(activities, ctx.input(|i| i.time));
972 self.busy.request_repaint(ctx);
973 }
974
975 fn show_stl_preview(&mut self, ui: &mut egui::Ui) -> bool {
976 use crate::panels::stl_import::PreviewAction;
977 let Some(preview) = self.stl_import.as_mut() else {
978 return false;
979 };
980 let action = if preview.destination != self.docs.active_id() {
981 PreviewAction::Cancel
982 } else {
983 preview.show(ui, &mut self.viewport)
984 };
985 if crate::automation::registry::enabled() {
986 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
987 crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", &preview.state_json());
988 crate::automation::registry::publish("__brepImportPreviewHit", "import preview dialog widget rects", &preview.hits_json());
989 crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &preview.engine.camera_state_json());
990 crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{}", ui.ctx().pixels_per_point()));
991 crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
992 crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
993 }
994 let close = match action {
995 PreviewAction::Accept => {
996 match preview.accept_into(self.docs.active_id(), self.docs.engine_mut()) {
997 Ok(()) => true,
998 Err(error) => {
999 self.docs.engine_mut().push_notice(error);
1000 false
1001 }
1002 }
1003 }
1004 PreviewAction::Cancel => true,
1005 PreviewAction::None => false,
1006 };
1007 if close {
1008 self.stl_import = None;
1009 self.viewport.forget_document();
1010 ui.ctx().request_repaint();
1011 }
1012 true
1013 }
1014
1015 /// Phase 3 of the automation frame (§4.4): read-only commands answer from
1016 /// THIS frame's registry and layout. A method rather than a block at the
1017 /// end of `ui` because the import preview returns from that function early
1018 /// and still owes the frame its reads.
1019 #[cfg(feature = "automation")]
1020 fn drain_reads(&mut self, ctx: &egui::Context) {
1021 let queue = self.automation.clone();
1022 let frame = ctx.cumulative_frame_nr();
1023 queue.drain_app(
1024 crate::automation::command::Phase::Read,
1025 &mut crate::automation::command::Ctx { app: self, egui: ctx },
1026 frame,
1027 );
1028 }
1029
1030 /// Reset everything the shared panels and the viewport hold ABOUT ONE
1031 /// DOCUMENT, run at the top of the first frame that sees a different active
1032 /// document.
1033 ///
1034 /// Panel state is deliberately NOT per-document (one History panel, one
1035 /// Scene tree, …): a second copy per tab would double every panel's state
1036 /// for a benefit — remembering which feature form was open in a background
1037 /// tab — nobody asked for. The price is that the transient state has to be
1038 /// dropped on a switch, because every bit of it (expansion sets, hit maps,
1039 /// an open feature form, a pinned Info window's object name) refers to the
1040 /// document that just went away.
1041 fn reset_document_scoped_state(&mut self) {
1042 self.history = HistoryPanel::new();
1043 self.scene = ScenePanel::new();
1044 self.bom = BomPanel::new();
1045 self.assembly_constraints = AssemblyConstraintsPanel::new();
1046 self.wire_harness = WireHarnessPanel::new();
1047 self.pmi = crate::panels::pmi::PmiPanel::new();
1048 self.sheets = crate::panels::sheets::SheetsPanel::new();
1049 self.expressions = ExpressionsPanel::new();
1050 self.qualify = crate::panels::qualify::QualifyPanel::new();
1051 self.family_table = crate::panels::family_table_editor::FamilyTableEditor::new();
1052 // A family-row preview left on in a document the user comes back to
1053 // would have no pane state to show or end it: switching ends it.
1054 self.docs.engine_mut().set_expression_preview(None);
1055 // Pinned to object NAMES of the old document ("Box" exists in most of
1056 // them), so these would silently retarget rather than go blank.
1057 self.info_windows = InfoWindows::new();
1058 self.interference = crate::panels::interference::InterferenceWindow::new();
1059 self.auto_constraints = crate::panels::auto_constraints::AutoConstraintsWindow::new();
1060 // The outdated-parts cache keys on `(applied_generation, save_generation)`,
1061 // and two documents' generations are unrelated — a switch can land on the
1062 // same key with an entirely different parts library.
1063 self.update_components.invalidate();
1064 self.viewport.forget_document();
1065 }
1066
1067 /// A signature of the CURRENT rendered model (rolled-to step) — solid count,
1068 /// per-solid triangle count + bbox, and total triangles. Published to JS so
1069 /// the headed verifier can prove each roll / edit produced different geometry
1070 /// (names alone don't: a SUBTRACT reuses the target's name).
1071 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1072 fn model_signature_json(&self) -> String {
1073 let solids: Vec<serde_json::Value> = self
1074 .docs
1075 .engine()
1076 .scene
1077 .solids()
1078 .iter()
1079 .map(|s| {
1080 serde_json::json!({
1081 "name": s.name,
1082 "tris": s.mesh.indices.len() / 3,
1083 "min": s.bbox.min,
1084 "max": s.bbox.max,
1085 })
1086 })
1087 .collect();
1088 let total_tris: usize = self
1089 .docs
1090 .engine()
1091 .scene
1092 .solids()
1093 .iter()
1094 .map(|s| s.mesh.indices.len() / 3)
1095 .sum();
1096 serde_json::json!({
1097 "step": self.docs.engine().history_rollback(),
1098 "solidCount": solids.len(),
1099 "totalTris": total_tris,
1100 "solids": solids,
1101 })
1102 .to_string()
1103 }
1104
1105 /// Open or close the floating Settings window — the toolbar gear's flag,
1106 /// reachable from the automation layer (`settings_window`).
1107 pub fn set_settings_window_open(&mut self, open: bool) {
1108 self.settings.open = open;
1109 }
1110
1111 /// Open or close the floating Part Properties window — the toolbar tag
1112 /// button's flag, reachable from the automation layer
1113 /// (`part_properties_window`).
1114 pub fn set_part_properties_window_open(&mut self, open: bool) {
1115 self.part_properties.open = open;
1116 }
1117
1118 /// Open or close the floating Info window — the toolbar info button's flag,
1119 /// reachable from the automation layer (`info_window`).
1120 pub fn set_info_window_open(&mut self, open: bool) {
1121 self.info.open = open;
1122 }
1123
1124 /// What this session is running on. The app's ONE record of it: the Info
1125 /// window draws it, a problem report carries it, the `diagnostics` command
1126 /// returns it and the MCP banner names its adapter — all from here.
1127 pub fn diagnostics(&self) -> &crate::diagnostics::Diagnostics {
1128 &self.diagnostics
1129 }
1130
1131 /// Begin the in-app problem report: the same call the Submit Bug button
1132 /// makes, and for the same reason it makes it THIS frame — `request`
1133 /// captures the current frame before its own dialog exists.
1134 pub fn begin_bug_report(&mut self, ctx: &egui::Context) {
1135 self.bug_report.request(ctx, self.docs.engine(), &self.diagnostics);
1136 }
1137
1138 /// Activate an available declarative panel using its stable package ID.
1139 pub(crate) fn show_plugin_panel(&mut self, id: &str) -> Result<(), String> {
1140 self.dock.show_plugin_panel(self.docs.engine(), id)
1141 }
1142
1143 /// Bring a built-in dock pane to the front (`show_pane`).
1144 pub fn show_pane(&mut self, kind: PaneKind) {
1145 self.dock.show_pane(kind);
1146 }
1147
1148 /// Shared document activation / unsaved-close handling for both presentations.
1149 fn apply_document_actions(&mut self, activate: Option<usize>, close: Option<usize>) {
1150 if (activate.is_some() || close.is_some()) && self.docs.engine().ref_select_in_form() {
1151 self.docs.engine_mut().finish_ref_select();
1152 }
1153 if let Some(index) = activate { self.docs.activate(index); }
1154 if let Some(index) = close { self.file.request_close(&mut self.docs, index); }
1155 }
1156
1157 /// The parts-library staleness check and its refresh — the Constraints
1158 /// header's "Update components (N)" button, reachable as the
1159 /// `component_update` command. Returns `(outdated, missing, refreshed)`;
1160 /// `run` is skipped when nothing is outdated.
1161 pub fn update_components(&mut self, run: bool) -> Result<(usize, Vec<String>, usize), String> {
1162 let store = self.model_store.as_ref();
1163 self.update_components.ensure_current(self.docs.engine_mut(), store, self.file.save_generation());
1164 let outdated = self.update_components.outdated_count();
1165 let missing = self.update_components.missing().to_vec();
1166 let refreshed = if run { self.update_components.run(self.docs.engine_mut(), store)? } else { 0 };
1167 Ok((outdated, missing, refreshed))
1168 }
1169
1170 /// Frame what the central tile is actually DRAWING: the OPEN SHEET's paper
1171 /// when a sheet is open, the 3D scene otherwise.
1172 ///
1173 /// This is where the fit branch lives, and it is here rather than in the
1174 /// toolbar because the two halves sit in different places — the 3D fit is an
1175 /// engine camera op (`EngineState::zoom_to_fit`) while the sheet's pan/zoom
1176 /// is viewport-transient state, refitted by clearing the marker the sheet
1177 /// paint compares against. One method, so the toolbar's Zoom-to-fit button
1178 /// and the `zoom_to_fit` command can never mean different things — and so
1179 /// the paper needs no Fit button of its own.
1180 ///
1181 /// The toolbar draws BEFORE the dock in `ui`, so a fit requested from the
1182 /// button lands on this very frame.
1183 pub fn zoom_to_fit(&mut self) {
1184 if self.docs.engine().sheet_open().is_some() {
1185 self.viewport.request_sheet_fit();
1186 } else {
1187 self.docs.engine_mut().zoom_to_fit();
1188 }
1189 }
1190
1191 /// Run what a WORKBENCH TOOLBAR button does, by its `WorkbenchButton::id`.
1192 ///
1193 /// Split out of `ui` so a toolbar click and the `workbench_button` command
1194 /// run the SAME arms: the workbench registry declares a button, this
1195 /// dispatches it, and the automation surface owns no second copy of the
1196 /// list. Returns whether the id was known.
1197 pub fn dispatch_workbench_button(&mut self, id: &str) -> bool {
1198 // The eCAD workbenches' buttons (Diagram, PCB, Symbol, Pads) are
1199 // eCAD's own actions: run on the active document's editors with
1200 // eCAD's `run_action`, so a press does exactly what eCAD's toolbar
1201 // button does. `None` is any other id, which falls through to the
1202 // arms below.
1203 if crate::workbench::ecad::dispatch(&mut self.docs.active_mut().ecad, id).is_some() {
1204 return true;
1205 }
1206 // The SHEET's constructions are the Drawing workbench's two tables plus
1207 // the section and the detail, and each press is the PMI annotation
1208 // buttons' workflow: the object is created on the open sheet with
1209 // nothing picked yet, and its dialog opens (through the dialog door,
1210 // which surfaces the Sheets pane) — where its reference rows are picked
1211 // on the paper with the reference picker.
1212 if let Some((kind, alignment)) = crate::workbench::drawing::dim_tool(id) {
1213 let engine = self.docs.engine_mut();
1214 if let Err(error) = engine.sheet_add_dimension(None, kind, Some(alignment), Vec::new()) {
1215 engine.push_notice(format!("Sheet dimension: {error}"));
1216 }
1217 return true;
1218 }
1219 if let Some(axis) = crate::workbench::drawing::ord_tool(id) {
1220 let engine = self.docs.engine_mut();
1221 if let Err(error) = engine.sheet_add_ordinate(None, axis, None, Vec::new()) {
1222 engine.push_notice(format!("Ordinate set: {error}"));
1223 }
1224 return true;
1225 }
1226 if id == crate::workbench::drawing::BOM_BUTTON_ID {
1227 let engine = self.docs.engine_mut();
1228 if let Err(error) = engine.sheet_insert_bom() { engine.push_notice(format!("BOM table: {error}")); }
1229 return true;
1230 }
1231 if id == crate::workbench::drawing::SECTION_BUTTON_ID {
1232 let engine = self.docs.engine_mut();
1233 if let Err(error) = engine.sheet_new_section(None) {
1234 engine.push_notice(format!("Section view: {error}"));
1235 }
1236 return true;
1237 }
1238 if id == crate::workbench::drawing::DETAIL_BUTTON_ID {
1239 let engine = self.docs.engine_mut();
1240 if let Err(error) = engine.sheet_new_detail(None) {
1241 engine.push_notice(format!("Detail view: {error}"));
1242 }
1243 return true;
1244 }
1245 // The SKETCH mode's draw tools are one table too, and they are SHARED —
1246 // every workbench's row carries them while a sketch is being edited, so
1247 // this arm answers for all of them whatever workbench is active.
1248 // Pressing the LIT tool puts it down (back to Select), matching the
1249 // sheet constructions above; pressing the lit Select is already Select.
1250 if let Some(tool) = crate::workbench::sketch::draw_tool(id) {
1251 let engine = self.docs.engine_mut();
1252 let armed = engine.sketch_active_tool().unwrap_or("select") == tool;
1253 engine.sketch_set_tool(Some(if armed { "select" } else { tool }));
1254 return true;
1255 }
1256 // The 3D sketch's tools and working-plane radio, shared the same way.
1257 if let Some(tool) = crate::workbench::sketch3d::draw_tool(id) {
1258 let engine = self.docs.engine_mut();
1259 let armed = engine.sketch3d_active_tool().unwrap_or("select") == tool;
1260 let _ = engine.sketch3d_set_tool(Some(if armed { "select" } else { tool }));
1261 return true;
1262 }
1263 if let Some(plane) = crate::workbench::sketch3d::work_plane(id) {
1264 let _ = self.docs.engine_mut().sketch3d_set_work_plane(plane);
1265 return true;
1266 }
1267 match id {
1268 // The sketch strip's one-shot action, now the row's: infer the
1269 // constraints the rough-in geometry already implies.
1270 crate::workbench::sketch::AUTOCONSTRAIN_BUTTON_ID => {
1271 self.docs.engine_mut().sketch_auto_constrain();
1272 }
1273 // Sheet Metal's flat pattern: open the export modal in its DXF /
1274 // SVG mode; the engine reports "no sheet-metal body in the part"
1275 // as a toast on export.
1276 "sheetmetal.flat_pattern" => {
1277 self.file.dispatch(FileAction::ExportFlatPattern, &mut self.docs, self.model_store.as_ref());
1278 }
1279 // Assembly's Add Component: open the insert-component modal (the
1280 // same flow as the ACOMP palette pick). ONE arm for both rows that
1281 // carry it — PCB BORROWS this very button (`workbench::BORROWED`)
1282 // rather than declaring a second id for the same action.
1283 crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID => {
1284 self.file.dispatch(FileAction::InsertComponent, &mut self.docs, self.model_store.as_ref());
1285 }
1286 // Assembly's interference check: run the engine's pairwise
1287 // intersect sweep NOW and open the results window.
1288 "assembly.interference" => {
1289 self.interference.open_and_run(self.docs.engine_mut());
1290 }
1291 // Assembly's Auto Constraints: open the inference window and scan
1292 // the current placement NOW, so it opens showing real counts.
1293 "assembly.auto_constrain" => {
1294 self.auto_constraints.open_and_scan(self.docs.engine_mut());
1295 }
1296 // Assembly's step.parts library: open the online-library browser
1297 // (search → thumbnails → import a STEP part → add as an ACOMP).
1298 "assembly.step_parts_library" => {
1299 self.step_parts.open();
1300 }
1301 // Wire harness's Declare connection point: the part's FIRST port
1302 // group, with one point at the origin. It exists because the
1303 // Qualify pane is on screen only while the part declares some — so
1304 // without it a 3D-only part could never get its first one from the
1305 // UI at all. The pane is surfaced by the dock's own ports door on
1306 // the next frame, once the visibility pass has let it back in.
1307 crate::workbench::wire_harness::DECLARE_POINT_BUTTON_ID => {
1308 let engine = self.docs.engine_mut();
1309 engine.history.set_ports_block(
1310 Some(serde_json::json!([{
1311 "name": crate::workbench::wire_harness::FIRST_GROUP,
1312 "purpose": crate::workbench::wire_harness::FIRST_PURPOSE,
1313 "points": [{
1314 "name": crate::workbench::wire_harness::FIRST_POINT,
1315 "transform": {
1316 "position": [0.0, 0.0, 0.0],
1317 "rotationEuler": [0.0, 0.0, 0.0],
1318 },
1319 }],
1320 }])),
1321 None,
1322 );
1323 }
1324 // PMI's Capture view: snapshot the camera + visibility into a new
1325 // active view and surface the PMI pane so its row is seen.
1326 crate::workbench::pmi::CAPTURE_BUTTON_ID => {
1327 self.docs.engine_mut().pmi_capture_view(None);
1328 self.dock.show_pane(PaneKind::Pmi);
1329 }
1330 // Drawing's Back to 3D: leave the open sheet. The paper's own
1331 // widgets go with it, and the sheet keeps its contents — the same
1332 // edit the Sheets pane's Close sheet makes.
1333 crate::workbench::drawing::SHEET_CLOSE_BUTTON_ID => {
1334 let _ = self.docs.engine_mut().sheet_set_open(None);
1335 self.viewport.forget_sheet();
1336 }
1337 // Drawing's Add sheet: a new sheet, opened on the paper, and the
1338 // Sheets pane brought forward so its row is seen.
1339 crate::workbench::drawing::ADD_SHEET_BUTTON_ID => {
1340 self.docs.engine_mut().sheet_add(None);
1341 self.dock.show_pane(PaneKind::Sheets);
1342 }
1343 // Drawing's Place view: the first saved PMI view on the open sheet;
1344 // the placement's form opens (the door surfaces the pane), and its
1345 // `view` field is where a different view is chosen.
1346 crate::workbench::drawing::PLACE_VIEW_BUTTON_ID => {
1347 let engine = self.docs.engine_mut();
1348 let placed = match engine.sheet_pmi_view_ids().first().cloned() {
1349 Some(view) => engine.sheet_place_view(None, &view, None, None).map(|_| ()),
1350 None => Err("capture a PMI view in the PMI workbench first".to_string()),
1351 };
1352 if let Err(error) = placed {
1353 engine.push_notice(format!("Place view: {error}"));
1354 }
1355 }
1356 _ => return false,
1357 }
1358 true
1359 }
1360
1361}
1362
1363impl eframe::App for BrepApp {
1364 /// Phase 1 of the automation frame (§4.4): queued input commands become
1365 /// egui events; a screenshot that landed completes its reply. Called by
1366 /// every eframe runner before `ui`; the headless host calls it itself.
1367 #[cfg(feature = "automation")]
1368 fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
1369 let view = self.viewport.last_rect();
1370 self.automation.drain_input(raw_input, ctx.cumulative_frame_nr(), ctx.pixels_per_point(), view, ctx);
1371 }
1372
1373
1374 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
1375 // Open this frame's timing window (banks the previous frame's phase
1376 // totals and its period) and bracket the whole body. See `perf`: on a
1377 // single-threaded shell a long frame IS a frozen UI, so what the frame
1378 // spends is the whole diagnosis.
1379 crate::perf::tick();
1380 let _ui_span = crate::perf::span(crate::perf::Phase::Ui);
1381
1382 // The native store and PLM queue (`plm::native`): every task whose I/O
1383 // answered since the last frame finishes here, on this thread, FIRST —
1384 // before any early return below (the mesh preview's replaces the whole
1385 // shell) can skip it. The browser drains its microtasks between events
1386 // the same way, whatever the page is showing.
1387 #[cfg(not(target_arch = "wasm32"))]
1388 crate::plm::native::run_pending();
1389 // A PLM save made mid-rebuild is pictured once its run lands.
1390 crate::plm::thumbnail::tick(|id| self.docs.iter().find(|d| d.id() == id).map(|d| &d.engine));
1391 // The File menu's recent-file pictures that finished since last frame.
1392 crate::plm::thumbnail::persist_recent(self.model_store.as_ref());
1393
1394 // --- global keyboard shortcuts (undo/redo/clear-selection) ------------
1395 // Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
1396 // this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
1397 // `&mut self` call).
1398 let ctx = ui.ctx().clone();
1399
1400 self.plugins.finish_installations(&mut self.docs, self.model_store.as_ref());
1401 self.plugins.sync_documents(&mut self.docs);
1402 self.javascript.poll(&ctx, &mut self.docs, self.model_store.as_ref());
1403 self.plugins.poll(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
1404
1405 // Phase 2 of the automation frame (§4.4): mutations run before any
1406 // panel draws, so this frame shows their effect.
1407 #[cfg(feature = "automation")]
1408 {
1409 let queue = self.automation.clone();
1410 let frame = ctx.cumulative_frame_nr();
1411 queue.drain_app(
1412 crate::automation::command::Phase::Mutate,
1413 &mut crate::automation::command::Ctx { app: self, egui: &ctx },
1414 frame,
1415 );
1416 }
1417
1418 // A measured spin (`perf_spin`) advances one pointer step per frame, here
1419 // — after the mutation drain that may have armed it, so the spin starts
1420 // on the frame it was asked for.
1421 #[cfg(feature = "automation")]
1422 self.step_spin(&ctx);
1423
1424 // --- a different document is active than the panels were drawn for ----
1425 // Checked FIRST, before anything draws: the switch itself happened late
1426 // in some earlier frame (a tab click, a close, an Open), and every
1427 // shared panel is still holding the previous document's transient state.
1428 if self.active_document != self.docs.active_id() {
1429 if !self.javascript.running_in(self.active_document) {
1430 for doc in self.docs.iter_mut() { if doc.id() == self.active_document { doc.engine.cancel_plugin_action(); } }
1431 }
1432 self.plugins.close_action();
1433 self.active_document = self.docs.active_id();
1434 self.reset_document_scoped_state();
1435 }
1436
1437 // --- the workbench decides whether placed parts show their ports -------
1438 // A workbench that shows the Wire Harness panel (Wire harness, All)
1439 // draws the ports components carry; the others — Modeling included,
1440 // though it offers the Port feature — keep the assembly clean. A no-op
1441 // when nothing changed.
1442 {
1443 // The document's name lives in the SHELL (the tab, the file), and a
1444 // drawing sheet's title block prints it. Told to the engine here,
1445 // every frame, because that is where the active tab is known; a
1446 // no-op when it has not changed. It is the DISPLAY name, the one the
1447 // tab shows: a stamp reading `bracket.nbrep` would be naming our
1448 // file format rather than the drawing.
1449 let name = self
1450 .docs
1451 .active()
1452 .name()
1453 .map(crate::store::model_display_name)
1454 .unwrap_or_default();
1455 let engine = self.docs.engine_mut();
1456 engine.set_document_name(&name);
1457 let show = crate::workbench::panel_visible(
1458 &engine.settings.workbench,
1459 crate::workbench::wire_harness::PANEL_ID,
1460 &crate::workbench::ButtonState::of(engine),
1461 );
1462 engine.set_component_ports_visible(show);
1463 // The PMI workbench IS the PMI editing mode: a workbench that shows
1464 // the PMI panel (PMI, All) enters it — the modeling camera /
1465 // visibility / wireframe are remembered — and one that hides it
1466 // leaves it, deactivating the view and restoring them.
1467 let pmi_shown = crate::workbench::panel_visible(
1468 &engine.settings.workbench,
1469 crate::workbench::pmi::PANEL_ID,
1470 &crate::workbench::ButtonState::of(engine),
1471 );
1472 if pmi_shown && !engine.pmi_workbench_entered() {
1473 engine.pmi_enter_workbench();
1474 } else if !pmi_shown && engine.pmi_workbench_entered() {
1475 engine.pmi_leave_workbench();
1476 }
1477 // The DRAWING workbench owns the paper: LEAVING it for a workbench
1478 // that hides the Sheets pane closes an open sheet, so the central
1479 // tile is not left drawing paper whose tools, pane and way back are
1480 // all hidden. It is a MODE change — the sheets and their contents
1481 // are untouched — and an edge, see `sheets_pane_shown`.
1482 let sheets_shown = crate::workbench::panel_visible(
1483 &engine.settings.workbench,
1484 crate::workbench::drawing::SHEETS_PANEL_ID,
1485 &crate::workbench::ButtonState::of(engine),
1486 );
1487 if self.sheets_pane_shown && !sheets_shown && engine.sheet_open().is_some() {
1488 let _ = engine.sheet_set_open(None);
1489 self.viewport.forget_sheet();
1490 }
1491 self.sheets_pane_shown = sheets_shown;
1492 }
1493
1494 // --- GUI chrome theme -------------------------------------------------
1495 // Apply the user's theme preference to the egui chrome every frame
1496 // (idempotent: `set_theme` just stores the preference). Auto follows the
1497 // OS/system theme (prefers-color-scheme on web); egui falls back to dark
1498 // when no OS signal is available. This controls panels/windows/toolbar/
1499 // text only — the 3D viewport `background` is a separate setting.
1500 ctx.set_theme(match self.docs.engine().settings.theme {
1501 ThemeMode::Auto => egui::ThemePreference::System,
1502 ThemeMode::Light => egui::ThemePreference::Light,
1503 ThemeMode::Dark => egui::ThemePreference::Dark,
1504 });
1505
1506 // --- global UI size scale --------------------------------------------
1507 // Apply the user's "UI scale" to the whole egui chrome every frame. This
1508 // is idempotent when unchanged (`set_zoom_factor` only repaints on an
1509 // actual change) and composes with the native device pixel ratio
1510 // (pixels_per_point = zoom_factor * native_pixels_per_point).
1511 //
1512 // Defer live UI rescale while the user drags the Settings "UI scale" slider:
1513 // the slider value updates continuously, but only commit it to the actual egui
1514 // zoom once the pointer is released, so the whole UI doesn't rescale under the
1515 // cursor mid-drag.
1516 let pointer_down = ctx.input(|i| i.pointer.any_down());
1517 if !pointer_down {
1518 self.applied_ui_scale = self.docs.engine().settings.ui_scale;
1519 }
1520 ctx.set_zoom_factor(self.applied_ui_scale);
1521 crate::viewport::touch::prepare_browser_input(ui);
1522
1523 // --- history-runner pump ---------------------------------------------
1524 // Apply any completed background history run BEFORE panels read the scene.
1525 // For the synchronous InlineRunner this is a no-op (`rerun_history` already
1526 // pumped its own submit), so nothing changes today; it is the seam a future
1527 // native-thread / wasm-worker runner lands its reply through. While a run is
1528 // still in flight, keep the frame loop alive so its reply gets pumped — for
1529 // Inline `run_pending()` is always false, so this never fires.
1530 //
1531 // EVERY open document is pumped, not just the active one — see
1532 // `Documents::pump_all`, which also keeps the active document's parts
1533 // library in the kernel's store when a background tab's run lands.
1534 let work_in_flight = self.docs.pump_all();
1535 if work_in_flight {
1536 ctx.request_repaint();
1537 }
1538 if self.show_stl_preview(ui) {
1539 // The preview REPLACES the shell for the frame, and this early
1540 // return used to take the frame's READ drain with it: while a
1541 // preview was open every read-only automation command — `ping`,
1542 // `state_get`, `hit_rects`, a screenshot — went unanswered until
1543 // the host gave up on it, so no script could observe the preview it
1544 // had just opened. The preview publishes its own state and rects
1545 // above; this answers the reads that ask for them.
1546 #[cfg(feature = "automation")]
1547 self.drain_reads(&ctx);
1548 return;
1549 }
1550 if crate::automation::registry::enabled() {
1551 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
1552 crate::automation::registry::publish("__brepImportPreview", "STL/OBJ import preview state (tolerances, counts, accept readiness); null when no preview is open", "null");
1553 }
1554
1555 // The tab strip's dirty dots, refreshed once per frame (cheap — see
1556 // `Document::refresh_dirty_marker`).
1557 self.docs.refresh_dirty_markers();
1558
1559 // --- boot-load (?loadModel=): apply the fetched model once it lands ----
1560 // Replaces the seed with the URL-specified document (armed in `new`). The
1561 // ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
1562 // `load_model_and_fit` arms deferred framing; the pump above reframes it
1563 // next frame. `mark_clean` opens it as a non-dirty document.
1564 if self.pending_boot_load.is_some() {
1565 let received = self
1566 .pending_boot_load
1567 .as_ref()
1568 .and_then(|rx| rx.try_recv().ok());
1569 if let Some(result) = received {
1570 self.pending_boot_load = None;
1571 match result {
1572 Ok(json) => {
1573 // REPLACES the seed in place rather than adding a tab.
1574 // It lands on the document that is already active,
1575 // whatever the session restored.
1576 let _ = self.docs.engine_mut().load_model_and_fit(&json);
1577 self.docs.active_mut().mark_clean();
1578 }
1579 Err(e) => self
1580 .docs
1581 .engine_mut()
1582 .push_notice(format!("Could not load model from URL: {e}")),
1583 }
1584 }
1585 }
1586
1587 // --- "Open in CAD" (?open=part/…/rev/…) --------------------------------
1588 if let Some(launch) = self.plm_launch.as_mut() {
1589 use crate::panels::plm_launch::Step;
1590 let step = launch.step(self.model_store.as_ref(), &mut self.docs, &mut self.file);
1591 match &step {
1592 Step::SignIn(sentence) => {
1593 self.settings.show_plm_tab();
1594 self.docs.engine_mut().push_notice(sentence.clone());
1595 }
1596 Step::Refused(sentence) => self.docs.engine_mut().push_notice(sentence.clone()),
1597 _ => {}
1598 }
1599 if step.settles() {
1600 crate::panels::plm_launch::forget_query();
1601 }
1602 if launch.is_done() {
1603 self.plm_launch = None;
1604 } else {
1605 ui.ctx().request_repaint();
1606 }
1607 }
1608
1609 crate::panels::ecad_parts::sync(self.docs.active_mut());
1610 // A sheet's Duplicate past the last offered occurrence: another assembly component.
1611 crate::panels::ecad_parts::answer_duplicate_requests(self.docs.active_mut());
1612 let requests = crate::panels::ecad_parts::take_open_requests(self.docs.active_mut());
1613 for key in requests {
1614 self.file.open_document(&mut self.docs, self.model_store.as_ref(), &key);
1615 }
1616 // The parts pane's Add part button presses Assembly's Add Component: one arm.
1617 if crate::panels::ecad_parts::take_add_part_request(self.docs.active_mut()) {
1618 self.dispatch_workbench_button(crate::workbench::assembly::ADD_COMPONENT_BUTTON_ID);
1619 }
1620 // The eCAD host's Update parts button is Constraints' Update components.
1621 if std::mem::take(&mut self.docs.active_mut().ecad_update_requested) {
1622 if let Err(error) = self.update_components.run(self.docs.engine_mut(), self.model_store.as_ref()) {
1623 self.docs.engine_mut().push_notice(format!("Update components: {error}"));
1624 }
1625 }
1626
1627 // --- update-components badge freshness ---------------------------------
1628 // Keep the outdated-parts checker current BEFORE any assembly panel draws
1629 // (the structure tree renders per-node badges ahead of the constraints
1630 // header). Cheap: a real recompute happens only when an applied run or a
1631 // successful store save moved the generation key.
1632 self.update_components.ensure_current(
1633 self.docs.engine_mut(),
1634 self.model_store.as_ref(),
1635 self.file.save_generation(),
1636 );
1637 // …and the eCAD host marks the components placed from those parts.
1638 self.docs.active_mut().ecad_outdated = self.update_components.outdated().to_vec();
1639
1640 // --- async-safe first-model framing -----------------------------------
1641 // The seed run is async under a background runner (native thread / wasm
1642 // worker), so the boot `zoom_to_fit` may have run before any solid existed.
1643 // Frame the model ONCE, the first frame the seed run has fully landed (solids
1644 // present AND no run still in flight). Under the synchronous Inline runner
1645 // (tests) both hold on the very first frame, so this is identical to today.
1646 if !self.first_run_framed
1647 && !self.docs.engine().run_pending()
1648 && self.docs.engine().has_solids()
1649 {
1650 self.docs.engine_mut().zoom_to_fit();
1651 self.first_run_framed = true;
1652 }
1653
1654 self.handle_shortcuts(&ctx);
1655
1656 // --- top toolbar: primary actions, drawn FIRST so its top strip is
1657 // reserved above the left panel + central viewport. A clicked File button
1658 // returns an action the file dialog acts on (open its modal / save / new).
1659 self.toolbar.read_only = !self.docs.active().access().is_editable();
1660 if crate::viewport::touch::compact(&ctx) {
1661 self.toolbar.documents = document_tabs::MenuState::from_documents(&self.docs);
1662 }
1663 let doc = self.docs.active_mut();
1664 let toolbar_outcome = self.toolbar.show(
1665 ui,
1666 &mut doc.engine,
1667 Some(&doc.ecad),
1668 self.model_store.as_ref(),
1669 &mut self.settings.open,
1670 &mut self.part_properties.open,
1671 &mut self.info.open,
1672 );
1673 crate::panels::set_chrome_bottom(&ctx, ui.available_rect_before_wrap().top());
1674 if toolbar_outcome.toggle_mobile_panels { self.dock.toggle_mobile_drawer(); }
1675 if crate::viewport::touch::compact(&ctx) {
1676 self.apply_document_actions(toolbar_outcome.documents.activate, toolbar_outcome.documents.close);
1677 self.document_tab_hits = toolbar_outcome.documents.hits;
1678 }
1679 if toolbar_outcome.plugins { self.plugins.open = !self.plugins.open; }
1680 if toolbar_outcome.javascript { self.javascript.open = !self.javascript.open; }
1681 if let Some(id) = toolbar_outcome.plugin_action {
1682 if let Err(error) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(error); }
1683 }
1684 self.plugins.active_document = self.docs.active_id();
1685 self.plugins.show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
1686 self.javascript.show(&ctx, &mut self.docs, self.model_store.as_ref());
1687 // An inbox row opens its review in the PLM pane.
1688 for event in toolbar_outcome.plm_review {
1689 self.plm.open_review(event);
1690 }
1691 if let Some(name) = toolbar_outcome.recent_document {
1692 self.file
1693 .open_document(&mut self.docs, self.model_store.as_ref(), &name);
1694 }
1695 if let Some(action) = toolbar_outcome.file {
1696 self.file
1697 .dispatch(action, &mut self.docs, self.model_store.as_ref());
1698 }
1699 // Submit Bug: begin the screenshot-capture + report flow. `request`
1700 // grabs the current frame (before its dialog exists) and the model, so
1701 // it must run THIS frame while the shot is still dialog-free.
1702 if toolbar_outcome.bug_report {
1703 self.bug_report.request(&ctx, self.docs.engine(), &self.diagnostics);
1704 }
1705 // A workbench toolbar button click surfaces its id here; the dispatch
1706 // itself is a METHOD so the `workbench_button` command runs the very
1707 // same arms (a button an agent can only reach by clicking is a button
1708 // an agent cannot reach when something covers it).
1709 if let Some(id) = toolbar_outcome.workbench_button {
1710 self.dispatch_workbench_button(id);
1711 }
1712 // Zoom-to-fit: the ONE button frames whatever the tile draws — the open
1713 // sheet's paper, or the 3D scene. See `BrepApp::zoom_to_fit`.
1714 if toolbar_outcome.zoom_to_fit {
1715 self.zoom_to_fit();
1716 }
1717
1718 // --- workbench actions toolbar: a second strip directly UNDER the
1719 // primary toolbar (egui stacks top panels in call order) listing the
1720 // active workbench's creatable features + the constraint types. Off via
1721 // the Settings checkbox, and never in sketch / reference-selection mode
1722 // (the panel decides — see `WorkbenchToolbarPanel::visible`). A feature
1723 // click adds exactly what the palette pick would, so ACOMP still routes
1724 // to the component selector; a constraint click is the context bar's
1725 // constraint offer, seeded from the current selection.
1726 // A read-only document (a PLM revision not checked out, or released)
1727 // draws the strip disabled: every button on it creates something.
1728 self.workbench_toolbar.read_only = !self.docs.active().access().is_editable();
1729 let actions = if self.docs.engine().settings.toolbar_style
1730 == brep_render::style::ToolbarStyle::Classic
1731 {
1732 self.workbench_toolbar.show(ui, self.docs.engine())
1733 } else {
1734 self.workbench_toolbar
1735 .set_ribbon_hits(self.toolbar.home_hits());
1736 toolbar_outcome.creation
1737 };
1738 if let Some(type_code) = actions.feature {
1739 self.history
1740 .add_feature_of_type(self.docs.engine_mut(), &type_code);
1741 // No `show_pane` here: `HistoryPanel::open_form` moved the target and
1742 // the dialog door surfaces History for it (`surface_open_dialogs`
1743 // below). ACOMP opens the component-selector MODAL instead of a
1744 // feature dialog, so it moves no target and surfaces no pane.
1745 if self.history.take_insert_component_request() {
1746 self.file.dispatch(
1747 FileAction::InsertComponent,
1748 &mut self.docs,
1749 self.model_store.as_ref(),
1750 );
1751 }
1752 }
1753 if let Some(type_id) = actions.constraint {
1754 // The strip offers every type regardless of the selection (unlike the
1755 // context bar's gated offers), so a refusal has to be SAID: a toast,
1756 // never a silent no-op. A document with no components has no assembly
1757 // session to add to (the kernel's own message names the session, not
1758 // the cause) — say what is actually missing.
1759 let engine = self.docs.engine_mut();
1760 if !engine.history_has_assembly() {
1761 engine.push_notice(
1762 "Add constraint: the document has no components — insert a component first"
1763 .to_string(),
1764 );
1765 } else {
1766 // The add leaves the new constraint OPEN (the kernel mints it
1767 // that way), so the dialog door surfaces the Constraints pane.
1768 if let Err(error) =
1769 crate::panels::context_bar::add_constraint_from_selection(engine, &type_id)
1770 {
1771 engine.push_notice(format!("Add constraint: {error}"));
1772 }
1773 }
1774 }
1775 if let Some(type_id) = actions.annotation {
1776 // The context bar's annotation offer, from the strip: seeded from
1777 // the selection, and the new annotation's form opens through the
1778 // dialog door. The strip offers every type whatever is selected, so
1779 // a refusal — no active view to add to — is said, not swallowed.
1780 let engine = self.docs.engine_mut();
1781 if let Err(error) = crate::panels::context_bar::add_pmi_from_selection(engine, &type_id) {
1782 engine.push_notice(format!("Add annotation: {error}"));
1783 }
1784 }
1785
1786 // --- sketch mode has NO strip of its own: its draw tools are workbench
1787 // buttons in the row above (`workbench::sketch`), offered while a sketch
1788 // is being edited, so the 3D viewport is the full-width sketching
1789 // surface with nothing between it and the one toolbar.
1790
1791 // --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
1792 // chosen by context each frame. Drawn AFTER the top bars but BEFORE the
1793 // left panel(s) so it reserves the FULL bottom width and the left column
1794 // stops above it (egui resolves reserved space by call order). It is a
1795 // HOST: the branch below picks what to draw. A NEW context is added by
1796 // extending this branch (e.g. `else if engine.some_mode() { … }`) and
1797 // routing through the owning panel's `show_status_bar` for DRY styling.
1798 // Its top is the floor the toasts stack up from (`Toasts::show`).
1799 //
1800 // Its RIGHT END is the app-wide working indicator (`panels::busy`),
1801 // whatever the context: collected here from every source, drawn first
1802 // in a right-to-left row so the context content gets what is left of
1803 // the width and never runs under it.
1804 self.collect_busy(&ctx);
1805 let status_bar_top = egui::containers::panel::Panel::bottom("brep-status-bar")
1806 .resizable(false)
1807 .min_size(30.0)
1808 .show(ui, |ui| {
1809 ui.add_space(2.0);
1810 // `Align::Min`, not `Center`: the bar's height is not known
1811 // until its content is laid out, and centring against the
1812 // unbounded space below doubled the bar with the indicator on
1813 // a row of its own.
1814 ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
1815 self.busy.show(ui);
1816 ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
1817 if self.docs.engine().sketch_mode() {
1818 // Sketch context: the status row (title / DOF / N selected /
1819 // undo-redo / Lock). The selection-filter row is NOT drawn
1820 // now, so drop its stale hit-rects (the verifier must never
1821 // click a phantom rect for an off-screen widget).
1822 self.selection.clear_hits();
1823 self.sketch.show_status_bar(ui, self.docs.engine_mut());
1824 } else if self.docs.engine().sketch3d_mode() {
1825 // The 3D sketch's status row: DOF, selection, the
1826 // working plane in force and what the last click hit.
1827 self.selection.clear_hits();
1828 self.sketch3d.show_status_bar(ui, self.docs.engine_mut());
1829 } else if crate::workbench::ecad::Target::of_workbench(&self.docs.engine().settings.workbench).is_some() {
1830 // An eCAD editor owns the central tile, and a click there
1831 // picks nothing 3D: the pickable-kinds filter would be a
1832 // row of dead checkboxes. The strip carries the editor's
1833 // own line instead: where the pointer is and what is under it.
1834 self.selection.clear_hits();
1835 self.viewport.show_ecad_status(ui);
1836 } else {
1837 // Modeling context: the selection filter (pickable kinds).
1838 self.selection.show_status_bar(ui, self.docs.engine_mut());
1839 }
1840 });
1841 });
1842 ui.add_space(2.0);
1843 })
1844 .response
1845 .rect
1846 .top();
1847 // Cancel on the working indicator: sent to the document whose run it
1848 // named, which a background tab's run need not be the active one.
1849 if let Some(activity) = self.busy.take_cancel() {
1850 if let Some(doc) = self.docs.iter_mut().find(|doc| Some(doc.id()) == activity.document) {
1851 doc.engine.cancel_run();
1852 }
1853 }
1854
1855 // --- central region: the dock tree, OR (special modes) the bare 3D view
1856 // ---------------------------------------------------------------------
1857 // Normal modeling mode: ONE egui_tiles tree fills the whole remaining
1858 // area between the top toolbar and the bottom status bar. Every side-panel
1859 // section AND the 3D viewport are tiles the user can split / tab / resize /
1860 // drag-rearrange, and the layout persists. Which side panes are visible is
1861 // filtered per-workbench inside the dock (`workbench::panel_visible`).
1862 //
1863 // Sketch mode and reference-selection are "special modes" that take over
1864 // the shell: they BYPASS the tree and draw the viewport directly, so the
1865 // modeling side panes don't appear (sketch's own entity-list panel + the
1866 // top-right mode card own those flows). Drawing the viewport HERE — before
1867 // the top-right overlay below — keeps `viewport.last_rect()` current-frame
1868 // so the overlay anchors to the live 3D-view rect with no lag.
1869 let sketch = self.docs.engine().any_sketch_mode();
1870 // Only a CARD-based pick (a connection point's seat, a spline anchor's
1871 // port) takes the shell over. A FORM FIELD's pick — a feature's, a
1872 // constraint's, an annotation's, a sheet object's — keeps the dock: the
1873 // field is drawn focused in its own pane and the picks go to it, the
1874 // way other CAD applications keep the dialog up while you click in the
1875 // view (`EngineState::ref_select_in_form`).
1876 let modal_pick = self.docs.engine().ref_select_modal();
1877
1878 let compact = crate::viewport::touch::compact(&ctx);
1879 let entity_drawer_id = egui::Id::new("brep-mobile-sketch-entities");
1880 let mut entity_drawer_open = sketch && ctx.data(|d| d.get_temp::<bool>(entity_drawer_id)).unwrap_or(false);
1881 let mut mobile_sketch_hits: std::collections::HashMap<String, egui::Rect> = std::collections::HashMap::new();
1882 if sketch && compact {
1883 egui::containers::panel::Panel::top("brep-mobile-sketch-controls").resizable(false).show(ui, |ui| {
1884 ui.horizontal(|ui| {
1885 self.mode_bar.card(ui, self.docs.engine_mut());
1886 });
1887 });
1888 self.sketch.clear_list_hits();
1889 self.sketch3d.clear_list_hits();
1890 } else if sketch {
1891 // Sketch entity lists (Curves / Points / Constraints) + solver
1892 // settings — a dedicated left panel, drawn BEFORE the viewport so it
1893 // reserves the left and the viewport fills the rest.
1894 egui::containers::panel::Panel::left("sketch-entities")
1895 .resizable(true)
1896 .default_size(300.0)
1897 .size_range(200.0..=560.0)
1898 .show(ui, |ui| {
1899 if self.docs.engine().sketch3d_mode() {
1900 self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
1901 } else {
1902 self.sketch.show_entity_lists(ui, self.docs.engine_mut());
1903 }
1904 });
1905 }
1906
1907 self.history.sync_palette_display(self.model_store.as_ref());
1908 // The PLM panels (a PLM session only): each PLM document's access from
1909 // its revision, and the revisions the user asked to open.
1910 let plm = self.plm.sync(self.model_store.as_ref(), &mut self.docs, self.file.save_generation());
1911 for name in plm.open {
1912 self.file.open_document(&mut self.docs, self.model_store.as_ref(), &name);
1913 }
1914 // A PLM section asked for a file (Attach a file…, Add file…): the file
1915 // chooser answers, and the file goes back to the section that asked.
1916 if let Some((tag, title)) = plm.pick_file {
1917 if let Err(error) = self.file.request_pick_file(self.model_store.as_ref(), &tag, &title) {
1918 self.file.status = error;
1919 }
1920 }
1921 for tag in self.plm.pick_tags() {
1922 if let Some(file) = self.file.take_picked_file(&tag) {
1923 self.plm.deliver_picked(&tag, file);
1924 }
1925 }
1926 if plm.refresh_inbox {
1927 self.toolbar.refresh_inbox();
1928 }
1929 // Save As → "A new part" (D9): the PLM pane's New part form, shown.
1930 if self.file.take_plm_new_part() {
1931 self.plm.open_new_part();
1932 self.show_pane(PaneKind::Plm);
1933 }
1934 if self.plm.busy() {
1935 ui.ctx().request_repaint();
1936 }
1937 // Following other clients (`panels::plm_follow`): when the feed moved,
1938 // the inbox asks again too; and the next question gets a frame.
1939 if self.plm.take_followed() {
1940 self.toolbar.refresh_inbox();
1941 }
1942 if let Some(next) = self.plm.next_follow_in() {
1943 ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(next.max(0.1)));
1944 }
1945 if sketch || modal_pick {
1946 self.viewport.set_chrome_obscured(compact && entity_drawer_open);
1947 // Special mode: the viewport fills the remaining central area; no
1948 // dock, no modeling side panes — and therefore no DOCUMENT TAB
1949 // STRIP either, which is the guard that keeps a live sketch /
1950 // reference-pick session from having its document swapped out from
1951 // under it.
1952 self.viewport.show(ui, self.docs.engine_mut());
1953 if compact && sketch {
1954 let view = self.viewport.last_rect().unwrap_or(ui.max_rect());
1955 egui::Area::new(egui::Id::new("brep-mobile-sketch-tab"))
1956 .order(egui::Order::Middle).fixed_pos(view.left_top() + egui::vec2(2.0, 4.0)).movable(false)
1957 .show(&ctx, |ui| {
1958 let tab = crate::panels::dock::mobile_tab(ui, "Entities", entity_drawer_open);
1959 mobile_sketch_hits.insert("toggle".into(), tab.rect);
1960 if entity_drawer_open { mobile_sketch_hits.insert("close".into(), tab.rect); }
1961 if tab.clicked() { entity_drawer_open = !entity_drawer_open; ctx.request_repaint(); }
1962 });
1963 if entity_drawer_open {
1964 let panel = egui::Rect::from_min_size(view.left_top() + egui::vec2(52.0, 4.0),
1965 egui::vec2((view.width() - 60.0).min(400.0).max(100.0), (view.height() - 8.0).max(100.0)));
1966 crate::panels::dock::mobile_panel(&ctx, entity_drawer_id, panel, |ui| {
1967 if self.docs.engine().sketch3d_mode() {
1968 self.sketch3d.show_entity_lists(ui, self.docs.engine_mut());
1969 } else {
1970 self.sketch.show_entity_lists(ui, self.docs.engine_mut());
1971 }
1972 });
1973 }
1974 }
1975 DockState::retract_all(DockContext {
1976 docs: &mut self.docs,
1977 plugins: &mut self.plugins,
1978 viewport: &mut self.viewport,
1979 history: &mut self.history,
1980 bom: &mut self.bom,
1981 assembly_constraints: &mut self.assembly_constraints,
1982 wire_harness: &mut self.wire_harness,
1983 pmi: &mut self.pmi,
1984 sheets: &mut self.sheets,
1985 scene: &mut self.scene,
1986 expressions: &mut self.expressions,
1987 qualify: &mut self.qualify,
1988 family_table: &mut self.family_table,
1989 update_components: &mut self.update_components,
1990 model_store: self.model_store.as_ref(),
1991 plm: &mut self.plm,
1992 });
1993 } else {
1994 // Normal mode: the dock owns the whole central area (the viewport is a
1995 // pane). Cross-panel requests the panels can't act on while their
1996 // borrows are held bubble OUT via the returned outcome — the SAME
1997 // requests the old left-panel closure produced.
1998 let outcome = self.dock.ui(
1999 ui,
2000 DockContext {
2001 docs: &mut self.docs,
2002 plugins: &mut self.plugins,
2003 viewport: &mut self.viewport,
2004 history: &mut self.history,
2005 bom: &mut self.bom,
2006 assembly_constraints: &mut self.assembly_constraints,
2007 wire_harness: &mut self.wire_harness,
2008 pmi: &mut self.pmi,
2009 sheets: &mut self.sheets,
2010 scene: &mut self.scene,
2011 expressions: &mut self.expressions,
2012 qualify: &mut self.qualify,
2013 family_table: &mut self.family_table,
2014 update_components: &mut self.update_components,
2015 model_store: self.model_store.as_ref(),
2016 plm: &mut self.plm,
2017 },
2018 );
2019
2020 // A form field's pick, settled for the frame: finished when its
2021 // single field has its one pick, when its form asked (an edit
2022 // elsewhere on it), or when no form drew it.
2023 self.settle_form_pick();
2024
2025 // The ACOMP palette pick must open the COMPONENT SELECTOR, never a
2026 // bare feature dialog — the file dialog is shell-owned.
2027 if outcome.insert_component_requested {
2028 self.file.dispatch(
2029 FileAction::InsertComponent,
2030 &mut self.docs,
2031 self.model_store.as_ref(),
2032 );
2033 }
2034 // The DOCUMENT TAB STRIP inside the viewport tile. Activation is
2035 // immediate; a close routes through the file dialog because a dirty
2036 // document has to be confirmed first, and that prompt lives there.
2037 // A pick runs in ONE document's engine: switching or closing a
2038 // document commits a form field's pick first, so it is never left
2039 // running in a document that is no longer on screen.
2040 self.apply_document_actions(outcome.document_tabs.activate, outcome.document_tabs.close);
2041 if !compact { self.document_tab_hits = outcome.document_tabs.hits; }
2042 // A structure-tree Edit — or a BOM row's action button, which
2043 // reports through the same outcome field so there is one arm and
2044 // not two — expands its feature in the history tree.
2045 if let Some(focus) = outcome.feature_focus {
2046 self.history.focus_feature(focus);
2047 if compact { self.dock.show_pane(PaneKind::History); }
2048 }
2049 // Structure-tree interaction hooks route through the SAME dispatcher
2050 // as the context bar (one truth per action); document-level flows
2051 // (edit-in-place / open-part) come back as requests the shell runs.
2052 // A BOM row menu's document-level flow: its engine-mutating half
2053 // already ran inside the panel, through the same dispatcher.
2054 match outcome.component_request {
2055 Some(ComponentActionRequest::OpenPart { component_id }) => {
2056 self.edit_part(&component_id);
2057 }
2058 None => {}
2059 }
2060 }
2061
2062 self.history.sync_palette_display(self.model_store.as_ref());
2063
2064 // --- file dialog: a ctx-level modal (like the command palette), drawn
2065 // after the panels so its backdrop dims the whole shell. Idempotent when
2066 // closed; also polls for a completed async import each frame.
2067 self.file
2068 .show(&ctx, &mut self.docs, self.model_store.as_ref());
2069 self.plm.save_prompt(&ctx, &mut self.docs, &mut self.file, self.model_store.as_ref());
2070
2071 // --- crash recovery: the boot prompt, then the debounced autosave -----
2072 // Drawn with the same ctx-level modal treatment as the file dialog. The
2073 // autosave ticks only once the prompt has resolved (or never existed).
2074 if self.recovery.is_open() {
2075 if let Some(resolution) =
2076 self.recovery.show(&ctx, &mut self.docs, self.model_store.as_ref())
2077 {
2078 self.autosave.note_cleared();
2079 if let crate::recovery::Resolution::Restored(count) = resolution {
2080 self.docs.engine_mut().push_notice_as(NoticeSeverity::Info, format!(
2081 "Restored {count} unsaved document{}",
2082 if count == 1 { "" } else { "s" }
2083 ));
2084 }
2085 }
2086 } else {
2087 self.recovery.clear_hits();
2088 let now = ctx.input(|i| i.time);
2089 if let Some(due) = self.autosave.tick(&self.docs, self.model_store.as_ref(), now) {
2090 // The frame loop idles between inputs; wake it when the write is due.
2091 ctx.request_repaint_after(std::time::Duration::from_secs_f64(due.max(0.05)));
2092 }
2093 }
2094
2095 if let Some((format, name, bytes)) = self.file.take_stl_import() {
2096 self.start_mesh_preview(format, name, bytes);
2097 ctx.request_repaint();
2098 }
2099
2100 // --- Submit Bug: the screenshot-capture state machine + report modal.
2101 // Drawn at ctx level like the file dialog; idempotent while idle. Draws
2102 // NOTHING during capture, so the screenshot it requested never contains
2103 // this dialog.
2104 self.bug_report.show(&ctx, self.docs.engine_mut());
2105
2106 // --- Info: the licences + this session's diagnostics + the MCP start
2107 // button, a floating window toggled from the toolbar's info button.
2108 // Idempotent when closed. The server's state is read here, each frame
2109 // the window is open, and a click is answered here: the shell owns the
2110 // queue the server attaches to and the adapter line its session names.
2111 #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2112 let mcp_view = if self.info.open { crate::mcp::info_view() } else { crate::panels::info::McpView::Unavailable };
2113 #[cfg(not(all(not(target_arch = "wasm32"), feature = "mcp")))]
2114 let mcp_view = crate::panels::info::McpView::Unavailable;
2115 self.info.show(&ctx, &self.diagnostics, &mcp_view);
2116 #[cfg(all(not(target_arch = "wasm32"), feature = "mcp"))]
2117 if self.info.take_mcp_start() {
2118 // The failure is already in `mcp::status()`, which the window
2119 // shows next frame, and on stderr in the same words.
2120 let _ = crate::mcp::start_from_window(self.automation.clone(), self.diagnostics.adapter_line());
2121 }
2122
2123 // --- Settings: a floating (movable + resizable) window, toggled from the
2124 // toolbar gear button, drawn at ctx level like Properties so it floats
2125 // over the shell. Idempotent when closed. Replaces the old sidebar section.
2126 // This preview runs every frame, including when Settings is closed.
2127 // Exact dirty checks serialize the whole assembly; use the tab's cached
2128 // marker here. reconnect_plm still checks the exact saved bytes before
2129 // switching stores, including metadata edits the marker may not see.
2130 self.settings.reconnect_blocker =
2131 self.reconnect_blocker_with(crate::document::Document::dirty_marker);
2132 self.settings
2133 .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2134 // The PLM tab's Connect now: switch this session to the PLM live.
2135 #[cfg(not(target_arch = "wasm32"))]
2136 if let Some(root) = self.settings.take_reconnect_request() {
2137 let outcome = self.reconnect_plm(root);
2138 self.settings.reconnected(outcome);
2139 }
2140
2141 // --- Part Properties: the active document's own BOM attribute record,
2142 // in a floating window beside Settings. The title is passed in because
2143 // the panel takes only the engine, and a user with several tabs open
2144 // must be able to see WHICH part they are annotating.
2145 let part_title = self.docs.active().title();
2146 let part_document = self.docs.active().id();
2147 self.part_properties.configure_plm(self.model_store.plm_client(), self.docs.active().name());
2148 self.part_properties
2149 .show(&ctx, self.docs.engine_mut(), &part_title, part_document);
2150
2151 ctx.data_mut(|d| d.insert_temp(entity_drawer_id, entity_drawer_open));
2152
2153 // --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
2154 // for reference-selection / sketch mode) stacked ABOVE the selection-driven
2155 // CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
2156 // top-right so they never overlap, and the context rail uses the SAME
2157 // renderer whether it is showing modeling actions or sketch actions
2158 // (`panels::action_rail`). A modeling create/edit action returns a feature
2159 // id to expand in the history tree.
2160 #[cfg(all(feature = "automation", target_arch = "wasm32"))]
2161 crate::automation::registry::publish("__brepMobileActions", "compact action menu button rectangle", "{}");
2162 if compact && (entity_drawer_open || (!sketch && self.dock.mobile_drawer_open())) {
2163 crate::panels::action_rail::close_mobile_actions(&ctx);
2164 self.mode_bar.clear_hits();
2165 self.sketch.clear_context_hits();
2166 self.sketch3d.clear_context_hits();
2167 self.context_bar.clear_view();
2168 } else {
2169 let mut focus: Option<String> = None;
2170 let mut info_targets: Vec<String> = Vec::new();
2171 let mut plugin_action = None;
2172 let mut component_request: Option<ComponentActionRequest> = None;
2173 // Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
2174 // tile), not the window — so it stays glued to the viewport wherever
2175 // docking frames it. The viewport was drawn earlier THIS frame, so its
2176 // rect is current. Before the first draw (`None`) fall back to the
2177 // window's top-right.
2178 let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
2179 .order(egui::Order::Foreground);
2180 overlay = match self.viewport.last_rect() {
2181 Some(rect) => overlay
2182 .fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
2183 .pivot(egui::Align2::RIGHT_TOP),
2184 None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
2185 };
2186 overlay
2187 .show(&ctx, |ui| {
2188 // 0. The family-row preview's card: which row, and Exit.
2189 self.family_table.preview_card(ui, self.docs.engine_mut());
2190 // 1. Exit controls for whatever special mode is active.
2191 if !(compact && sketch) { self.mode_bar.card(ui, self.docs.engine_mut()); }
2192 // 2. Context actions: sketch actions in sketch mode, else the
2193 // modeling selection actions. Same rail, mode-appropriate items.
2194 let has_actions = if self.docs.engine().sketch_mode() { self.docs.engine().sketch_selection_count() > 0 }
2195 else if self.docs.engine().sketch3d_mode() { self.docs.engine().sketch3d_selection_count() > 0 }
2196 else { self.docs.engine().has_selection() || self.docs.engine().selected_constraint().is_some() };
2197 let mut draw_actions = |ui: &mut egui::Ui| {
2198 if self.docs.engine().sketch_mode() {
2199 self.sketch.context_card(ui, self.docs.engine_mut());
2200 } else if self.docs.engine().sketch3d_mode() {
2201 self.sketch3d.context_card(ui, self.docs.engine_mut());
2202 } else {
2203 // Room down to the ViewCube's top, from wherever the
2204 // cards above left the cursor.
2205 self.context_bar.max_height = if compact { None } else { self.viewport.last_rect().and_then(|view| {
2206 let cube: serde_json::Value =
2207 serde_json::from_str(&self.docs.engine().viewcube_rect_json()).ok()?;
2208 let (top, h) = (cube["y"].as_f64()? as f32, cube["h"].as_f64()? as f32);
2209 (h > 0.0).then(|| view.top() + top - 8.0 - ui.cursor().top())
2210 }) };
2211 let outcome = self.context_bar.card(ui, self.docs.engine_mut());
2212 focus = outcome.focus;
2213 info_targets = outcome.info_targets;
2214 plugin_action = outcome.plugin_action;
2215 component_request = outcome.component;
2216 }
2217 };
2218 if compact {
2219 if has_actions {
2220 ui.set_min_width(110.0);
2221 let button = ui.add_sized([110.0, 44.0], egui::Button::new("Actions"));
2222 if button.clicked() { crate::panels::action_rail::open_mobile_actions(ui.ctx()); }
2223 draw_actions(ui);
2224 #[cfg(all(feature = "automation", target_arch = "wasm32"))]
2225 crate::automation::registry::publish("__brepMobileActions", "shared action chooser and entry rectangles",
2226 &crate::panels::action_rail::mobile_actions_json(ui.ctx(), button.rect));
2227 } else {
2228 crate::panels::action_rail::close_mobile_actions(ui.ctx());
2229 self.sketch.clear_context_hits();
2230 self.sketch3d.clear_context_hits();
2231 self.context_bar.clear_view();
2232 }
2233 } else { draw_actions(ui); }
2234 });
2235 if let Some(focus) = focus {
2236 self.history.focus_feature(focus);
2237 if compact { self.dock.show_pane(PaneKind::History); }
2238 }
2239 // The Info action returns one target per selected entity — open (or, on
2240 // dedup, keep) a pinned Info window for each. Drawn below.
2241 if let Some(id) = plugin_action {
2242 if let Err(e) = self.plugins.open_action(self.docs.engine(), &id) { self.plugins.error = Some(e); }
2243 }
2244 if !info_targets.is_empty() {
2245 self.info_windows.open_for(&info_targets, self.viewport.last_rect());
2246 }
2247 // Component document-level flows (the engine-mutating component
2248 // actions already ran inside the bar).
2249 match component_request {
2250 Some(ComponentActionRequest::OpenPart { component_id }) => {
2251 self.edit_part(&component_id);
2252 }
2253 None => {}
2254 }
2255 }
2256
2257 if compact { crate::panels::action_rail::show_mobile_actions(&ctx); }
2258
2259 // --- THE DIALOG DOOR: every dynamically generated dialog that opened
2260 // this frame makes its host pane's tab current.
2261 //
2262 // ONE call, placed after everything that can open one — the dock's panes
2263 // and viewport, and the context bar's overlay above — so a form opened
2264 // anywhere is surfaced in the same frame, before `__brepDock` publishes
2265 // the active tab below. It watches the TARGETS rather than the callers
2266 // because most of them set the target inside the engine or inside a
2267 // panel's draw, with no dock in reach; `panels::dock::DialogTargets`
2268 // carries that reasoning and the openers it covers.
2269 let targets = crate::panels::dock::DialogTargets::read(
2270 &self.history,
2271 self.docs.engine(),
2272 self.docs.active_id(),
2273 );
2274 self.dock.surface_opened_dialogs(targets);
2275
2276 // --- Info windows: the pinned per-entity inspector windows, drawn at ctx
2277 // level like the file dialog so they float over the shell. Each is pinned to
2278 // its open-time object name (selection changes never retarget them); closed
2279 // windows (their `×`) are pruned here. Drawn AFTER the context bar so a
2280 // window opened THIS frame paints this frame.
2281 self.info_windows.show(&ctx, self.docs.engine_mut());
2282
2283 // --- interference results window: same floating idiom, owned report;
2284 // its Re-run button re-drives the engine check.
2285 self.interference.show(&ctx, self.docs.engine_mut());
2286
2287 // --- auto-constraints window: the inference scan + its Create button.
2288 self.auto_constraints.show(&ctx, self.docs.engine_mut());
2289 self.step_parts
2290 .show(&ctx, self.docs.engine_mut(), self.model_store.as_ref());
2291
2292 // --- transient toasts: drain the engine's queued notices (e.g. a sketch
2293 // solve that failed after an edit) and show each briefly. Drawn last so
2294 // the cards float over the whole shell.
2295 let now = ctx.input(|i| i.time);
2296 // All three sources gathered FIRST, because the automation queue must see
2297 // every one of them: `Toasts::extend` keeps only the last few on screen,
2298 // and a message dropped for space is exactly the one a host wants told.
2299 use brep_render::engine_state::NoticeSeverity;
2300 let mut raised = self.docs.engine_mut().take_graded_notices();
2301 // Same lane for STORAGE failures the store could only discover after its
2302 // synchronous `write` returned `Ok` (the browser backend writes behind an
2303 // in-memory mirror). A save that did not persist must never be silent.
2304 let errors = self.model_store.take_persistence_errors().into_iter().chain(self.autosave.take_errors());
2305 raised.extend(errors.map(|text| (NoticeSeverity::Error, text)));
2306 // An edit a read-only document refused (a PLM revision not checked out,
2307 // or released): its history kept the saved bytes, and this says why.
2308 for doc in self.docs.iter_mut() {
2309 if let Some(text) = doc.take_refused() {
2310 raised.push((NoticeSeverity::Warning, text));
2311 }
2312 }
2313 // The automation channel's `notices` drain: what the user would have SEEN.
2314 // Without this the drain has no producers at all and every script's
2315 // `{"notices": []}` asserts nothing.
2316 #[cfg(feature = "automation")]
2317 for (_, text) in &raised {
2318 self.automation.push_notice(
2319 crate::automation::command::NoticeKind::Toast,
2320 text.clone(),
2321 );
2322 }
2323 self.toasts.extend(raised, now);
2324 // An edit after a card was raised supersedes it (`Toasts::note_document`).
2325 self.toasts
2326 .note_document(self.docs.active_id(), self.docs.engine().history.undo_top(), now);
2327 self.toasts.show(&ctx, self.viewport.last_rect(), status_bar_top);
2328
2329 // The state registry (`automation::registry`): the live app + engine
2330 // state, published by name with a one-line doc so a host can read it
2331 // (and, on wasm, mirrored to `window.__brep*` for the verify scripts).
2332 // Purely additive; no render effect. Published AFTER the panels draw so
2333 // the hit-rects are for THIS frame's layout. Off unless a host enabled it.
2334 if crate::automation::registry::enabled() {
2335 let _publish_span = crate::perf::span(crate::perf::Phase::Publish);
2336 let ppp = ui.ctx().pixels_per_point();
2337 // Keyboard focus, for a driver typing into a widget by its rect: the
2338 // frame counter says a click has been drawn, `rect` is the widget
2339 // egui gave the focus to (null while nothing has it). A typed
2340 // string lands only in a focused field, so a verifier waits on this
2341 // rather than on time (BREP_app/web/verify_plm_helpers.mjs).
2342 let focused = crate::column_tree::focused_rect(ui.ctx());
2343 // The focused value editor's text, taken (not left) so a frame
2344 // with no focused editor publishes null.
2345 let text: Option<String> = ui.ctx().data_mut(|d| d.remove_temp::<String>(crate::column_tree::focused_text_id())).filter(|_| focused.is_some());
2346 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!({
2347 "frame": ui.ctx().cumulative_frame_nr(),
2348 "rect": focused.map(|r| [r.min.x, r.min.y, r.width(), r.height()]),
2349 "text": text,
2350 }).to_string());
2351 crate::automation::registry::publish("__brepCamera", "camera state: kind, eye, target, up, near/far, projection block, worldPerPixel", &self.docs.engine().camera_state_json());
2352 crate::automation::registry::publish("__brepSettings", "render and UI settings", &self.docs.engine().settings_json());
2353 crate::automation::registry::publish("__brepSolidColors", "per-solid colour overrides", &self.docs.engine().solid_color_overrides_json());
2354 crate::automation::registry::publish("__brepHistory", "history listing {step, features:[{index,type,id}]}", &self.docs.engine().history_listing_json());
2355 crate::automation::registry::publish("__brepGizmo", "transform gizmo state", &self.docs.engine().gizmo_state_json());
2356 crate::automation::registry::publish("__brepFile", "file dialog state (mode, entries, current name)",
2357 &self.file.file_state_json(&self.docs, self.model_store.as_ref()),
2358 );
2359 crate::automation::registry::publish("__brepFileHit", "file dialog widget rects", &self.file.hits_json());
2360 crate::automation::registry::publish("__brepModel", "model signature: solid count, triangle count, per-solid bounds (change detection)", &self.model_signature_json());
2361 crate::automation::registry::publish("__brepReport", "last run report {featureErrors, featureNotes, featureFulfilment, featureRefusals (only when a feature failed with a typed refusal), featureApproximations (only when a successful feature carries a measured approximation: feature id → [{code, body, measured, bar, volume_bound, edges, message, summary}]), unresolved, displayErrors, featureTimings, featureOutputs}", &self.docs.engine().history_report_json());
2362 // The in-flight run: whether one is pending, the feature the runner
2363 // says it is executing, and the feature a cancelled run was stuck on.
2364 crate::automation::registry::publish("__brepRun", "in-flight run {pending, progress:{generation,index,total,featureId,featureType}|null, cancelled}",
2365 &serde_json::json!({
2366 "pending": self.docs.engine().run_pending(),
2367 "progress": self.docs.engine().run_progress().map(|p| serde_json::json!({
2368 "generation": p.generation,
2369 "index": p.index,
2370 "total": p.total,
2371 "featureId": p.feature_id,
2372 "featureType": p.feature_type,
2373 })),
2374 "cancelled": self.docs.engine().cancelled_run(),
2375 })
2376 .to_string(),
2377 );
2378 crate::automation::registry::publish("__brepBusy", "the status bar's working indicator {shown, activities:[{kind, label, document, cancellable, elapsed}]} — everything in flight, most important first; shown once work has lasted 0.2 s", &self.busy.state_json());
2379 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());
2380 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());
2381 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());
2382 crate::automation::registry::publish("__brepExprHit", "expressions panel widget rects", &self.expressions.hits_json());
2383 crate::automation::registry::publish("__brepExpr", "expressions script, its variables and the configurator",
2384 &serde_json::json!({
2385 "expressions": self.docs.engine().expressions_json(),
2386 "variables": serde_json::from_str::<serde_json::Value>(
2387 &self.docs.engine().expression_variables_json()
2388 )
2389 .unwrap_or(serde_json::Value::Null),
2390 "configurator": serde_json::from_str::<serde_json::Value>(
2391 &self.docs.engine().configurator_json()
2392 )
2393 .unwrap_or(serde_json::Value::Null),
2394 })
2395 .to_string(),
2396 );
2397 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());
2398 // The workbench actions strip's button rects (`wbtb:feature:<type>` /
2399 // `wbtb:constraint:<type>` / `wbtb:annotation:<type>`); an empty map
2400 // while the strip is hidden.
2401 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());
2402 // The PLM pane (a PLM session only): its sections' widget rects,
2403 // `plm/section:<id>` and `plm/<section>:<widget>`, and the active
2404 // document's access and revision.
2405 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());
2406 crate::automation::registry::publish("__brepPlmDoc", "the active document on the PLM: {access:{editable, reason}, plmDocument, revision:{id,label,lifecycle,lockedBy,lockedByMe}, busy, message, offered:[verb key], files:{staged:{name,mediaType,kind,size,target}, revision:[name], part:[name], problem, notice, status, busy}}; null with no PLM session", &self.plm.state_json(&self.docs));
2407 // The queued toast texts — the only trace of a refusal the app shows
2408 // as a transient card (e.g. a constraint the strip could not add).
2409 crate::automation::registry::publish("__brepNotices", "queued toast texts", &self.toasts.texts_json());
2410 crate::automation::registry::publish("__brepToasts", "toast cards {cards: [{text, severity (error|warning|info), count, shown, superseded}], shown, folded}", &self.toasts.state_json());
2411 crate::automation::registry::publish("__brepToastsHit", "toast card rects (toast:<i> top to bottom, toast:more)", &self.toasts.hits_json());
2412 crate::automation::registry::publish("__brepBug", "bug report panel state", &self.bug_report.state_json());
2413 crate::automation::registry::publish("__brepDiagnostics", "what this session is running on: renderer, adapter, texture ceiling, version, platform", &self.diagnostics.json().to_string());
2414 // Where the last ~120 frames' milliseconds went. `__brepDiagnostics`
2415 // says WHICH renderer is drawing; this says what it costs, in the
2416 // same split the Info window's Performance rows show.
2417 crate::automation::registry::publish("__brepPerf", "rolling per-frame timings in ms {frames, window, dt, ui, publish, sync, fit, overlays, draw} each {avg,p95,max}", &crate::perf::json());
2418 crate::automation::registry::publish("__brepBugHit", "bug report panel widget rects", &self.bug_report.hits_json());
2419 // The JavaScript editor window: its outer rect against the surface
2420 // it may fill, and its widget rects (`javascript/panel:clip`,
2421 // `javascript/source`, `javascript/output`, the buttons).
2422 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());
2423 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());
2424 // The wire harness: the document's connections + the last run's
2425 // routing report (engine truth), and the panel's widget rects.
2426 crate::automation::registry::publish("__brepWireHarness", "wire harness connections and the last routing report", &self.docs.engine().wire_harness_state_json());
2427 crate::automation::registry::publish("__brepWireHarnessHit", "wire harness panel widget rects", &self.wire_harness.hits_json());
2428 // The part's declared connection points (the `ports` block) and the
2429 // ports tail's report, beside the Qualify panel's own selection and
2430 // its rects (`qualify:*`).
2431 crate::automation::registry::publish("__brepPorts", "the part's declared connection points (the `ports` block) and the ports tail's report of the last run", &self.docs.engine().ports_state_json());
2432 crate::automation::registry::publish("__brepQualify", "Qualify panel state {selected: the connection-point address it has selected}", &self.qualify.state_json());
2433 crate::automation::registry::publish("__brepQualifyHit", "Qualify panel widget rects (qualify:*)", &self.qualify.hits_json());
2434 // The family table as the Family table pane last read it, its
2435 // problems, selection and the last Generate report; and its rects.
2436 crate::automation::registry::publish("__brepFamily", "Family table pane state {table, expressions, notColumns, selected, editing, problems, columnProblems, values, generateCalls, report, status, preview: {row, partNumber, applied, showing, message, featureErrors} or null, runsReplied}", &self.family_table.state_json());
2437 crate::automation::registry::publish("__brepFamilyHit", "Family table pane widget rects (family:*)", &self.family_table.hits_json());
2438 // The PMI block + report + active view / open annotation, and the
2439 // PMI panel's rects (`pmi:capture`, `pmi:add`, `pmi:add:<type>`,
2440 // `pmi:row:<id>`, `pmi:cell:<id>:<column>`, `pmi:menu:<id>`, the
2441 // form's `pmi:` keys).
2442 crate::automation::registry::publish("__brepPmi", "PMI block, report, active view, open annotation or view dialog, and the selected annotation or view", &self.docs.engine().pmi_state_json());
2443 crate::automation::registry::publish("__brepPluginsHit", "Declarative plugin panel action controls", &self.plugins.panel_hits_json());
2444 crate::automation::registry::publish("__brepPmiHit", "PMI panel widget rects (pmi:*)", &self.pmi.hits_json());
2445 // The `sheets` block, which sheet the viewport draws, and the OPEN
2446 // sheet's whole projection — the paper, the visible edge runs and
2447 // the annotations that passed the parallel rule, in millimetres.
2448 // The panel's rects (`sheets:add`, `sheets:place:<view>`,
2449 // `sheets:row:<id>`, the form's `sheets:` keys) ride beside it.
2450 crate::automation::registry::publish("__brepSheets", "drawing sheets, the open sheet and its projection, the open and the selected sheet object", &self.docs.engine_mut().sheet_state_json());
2451 crate::automation::registry::publish("__brepSheetsHit", "Sheets panel widget rects (sheets:*)", &self.sheets.hits_json());
2452 // The workbench logical state (resolved current id + available ids) so
2453 // the verifier can drive the dropdown and confirm the active workbench.
2454 // Hit-rects for the dropdown ride in `__brepToolbar` (self.toolbar.hits).
2455 crate::automation::registry::publish("__brepWorkbench", "active workbench id and the available ids",
2456 &crate::workbench::workbench_state_scoped(self.docs.engine()).to_string(),
2457 );
2458 crate::automation::registry::publish("__brepSelection", "selection {solids, faces, edges, datums, vertices}; the name arrays are in pick order, oldest first", &self.docs.engine().selection_json());
2459 crate::automation::registry::publish("__brepInfoWindows", "open info windows (mass properties, topology, metadata)",
2460 &self.info_windows.published_json(self.docs.engine_mut()),
2461 );
2462 crate::automation::registry::publish("__brepInfoWindowsHit", "info window widget rects", &self.info_windows.hits_json());
2463 crate::automation::registry::publish("__brepInterference", "interference check state", &self.interference.state_json());
2464 crate::automation::registry::publish("__brepInterferenceHit", "interference panel widget rects", &self.interference.hits_json());
2465 crate::automation::registry::publish("__brepAutoConstraints", "auto-constraint inference state", &self.auto_constraints.state_json());
2466 crate::automation::registry::publish("__brepAutoConstraintsHit", "auto-constraints widget rects", &self.auto_constraints.hits_json());
2467 crate::automation::registry::publish("__brepStepParts", "STEP parts library panel state", &self.step_parts.state_json());
2468 crate::automation::registry::publish("__brepStepPartsHit", "STEP parts library widget rects", &self.step_parts.hits_json());
2469 crate::automation::registry::publish("__brepSelectionFilter", "which entity kinds a viewport click may pick", &self.docs.engine().selection_filter_json());
2470 crate::automation::registry::publish("__brepSelectionHit", "selection bar widget rects (filter:kind, clear, hide)", &self.selection.hits_json());
2471 crate::automation::registry::publish("__brepContext", "context action bar state (offers for the selection)", &self.context_bar.state_json());
2472 crate::automation::registry::publish("__brepContextHit", "context action bar widget rects", &self.context_bar.hits_json());
2473 crate::automation::registry::publish("__brepModeBarHit", "mode bar widget rects (refsel:*, Sketch:*)", &self.mode_bar.hits_json());
2474 // The DOCUMENT TABS: the open models, which one is active, and the
2475 // strip's per-tab hit-rects, so an e2e script can switch and close
2476 // documents the way a user does.
2477 crate::automation::registry::publish("__brepDocuments", "open document tabs {active, tabs:[{title,name,dirty}]}", &document_tabs::state_json(&self.docs));
2478 crate::automation::registry::publish(
2479 "__brepDocumentsHit",
2480 "document tab strip widget rects",
2481 &crate::automation::hit_rects::hits_json(
2482 self.document_tab_hits.iter().map(|(key, rect)| (key, rect)),
2483 ),
2484 );
2485 // The boot-time recovery prompt: its entries and its two buttons.
2486 crate::automation::registry::publish("__brepRecovery", "boot-time recovery prompt entries", &self.recovery.state_json());
2487 crate::automation::registry::publish("__brepRecoveryHit", "recovery prompt widget rects", &self.recovery.hits_json());
2488 crate::automation::registry::publish("__brepComponentMove", "assembly component move state", &self.docs.engine().component_move_json());
2489 crate::automation::registry::publish("__brepSketch", "sketch mode state (session, tool, selection, constraints)", &self.sketch.published_json(self.docs.engine()));
2490 #[cfg(target_arch = "wasm32")]
2491 crate::automation::registry::publish("__brepMobileDock", "compact dock drawer controls", &self.dock.mobile_state_json());
2492 #[cfg(target_arch = "wasm32")]
2493 crate::automation::registry::publish("__brepMobileSketch", "compact sketch entity drawer controls", &serde_json::json!({
2494 "compact": compact, "open": entity_drawer_open,
2495 "rects": serde_json::from_str::<serde_json::Value>(&crate::automation::hit_rects::hits_json(mobile_sketch_hits.iter())).unwrap_or_default(),
2496 }).to_string());
2497 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()));
2498 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()));
2499 crate::automation::registry::publish("__brepSketch3d", "3D sketch mode state: {sketch3dMode, featureId, dof, status, conflicting, tool, workPlane, workPlaneAnchor, pointCount, geometryCount, constraintCount, pendingLen, selection, applicableConstraints, dimensions, externalRefs, lastHit, canUndo, canRedo, entities}", &self.sketch3d.published_json(self.docs.engine()));
2500 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()));
2501 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()));
2502 crate::automation::registry::publish("__brepWireframe", "wireframe toggle", &format!("{}", self.docs.engine().settings.wireframe));
2503 // The pick's `subject` and `path` name the FORM FIELD it runs in
2504 // (`<panel>/field:<path>#activate` is that field's button, Done
2505 // while the pick runs); `inForm` is false for the card-based
2506 // picks, whose controls are `modebar/refsel:*`.
2507 crate::automation::registry::publish("__brepRefSelect", "reference-selection pick {active, prompt, names, subject, path, multiple, picks, inForm}",
2508 &{
2509 let engine = self.docs.engine();
2510 let field = engine.ref_select_field();
2511 serde_json::json!({
2512 "active": engine.ref_select_active(),
2513 "prompt": engine.ref_select_prompt(),
2514 "names": engine.ref_select_names(),
2515 "subject": field.as_ref().map(|f| f.subject.clone()),
2516 "path": field.as_ref().map(|f| f.path.join(".")),
2517 "multiple": field.as_ref().map(|f| f.multiple),
2518 "picks": field.as_ref().map(|f| f.picks),
2519 "inForm": engine.ref_select_in_form(),
2520 })
2521 }
2522 .to_string(),
2523 );
2524 // Viewport origin + projected probe points (viewport-local logical
2525 // px) so the verifier can click precise spots ON the Box and ON the
2526 // Pin during ref-select mode. Index 0 is a Box top-corner clear of the
2527 // pin; indices 1..4 are points on the Pin's cylindrical stub that
2528 // protrudes above the Box top (y=20), on the camera-facing sides — the
2529 // verifier tries them until one picks "Pin".
2530 crate::automation::registry::publish("__brepView", "the 3D viewport rect {x,y,w,h} in egui points", &self.viewport.viewport_rect_json());
2531 // Dock layout snapshot (per-pane visible / rendered) so the verifier
2532 // can see which side panels are on-screen and, once a user tabs panels
2533 // together, activate the right tab before asserting on its widgets.
2534 // `active=false` in sketch / ref-select (the dock is bypassed).
2535 crate::automation::registry::publish("__brepDock", "dock layout in tab-strip order: per-pane visible/activeTab/rendered", &self.dock.state_json(!sketch && !modal_pick));
2536 crate::automation::registry::publish("__brepProbe", "projected seed-model probe points (viewport-local px) for the verifier",
2537 &self
2538 .docs
2539 .engine()
2540 .world_to_screen_json(
2541 "[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
2542 [10.0,22.5,16.0],[10.0,25.0,10.0]]",
2543 )
2544 .unwrap_or_else(|_| "[]".to_string()),
2545 );
2546 crate::automation::registry::publish("__brepPpp", "pixels per point of the surface", &format!("{ppp}"));
2547 crate::automation::registry::publish("__brepStep", "the rolled-to feature index", &format!("{}", self.docs.engine().history_rollback()));
2548 crate::automation::registry::publish("__brepParams", "inputParams of the rolled-to feature", self.params_blob());
2549 }
2550
2551 // Phase 3 of the automation frame (§4.4): reads answer from THIS frame's
2552 // registry and layout.
2553 #[cfg(feature = "automation")]
2554 self.drain_reads(&ctx);
2555
2556 #[cfg(target_arch = "wasm32")]
2557 crate::viewport::touch::publish_editor(&ctx);
2558
2559 // NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
2560 // earlier this frame (normal mode) or drawn directly in the sketch /
2561 // ref-select branch above. Drawing it before the top-right overlay is what
2562 // keeps that overlay anchored to the live viewport rect.
2563 }
2564}
2565
2566
2567/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
2568
2569/// The seed model handed to the engine at startup: a 3-feature history so the
2570/// tree / roll / edit are real —
2571/// 0. `P.CU` "Box" — a 20 mm cube at the origin (spans `[0,20]³`).
2572/// 1. `P.CY` "Pin" — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
2573/// cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
2574/// 2. `B` "Cut" — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
2575/// with a cylindrical through-hole (the ref-select field is visible for the
2576/// next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
2577///
2578/// This is just the INITIAL document — once handed to `EngineState`, the engine
2579/// OWNS the mutable history; the app keeps no copy.
2580pub(crate) fn seed_history_json() -> String {
2581 serde_json::json!({
2582 "expressions": "",
2583 "configurator": {},
2584 "features": [
2585 {
2586 "type": "P.CU",
2587 "inputParams": {
2588 "id": "Box",
2589 "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
2590 "transform": {
2591 "position": [0.0, 0.0, 0.0],
2592 "rotationEuler": [0.0, 0.0, 0.0],
2593 "scale": [1.0, 1.0, 1.0]
2594 },
2595 "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
2596 },
2597 "persistentData": {}
2598 },
2599 {
2600 "type": "P.CY",
2601 "inputParams": {
2602 "id": "Pin",
2603 "radius": 6.0, "height": 30.0,
2604 "transform": {
2605 "position": [10.0, -5.0, 10.0],
2606 "rotationEuler": [0.0, 0.0, 0.0],
2607 "scale": [1.0, 1.0, 1.0]
2608 },
2609 "boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
2610 },
2611 "persistentData": {}
2612 },
2613 {
2614 "type": "B",
2615 "inputParams": {
2616 "id": "Cut",
2617 "targetSolid": "Box",
2618 "boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
2619 },
2620 "persistentData": {}
2621 }
2622 ]
2623 })
2624 .to_string()
2625}