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