BREP_app 0.4.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
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
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
use super::*;

/// The RAW (unsmoothed) vertical wheel delta this frame, in UI points — summed
/// straight from the `MouseWheel` events instead of egui's `smooth_scroll_delta`.
/// egui smooths a wheel notch across ~6-10 frames (an ease-in/ease-out ramp that
/// reads as "dampening" at the start/end of a zoom); the raw events give one clean
/// discrete step per notch. Line/Page units are normalized to points the SAME way
/// egui's smoothing would (default `line_scroll_speed` = 40, private on
/// `InputState`, so mirrored here), so the calibrated `controls::wheel` step is
/// unchanged — only the ramp is gone.
fn raw_wheel_delta_y(ctx: &egui::Context) -> f32 {
    const LINE_POINTS: f32 = 40.0;
    ctx.input(|i| {
        i.events
            .iter()
            .filter_map(|event| match event {
                egui::Event::MouseWheel { unit, delta, .. } => Some(match unit {
                    egui::MouseWheelUnit::Point => delta.y,
                    egui::MouseWheelUnit::Line => delta.y * LINE_POINTS,
                    egui::MouseWheelUnit::Page => delta.y * LINE_POINTS * 20.0,
                }),
                _ => None,
            })
            .sum()
    })
}

impl Viewport {
    /// The rect (egui points) the viewport last drew into. Now that the viewport
    /// is a dock PANE (its rect moves as the user re-frames it), the shell anchors
    /// the floating context / Finish-Cancel overlays to THIS rect's right edge so
    /// they stay glued to the 3D view — see the top-right overlay in `app.rs`.
    /// `None` before the first draw (shell falls back to window-right until then).
    pub fn last_rect(&self) -> Option<egui::Rect> {
        self.last_rect
    }

    /// The last viewport rect as `{x, y, w, h}` in egui points (verification: the
    /// origin lets the verifier map engine viewport-local pick coords → page px).
    pub fn viewport_rect_json(&self) -> String {
        match self.last_rect {
            Some(r) => {
                serde_json::json!({ "x": r.min.x, "y": r.min.y, "w": r.width(), "h": r.height() })
                    .to_string()
            }
            None => "null".to_string(),
        }
    }

