1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
//! `BrepApp` — the THIN shell of the engine-native UI.
//!
//! One eframe [`App`] that hosts the EXISTING `brep-render` engine and lays out
//! the panels. The heavy lifting lives in focused modules; this file only owns
//! the shell:
//!
//! * [`EngineState`] (`brep-render`) stays the single windowing-agnostic BRAIN
//! (scene / camera / controls / settings / widgets + pointer/wheel/viewcube/
//! pick). We do NOT fork it — panels borrow `&mut EngineState`.
//! * [`crate::viewport::Viewport`] draws + drives the central 3D viewport (the
//! offscreen texture, the `egui_wgpu` blit callback, input routing).
//! * [`crate::panels`] — one module per left-panel section, each a small state
//! struct + a `show(&mut self, ui, state, …)` method. Adding a panel = add
//! `panels/<name>.rs`, one field here, one `self.<name>.show(…)` call in
//! [`eframe::App::ui`] below (see `README.md` → "Adding a panel").
//!
//! Native (`run_native`) and wasm (`WebRunner`) run this SAME code.
use crate::panels::assembly_constraints::AssemblyConstraintsPanel;
use crate::panels::assembly_edit::AssemblyEdit;
use crate::panels::bug_report::BugReportPanel;
use crate::panels::component_actions::ComponentActionRequest;
use crate::panels::context_bar::ContextBarPanel;
use crate::panels::mode_bar::ModeBar;
use crate::panels::expressions::ExpressionsPanel;
use crate::panels::file::{FileAction, FileDialog};
use crate::panels::history::HistoryPanel;
use crate::panels::info_windows::InfoWindows;
use crate::panels::scene::ScenePanel;
use crate::panels::selection::SelectionPanel;
use crate::panels::sketch::SketchPanel;
use crate::panels::settings::SettingsPanel;
use crate::panels::toasts::Toasts;
use crate::panels::toolbar::ToolbarPanel;
use crate::panels::update_components::UpdateComponents;
use crate::panels::bom::BomPanel;
use crate::panels::dock::{DockContext, DockState, PaneKind};
use crate::store::{default_model_store, ModelStore, SETTINGS_KEY};
use crate::viewport::Viewport;
use brep_render::engine_state::EngineState;
use brep_render::style::ThemeMode;
use eframe::egui;
pub struct BrepApp {
/// The shared viewer brain — identical to what `desktop.rs` / the wasm shell
/// wrap. Never forked; panels borrow it.
state: EngineState,
/// The central 3D viewport: engine render core + offscreen texture + blit +
/// input routing.
viewport: Viewport,
/// The single persistence seam for settings, layout, and model documents
/// (native filesystem / wasm IndexedDB + download/upload).
model_store: Box<dyn ModelStore>,
// --- one small state value per panel --------------------------------------
/// Top toolbar: undo/redo, wireframe toggle, zoom-to-fit + standard views,
/// and the File-actions seam (owned by the concurrent file panel).
toolbar: ToolbarPanel,
/// New / Open / Save / Save As of the model document (the `.BREP.json`
/// recipe) — a reusable modal file dialog opened from the toolbar.
file: FileDialog,
/// The "Submit Bug" flow: on the toolbar bug button it screenshots the app
/// (UI + 3D model) BEFORE its own dialog opens, then collects a description
/// (+ optional email) and POSTs the model + screenshot to the public reports
/// endpoint. Native + wasm, one path.
bug_report: BugReportPanel,
/// Display-settings + per-solid color panel (Phase 1): a FLOATING window
/// (movable + resizable, toggled from the toolbar gear button), no longer a
/// left-panel section.
settings: SettingsPanel,
/// History feature-tree + schema-driven feature dialog panel (Phase 2).
/// NOTE: the editable history is NOT owned here — it lives in the engine core
/// (`EngineState.history`), the single source of truth; this panel only reads
/// it back to draw and calls the engine's `history_*` methods to mutate.
history: HistoryPanel,
/// Scene tree ("Scene Manager"): the display scene as a file-tree — per-solid
/// visibility + Faces/Edges/Vertices with two-way selection sync. Reads the
/// engine scene/emphasis; owns only transient expand + hit state.
scene: ScenePanel,
/// Assembly Structure tree (claimed by the Assembly workbench): a VIEW over
/// the scene's component records — per-instance fixed/visibility/status
/// adornments, actions routed to the owning ACOMP feature.
/// The BOM panel (claimed by the Assembly workbench): the parts list on
/// the shared column-tree widget, with the editable part/occurrence
/// attribute columns the Settings "Assemblies" section configures.
bom: BomPanel,
/// Assembly Constraints panel (claimed by the Assembly workbench): the
/// schema-driven constraint collection widget + Solve/auto-solve/DOF header.
assembly_constraints: AssemblyConstraintsPanel,
/// Update-components checker (build-spec §8.6): compares each parts-library
/// entry's `sourceSignature` against the model store's current content.
/// Kept current once per frame (cheap generation key: applied run + store
/// save); the constraints header reads the count + runs the batch refresh,
/// the structure tree reads per-part badges.
update_components: UpdateComponents,
/// Expressions / parameters panel: the variable sheet (engine-owned history
/// `expressions`) feature params reference. Owns only its editor buffer.
expressions: ExpressionsPanel,
/// Info windows: MULTIPLE pinned per-entity inspector windows opened from the
/// selection-driven context bar's Info action. Each floating (movable +
/// resizable) window is PINNED to one object name at open time — a Metadata
/// (editable attribute) tab + a read-only Info (measurements + provenance) tab —
/// and keeps showing that entity regardless of later selection changes. Replaces
/// the old single Properties window.
info_windows: InfoWindows,
/// Interference results window (assemblies build-spec §9): opened by the
/// Assembly workbench's `∩` toolbar button, which runs the engine's
/// pairwise-intersect check; a floating window like the Info windows with a
/// row per interfering pair (click = select both components), a green
/// all-clear pass line, and a Re-run button.
interference: crate::panels::interference::InterferenceWindow,
/// step.parts online model library browser (Assembly workbench): a ctx-level
/// window (opened by the library toolbar button) that searches the public
/// step.parts v1 API, shows results with thumbnails, and imports a chosen
/// STEP model as a new part document + adds it to the assembly as an ACOMP.
step_parts: crate::panels::step_parts::StepPartsPanel,
/// Selection panel: the pickable-kinds filter (which entity kinds a viewport
/// click may select — honored by the engine's `select_top_at`). The filter +
/// selection live in `EngineState`; this panel only reads/writes them.
selection: SelectionPanel,
/// Context action toolbar: the selection-driven action bar (Clear / Hide /
/// Edit-owning-feature + the feature-from-selection actions whose primary
/// reference accepts the selected kind). Shown only while something is
/// selected; drives the engine directly and returns a feature id for the shell
/// to expand in the history tree.
context_bar: ContextBarPanel,
/// Sketch (S0): a seeded, read-only sketch preview — pushes a solved rectangle
/// + circle to the `set_overlay` channel colored by solver mobility, and shows
/// the DOF status readout. The engine-native sketcher's foundation surface.
sketch: SketchPanel,
/// Special-mode EXIT controls (Finish/Cancel), always pinned to the top-right
/// corner — reference-selection, sketch mode, and any future special mode.
mode_bar: ModeBar,
/// Assembly EDIT-IN-PLACE (build-spec §8.5): the shell-owned document
/// stash + banner state machine. Activated from the context bar's (or,
/// via the shared ComponentAction seam, the structure tree's) "Edit in
/// place" action; its banner renders through the ModeBar card.
assembly_edit: AssemblyEdit,
/// Transient toast overlay: drains the engine's queued notices each frame
/// (e.g. a sketch solve that failed after an edit) and shows each briefly.
toasts: Toasts,
/// Dockable / tabbed side-panel layout (egui_tiles): the shared, persisted
/// tree that hosts every side-panel section AND the 3D viewport as tiles the
/// user can split, tab, resize, and drag-rearrange. Owns the layout; borrows
/// each panel + the engine per frame through [`DockContext`]. Drawn in normal
/// modeling mode; sketch / ref-select mode bypasses it (viewport drawn direct).
dock: DockState,
/// Whether the ONE-SHOT first-model framing has fired. The seed run is async
/// under a background runner (native thread / wasm worker), so the boot
/// `zoom_to_fit` can run before the first solids exist → an unframed first
/// model. Once the seed run has landed (`has_solids() && !run_pending()`), the
/// `ui` loop frames it once and sets this. Under the synchronous Inline runner
/// (tests) the scene is already populated, so this fires on the very first frame.
first_run_framed: bool,
/// A model fetch kicked off at boot from a `?loadModel=<url>` query param
/// (wasm only — the cadDev admin "Launch model in CAD app" opens the app with
/// a report's model URL). When the fetch lands it REPLACES the seed model.
/// `None` on native and once applied.
pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
/// The UI zoom scale CURRENTLY applied to the egui context. Tracks
/// `settings.ui_scale` but is only synced to it while the pointer is up, so
/// dragging the Settings "UI scale" slider doesn't rescale the whole UI under
/// the cursor mid-drag — the settled value is committed on release. See the
/// zoom-apply block in `ui`.
applied_ui_scale: f32,
}
impl BrepApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, String> {
let render_state = cc
.wgpu_render_state
.as_ref()
.ok_or_else(|| "eframe was not created with a wgpu render state".to_string())?;
// The viewport owns the render core + blit pipeline, built from eframe's
// SHARED device/queue/format.
let viewport = Viewport::new(render_state);
// --- seed the ENGINE-owned mutable history + roll to the last step ----
// The engine now owns the recipe; we only hand it the initial document.
let mut state = EngineState::new();
// Native: run the whole history — and per-object measurement queries — on a
// persistent background thread so the UI never freezes during a run or a
// selection (M2b). Installed BEFORE the seed so the seed builds through it.
#[cfg(not(target_arch = "wasm32"))]
state.set_runner(Box::new(brep_render::runner::ThreadRunner::new()));
// wasm: the browser-thread analogue — a dedicated web worker (M3b) so the
// single-threaded wasm UI stays responsive during a run. Same seam; installed
// BEFORE the seed so the (now async) seed run builds through the worker. Tests
// (which never hit this wasm path) keep the default synchronous InlineRunner.
#[cfg(target_arch = "wasm32")]
state.set_runner(Box::new(crate::worker::WorkerRunner::new()));
let _ = state.set_history_json(&seed_history_json());
state.set_viewcube_enabled(true);
state.zoom_to_fit();
// --- storage seam: load + apply any persisted settings ----------------
let model_store = default_model_store();
// wasm: hand the store the egui context so an async file-upload load
// callback can wake the reactive frame loop (see `store::set_repaint_ctx`).
#[cfg(target_arch = "wasm32")]
crate::store::set_repaint_ctx(cc.egui_ctx.clone());
if let Some(saved) = model_store.read(SETTINGS_KEY) {
// Partial-override apply: unknown/absent keys keep their defaults.
let _ = state.apply_settings_json(&saved);
}
// The settings panel seeds its working JSON from the (post-load) engine
// settings so the widgets reflect the persisted state on first paint.
let settings = SettingsPanel::new();
// The model-document store + the file panel (seeded clean from the seed
// model, so the first edit marks it dirty).
let file = FileDialog::new(&state);
// Boot at the saved UI scale (captured before `state` is moved into Self).
let applied_ui_scale = state.settings.ui_scale;
// The dock layout (loads the persisted tree, or the default). Built before
// `model_store` is moved into `Self`.
let dock = DockState::new(model_store.as_ref());
// Boot-load: if the page URL carries `?loadModel=<url>` (wasm only), start
// fetching that model NOW; the seed still loads this frame and the fetched
// model REPLACES it when it lands (drained in `ui`). See the drain block.
#[cfg(target_arch = "wasm32")]
let pending_boot_load = web_sys::window()
.and_then(|w| w.location().search().ok())
.and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
.and_then(|params| params.get("loadModel"))
.filter(|url| !url.is_empty())
.map(|url| fetch_model(&cc.egui_ctx, url));
#[cfg(not(target_arch = "wasm32"))]
let pending_boot_load: Option<std::sync::mpsc::Receiver<Result<String, String>>> = None;
Ok(Self {
state,
viewport,
toolbar: ToolbarPanel::new(),
model_store,
file,
bug_report: BugReportPanel::new(),
settings,
history: HistoryPanel::new(),
scene: ScenePanel::new(),
bom: BomPanel::new(),
assembly_constraints: AssemblyConstraintsPanel::new(),
update_components: UpdateComponents::new(),
expressions: ExpressionsPanel::new(),
info_windows: InfoWindows::new(),
interference: crate::panels::interference::InterferenceWindow::new(),
step_parts: crate::panels::step_parts::StepPartsPanel::new(),
selection: SelectionPanel::new(),
context_bar: ContextBarPanel::new(),
sketch: SketchPanel::new(),
mode_bar: ModeBar::new(),
assembly_edit: AssemblyEdit::new(),
toasts: Toasts::new(),
dock,
first_run_framed: false,
pending_boot_load,
applied_ui_scale,
})
}
/// Global keyboard shortcuts (egui input): **Ctrl/Cmd+Z** undo,
/// **Ctrl/Cmd+Shift+Z** or **Ctrl/Cmd+Y** redo, **Esc** clears the selection.
///
/// `Modifiers::COMMAND` is Ctrl on Windows/Linux and ⌘ on macOS, so one map
/// covers both. Skipped entirely while an egui TEXT edit is focused so typing
/// (and text-field Ctrl+Z / Esc-to-defocus) is never hijacked. Redo is
/// consumed BEFORE undo because egui's `consume_key` matches modifiers
/// logically (a plain `COMMAND+Z` pattern would also swallow `COMMAND+Shift+Z`).
fn handle_shortcuts(&mut self, ctx: &egui::Context) {
if ctx.text_edit_focused() {
return;
}
use egui::{Key, Modifiers};
let (redo, undo, esc) = ctx.input_mut(|i| {
let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)
|| i.consume_key(Modifiers::COMMAND, Key::Y);
let undo = i.consume_key(Modifiers::COMMAND, Key::Z);
let esc = i.consume_key(Modifiers::NONE, Key::Escape);
(redo, undo, esc)
});
// While editing a sketch, Ctrl+Z / Ctrl+Shift+Z drive the PER-SESSION sketch
// history (S6a), not the model-level undo — this global router consumes the
// keys first (before the viewport), so it must intercept here. Esc drops the
// active draw/trim/pick tool back to Select/drag (clearing any in-progress
// placement): this is the ONLY reliable capture point, since `consume_key`
// above already swallowed the Escape before the viewport can see it.
if self.state.sketch_mode() {
if redo {
self.state.sketch_redo();
}
if undo {
self.state.sketch_undo();
}
if esc {
self.state.sketch_set_tool(Some("select"));
}
return;
}
if redo {
self.state.redo();
}
if undo {
self.state.undo();
}
if esc {
// An open pick-list popup owns the first Escape: close it WITHOUT
// clearing the selection (a popup-built multi-selection must survive
// dismissing the list); the next Escape clears as before.
if !self.viewport.close_candidate_popup() {
self.state.clear_selection();
}
}
}
/// OPEN PART (assemblies §8.5, the secondary flow): open the component's
/// SOURCE document as the active document via the ModelStore, with the
/// File>Open dirty-prompt contract on the current assembly first. A part
/// with no store document under its `sourceKey` (imported / embedded-only)
/// falls back to EDIT-IN-PLACE with a note.
fn open_part(&mut self, component_id: &str) {
let source = crate::panels::component_actions::part_source_key(&self.state, component_id);
match source {
Some(key) if self.model_store.read(&key).is_some() => {
self.file
.request_open_stored(&mut self.state, self.model_store.as_ref(), &key);
}
_ => {
self.state.push_notice(
"Part has no source document in the store — editing in place instead"
.to_string(),
);
self.assembly_edit.enter(&mut self.state, component_id);
}
}
}
/// A signature of the CURRENT rendered model (rolled-to step) — solid count,
/// per-solid triangle count + bbox, and total triangles. Published to JS so
/// the headed verifier can prove each roll / edit produced different geometry
/// (names alone don't: a SUBTRACT reuses the target's name).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn model_signature_json(&self) -> String {
let solids: Vec<serde_json::Value> = self
.state
.scene
.solids()
.iter()
.map(|s| {
serde_json::json!({
"name": s.name,
"tris": s.mesh.indices.len() / 3,
"min": s.bbox.min,
"max": s.bbox.max,
})
})
.collect();
let total_tris: usize = self
.state
.scene
.solids()
.iter()
.map(|s| s.mesh.indices.len() / 3)
.sum();
serde_json::json!({
"step": self.state.history_rollback(),
"solidCount": solids.len(),
"totalTris": total_tris,
"solids": solids,
})
.to_string()
}
}
impl eframe::App for BrepApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// --- global keyboard shortcuts (undo/redo/clear-selection) ------------
// Handled before any panel draws so a Ctrl+Z etc. this frame takes effect
// this frame. `ctx` is a cheap Arc clone (avoids borrowing `ui` across the
// `&mut self` call).
let ctx = ui.ctx().clone();
// --- GUI chrome theme -------------------------------------------------
// Apply the user's theme preference to the egui chrome every frame
// (idempotent: `set_theme` just stores the preference). Auto follows the
// OS/system theme (prefers-color-scheme on web); egui falls back to dark
// when no OS signal is available. This controls panels/windows/toolbar/
// text only — the 3D viewport `background` is a separate setting.
ctx.set_theme(match self.state.settings.theme {
ThemeMode::Auto => egui::ThemePreference::System,
ThemeMode::Light => egui::ThemePreference::Light,
ThemeMode::Dark => egui::ThemePreference::Dark,
});
// --- global UI size scale --------------------------------------------
// Apply the user's "UI scale" to the whole egui chrome every frame. This
// is idempotent when unchanged (`set_zoom_factor` only repaints on an
// actual change) and composes with the native device pixel ratio
// (pixels_per_point = zoom_factor * native_pixels_per_point).
//
// Defer live UI rescale while the user drags the Settings "UI scale" slider:
// the slider value updates continuously, but only commit it to the actual egui
// zoom once the pointer is released, so the whole UI doesn't rescale under the
// cursor mid-drag.
let pointer_down = ctx.input(|i| i.pointer.any_down());
if !pointer_down {
self.applied_ui_scale = self.state.settings.ui_scale;
}
ctx.set_zoom_factor(self.applied_ui_scale);
// --- history-runner pump ---------------------------------------------
// Apply any completed background history run BEFORE panels read the scene.
// For the synchronous InlineRunner this is a no-op (`rerun_history` already
// pumped its own submit), so nothing changes today; it is the seam a future
// native-thread / wasm-worker runner lands its reply through. While a run is
// still in flight, keep the frame loop alive so its reply gets pumped — for
// Inline `run_pending()` is always false, so this never fires.
self.state.pump();
if self.state.run_pending()
|| self.state.queries_pending()
|| self.state.mesh_imports_pending()
{
ctx.request_repaint();
}
// --- boot-load (?loadModel=): apply the fetched model once it lands ----
// Replaces the seed with the URL-specified document (armed in `new`). The
// ehttp callback wakes the frame loop, so a plain per-frame drain suffices.
// `load_model_and_fit` arms deferred framing; the pump above reframes it
// next frame. `mark_clean` opens it as a non-dirty document.
if self.pending_boot_load.is_some() {
let received = self
.pending_boot_load
.as_ref()
.and_then(|rx| rx.try_recv().ok());
if let Some(result) = received {
self.pending_boot_load = None;
match result {
Ok(json) => {
let _ = self.state.load_model_and_fit(&json);
self.file.mark_clean(&self.state);
}
Err(e) => self
.state
.push_notice(format!("Could not load model from URL: {e}")),
}
}
}
// --- update-components badge freshness ---------------------------------
// Keep the outdated-parts checker current BEFORE any assembly panel draws
// (the structure tree renders per-node badges ahead of the constraints
// header). Cheap: a real recompute happens only when an applied run or a
// successful store save moved the generation key.
self.update_components.ensure_current(
&mut self.state,
self.model_store.as_ref(),
self.file.save_generation(),
);
// --- async-safe first-model framing -----------------------------------
// The seed run is async under a background runner (native thread / wasm
// worker), so the boot `zoom_to_fit` may have run before any solid existed.
// Frame the model ONCE, the first frame the seed run has fully landed (solids
// present AND no run still in flight). Under the synchronous Inline runner
// (tests) both hold on the very first frame, so this is identical to today.
if !self.first_run_framed && !self.state.run_pending() && self.state.has_solids() {
self.state.zoom_to_fit();
self.first_run_framed = true;
}
self.handle_shortcuts(&ctx);
// --- top toolbar: primary actions, drawn FIRST so its top strip is
// reserved above the left panel + central viewport. A clicked File button
// returns an action the file dialog acts on (open its modal / save / new).
let toolbar_outcome = self.toolbar.show(
ui,
&mut self.state,
self.model_store.as_ref(),
&mut self.settings.open,
);
if let Some(action) = toolbar_outcome.file {
self.file
.dispatch(action, &mut self.state, self.model_store.as_ref());
}
// Submit Bug: begin the screenshot-capture + report flow. `request`
// grabs the current frame (before its dialog exists) and the model, so
// it must run THIS frame while the shot is still dialog-free.
if toolbar_outcome.bug_report {
self.bug_report.request(&ctx, &self.state);
}
// A workbench toolbar button click surfaces its id here. Sheet Metal's
// flat-pattern button opens the export modal in its DXF / SVG mode; the
// engine reports "no sheet-metal body in the part" as a toast on export.
match toolbar_outcome.workbench_button {
Some("sheetmetal.flat_pattern") => {
self.file.dispatch(
FileAction::ExportFlatPattern,
&mut self.state,
self.model_store.as_ref(),
);
}
// Assembly's Add Component: open the insert-component modal (the
// same flow as the ACOMP palette pick).
Some("assembly.add_component") => {
self.file.dispatch(
FileAction::InsertComponent,
&mut self.state,
self.model_store.as_ref(),
);
}
// Assembly's interference check: run the engine's pairwise
// intersect sweep NOW and open the results window (drawn below,
// next to the Info windows).
Some("assembly.interference") => {
self.interference.open_and_run(&mut self.state);
}
// Assembly's step.parts library: open the online-library browser
// (search → thumbnails → import a STEP part → add as an ACOMP).
Some("assembly.step_parts_library") => {
self.step_parts.open();
}
Some(_) | None => {}
}
// --- sketch mode: a slim top bar (the draw tools) drawn just below the
// toolbar while editing a sketch. The normal side panel is hidden (below)
// so the 3D viewport is the full-width sketching surface.
if self.state.sketch_mode() {
self.sketch.show_mode_bar(ui, &mut self.state);
}
// --- bottom STATUS BAR: a persistent, full-width strip whose CONTENT is
// chosen by context each frame. Drawn AFTER the top bars but BEFORE the
// left panel(s) so it reserves the FULL bottom width and the left column
// stops above it (egui resolves reserved space by call order). It is a
// HOST: the branch below picks what to draw. A NEW context is added by
// extending this branch (e.g. `else if self.state.some_mode() { … }`) and
// routing through the owning panel's `show_status_bar` for DRY styling.
egui::containers::panel::Panel::bottom("brep-status-bar")
.resizable(false)
.min_size(30.0)
.show(ui, |ui| {
ui.add_space(2.0);
if self.state.sketch_mode() {
// Sketch context: the status row (title / DOF / N selected /
// undo-redo / Lock). The selection-filter row is NOT drawn
// now, so drop its stale hit-rects (the verifier must never
// click a phantom rect for an off-screen widget).
self.selection.clear_hits();
self.sketch.show_status_bar(ui, &mut self.state);
} else {
// Modeling context: the selection filter (pickable kinds).
self.selection.show_status_bar(ui, &mut self.state);
}
ui.add_space(2.0);
});
// --- central region: the dock tree, OR (special modes) the bare 3D view
// ---------------------------------------------------------------------
// Normal modeling mode: ONE egui_tiles tree fills the whole remaining
// area between the top toolbar and the bottom status bar. Every side-panel
// section AND the 3D viewport are tiles the user can split / tab / resize /
// drag-rearrange, and the layout persists. Which side panes are visible is
// filtered per-workbench inside the dock (`workbench::panel_visible`).
//
// Sketch mode and reference-selection are "special modes" that take over
// the shell: they BYPASS the tree and draw the viewport directly, so the
// modeling side panes don't appear (sketch's own entity-list panel + the
// top-right mode card own those flows). Drawing the viewport HERE — before
// the top-right overlay below — keeps `viewport.last_rect()` current-frame
// so the overlay anchors to the live 3D-view rect with no lag.
let sketch = self.state.sketch_mode();
let ref_select = self.state.ref_select_active();
if sketch {
// Sketch entity lists (Curves / Points / Constraints) + solver
// settings — a dedicated left panel, drawn BEFORE the viewport so it
// reserves the left and the viewport fills the rest.
egui::containers::panel::Panel::left("sketch-entities")
.resizable(true)
.default_size(300.0)
.size_range(200.0..=560.0)
.show(ui, |ui| {
self.sketch.show_entity_lists(ui, &mut self.state);
});
}
if sketch || ref_select {
// Special mode: the viewport fills the remaining central area; no
// dock, no modeling side panes.
self.viewport.show(ui, &mut self.state);
} else {
// Normal mode: the dock owns the whole central area (the viewport is a
// pane). Cross-panel requests the panels can't act on while their
// borrows are held bubble OUT via the returned outcome — the SAME
// requests the old left-panel closure produced.
let outcome = self.dock.ui(
ui,
DockContext {
state: &mut self.state,
viewport: &mut self.viewport,
history: &mut self.history,
bom: &mut self.bom,
assembly_constraints: &mut self.assembly_constraints,
scene: &mut self.scene,
expressions: &mut self.expressions,
update_components: &mut self.update_components,
model_store: self.model_store.as_ref(),
},
);
// The ACOMP palette pick must open the COMPONENT SELECTOR, never a
// bare feature dialog — the file dialog is shell-owned.
if outcome.insert_component_requested {
self.file.dispatch(
FileAction::InsertComponent,
&mut self.state,
self.model_store.as_ref(),
);
}
// A structure-tree Edit — or a BOM row's action button, which
// reports through the same outcome field so there is one arm and
// not two — expands its feature in the history tree.
if let Some(focus) = outcome.feature_focus {
self.history.focus_feature(focus);
// Surface History so the expanded feature is actually visible.
self.dock.show_pane(PaneKind::History);
}
// Structure-tree interaction hooks route through the SAME dispatcher
// as the context bar (one truth per action); document-level flows
// (edit-in-place / open-part) come back as requests the shell runs.
// A BOM row menu's document-level flow: its engine-mutating half
// already ran inside the panel, through the same dispatcher.
match outcome.component_request {
Some(ComponentActionRequest::EditInPlace { component_id }) => {
self.assembly_edit.enter(&mut self.state, &component_id);
}
Some(ComponentActionRequest::OpenPart { component_id }) => {
self.open_part(&component_id);
}
None => {}
}
}
// --- file dialog: a ctx-level modal (like the command palette), drawn
// after the panels so its backdrop dims the whole shell. Idempotent when
// closed; also polls for a completed async import each frame.
self.file
.show(&ctx, &mut self.state, self.model_store.as_ref());
// --- Submit Bug: the screenshot-capture state machine + report modal.
// Drawn at ctx level like the file dialog; idempotent while idle. Draws
// NOTHING during capture, so the screenshot it requested never contains
// this dialog.
self.bug_report.show(&ctx, &mut self.state);
// --- Settings: a floating (movable + resizable) window, toggled from the
// toolbar gear button, drawn at ctx level like Properties so it floats
// over the shell. Idempotent when closed. Replaces the old sidebar section.
self.settings
.show(&ctx, &mut self.state, self.model_store.as_ref());
// --- top-right overlay column: the special-mode EXIT card (Finish/Cancel
// for reference-selection / sketch mode) stacked ABOVE the selection-driven
// CONTEXT ACTION rail. Both cards live in ONE ctx-level Area anchored
// top-right so they never overlap, and the context rail uses the SAME
// renderer whether it is showing modeling actions or sketch actions
// (`panels::action_rail`). A modeling create/edit action returns a feature
// id to expand in the history tree.
{
let mut focus: Option<String> = None;
let mut info_targets: Vec<String> = Vec::new();
let mut component_request: Option<ComponentActionRequest> = None;
// Anchor the overlay to the RIGHT edge of the 3D VIEW (the viewport
// tile), not the window — so it stays glued to the viewport wherever
// docking frames it. The viewport was drawn earlier THIS frame, so its
// rect is current. Before the first draw (`None`) fall back to the
// window's top-right.
let mut overlay = egui::Area::new(egui::Id::new("brep-top-right-overlay"))
.order(egui::Order::Foreground);
overlay = match self.viewport.last_rect() {
Some(rect) => overlay
.fixed_pos(rect.right_top() + egui::vec2(-12.0, 8.0))
.pivot(egui::Align2::RIGHT_TOP),
None => overlay.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 56.0)),
};
overlay
.show(&ctx, |ui| {
// 1. Exit controls for whatever special mode is active
// (incl. the persistent edit-in-place banner).
self.mode_bar.card(
ui,
&mut self.state,
&mut self.assembly_edit,
self.model_store.as_ref(),
);
// 2. Context actions: sketch actions in sketch mode, else the
// modeling selection actions. Same rail, mode-appropriate items.
if self.state.sketch_mode() {
self.sketch.context_card(ui, &mut self.state);
} else {
let outcome = self.context_bar.card(ui, &mut self.state);
focus = outcome.focus;
info_targets = outcome.info_targets;
component_request = outcome.component;
}
});
if let Some(focus) = focus {
self.history.focus_feature(focus);
// Adding a feature from the context bar can happen while another
// side tab is active — bring History forward so the new row shows.
self.dock.show_pane(PaneKind::History);
}
// The Info action returns one target per selected entity — open (or, on
// dedup, keep) a pinned Info window for each. Drawn below.
if !info_targets.is_empty() {
self.info_windows.open_for(&info_targets);
}
// Component document-level flows (the engine-mutating component
// actions already ran inside the bar).
match component_request {
Some(ComponentActionRequest::EditInPlace { component_id }) => {
self.assembly_edit.enter(&mut self.state, &component_id);
}
Some(ComponentActionRequest::OpenPart { component_id }) => {
self.open_part(&component_id);
}
None => {}
}
}
// --- edit-in-place dirty-guard confirm modal (ctx level, like the file
// dialog's ConfirmNew) — draws only while a dirty Cancel is pending.
self.assembly_edit.show_confirm(&ctx, &mut self.state);
// --- Info windows: the pinned per-entity inspector windows, drawn at ctx
// level like the file dialog so they float over the shell. Each is pinned to
// its open-time object name (selection changes never retarget them); closed
// windows (their `×`) are pruned here. Drawn AFTER the context bar so a
// window opened THIS frame paints this frame.
self.info_windows.show(&ctx, &mut self.state);
// --- interference results window: same floating idiom, owned report;
// its Re-run button re-drives the engine check.
self.interference.show(&ctx, &mut self.state);
self.step_parts
.show(&ctx, &mut self.state, self.model_store.as_ref());
// --- transient toasts: drain the engine's queued notices (e.g. a sketch
// solve that failed after an edit) and show each briefly. Drawn last so
// the cards float over the whole shell.
let now = ctx.input(|i| i.time);
self.toasts.extend(self.state.take_notices(), now);
// Same lane for STORAGE failures the store could only discover after its
// synchronous `write` returned `Ok` (the browser backend writes behind an
// in-memory mirror). A save that did not persist must never be silent.
self.toasts
.extend(self.model_store.take_persistence_errors(), now);
self.toasts.show(&ctx);
// Verification hook (wasm only): mirror the live app + engine state to JS
// globals so the headed verifier can assert roll-to-step / edit-re-run /
// add / delete took effect, and locate the real egui widgets to click.
// Purely additive; no render effect. Published AFTER the panel draws so
// the hit-rects are for THIS frame's layout.
#[cfg(target_arch = "wasm32")]
{
let ppp = ui.ctx().pixels_per_point();
publish_to_js("__brepCamera", &self.state.camera_state_json());
publish_to_js("__brepSettings", &self.state.settings_json());
publish_to_js("__brepSolidColors", &self.state.solid_color_overrides_json());
publish_to_js("__brepHistory", &self.state.history_listing_json());
publish_to_js(
"__brepFile",
&self.file.file_state_json(&self.state, self.model_store.as_ref()),
);
publish_to_js("__brepFileHit", &self.file.hits_json());
publish_to_js("__brepModel", &self.model_signature_json());
publish_to_js("__brepReport", &self.state.history_report_json());
publish_to_js("__brepHit", &self.history.hits_json());
publish_to_js("__brepExprHit", &self.expressions.hits_json());
publish_to_js(
"__brepExpr",
&serde_json::json!({
"expressions": self.state.expressions_json(),
"variables": serde_json::from_str::<serde_json::Value>(
&self.state.expression_variables_json()
)
.unwrap_or(serde_json::Value::Null),
"configurator": serde_json::from_str::<serde_json::Value>(
&self.state.configurator_json()
)
.unwrap_or(serde_json::Value::Null),
})
.to_string(),
);
publish_to_js("__brepToolbar", &self.toolbar.hits_json());
publish_to_js("__brepBug", &self.bug_report.state_json());
publish_to_js("__brepBugHit", &self.bug_report.hits_json());
// The workbench logical state (resolved current id + available ids) so
// the verifier can drive the dropdown and confirm the active workbench.
// Hit-rects for the dropdown ride in `__brepToolbar` (self.toolbar.hits).
publish_to_js(
"__brepWorkbench",
&crate::workbench::workbench_state_json(&self.state.settings.workbench),
);
publish_to_js("__brepSelection", &self.state.selection_json());
publish_to_js(
"__brepInfoWindows",
&self.info_windows.published_json(&mut self.state),
);
publish_to_js("__brepInfoWindowsHit", &self.info_windows.hits_json());
publish_to_js("__brepInterference", &self.interference.state_json());
publish_to_js("__brepInterferenceHit", &self.interference.hits_json());
publish_to_js("__brepStepParts", &self.step_parts.state_json());
publish_to_js("__brepStepPartsHit", &self.step_parts.hits_json());
publish_to_js("__brepSelectionFilter", &self.state.selection_filter_json());
publish_to_js("__brepSelectionHit", &self.selection.hits_json());
publish_to_js("__brepContext", &self.context_bar.state_json());
publish_to_js("__brepContextHit", &self.context_bar.hits_json());
publish_to_js("__brepModeBarHit", &self.mode_bar.hits_json());
publish_to_js(
"__brepAssemblyEdit",
&self.assembly_edit.state_json(&self.state),
);
publish_to_js("__brepAssemblyEditHit", &self.assembly_edit.hits_json());
publish_to_js("__brepComponentMove", &self.state.component_move_json());
publish_to_js("__brepSketch", &self.sketch.published_json(&self.state));
publish_to_js("__brepWireframe", &format!("{}", self.state.settings.wireframe));
publish_to_js(
"__brepRefSelect",
&serde_json::json!({
"active": self.state.ref_select_active(),
"prompt": self.state.ref_select_prompt(),
"names": self.state.ref_select_names(),
})
.to_string(),
);
// Viewport origin + projected probe points (viewport-local logical
// px) so the verifier can click precise spots ON the Box and ON the
// Pin during ref-select mode. Index 0 is a Box top-corner clear of the
// pin; indices 1..4 are points on the Pin's cylindrical stub that
// protrudes above the Box top (y=20), on the camera-facing sides — the
// verifier tries them until one picks "Pin".
publish_to_js("__brepView", &self.viewport.viewport_rect_json());
// Dock layout snapshot (per-pane visible / rendered) so the verifier
// can see which side panels are on-screen and, once a user tabs panels
// together, activate the right tab before asserting on its widgets.
// `active=false` in sketch / ref-select (the dock is bypassed).
publish_to_js("__brepDock", &self.dock.state_json(!sketch && !ref_select));
publish_to_js(
"__brepProbe",
&self
.state
.world_to_screen_json(
"[[2.0,20.0,2.0],[14.243,22.5,14.243],[16.0,22.5,10.0],\
[10.0,22.5,16.0],[10.0,25.0,10.0]]",
)
.unwrap_or_else(|_| "[]".to_string()),
);
publish_to_js("__brepPpp", &format!("{ppp}"));
publish_to_js("__brepStep", &format!("{}", self.state.history_rollback()));
publish_to_js(
"__brepParams",
&self.state.feature_params_json(self.state.history_rollback()),
);
}
// NOTE: the 3D viewport is no longer drawn here — it is a dock tile drawn
// earlier this frame (normal mode) or drawn directly in the sketch /
// ref-select branch above. Drawing it before the top-right overlay is what
// keeps that overlay anchored to the live viewport rect.
}
}
/// Fetch a `.BREP.json` document over HTTP for the `?loadModel=` boot path; the
/// reply (or a human error) arrives on the returned channel and `ctx` is
/// repainted so the frame loop drains it. Mirrors `step_parts::fetch_text`.
#[cfg(target_arch = "wasm32")]
fn fetch_model(
ctx: &egui::Context,
url: String,
) -> std::sync::mpsc::Receiver<Result<String, String>> {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
ehttp::fetch(ehttp::Request::get(url), move |result| {
let out = match result {
Ok(resp) if resp.ok => Ok(resp
.text()
.map(str::to_owned)
.unwrap_or_else(|| String::from_utf8_lossy(&resp.bytes).into_owned())),
Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
Err(err) => Err(err),
};
let _ = tx.send(out);
ctx.request_repaint();
});
rx
}
/// Mirror an engine JSON string to `window.<name>` (wasm/verification only).
#[cfg(target_arch = "wasm32")]
fn publish_to_js(name: &str, json: &str) {
if let Some(win) = web_sys::window() {
let _ = js_sys::Reflect::set(
&win,
&wasm_bindgen::JsValue::from_str(name),
&wasm_bindgen::JsValue::from_str(json),
);
}
}
/// The seed model handed to the engine at startup: a 3-feature history so the
/// tree / roll / edit are real —
/// 0. `P.CU` "Box" — a 20 mm cube at the origin (spans `[0,20]³`).
/// 1. `P.CY` "Pin" — a r=6, h=30 cylinder (axis +Y) positioned to pierce the
/// cube through its centre in XZ (x=10, z=10) from below (y=-5) to above.
/// 2. `B` "Cut" — SUBTRACT: `targetSolid = Box`, tools `[Pin]` → the cube
/// with a cylindrical through-hole (the ref-select field is visible for the
/// next slice). Roll-to-step shows: cube → cube+cylinder → subtracted cube.
///
/// This is just the INITIAL document — once handed to `EngineState`, the engine
/// OWNS the mutable history; the app keeps no copy.
fn seed_history_json() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [
{
"type": "P.CU",
"inputParams": {
"id": "Box",
"sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
},
"persistentData": {}
},
{
"type": "P.CY",
"inputParams": {
"id": "Pin",
"radius": 6.0, "height": 30.0,
"transform": {
"position": [10.0, -5.0, 10.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE", "mergeCoplanarFaces": true }
},
"persistentData": {}
},
{
"type": "B",
"inputParams": {
"id": "Cut",
"targetSolid": "Box",
"boolean": { "operation": "SUBTRACT", "targets": ["Pin"], "mergeCoplanarFaces": true }
},
"persistentData": {}
}
]
})
.to_string()
}