    /// The clean entry the shell calls: fill the central panel with the 3D
    /// viewport — size tracking, input routing, on-demand render, and the blit
    /// composite. Borrows the engine brain to draw + drive.
    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
        let ppp = ui.ctx().pixels_per_point();
        // Empty frame → NO inner margin/padding: the 3D view fills the central
        // area edge-to-edge (the viewport paints the whole rect anyway).
        egui::containers::panel::CentralPanel::default()
            .frame(egui::Frame::NONE)
            .show(ui, |ui| {
                let rect = ui.available_rect_before_wrap();
            self.last_rect = Some(rect);
            let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());

            // Track viewport size in the engine (logical px) + offscreen (physical).
            let phys_w = (rect.width() * ppp).round().max(1.0) as u32;
            let phys_h = (rect.height() * ppp).round().max(1.0) as u32;
            self.ensure_offscreen(phys_w, phys_h);
            state.resize(rect.width() as f64, rect.height() as f64);

            // Feed input BEFORE rendering so a change is reflected this frame.
            self.handle_viewport_input(ui, rect, &response, state);

            // Per-frame overlay upkeep, AFTER the input + resize above so a zoom
            // is reflected in the SAME frame it happened. Re-bakes the
            // screen-constant sizing of every draggable gizmo that rides the
            // pre-expanded `set_overlay` channel — assembly-constraint handles
            // (§8.4), the ◎ feature-dimension gizmo, the live sketch overlay —
            // on a material world-per-pixel change, and hides/restores the
            // constraint graphics with their toggle + sketch mode.
            state.ensure_overlays_current();

            if state.dirty {
                self.render_viewport(phys_w, phys_h, ppp, state);
                // Keep animating while a drag is live / more input pending.
                ui.ctx().request_repaint();
            }

            // Composite the offscreen 3D texture into egui's frame via the
            // wgpu paint callback.
            ui.painter().add(egui_wgpu::Callback::new_paint_callback(
                rect,
                ViewportCallback,
            ));
        });

        // The "candidates under the cursor" disambiguation popup (Alt+click)
        // floats over the viewport at ctx level, like the file dialog / palette.
        let ctx = ui.ctx().clone();
        self.show_candidate_popup(&ctx, state);

        // Editable dimension labels (S5): value text drawn at each dimension's
        // screen-projected anchor, click-to-edit + drag-to-reposition. Drawn at ctx
        // level (foreground of the viewport) so it floats over the 3D like the popup.
        if let Some(rect) = self.last_rect {
            self.draw_dimension_labels(&ctx, rect, state);
            // Feature-dimension labels (FD-1): the ◎ dimension-gizmo mode draws the
            // primitive's param dims here, click-to-edit + drag-to-resize.
            self.draw_feature_dimension_labels(&ctx, rect, state);
            // Transform-gizmo axis labels (XC/YC/ZC): the colored cone-tip labels.
            self.draw_transform_axis_labels(&ctx, rect, state);
            // Assembly-constraint labels (§8.4): status-colored chips at each
            // constraint's anchor — hover highlights the referenced geometry,
            // click expands the row in the Assembly Constraints panel.
            self.draw_constraint_labels(&ctx, rect, state);
            // PMI labels: the active view's annotation chips — drag to move
            // the label, click to open the annotation, hover to highlight its
            // geometry.
            self.draw_pmi_labels(&ctx, rect, state);
            // DEBUG: 1px RED outline of the EXACT gizmo-arrow hit regions (axis
            // capsules + grab circles of the transform widget — feature transform
            // mode AND the component Move gizmo — or the dimension arrowheads).
            // Drawn LAST so the outlines overlay everything.
            self.draw_gizmo_hit_areas(&ctx, rect, state);
        }
        if self.candidate_popup.is_some() || state.dirty {
            // Keep animating while the popup is open (its entry hover / a fresh
            // highlight is applied AFTER this frame's render).
            ctx.request_repaint();
        }

        // Verification hooks (wasm only): the selection-UX globals the headed
        // verifier reads. Published from HERE because viewport.rs owns hover + the
        // candidate popup (keeps app.rs untouched). Purely additive.
        if crate::automation::registry::enabled() {
            crate::automation::registry::publish("__brepHover", "hovered entity", &state.hovered_json());
            crate::automation::registry::publish("__brepCandidates", "open pick-candidate popup entries [{index,kind,name,solid,depth}]", &self.candidates_json(state));
            crate::automation::registry::publish("__brepCandidateHit", "pick-candidate popup entry rects", &self.candidate_hits_json());
            // Re-publish the selection with the POST-input value: the app shell
            // publishes `__brepSelection` before this viewport draws (so its copy
            // lags a viewport click by a frame); overwrite it here with the value
            // that reflects this frame's click so the verifier reads it live.
            crate::automation::registry::publish("__brepSelection", "selection {solids, faces, edges, datums, vertices}", &state.selection_json());
            // The ◎ dimension-gizmo state (mode + annotations) for the verifier.
            crate::automation::registry::publish("__brepFeatureDim", "dimension gizmo state (mode, annotations)", &state.feature_dimension_state_json());
            // The assembly-constraint overlay labels (id/text/status/color/world/
            // draggable) so the verifier can locate + drag a constraint handle.
            crate::automation::registry::publish("__brepConstraints", "assembly constraint overlay labels", &state.constraint_labels_json());
            // The active PMI view's label chips (id/type/text/status/world) so
            // the verifier can locate, drag and click an annotation.
            crate::automation::registry::publish("__brepPmiLabels", "PMI label chips of the active view", &state.pmi_labels_json());
            crate::automation::registry::publish("__brepPmiLabelHit", "PMI label chip rects {id: [x,y,w,h]}", &self.pmi_label_hits_json());
            // The ViewCube corner rect (viewport-local `{x,y,w,h}`) so the verifier
            // can click a cube face/edge/corner by hit-rect (offset by `__brepView`).
            crate::automation::registry::publish("__brepViewCube", "ViewCube rect (viewport-local)", &state.viewcube_rect_json());
        }
    }

    /// The PMI chips' screen rects (`{id: [x, y, w, h]}`, egui points) —
    /// the `__brepPmiLabelHit` verifier global.
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    fn pmi_label_hits_json(&self) -> String {
        let map: serde_json::Map<String, serde_json::Value> = self
            .pmi_label_hits
            .iter()
            .map(|(id, rect)| {
                (
                    id.clone(),
                    serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
                )
            })
            .collect();
        serde_json::Value::Object(map).to_string()
    }

    /// The ViewCube corner rect (logical px, viewport-local), if enabled.
    fn viewcube_local(&self, state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
        viewcube_local(state, x, y)
    }

    /// Route pointer/wheel over the viewport into the engine's SKETCH interaction
    /// (S2), while [`EngineState::sketch_mode`]. Point drags move sketch points;
    /// empty-space drags still orbit/pan the camera; clicks select (Ctrl/Cmd adds);
    /// hover lights the entity under the cursor. The ViewCube corner + wheel zoom
    /// keep working. NONE of the modeling select/candidate/transform/ref-select
    /// branches run here.
    fn handle_sketch_input(
        &mut self,
        ui: &egui::Ui,
        rect: egui::Rect,
        response: &egui::Response,
        state: &mut EngineState,
    ) {
        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);

        // A DRAW tool (point/line/rect/circle/arc) is active vs S2 selection mode.
        // In draw mode clicks PLACE geometry and points are NOT grabbed for dragging
        // (so an empty drag still orbits the camera); hover still runs for snap + the
        // rubber-band preview.
        let draw_mode = state.sketch_active_tool().is_some();

        // Right-click aborts the in-progress draw geometry. (Escape → drop back to
        // the Select/drag tool is handled globally in `BrepApp::handle_shortcuts`,
        // the only reliable capture point: it `consume_key`s Escape before the
        // viewport runs, and it fires for EVERY armed tool, not just draw mode.)
        if draw_mode && response.secondary_clicked() {
            state.sketch_tool_cancel();
        }

        // Delete / Backspace removes the current sketch selection (S3b) — geometries +
        // points + constraints + orphan cleanup, driven by the engine. Gated on sketch
        // mode AND on no egui TEXT edit being focused, so a Backspace typed into an open
        // dimension value editor edits the number rather than deleting the selection
        // (same guard `handle_shortcuts` uses for the global keys).
        let delete = !ui.ctx().text_edit_focused()
            && ui
                .ctx()
                .input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
        if delete {
            state.sketch_delete_selection();
        }

        if response.drag_started() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                // ViewCube first (a view snap); then the freehand handdraw stroke
                // capture (S6b-3 — a drag IS the stroke, never a camera orbit); then a
                // sketch point grab (SELECT mode only — other draw modes never grab, so
                // an empty drag orbits); else an empty-space camera orbit/pan so
                // navigation still works.
                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    state.viewcube_click(cx, cy);
                } else if state.sketch_active_tool() == Some("handdraw") {
                    state.sketch_handdraw_begin(lx, ly);
                    self.sketch_handdrawing = true;
                } else if !draw_mode && state.sketch_drag_begin(lx, ly) {
                    self.sketch_dragging = true;
                } else {
                    let btn = if response.dragged_by(egui::PointerButton::Secondary) {
                        BUTTON_RIGHT
                    } else if response.dragged_by(egui::PointerButton::Middle) {
                        BUTTON_MIDDLE
                    } else {
                        BUTTON_LEFT
                    };
                    state.pointer_down(lx, ly, btn);
                    self.dragging = true;
                }
            }
        }
        if response.dragged() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if self.sketch_handdrawing {
                    state.sketch_handdraw_move(lx, ly);
                } else if self.sketch_dragging {
                    state.sketch_drag_to(lx, ly);
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.sketch_handdrawing {
                state.sketch_handdraw_end();
                self.sketch_handdrawing = false;
            }
            if self.sketch_dragging {
                state.sketch_drag_end();
                self.sketch_dragging = false;
            }
            if self.dragging {
                state.pointer_up();
                self.dragging = false;
            }
        }

        // A plain click either PLACES draw-tool geometry (draw mode) or selects the
        // entity under the cursor (SELECT mode: Ctrl/Cmd adds/toggles, empty clears).
        // A click over the ViewCube corner snaps the camera, never a sketch pick/place.
        if response.clicked() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    // A plain click on the ViewCube corner snaps the camera. A plain
                    // click never fires drag_started (see the modeling path), so the
                    // snap must run here, not only on the drag-start branch above.
                    state.viewcube_click(cx, cy);
                } else if state.sketch_active_tool() == Some("handdraw") {
                    // handdraw (S6b-3) captures a DRAG as a stroke; a plain click (no
                    // drag) is a deliberate no-op.
                } else if state.sketch_active_tool() == Some("pickEdges") {
                    // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor
                    // (pixel coords), never a plane place/select.
                    state.sketch_pick_edge_at(lx, ly);
                } else if draw_mode {
                    state.sketch_tool_click_at(lx, ly);
                } else {
                    let mods = ui.ctx().input(|i| i.modifiers);
                    state.sketch_click_at(lx, ly, mods.command || mods.ctrl);
                }
            }
        }

        // Hover: the entity under the pointer (skip while dragging / over the cube).
        // Also FREEZE the entity hover while the primary button is held in SELECT
        // mode: between the press and egui's drag-start (~6px of movement later) the
        // pointer keeps moving, and re-hovering there would slide the highlight off
        // the point the user pressed — so the grab (which takes the hovered entity)
        // would miss. Draw mode keeps updating (its rubber-band preview rides the
        // live hover).
        let primary_held = ui.input(|i| i.pointer.primary_down());
        if !self.sketch_dragging
            && !self.dragging
            && !self.sketch_handdrawing
            && !(primary_held && !draw_mode)
        {
            match response.hover_pos() {
                Some(pos) => {
                    let (lx, ly) = local(pos);
                    match self.viewcube_local(state, lx, ly) {
                        Some((cx, cy)) => {
                            state.viewcube_hover(cx, cy);
                            state.sketch_clear_hover(); // over the cube, not the sketch
                        }
                        None => {
                            state.viewcube_clear_hover();
                            state.sketch_hover_at(lx, ly);
                            // pickEdges (S6b-2) targets a 3D scene edge, so also
                            // hover-highlight the edge under the cursor (modeling
                            // emphasis) as a link affordance.
                            if state.sketch_active_tool() == Some("pickEdges") {
                                state.hover_at(lx, ly);
                            }
                        }
                    }
                }
                None => {
                    state.viewcube_clear_hover();
                    // Pointer is off the viewport entirely. Don't clobber a hover the
                    // entity-LIST panel set THIS frame (it drew before us) — that is
                    // the list→canvas highlight. When the panel didn't set one, clear
                    // as usual so a stale highlight doesn't linger.
                    if !state.take_sketch_list_hover() {
                        state.sketch_clear_hover();
                    }
                }
            }
        }

        // Wheel zoom toward the cursor (same as modeling).
        if response.hovered() {
            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
            // scroll). See [`raw_wheel_delta_y`].
            let scroll_y = raw_wheel_delta_y(ui.ctx());
            if scroll_y != 0.0 {
                let cursor = response.hover_pos().map(|p| {
                    let (lx, ly) = local(p);
                    [lx, ly]
                });
                state.wheel(-(scroll_y as f64), cursor);
            }
        }
    }

    /// Route pointer/wheel over the viewport into `EngineState` (mirrors
    /// `desktop.rs`). `rect` is the viewport in egui points; coords fed to the
    /// engine are viewport-local logical px, the space `state.camera` lives in.
    fn handle_viewport_input(
        &mut self,
        ui: &egui::Ui,
        rect: egui::Rect,
        response: &egui::Response,
        state: &mut EngineState,
    ) {
        let local = |p: egui::Pos2| ((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64);

        // Sketch mode (S2) owns the pointer: hover/select/point-drag in plane space,
        // never the modeling select/candidate/transform/ref-select branches. Routed
        // BEFORE the modeling path, which stays byte-for-byte for `!sketch_mode()`.
        if state.sketch_mode() {
            self.handle_sketch_input(ui, rect, response, state);
            return;
        }

        if response.drag_started() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                // Every drag-start ends the hover: the pointer is now steering
                // the camera / the cube / a gizmo handle / a dimension arrow,
                // not hovering the model — a frozen highlight riding a camera
                // snap, an orbit, or geometry that a gizmo/dim drag is reshaping
                // live reads as a stale pick. Hover re-resolves at the new pose
                // as soon as the interaction ends (the per-frame hover branch).
                state.clear_hover();
                // WHERE the press landed — the point every handle hit test below is
                // taken at ([`drag_start_local`]), which is NOT `pos`: egui only calls
                // a press a drag once the pointer has left the click radius, so by
                // this frame `pos` has already drifted off whatever the user
                // pressed on.
                let (gx, gy) = drag_start_local(response, rect).unwrap_or((lx, ly));
                // A drag that STARTS over the ViewCube corner snaps/orbits via the
                // cube (a plain click — which never fires drag_started — is snapped
                // in the `clicked()` branch below); a press on an armed
                // transform-gizmo HANDLE drives the gizmo; anywhere else starts a
                // camera drag through the controls.
                match route_drag_start(state, gx, gy) {
                    // The cube already snapped the camera inside the router.
                    DragStart::ViewCube => {}
                    // Grabbed a gizmo handle → route the drag to the transform.
                    DragStart::Gizmo => self.gizmo_dragging = true,
                    // Grabbed the armed COMPONENT Move gizmo → free-move the gizmo
                    // during the drag, commit the pose (+ re-solve) on release.
                    DragStart::Component => self.component_gizmo_dragging = true,
                    // Grabbed a ◎ dimension ARROWHEAD (Fix 4) → route the drag to
                    // that param's live edit instead of orbiting the camera.
                    DragStart::Dimension(field) => self.dim_dragging = Some(field),
                    // Grabbed an ASSEMBLY-CONSTRAINT handle (a distance leader /
                    // angle-arc handle, §8.4 grabbable arrows) → the drag previews
                    // that constraint's value; release commits + auto-solves.
                    DragStart::Constraint => self.constraint_dragging = true,
                    DragStart::Camera => {
                        let btn = if response.dragged_by(egui::PointerButton::Secondary) {
                            BUTTON_RIGHT
                        } else if response.dragged_by(egui::PointerButton::Middle) {
                            BUTTON_MIDDLE
                        } else {
                            BUTTON_LEFT
                        };
                        // The camera anchors at the CURRENT pointer position, not
                        // the press origin: `pointer_move` deltas run from whatever
                        // `pointer_down` recorded, so anchoring at the (older) press
                        // origin would make the first orbit frame jump by the whole
                        // click-radius drift.
                        state.pointer_down(lx, ly, btn);
                        self.dragging = true;
                    }
                }
            }
        }
        if response.dragged() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if self.gizmo_dragging {
                    // Drive the transform gizmo: updates the feature's transform +
                    // re-runs the history (the model moves live).
                    state.transform_drag_to(lx, ly);
                } else if self.component_gizmo_dragging {
                    // Drive the component Move gizmo: the GIZMO follows the pointer
                    // (free move); the pose commits on release.
                    state.component_drag_to(lx, ly);
                } else if let Some(field) = self.dim_dragging.clone() {
                    // Drive the dimension arrow (Fix 4): edit the param + re-run the
                    // history live, so the geometry AND its arrow follow the pointer.
                    let feature = state.dimension_armed_feature();
                    if !feature.is_empty() {
                        state.feature_dimension_drag(&feature, &field, lx, ly);
                    }
                } else if self.constraint_dragging {
                    // Drive the constraint handle: the value PREVIEWS live (arrow +
                    // label track the pointer); nothing commits until release.
                    state.constraint_drag_to(lx, ly);
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.gizmo_dragging {
                state.transform_release();
                self.gizmo_dragging = false;
            }
            if self.component_gizmo_dragging {
                // COMMIT: compose the drag delta onto the ACOMP transform, one
                // param write + rerun (the constraint tail re-solves — by design).
                state.component_release();
                self.component_gizmo_dragging = false;
            }
            if self.dim_dragging.take().is_some() {
                // The overlay is already glued to the final value from the last drag
                // frame; just drop the flag so hover/select resume.
            }
            if self.constraint_dragging {
                // COMMIT the previewed constraint value: updates the constraint
                // (auto-solves), re-tessellates the re-posed components, folds the
                // solved poses into the history document, refreshes the overlay.
                state.constraint_drag_release();
                self.constraint_dragging = false;
            }
            if self.dragging {
                state.pointer_up();
                self.dragging = false;
            }
        }

        // A plain click (press+release, no drag) over the viewport: in
        // reference-selection mode it type-constrained-picks a reference under the
        // cursor (engine drives the highlight); otherwise the modeling selection
        // UX — a PLAIN click on ONE filter-admitted item selects it (replace, or
        // toggle in the Click-toggles multi-select mode), a plain click on SEVERAL
        // overlapping items opens the PICK LIST popup at the cursor (front/back
        // faces, obstructed geometry), Ctrl/Cmd+click ADDS/TOGGLES the top pick
        // directly, and Alt+click opens the pick list explicitly. ViewCube clicks
        // are snapped in this branch (checked right after the popup), never a
        // selection/pick.
        if response.clicked() {
            if let Some(pos) = response.interact_pointer_pos() {
                let on_popup = self.candidate_popup.is_some()
                    && self
                        .candidate_popup_rect
                        .map(|r| r.contains(pos))
                        .unwrap_or(false);
                let (lx, ly) = local(pos);
                let mods = ui.ctx().input(|i| i.modifiers);
                if on_popup {
                    // A click inside the OPEN popup belongs to the popup — its
                    // entry buttons handle it in `show_candidate_popup`. Don't let
                    // the viewport close it or pick the geometry behind it.
                } else if self.candidate_popup.is_some() {
                    // A click OUTSIDE the open pick list DISMISSES it and is
                    // SWALLOWED — it must not fall through to the selection
                    // branches below, or dismissing the list would re-pick (or, on
                    // empty space, CLEAR a multi-selection built through the
                    // list). The NEXT click acts normally.
                    self.candidate_popup = None;
                    self.candidate_popup_rect = None;
                } else if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    // A click over the ViewCube corner snaps the camera to that
                    // region's standard view. Checked FIRST (after the popup) so a
                    // corner click is always a view snap, never a scene / ref-select
                    // / gizmo pick. A PLAIN click never fires `drag_started` (egui
                    // postpones the click/drag decision for a click_and_drag widget
                    // and only fires drag_started once the pointer is decidedly
                    // dragging), so the snap MUST run here — the drag-start path only
                    // catches a press that egui classifies as a drag.
                    state.viewcube_click(cx, cy);
                } else if state.ref_select_active() {
                    state.ref_select_click(lx, ly);
                } else if state.transform_center_pick(lx, ly) {
                    // The orange CENTER sphere in transform mode toggles to the
                    // DIMENSION arrows (the old app's center-handle ◎ toggle).
                    // Must precede the generic handle-swallow below so a center
                    // click flips modes instead of being swallowed; a center
                    // DRAG still free-moves (handled on drag-start, not here).
                    state.toggle_to_dimension();
                } else if state.dimension_origin_pick(lx, ly) {
                    // The orange ORIGIN sphere in dimension mode toggles back to
                    // the TRANSFORM controls (the reverse ◎ toggle).
                    state.toggle_to_transform();
                } else if state.dimension_arrow_pick(lx, ly).is_some() {
                    // A bare click on a dimension ARROWHEAD is a no-op — only a DRAG
                    // on it edits the value (Fix 4). Swallow it so the solid behind
                    // the arrow isn't selected.
                } else if state.constraint_arrow_pick(lx, ly).is_some() {
                    // Same rule for an assembly-constraint handle: only a DRAG edits
                    // the value; swallow the bare click so the geometry behind the
                    // leader/arc isn't selected.
                } else if (state.transform_armed() || state.component_move_armed())
                    && state.transform_pick(lx, ly) != 0
                {
                    // A bare click on an armed (non-center) gizmo handle — feature
                    // OR component Move gizmo — swallow it so the solid behind the
                    // gizmo is not selected.
                } else if mods.alt {
                    // Alt+click → open the pick list explicitly, even for a
                    // single candidate (the power-user inspection trigger).
                    let cands = state.candidates_filtered_at(lx, ly);
                    self.candidate_popup = (!cands.is_empty())
                        .then(|| CandidatePopup { anchor: pos, candidates: cands });
                    self.candidate_popup_fresh = self.candidate_popup.is_some();
                } else if mods.command || mods.ctrl {
                    // Ctrl/Cmd+click ADDS/TOGGLES the top pick directly, no list
                    // (the classic additive shortcut, both multi-select modes).
                    state.select_toggle_at(lx, ly);
                } else if state.spline_anchor_pick_at(lx, ly).is_some() {
                    // An anchor dot of the spline whose editor is open: that
                    // anchor is selected (the gizmo arms on a free one) and the
                    // click stops here — the sheet under it is not selected.
                } else {
                    // A plain click: ONE admitted item under the cursor selects it
                    // directly — REPLACE in Ctrl+Click mode, TOGGLE in the
                    // Click-toggles mode (a second click on the same item
                    // unselects it). SEVERAL overlapping items open the PICK LIST
                    // popup so front/back faces, obstructed geometry AND the
                    // construction planes over them are all reachable. A miss
                    // clears.
                    //
                    // Construction planes are ORDINARY candidates in this list
                    // (`PickKind::Plane`, ranked right after faces), so there is
                    // no geometry-miss `datum_pick` fallback any more: a plane
                    // under other geometry used to be unreachable because the
                    // fallback only ran when the list came back EMPTY, and an
                    // unchecked Plane filter could not have excluded it.
                    let cands = state.candidates_filtered_at(lx, ly);
                    let toggles =
                        state.settings.multi_select == MultiSelectMode::ClickToggles;
                    match cands.len() {
                        0 => {
                            state.clear_selection();
                        }
                        1 => {
                            if toggles {
                                state.toggle_candidate(&cands[0]);
                            } else {
                                state.select_candidate(&cands[0]);
                            }
                        }
                        _ => {
                            self.candidate_popup =
                                Some(CandidatePopup { anchor: pos, candidates: cands });
                            self.candidate_popup_fresh = true;
                        }
                    }
                }
            }
        }

        // Hover: the ViewCube corner, an armed transform-gizmo handle, OR the
        // top filter-admitted scene entity under the pointer (the modeling
        // hover-highlight). Suppressed while dragging / over a gizmo handle / mid
        // dimension-arrow or constraint-handle drag (feature, component-move, or
        // constraint gizmo), while the candidate popup owns the highlight, and
        // for the ONE frame a constraint LABEL applied its element highlight
        // (the label pass draws after us and re-arms the flag while hovered —
        // mirrors the sketch entity-list hover yield).
        let label_hover = state.take_constraint_label_hover() | state.take_pmi_label_hover();
        // The SCENE-TREE row hover (row → viewport highlight). Taken every frame
        // so the one-frame flag never survives a skipped one, but honored only in
        // the pointer-left-the-viewport branch below: the pointer is over the
        // sidebar while a row is hovered, so that branch — and only that branch —
        // would clear the row's highlight. Folding it into `label_hover` would also
        // skip `viewcube_clear_hover` and leave the cube lit when the pointer moves
        // from it to the tree.
        let tree_hover = state.take_scene_tree_hover();
        // The DIALOG row hover (a form's reference / `Outputs` line, the picker
        // card's picked names) — the same deal as the tree's, from a different
        // slot so the two panes cannot end each other's highlight.
        let dialog_hover = state.take_dialog_hover();
        if !self.dragging
            && !self.gizmo_dragging
            && !self.component_gizmo_dragging
            && self.dim_dragging.is_none()
            && !self.constraint_dragging
            && self.pmi_label_dragging.is_none()
            && !label_hover
        {
            match response.hover_pos() {
                Some(pos) => {
                    let (lx, ly) = local(pos);
                    match self.viewcube_local(state, lx, ly) {
                        Some((cx, cy)) => {
                            state.viewcube_hover(cx, cy);
                            state.clear_hover(); // over the cube, not the scene
                        }
                        None => {
                            state.viewcube_clear_hover();
                            // Over an armed gizmo handle → highlight the handle, not
                            // the solid behind it (the previous app's "skip scene hover over
                            // the gizmo" rule). Else hover-highlight the top pick.
                            let over_handle = (state.transform_armed()
                                || state.component_move_armed())
                                && state.transform_hover(lx, ly) != 0;
                            if over_handle {
                                state.clear_hover();
                            } else if self.candidate_popup.is_none() {
                                state.hover_at(lx, ly);
                            }
                        }
                    }
                }
                None => {
                    state.viewcube_clear_hover();
                    // Pointer left the viewport (or moved onto the popup, which
                    // drives its own entry-hover) → drop the scene hover. UNLESS a
                    // Scene-tree row or a DIALOG row is hovering an entity: that
                    // highlight is the pointer's, drawn from the sidebar (mirrors
                    // the sketch entity-list hover yield).
                    if self.candidate_popup.is_none() && !tree_hover && !dialog_hover {
                        state.clear_hover();
                    }
                }
            }
        }

        // Wheel zoom toward the cursor when hovering the viewport.
        if response.hovered() {
            // Raw (unsmoothed) wheel delta so each notch is a discrete step with NO
            // ease-in/out ramp (egui's smooth_scroll_delta dampens the start/end of a
            // scroll). See [`raw_wheel_delta_y`].
            let scroll_y = raw_wheel_delta_y(ui.ctx());
            if scroll_y != 0.0 {
                let cursor = response.hover_pos().map(|p| {
                    let (lx, ly) = local(p);
                    [lx, ly]
                });
                // egui scroll: +y = wheel up = zoom in; the controls treat
                // negative delta_y as zoom-in (see desktop.rs), so negate.
                state.wheel(-(scroll_y as f64), cursor);
            }
        }
    }

    /// Draw the PICK LIST popup — a semi-transparent, scrollable list of the
    /// ranked, filter-admitted candidates at the cursor, in the category order
    /// points > edges > faces > solids > components (nearest first within each).
    /// Opens on a plain click with MULTIPLE items under the pointer (so front +
    /// back faces and obstructed geometry are reachable) and on Alt+click
    /// explicitly. HOVERING an entry pre-highlights that entity in the scene
    /// (and hover-out clears it); a row draws SELECTED while its entity is in
    /// the selection, so toggling reads back visually. CLICKING an entry picks
    /// it and ALWAYS closes the list: in the Click-toggles multi-select mode
    /// (and on Ctrl/Cmd+click in either mode) the entry TOGGLES into the
    /// selection — front AND back faces join one selection across two
    /// click→entry rounds — while in Ctrl+Click mode a plain entry click
    /// REPLACES the selection with exactly it. The header's "Clear Selection"
    /// clears + closes. Also closes on Escape (routed via the app shell so it
    /// never also clears the selection) and on a click outside (swallowed by
    /// the viewport click router). Rebuilds `candidate_hits` (per-entry screen
    /// rects) each frame for the headed verifier. Engine mutations are applied
    /// AFTER the draw closure (the codebase's "no engine mutation inside the
    /// draw" rule).
    fn show_candidate_popup(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        self.candidate_hits.clear();
        // A modal mode (reference-selection / sketch edit) supersedes the pick
        // list: drop a popup left open by modeling clicks so it neither draws
        // over the modal nor swallows the modal's first viewport click.
        if state.ref_select_active() || state.sketch_mode() {
            self.candidate_popup = None;
        }
        let Some(popup) = self.candidate_popup.as_ref() else {
            self.candidate_popup_rect = None;
            return;
        };
        let candidates = popup.candidates.clone();
        let anchor = popup.anchor;
        let mods = ctx.input(|i| i.modifiers);
        // Row selected-state, read BEFORE the draw (no engine borrow inside it).
        let selected_rows: Vec<bool> = candidates
            .iter()
            .map(|c| state.candidate_is_selected(c))
            .collect();

        let mut hits: Vec<egui::Rect> = Vec::with_capacity(candidates.len());
        let mut hovered_index: Option<usize> = None;
        let mut clicked_index: Option<usize> = None;
        let mut clear_clicked = false;

        let area = egui::Area::new(egui::Id::new("brep-candidate-popup"))
            .order(egui::Order::Foreground)
            .fixed_pos(anchor)
            // Keep the whole list on screen when the click lands near an edge.
            .constrain(true)
            .show(ctx, |ui| {
                // The standard popup frame at reduced opacity: the model stays
                // visible through the list while scrolling it.
                let mut frame = egui::Frame::popup(ui.style());
                frame.fill = frame.fill.gamma_multiply(0.85);
                frame.show(ui, |ui| {
                    ui.set_max_width(280.0);
                    // Header row: title + a "Clear Selection" action.
                    ui.horizontal(|ui| {
                        ui.label(egui::RichText::new("Select an object").weak().small());
                        ui.with_layout(
                            egui::Layout::right_to_left(egui::Align::Center),
                            |ui| {
                                if ui.small_button("Clear Selection").clicked() {
                                    clear_clicked = true;
                                }
                            },
                        );
                    });
                    // A long candidate list scrolls instead of growing past the
                    // viewport; hover keeps re-resolving as rows slide under the
                    // pointer, so scrolling through the list highlights each
                    // entity in turn.
                    egui::ScrollArea::vertical()
                        .max_height(240.0)
                        .show(ui, |ui| {
                            ui.set_min_width(220.0);
                            for (i, candidate) in candidates.iter().enumerate() {
                                let label = format!(
                                    "{}  {}",
                                    state.candidate_kind_label(candidate),
                                    candidate_label(candidate)
                                );
                                // TRUNCATE inside the popup's cap. A candidate
                                // is labelled with the entity's own name, which
                                // the modelling history makes as long as it
                                // likes; left to extend, it grows this overlay
                                // card past the 280 pt it just asked for and out
                                // over the viewport edge. A `Button` (which is
                                // what a selectable label is) has no elided-text
                                // tooltip of its own, so the full name is spelled
                                // out on hover — the rows the user is choosing
                                // between often differ only in their tails.
                                let resp = ui
                                    .add(
                                        egui::Button::selectable(
                                            selected_rows[i],
                                            label.as_str(),
                                        )
                                        .truncate(),
                                    )
                                    .on_hover_text(&label);
                                hits.push(resp.rect);
                                if resp.hovered() {
                                    hovered_index = Some(i);
                                }
                                if resp.clicked() {
                                    clicked_index = Some(i);
                                }
                            }
                        });
                });
            });

        self.candidate_hits = hits;
        self.candidate_popup_rect = Some(area.response.rect);

        // Apply engine mutations outside the draw closure.
        if let Some(i) = hovered_index {
            state.hover_candidate(&candidates[i]);
        } else {
            // No entry under the pointer → drop the pre-highlight, so the
            // last-hovered row's entity doesn't stay lit while the pointer
            // roams elsewhere.
            state.clear_hover();
        }
        let mut close = false;
        if clear_clicked {
            state.clear_selection();
            close = true;
        }
        if let Some(i) = clicked_index {
            // Picking an entry ALWAYS dismisses the list — the pick is the
            // list's job and it is done. In Click-toggles mode (or with
            // Ctrl/Cmd held) the entry TOGGLES into the selection, so a
            // front+back multi-selection is click → front, click → back (the
            // list reopens on the next click); in Ctrl+Click mode a plain
            // entry click REPLACES the selection with exactly that entity.
            let toggles = state.settings.multi_select == MultiSelectMode::ClickToggles;
            if toggles || mods.command || mods.ctrl {
                state.toggle_candidate(&candidates[i]);
            } else {
                state.select_candidate(&candidates[i]);
            }
            close = true;
        }
        // Fallback only: the app shell's global Escape router consumes the key
        // first and closes via `close_candidate_popup` (so Escape never ALSO
        // clears the selection); this fires only when that router is skipped
        // (e.g. a text edit had focus).
        if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
            close = true;
        }
        // Ignore the OPENING Alt+click on the frame it opened; honor click-outside
        // from the next frame on.
        if self.candidate_popup_fresh {
            self.candidate_popup_fresh = false;
        } else if area.response.clicked_elsewhere() {
            close = true;
        }
        if close {
            state.clear_hover();
            self.candidate_popup = None;
            self.candidate_popup_rect = None;
        }
    }

    /// The OPEN popup's candidate list as JSON `[{index,kind,name,solid,depth}]`
    /// (empty when closed) — the headed verifier asserts the sorted list.
    fn candidates_json(&self, state: &EngineState) -> String {
        match self.candidate_popup.as_ref() {
            Some(popup) => {
                let out: Vec<serde_json::Value> = popup
                    .candidates
                    .iter()
                    .enumerate()
                    .map(|(i, c)| {
                        serde_json::json!({
                            "index": i,
                            "kind": state.candidate_kind_label(c),
                            "name": c.name,
                            "solid": c.solid,
                            "depth": c.depth,
                        })
                    })
                    .collect();
                serde_json::Value::Array(out).to_string()
            }
            None => "[]".to_string(),
        }
    }

    /// The OPEN popup's per-entry screen rects as JSON `[{index,x,y,w,h}]` (egui
    /// points) so the verifier can click a specific candidate entry.
    fn candidate_hits_json(&self) -> String {
        let out: Vec<serde_json::Value> = self
            .candidate_hits
            .iter()
            .enumerate()
            .map(|(i, r)| {
                serde_json::json!({
                    "index": i,
                    "x": r.min.x,
                    "y": r.min.y,
                    "w": r.width(),
                    "h": r.height(),
                })
            })
            .collect();
        serde_json::Value::Array(out).to_string()
    }
}

/// Which branch CLAIMED a viewport drag-start — the one dispatch that decides
/// whether a press drives a gizmo handle or the camera. Returned by
/// [`route_drag_start`], which performs the claim; the caller only records which
/// drag is now live.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum DragStart {
    /// The ViewCube corner: the router already snapped the camera.
    ViewCube,
    /// A transform-gizmo handle (arrow / ring ball / center sphere).
    Gizmo,
    /// A handle of the armed assembly-component Move gizmo.
    Component,
    /// A ◎ dimension arrowhead; carries the param field key it edits.
    Dimension(String),
    /// An assembly-constraint distance/angle handle.
    Constraint,
    /// Nothing claimed it → the camera orbit/pan fallthrough.
    Camera,
}

/// The VIEWPORT-LOCAL point a drag-start hit test must be taken at. The ONE
/// place that choice is made — the dispatch and its tests both call THIS, so a
/// test can never pass against a call site that resolved the point differently.
///
/// NOT `response.interact_pointer_pos()`, which is the pointer's position on the
/// frame egui DECIDED the press was a drag — by then it has travelled at least
/// `max_click_dist` (6 pt) from the press, and a slow frame (the 3D viewport
/// waking from egui's on-demand repaint) coalesces the whole flick into one
/// step, so the reported point can be tens of px away. A gizmo handle carries
/// 7-9 px of grab radius (axis arrow / rotation ball / centre sphere), a
/// dimension leader 18, so hit-testing there misses the handle the user
/// actually pressed and the press falls through to the camera orbit.
///
/// `press_origin` is exactly where the button went down, so the test asks "what
/// did you press on", not "where has the cursor got to". It is `None` only when
/// no button is down — a press and release inside ONE frame — where the reported
/// interact position is all there is.
fn drag_start_local(response: &egui::Response, rect: egui::Rect) -> Option<(f64, f64)> {
    let p = response
        .ctx
        .input(|i| i.pointer.press_origin())
        .or_else(|| response.interact_pointer_pos())?;
    Some(((p.x - rect.min.x) as f64, (p.y - rect.min.y) as f64))
}

/// Dispatch ONE viewport drag-start at viewport-local px `(x, y)`: the first
/// branch whose handle is under the press CLAIMS it (and arms its drag inside
/// the engine), else the camera takes it.
///
/// Precedence is load-bearing and unchanged: ViewCube corner → transform gizmo →
/// component Move gizmo → ◎ dimension arrowhead → assembly-constraint handle →
/// camera. Split out of `handle_viewport_input` so the claim can be driven from
/// a test without a GPU-backed [`Viewport`].
pub(super) fn route_drag_start(state: &mut EngineState, x: f64, y: f64) -> DragStart {
    if let Some((cx, cy)) = viewcube_local(state, x, y) {
        state.viewcube_click(cx, cy);
        DragStart::ViewCube
    } else if state.transform_press(x, y) {
        DragStart::Gizmo
    } else if state.component_press(x, y) {
        DragStart::Component
    } else if let Some(field) = state.dimension_arrow_pick(x, y) {
        DragStart::Dimension(field)
    } else if state.constraint_drag_begin(x, y) {
        DragStart::Constraint
    } else {
        DragStart::Camera
    }
}

/// The ViewCube corner rect hit test (viewport-local logical px) — `Some` with
/// the CUBE-local coords when `(x, y)` is inside the drawn cube, else `None`.
fn viewcube_local(state: &EngineState, x: f64, y: f64) -> Option<(f64, f64)> {
    let v: serde_json::Value = serde_json::from_str(&state.viewcube_rect_json()).ok()?;
    let (rx, ry, rw, rh) = (
        v["x"].as_f64()?,
        v["y"].as_f64()?,
        v["w"].as_f64()?,
        v["h"].as_f64()?,
    );
    if rw > 0.0 && rh > 0.0 && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh {
        Some((x - rx, y - ry))
    } else {
        None
    }
}

/// A human label for a pick candidate: its kernel name, or a positional tag for
/// unnamed vertices.
fn candidate_label(candidate: &PickCandidate) -> String {
    if candidate.name.trim().is_empty() {
        let p = candidate.position;
        format!("({:.2}, {:.2}, {:.2})", p[0], p[1], p[2])
    } else {
        candidate.name.clone()
    }
}

// BREP private tests: b33029915c4119e4