BREP_app 0.16.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
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
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.
pub(super) 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()
    })
}

/// The shortest dwell that may ever open the pick list, whatever the style says.
/// A point-and-click ends with a corrective sub-movement and the button goes down
/// within roughly one to two tenths of a second of the pointer settling, so any
/// threshold inside that band would turn ordinary deliberate clicks into list
/// openings. A quarter second clears it with room to spare and is still short
/// enough to reach on purpose. It only BINDS if a theme drives `tooltip_delay`
/// below it; at the stock 0.5 s the style's value is what applies.
const DWELL_FLOOR_SECS: f64 = 0.25;

/// How long a hover highlight must stand before a plain click opens the PICK
/// LIST instead of selecting it: egui's OWN `interaction.tooltip_delay` (0.5 s by
/// default), floored at [`DWELL_FLOOR_SECS`].
///
/// Borrowed rather than re-invented, because it is the same judgement: the delay
/// is this toolkit's existing answer to "the pointer has stopped travelling and
/// is now inspecting what is under it", and that is exactly the moment the pick
/// list wants. It also means the list opens on the beat the user already knows
/// from every tooltip in the app, and that a slow-hands setting moves both
/// together instead of leaving the two disagreeing.
fn dwell_secs(ctx: &egui::Context) -> f64 {
    (f64::from(ctx.global_style().interaction.tooltip_delay)).max(DWELL_FLOOR_SECS)
}

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) {
        // An eCAD workbench active over a live sketch is not a state the app
        // keeps: the sketch is FINISHED, as its Finish button does, and the next
        // frame draws the dock and the editor. This is the one place every road
        // to it passes — the switcher, `settings_set`, a tab switch — because
        // in sketch mode the shell draws this and no dock. Finishing keeps the
        // drawing (discarding would lose it silently, and refusing the switch
        // leaves a workbench that cannot draw its editor), and Finish refuses
        // nothing, so neither does this.
        if crate::workbench::ecad::Target::of_workbench(&state.settings.workbench).is_some() {
            if state.sketch_mode() {
                let _ = state.exit_sketch_mode(true);
            }
            if state.sketch3d_mode() {
                let _ = state.exit_sketch3d_mode(true);
            }
        }
        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);

            // A document with an OPEN SHEET draws paper here instead of the
            // 3D scene: no offscreen render, no blit, and none of the 3D
            // input routing below. `show_sheet` says whether it took the
            // tile.
            if self.show_sheet(ui, rect, &response, state) {
                return;
            }

            // Feed input BEFORE rendering so a change is reflected this frame.
            self.handle_viewport_input(ui, rect, &response, state);
            // A right-click on a component: select it and open its menu.
            let request = self.component_menu_request(rect, &response, state);
            self.show_component_menu(ui.ctx(), state, request);

            // 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.
            {
                let _span = crate::perf::span(crate::perf::Phase::Overlays);
                state.ensure_overlays_current();
            }

            let render = state.dirty || std::mem::take(&mut self.force_render);
            if render {
                self.render_viewport(phys_w, phys_h, ppp, state);
                // Keep animating while a drag is live / more input pending.
                ui.ctx().request_repaint();
            }
            #[cfg(feature = "automation")]
            self.check_stale_frame(render, phys_w, phys_h, ppp, state);

            // 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,
            ));
            if super::touch::enabled(ui.ctx()) && state.sketch_mode()
                && state.sketch_active_tool().is_some_and(|tool| tool != "pickEdges" && tool != "handdraw")
                && !self.touch_suppressed
            {
                if let Some(finger) = ui.input(|i| i.pointer.interact_pos()).filter(|p| rect.contains(*p)) {
                    let (x, y) = super::touch::sketch_target(state,
                        f64::from(finger.x - rect.left()), f64::from(finger.y - rect.top()), true);
                    let cursor = rect.min + egui::vec2(x as f32, y as f32);
                    let painter = ui.painter().with_clip_rect(rect);
                    painter.line_segment([finger, cursor], egui::Stroke::new(1.5, egui::Color32::LIGHT_BLUE));
                    painter.circle_stroke(cursor, 8.0, egui::Stroke::new(3.0, egui::Color32::BLACK));
                    painter.circle_stroke(cursor, 8.0, egui::Stroke::new(1.0, egui::Color32::WHITE));
                    for axis in [egui::vec2(11.0, 0.0), egui::vec2(0.0, 11.0)] {
                        painter.line_segment([cursor - axis, cursor + axis], egui::Stroke::new(1.0, egui::Color32::WHITE));
                    }
                }
            }
        });

        // 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();
        if self.chrome_obscured {
            self.candidate_popup = None;
            self.candidate_popup_rect = None;
        } else { 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.
        // NONE of these belong on a sheet: they are 3D-anchored overlays, and
        // the sheet mode already drew everything the paper carries.
        let sheet_open = state.sheet_open().is_some();
        // Not drawn this frame (a sheet, or chrome over the view): no rects.
        self.move_card.hits.clear();
        if let Some(rect) = self.last_rect.filter(|_| !sheet_open && !self.chrome_obscured) {
            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.
            // PAINTED, not laid out — they must never take a press from the
            // gizmo underneath them (see the function's own note).
            Self::draw_transform_axis_labels(&ctx, rect, state);
            // The gizmo's own READOUT: what the drag is worth, and — when the
            // motion refused — why, said at the gizmo rather than in a dock on
            // the far side of the window. Also the Transform Face PIVOT marker,
            // for the pivot the gizmo has moved off.
            self.draw_transform_readout(&ctx, rect, state);
            // The Move gizmo's typed-value card, in the top-left corner (never
            // at the gizmo: it takes presses).
            self.move_card.draw(&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 select the annotation, double-click to open
            // it, right-click for its menu, 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());
            // The HOVER DWELL — which of the two plain-click behaviours is armed
            // right now, and how long the current highlight has stood. A script
            // dwells by stepping frames between a `pointer_move` and a `click`
            // (the headless harness advances egui's clock by a fixed dt per
            // frame), and reads this to know which click it is about to make.
            crate::automation::registry::publish("__brepHoverDwell", "hover dwell {armed, age, threshold} — armed = a plain click opens the pick list instead of selecting the highlight", &self.hover_dwell_json(&ctx));
            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}; the name arrays are in pick order, oldest first", &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 transform gizmo's readout chip — the live drag's distance or
            // angle, and the REFUSAL when the motion is one the kernel will not
            // build. `active` is false whenever the chip is not drawn.
            crate::automation::registry::publish("__brepGizmoReadout", "transform gizmo readout {active, kind, axis, value, unit, text, origin, dragging, refused, motion, reason, hint, kernel} \u{2014} the live drag's distance/angle and, on a refusal, why it refused", &state.transform_readout_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 free-motion glyph at the selected component (null when none).
            crate::automation::registry::publish("__brepFreedomGlyph", "free-motion glyph at the selected component {component, center, slides: [[x,y,z]], axes: [{point, direction}]} or null", &state.freedom_glyph_value().to_string());
            // 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 Move gizmo's typed-value card — `movecard/card`, `movecard/field:<axis>`.
            crate::automation::registry::publish("__brepMoveCardHit", "the Move gizmo's typed-value card rects in screen points (card, field:x|y|z|rx|ry|rz) while the component Move gizmo is armed", &crate::automation::hit_rects::hits_json(&self.move_card.hits));
            crate::automation::registry::publish("__brepComponentMenu", "the component right-click menu: {open: ACOMP id or null, last: the last right-click's hit and owning component}", &serde_json::json!({ "open": self.component_menu.as_ref().map(|(id, _)| id), "last": self.component_menu_probe }).to_string());
            crate::automation::registry::publish("__brepComponentMenuHit", "open component right-click menu entry rects (menuitem:<ACOMP id>:<action id>)", &crate::automation::hit_rects::hits_json(&self.component_menu_hits));
            // A chip's open right-click menu — `pmimenu/menuitem:<id>:<action>`.
            // The constraint chips' screen rects — `constraintlabel/<id>`.
            let chip_hits: std::collections::HashMap<String, egui::Rect> = self.constraint_chips_drawn.iter().cloned().collect();
            crate::automation::registry::publish("__brepConstraintLabelHit", "assembly-constraint chip rects {id: [x,y,w,h]}", &crate::automation::hit_rects::hits_json(&chip_hits));
            // A constraint chip's open right-click menu — `constraintmenu/menuitem:<id>:<action>`.
            crate::automation::registry::publish("__brepConstraintMenuHit", "open assembly-constraint chip right-click menu entry rects (menuitem:<constraint id>:<action id>)", &crate::automation::hit_rects::hits_json(&self.constraint_menu_hits));
            crate::automation::registry::publish("__brepPmiMenuHit", "open PMI label chip right-click menu entry rects (menuitem:<annotation id>:<action id>)", &crate::automation::hit_rects::hits_json(&self.pmi_menu_hits));
            // The armed gizmo's handle rects — `gizmo/…`, the VIEWPORT-hosted
            // widget panel, in SCREEN points like every other hit blob.
            crate::automation::registry::publish("__brepGizmoHit", "armed gizmo handle rects in screen points (anchor)", &gizmo_anchor_hits_json(self.last_rect, state));
            // The open NURBS Solid's control-cage dots — `cage/point:<i>`, a
            // VIEWPORT-hosted widget panel in SCREEN points.
            crate::automation::registry::publish("__brepCageHit", "control-cage dot rects in screen points (point:<index>) while a NURBS Solid's form is open", &cage_point_hits_json(self.last_rect, state));
            // The DRAGGABLE constraint handles' grab points — `constraint/<id>`,
            // the other VIEWPORT-hosted widget panel, in SCREEN points.
            crate::automation::registry::publish("__brepConstraintHit", "draggable assembly-constraint handle rects in screen points (by constraint id)", &constraint_handle_hits_json(self.last_rect, state));
            // 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 SHEET mode's widgets — the paper, each placement and each
            // authored mark — in SCREEN points, empty while no sheet is open.
            // The paper carries no toolbar: the sheet's TOOLS are workbench
            // buttons, published under `toolbar/workbench:btn:`.
            crate::automation::registry::publish("__brepSheetHit", "open sheet's widget rects in screen points (paper, view:<id>, dim:<id>, ord:<id>; anchor:<reference> while the reference picker is up for a sheet object)", &self.sheet_hits_json());
        }
        // No eCAD editor is drawn this frame.
        self.publish_no_ecad();
    }

    /// The sheet mode's widget rects (`sheet/…`, screen points).
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    fn sheet_hits_json(&self) -> String {
        self.sheet.hits_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.
    /// Pointer routing in 3D SKETCH mode — the planar handler's shape over the
    /// 3D editor's calls: hover / select / point-drag through the live camera,
    /// a draw tool's clicks PLACING points, right-click dropping the pending
    /// clicks, Delete removing the selection, and an empty drag orbiting.
    fn handle_sketch3d_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);
        let draw_mode = state.sketch3d_active_tool().is_some();
        if draw_mode && response.secondary_clicked() {
            state.sketch3d_tool_cancel();
        }
        // Delete / Backspace: the shell's one delete-selection command
        // (`crate::delete_selection`, run from `handle_shortcuts`).
        if response.drag_started() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                if let Some((cx, cy)) = self.viewcube_local(state, lx, ly) {
                    state.viewcube_click(cx, cy);
                } else if !draw_mode && state.sketch3d_drag_begin(lx, ly) {
                    self.sketch3d_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.sketch3d_dragging {
                    state.sketch3d_drag_to(lx, ly);
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.sketch3d_dragging {
                state.sketch3d_drag_end();
                self.sketch3d_dragging = false;
            }
            if self.dragging {
                state.pointer_up();
                self.dragging = false;
            }
        }
        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) {
                    state.viewcube_click(cx, cy);
                } else if draw_mode {
                    state.sketch3d_tool_click_at(lx, ly);
                } else {
                    let mods = ui.ctx().input(|i| i.modifiers);
                    state.sketch3d_click_at(lx, ly, mods.command || mods.ctrl);
                }
            }
        }
        let primary_held = ui.input(|i| i.pointer.primary_down());
        if !self.sketch3d_dragging && !self.dragging && !(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.sketch3d_clear_hover();
                        }
                        None => {
                            state.viewcube_clear_hover();
                            state.sketch3d_hover_at(lx, ly);
                            // A draw tool lands on model geometry under the
                            // cursor, and the reference tool links it: light
                            // the vertex / edge / face it would take.
                            if draw_mode {
                                state.hover_at(lx, ly);
                            }
                        }
                    }
                }
                None => {
                    state.viewcube_clear_hover();
                    state.sketch3d_clear_hover();
                }
            }
        }
        if response.hovered() {
            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);
            }
        }
    }

    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();
        let finger = super::touch::enabled(ui.ctx());
        let precision = finger && draw_mode
            && !matches!(state.sketch_active_tool(), Some("handdraw" | "pickEdges"));

        // 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) through the
        // shell's one delete-selection command (`crate::delete_selection`, run from
        // `handle_shortcuts` behind its text-edit guard).

        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 precision {
                    self.touch_drawing = true;
                } else if !draw_mode && {
                    self.touch_drag_offset = (0.0, 0.0);
                    let (gx, gy) = if finger {
                        let point = super::touch::sketch_target(state, lx, ly, false);
                        state.sketch_hover_at(point.0, point.1);
                        self.touch_drag_offset = (point.0 - lx, point.1 - ly);
                        point
                    } else { (lx, ly) };
                    state.sketch_drag_begin(gx, gy)
                } {
                    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.touch_drawing {
                    let (x, y) = super::touch::sketch_target(state, lx, ly, true);
                    state.sketch_hover_at(x, y);
                } else if self.sketch_dragging {
                    if finger {
                        state.sketch_drag_to(lx + self.touch_drag_offset.0, ly + self.touch_drag_offset.1);
                    } else {
                        state.sketch_drag_to(lx, ly);
                    }
                } else if self.dragging {
                    state.pointer_move(lx, ly);
                }
            }
        }
        if response.drag_stopped() {
            if self.touch_drawing {
                if let Some(pos) = response.interact_pointer_pos() {
                    let (x, y) = local(pos);
                    let point = super::touch::sketch_target(state, x, y, true);
                    state.sketch_tool_click_at(point.0, point.1);
                }
                self.touch_drawing = false;
            }
            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 {
                    let point = if precision { super::touch::sketch_target(state, lx, ly, true) } else { (lx, ly) };
                    state.sketch_tool_click_at(point.0, point.1);
                } else {
                    let mods = ui.ctx().input(|i| i.modifiers);
                    let point = if finger { super::touch::sketch_target(state, lx, ly, false) } else { (lx, ly) };
                    state.sketch_click_at(point.0, point.1, 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
            && !self.touch_drawing
            && !(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();
                            let point = if finger { super::touch::sketch_target(state, lx, ly, precision) } else { (lx, ly) };
                            state.sketch_hover_at(point.0, point.1);
                            // 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);
            }
        }
    }

    /// A plain RIGHT-click (no drag) on an assembly component's geometry:
    /// the component under the pointer, selected, and where its menu opens.
    /// Nothing while a reference pick, a sketch or a gizmo owns the pointer.
    fn component_menu_request(
        &mut self,
        rect: egui::Rect,
        response: &egui::Response,
        state: &mut EngineState,
    ) -> Option<(String, egui::Pos2)> {
        if !response.secondary_clicked()
            || state.ref_select_active()
            || state.sketch_mode()
            || state.sketch3d_mode()
        {
            return None;
        }
        let pos = response.interact_pointer_pos()?;
        let (lx, ly) = ((pos.x - rect.min.x) as f64, (pos.y - rect.min.y) as f64);
        let hit: serde_json::Value = serde_json::from_str(&state.hover_json(lx, ly)).unwrap_or_default();
        let component = hit["solid"]
            .as_str()
            .or_else(|| hit["name"].as_str())
            .and_then(|solid| state.component_of_solid(solid));
        self.component_menu_probe = serde_json::json!({ "at": [pos.x, pos.y], "hit": hit, "component": component });
        let component = component?;
        state.select_component(&component);
        Some((component, pos))
    }

    /// A component's RIGHT-CLICK menu: [`ComponentAction::ALL`] through the
    /// one dispatcher the context bar and the BOM rows use. `request` (a
    /// right-click this frame) is applied after the open menu has drawn, so
    /// the click that opens a menu is not the one that closes it.
    ///
    /// [`ComponentAction::ALL`]: crate::panels::component_actions::ComponentAction::ALL
    fn show_component_menu(
        &mut self,
        ctx: &egui::Context,
        state: &mut EngineState,
        request: Option<(String, egui::Pos2)>,
    ) {
        use crate::panels::component_actions::{component_menu, run_component_actions, ComponentAction, MenuSubject};
        self.component_menu_hits.clear();
        let mut chosen: Option<(String, ComponentAction)> = None;
        if let Some((id, pos)) = self.component_menu.clone() {
            // THE shared component menu (the context bar's and the BOM row's
            // too). A Pattern Component instance's says whose copy it is and
            // acts on the SEED.
            let instance = brep_render::brep_kernel::pattern_component_instance(&id);
            let seed = instance.as_ref().map_or(id.clone(), |(_, seed, _)| seed.clone());
            if state.component_info(&seed).is_some() {
                let actions = component_menu(&MenuSubject::of(state, std::slice::from_ref(&id)));
                let hits = &mut self.component_menu_hits;
                let (action, still_open) = crate::column_tree::action_menu(
                    ctx,
                    egui::LayerId::new(egui::Order::Foreground, egui::Id::new("brep-component-menu-layer")),
                    egui::Id::new("brep-component-menu"),
                    pos,
                    &actions,
                    |entry, rect| {
                        hits.insert(format!("menuitem:{id}:{}", entry.id), rect);
                    },
                );
                if !still_open {
                    self.component_menu = None;
                }
                chosen = action.and_then(|action| ComponentAction::from_id(&action)).map(|action| (id, action));
            } else {
                self.component_menu = None;
            }
        }
        if let Some((id, action)) = chosen {
            if let Some(request) = run_component_actions(state, action, &[id]) {
                self.component_request = Some(request);
            }
        }
        if let Some(request) = request {
            self.component_menu = Some(request);
        }
    }

    /// Whether empty space was double-clicked this frame (the shell returns
    /// from a Work part on it).
    pub fn take_empty_double_click(&mut self) -> bool {
        std::mem::take(&mut self.empty_double_click)
    }

    /// The document-level flow the component menu asked for this frame.
    pub fn take_component_request(&mut self) -> Option<crate::panels::component_actions::ComponentActionRequest> {
        self.component_request.take()
    }

    /// 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);

        let (events, focused, any_touches) =
            ui.ctx().input(|i| (i.events.clone(), i.focused, i.any_touches()));
        let touch = self.touch.update_for_frame(
            ui.ctx().cumulative_frame_nr(),
            &events,
            focused,
            any_touches,
            |pos| {
                ui.is_enabled()
                    && rect.contains(pos)
                    && ui.ctx().layer_id_at(pos) == Some(response.layer_id)
            },
        );
        self.touch_suppressed = touch.suppress_pointer;
        if touch.started {
            // Finish the existing single-finger interaction at its last valid
            // position before multitouch takes over, or when touch is cancelled.
            self.finish_pointer_drag(state);
            self.hover_lit_since = None;
            state.clear_hover();
            state.sketch_clear_hover();
            state.viewcube_clear_hover();
        }
        if touch.suppress_pointer {
            if let Some((from, to, scale)) = touch.motion {
                let (fx, fy) = local(from);
                let (tx, ty) = local(to);
                state.touch_navigation([fx, fy], [tx, ty], scale);
            }
            // Includes release/cancel frames: egui synthesizes mouse events for
            // touch, which must not also select, orbit, or drive a sketch tool.
            return;
        }

        // Touch has no hover dwell or Alt key. A stationary held contact asks
        // for the same ranked candidate list as Alt+click on a mouse. egui's
        // long-touch flag never fires for desktop mouse input.
        if response.long_touched() && !state.any_sketch_mode() {
            if let Some(pos) = response.interact_pointer_pos() {
                let (lx, ly) = local(pos);
                let reference = state.ref_select_active();
                let candidates = if reference {
                    state.ref_select_candidates_at(lx, ly)
                } else {
                    state.candidates_filtered_at(lx, ly)
                };
                self.candidate_popup = (!candidates.is_empty())
                    .then(|| CandidatePopup { anchor: pos, candidates, reference });
                self.candidate_popup_fresh = self.candidate_popup.is_some();
                self.hover_lit_since = None;
            }
            return;
        }

        // 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;
        }
        // 3D sketch mode owns the pointer the same way, in screen space.
        if state.sketch3d_mode() {
            self.handle_sketch3d_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();
                // …and with the highlight goes the dwell clock: an orbit is not
                // an inspection, and the release must not land on an armed list.
                self.hover_lit_since = None;
                // 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 (mut gx, mut gy) = drag_start_local(response, rect).unwrap_or((lx, ly));
                self.touch_drag_offset = (0.0, 0.0);
                if super::touch::enabled(ui.ctx()) && self.viewcube_local(state, gx, gy).is_none() {
                    if let Some((tx, ty)) = super::touch::handle_target(state, gx, gy) {
                        self.touch_drag_offset = (tx - gx, ty - gy);
                        (gx, gy) = (tx, ty);
                    }
                }
                // 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);
                let (lx, ly) = (lx + self.touch_drag_offset.0, ly + self.touch_drag_offset.1);
                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 let (Some(field), Some(pos)) =
                (self.dim_dragging.as_ref(), response.interact_pointer_pos())
            {
                let (lx, ly) = local(pos);
                let (lx, ly) = (lx + self.touch_drag_offset.0, ly + self.touch_drag_offset.1);
                let feature = state.dimension_armed_feature();
                state.feature_dimension_drag(&feature, field, lx, ly);
            }
            self.finish_pointer_drag(state);
        }

        // A plain click (press+release, no drag — egui only reports `clicked()`
        // when the pointer never left the click radius, so an orbit or a pan is
        // never one of these) over the viewport: in reference-selection mode it
        // type-constrained-picks a reference under the cursor (engine drives the
        // highlight, and none of what follows applies); otherwise the modeling
        // selection UX, whose two plain-click behaviours are told apart by the
        // HOVER DWELL rather than by how much geometry happens to overlap:
        //
        //   * a click that arrives WITHOUT dwelling SELECTS the highlight — the
        //     nearest admitted hit by depth, the one already lit under the
        //     pointer (replace, or toggle in the Click-toggles multi-select
        //     mode). One click, the thing you were looking at, whether or not
        //     other geometry shares the pixel.
        //   * a click on a highlight that has been STANDING for the dwell opens
        //     the PICK LIST popup at the cursor, for the front/back faces and the
        //     obstructed geometry behind it.
        //
        // The list is the same list it has always been — same builder, same
        // ranking, same rows, same dismissal; only its TRIGGER moved, from "the
        // pixel was ambiguous" (which ambushed a click the user thought was
        // unambiguous) to "the user held still and asked for it". What makes that
        // legible is that the highlight is the promise: it shows what a quick
        // click takes, and `hover_dwell_armed` — the cursor turns into the
        // context-menu cursor — shows when a click has become a list instead.
        //
        // Ctrl/Cmd+click ADDS/TOGGLES that same highlighted pick directly (no
        // list, no dwell), and Alt+click still opens the pick list explicitly:
        // the dwell now reaches it too, but Alt remains the no-waiting path, and
        // the only path at all for a pointer that never dwells (an automation
        // script teleports and clicks in the same breath). 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);
                let (lx, ly) = if super::touch::enabled(ui.ctx()) && self.viewcube_local(state, lx, ly).is_none() {
                    super::touch::handle_target(state, lx, ly).unwrap_or((lx, ly))
                } else { (lx, ly) };
                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() {
                    // A reference field is picking. The same two triggers open
                    // the pick list here — a dwelled click or Alt+click — but
                    // over the FIELD's admitted candidates, and a row goes into
                    // the field's running list. A plain click takes the top
                    // admitted hit by kind, so with a field that admits a face
                    // and a solid the face always wins it: the list is the only
                    // way to the solid (the SM Cutout tool body, 2026-10-29).
                    if mods.alt || self.hover_dwell_armed(ui.ctx()) {
                        let cands = state.ref_select_candidates_at(lx, ly);
                        self.candidate_popup = (!cands.is_empty())
                            .then(|| CandidatePopup { anchor: pos, candidates: cands, reference: true });
                        self.candidate_popup_fresh = self.candidate_popup.is_some();
                    } else {
                        state.ref_select_click(lx, ly);
                    }
                    self.hover_lit_since = None;
                } 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, reference: false });
                    self.candidate_popup_fresh = self.candidate_popup.is_some();
                    // The dwell is SPENT on the list it opened: dismissing this
                    // one must not leave a clock that re-opens it on the spot.
                    self.hover_lit_since = None;
                } else if state.cage_edit_feature().is_some()
                    && state
                        .cage_point_pick_at(lx, ly, mods.command || mods.ctrl || mods.shift)
                        .is_some()
                {
                    // A control point of the NURBS Solid whose cage editor is
                    // open: selected — toggled into the selection with Ctrl /
                    // Cmd / Shift held — and the transform gizmo arms at the
                    // selection's centroid. The click stops here: the body
                    // under the dot is not selected. Ahead of the Ctrl branch
                    // because the modifier is the cage's additive select too.
                } 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 if self.hover_dwell_armed(ui.ctx()) {
                    // HOVERED, then clicked: the user held still over a highlight
                    // for the dwell and then pressed, which is how the pick list
                    // is asked for. The list itself is untouched —
                    // `candidates_filtered_at` builds the same ranked,
                    // filter-admitted rows it always did (construction planes
                    // among them as ordinary `PickKind::Plane` candidates), and
                    // `show_candidate_popup` draws and drives them. Only the
                    // question "should it open at all" is answered differently.
                    let cands = state.candidates_filtered_at(lx, ly);
                    self.candidate_popup = (!cands.is_empty())
                        .then(|| CandidatePopup { anchor: pos, candidates: cands, reference: false });
                    self.candidate_popup_fresh = self.candidate_popup.is_some();
                    // Spent on the list it opened (see the Alt+click twin).
                    self.hover_lit_since = None;
                } else {
                    // A REGULAR click — no dwell — takes the highlight: the
                    // nearest admitted candidate by depth, the one already lit
                    // under the pointer. REPLACE in Ctrl+Click mode, TOGGLE in
                    // the Click-toggles mode (a second click on the same item
                    // unselects it). A miss clears.
                    //
                    // Overlapping geometry no longer diverts this click into a
                    // list: the thing in front is what the user aimed at and
                    // what the highlight promised, and the ones behind it are a
                    // dwell (or an Alt) away. `nearest_candidate_at` is the ONE
                    // query the highlight also resolves through, so the promise
                    // and the pick cannot drift apart; construction planes are
                    // ordinary candidates in it, so a plane genuinely in front of
                    // the model is now what a click takes, and a plane behind it
                    // is not (the pick list still ranks it by kind, as before).
                    let toggles =
                        state.settings.multi_select == MultiSelectMode::ClickToggles;
                    // A Work part's context face in front: THE context
                    // selection, apart from the part's own (it is not the
                    // part's to reference).
                    if let Some(context) = state.context_hit_at(lx, ly) {
                        state.clear_selection();
                        state.select_context(Some(context));
                    } else {
                        match state.nearest_candidate_at(lx, ly) {
                            Some(candidate) => {
                                state.select_context(None);
                                if toggles {
                                    state.toggle_candidate(&candidate);
                                } else {
                                    state.select_candidate(&candidate);
                                }
                            }
                            None => {
                                state.clear_selection();
                                // A double click on empty space: the shell
                                // ends a Work part session with it.
                                self.empty_double_click |= response.double_clicked();
                            }
                        }
                    }
                    // A click SPENDS the rest that preceded it, exactly as the
                    // two list-opening branches do. Without this the clock kept
                    // running across the click, and the commonest repeat gesture
                    // there is — click to toggle an entity on, then click the
                    // same spot again to toggle it off — would find itself armed
                    // the second time and open a list instead. The next dwell
                    // starts from here.
                    self.hover_lit_since = None;
                }
            }
        }

        // 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
                            self.hover_lit_since = None;
                        }
                        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 nearest pick.
                            let over_handle = (state.transform_armed()
                                || state.component_move_armed())
                                && state.transform_hover(lx, ly) != 0;
                            if over_handle {
                                state.clear_hover();
                                self.hover_lit_since = None;
                            } else if self.candidate_popup.is_none() {
                                // THE modeling hover: lights what a regular click
                                // would take, and its return value says whether
                                // the highlight CHANGED — which is where the
                                // dwell clock restarts.
                                let changed = state.hover_at(lx, ly);
                                let lit = state.hover_is_lit();
                                self.note_hover(ui.ctx(), pos, lit, changed);
                            } else {
                                // The open pick list owns the highlight (its rows
                                // drive their own hover), so no dwell runs under
                                // it — a list cannot arm another list.
                                self.hover_lit_since = None;
                            }
                        }
                    }
                }
                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();
                    }
                    // Either way no dwell is accruing: the pointer is not over
                    // the scene, and a highlight lit from a sidebar row is not
                    // one this viewport's click is about to act on.
                    self.hover_lit_since = None;
                }
            }
        } else {
            // Hover is SUPPRESSED this frame (a drag, a gizmo/arrow/handle drag,
            // or the one frame a label owns the highlight). Nothing on screen is
            // this viewport's hover promise, so the dwell cannot be accruing
            // toward a list either.
            self.hover_lit_since = None;
        }

        // 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);
            }
        }
    }

    /// Close captures before handing a single pointer to multitouch navigation.
    fn finish_pointer_drag(&mut self, state: &mut EngineState) {
        self.touch_drag_offset = (0.0, 0.0);
        self.touch_drawing = false;
        if self.gizmo_dragging {
            state.transform_release();
            self.gizmo_dragging = false;
        }
        if self.component_gizmo_dragging {
            state.component_release();
            self.component_gizmo_dragging = false;
        }
        if self.dim_dragging.take().is_some() {
            let feature = state.dimension_armed_feature();
            state.feature_dimension_release(&feature);
        }
        if self.constraint_dragging {
            state.constraint_drag_release();
            self.constraint_dragging = false;
        }
        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;
        }
    }

    /// Whether a plain click RIGHT NOW opens the pick list instead of selecting
    /// the highlight — the whole discriminator, in one place.
    ///
    /// One clock, restarted by either of the two things that mean "this is not a
    /// highlight the user has been resting on":
    ///
    /// * the HIGHLIGHT changed — the pointer crossed onto another entity, or the
    ///   camera or the geometry moved under a pointer that never did. Without
    ///   this, an orbit that parks new geometry under a resting cursor would
    ///   arrive pre-armed.
    /// * the POINTER moved. Without this, travelling a long way across one big
    ///   face would accrue dwell for the whole journey, because the highlight
    ///   never changed, and the click at the end of the trip would open a list
    ///   nobody asked for.
    ///
    /// Read BEFORE this frame's hover resolve (the click branch runs first), so
    /// the answer describes the state the user could see when they pressed —
    /// never one this frame's own movement has already invalidated.
    fn hover_dwell_armed(&self, ctx: &egui::Context) -> bool {
        let Some(since) = self.hover_lit_since else {
            return false; // nothing lit → nothing to open a list about.
        };
        // `dwell_secs` reads the style, and `Context::input` holds the context
        // lock for the whole closure — calling it in there deadlocks (in release;
        // in debug egui panics on the recursion). Read it first.
        let dwell = dwell_secs(ctx);
        ctx.input(|i| i.time - since >= dwell)
    }

    /// Advance the hover DWELL clock after this frame's hover resolve, and keep
    /// the frames coming until it arms.
    ///
    /// The repaint request is not an optimization: this app renders ON DEMAND, so
    /// with the pointer at rest the frames simply stop, and a threshold that is
    /// only ever tested on input would never be crossed — the list would need a
    /// wiggle to become reachable. Asking for the frame that crosses it also
    /// paints the cursor cue at the right moment.
    fn note_hover(&mut self, ctx: &egui::Context, pos: egui::Pos2, lit: bool, changed: bool) {
        // Movement is measured against the pointer's own last position rather
        // than taken from an event, and with a couple of points of slop: a hand
        // resting on a mouse is not perfectly still, and a pixel of jitter is
        // not the user setting off somewhere.
        const SLOP: f32 = 2.0;
        let moved = self
            .hover_last_pos
            .is_none_or(|last| last.distance(pos) > SLOP);
        if moved {
            self.hover_last_pos = Some(pos);
        }
        if !lit {
            self.hover_lit_since = None;
            return;
        }
        let now = ctx.input(|i| i.time);
        if changed || moved || self.hover_lit_since.is_none() {
            self.hover_lit_since = Some(now);
        }
        if self.hover_dwell_armed(ctx) {
            // The one visible tell that this click has become a list: the
            // pointer takes the context-menu cursor, the same shape every other
            // "a click here opens a menu" in the desktop means. The rule is then
            // something the user can SEE coming instead of a hidden timer.
            ctx.set_cursor_icon(egui::CursorIcon::ContextMenu);
            return;
        }
        let dwell = dwell_secs(ctx);
        let since = self.hover_lit_since.unwrap_or(now);
        let remaining = ctx.input(|i| dwell - (i.time - since));
        if remaining > 0.0 {
            ctx.request_repaint_after(std::time::Duration::from_secs_f64(remaining.min(dwell)));
        }
    }

    /// 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 click that arrives after the pointer has DWELLED on its hover
    /// highlight (so front + back faces and obstructed geometry are reachable
    /// behind what the highlight is promising) and on Alt+click explicitly. A
    /// click without that dwell selects the highlight instead and never comes
    /// here. Nothing below this line changed with that trigger. 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 supersedes a pick list opened outside it: sketch edit
        // drops any list, and a reference pick starting or ending drops the
        // list of the other mode, so it neither draws over the modal nor
        // swallows the modal's first viewport click (nor feeds a field that is
        // no longer picking).
        let reference = state.ref_select_active();
        if state.any_sketch_mode()
            || self.candidate_popup.as_ref().is_some_and(|p| p.reference != reference)
        {
            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| if reference { state.ref_select_has_candidate(c) } else { 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. A
                    // reference pick has no modeling selection to clear (the
                    // field's own list removes its picks).
                    ui.horizontal(|ui| {
                        ui.label(egui::RichText::new("Select an object").weak().small());
                        if !reference {
                            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 reference {
                // A reference field's row: into the field's running list,
                // named exactly as a plain click would name it.
                state.ref_select_take_candidate(&candidates[i]);
            } else 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 the `{key: [x, y, w, h]}` map
    /// (egui points) every other panel publishes, keyed by the entry's INDEX —
    /// the same index `__brepCandidates` carries, and the rect list is built one
    /// per candidate in the same order, so row N of the list is key `"N"` here.
    ///
    /// The hover DWELL as JSON `{armed, age, threshold}` — the verification hook
    /// for the discriminator between the two plain-click behaviours: `armed` is
    /// what [`hover_dwell_armed`](Self::hover_dwell_armed) would answer for a
    /// click arriving now, `age` is how long the current highlight has stood (−1
    /// while none is lit) and `threshold` is the dwell in force.
    ///
    /// Published because the rule is a TIMING rule, and a script asserting "this
    /// click selected rather than opened a list" should be able to say WHY rather
    /// than infer it from the outcome it was testing.
    fn hover_dwell_json(&self, ctx: &egui::Context) -> String {
        let age = match self.hover_lit_since {
            Some(since) => ctx.input(|i| i.time - since),
            None => -1.0,
        };
        serde_json::json!({
            "armed": self.hover_dwell_armed(ctx),
            "age": age,
            "threshold": dwell_secs(ctx),
        })
        .to_string()
    }

    /// The map shape is what makes the popup reachable without a coordinate:
    /// `Registry::hit_rects` only collects a blob whose values are four-element
    /// rect arrays, so the `[{index, x, y, w, h}]` array this used to publish
    /// left `hit_rects candidate/` empty and `click_widget candidate/0`
    /// unresolvable, while `viewport::HIT_KEYS` documented the panel as
    /// "keyed by index" all along.
    fn candidate_hits_json(&self) -> String {
        let out: serde_json::Map<String, serde_json::Value> = self
            .candidate_hits
            .iter()
            .enumerate()
            .map(|(i, r)| {
                (
                    i.to_string(),
                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
                )
            })
            .collect();
        serde_json::Value::Object(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 armed gizmo's frame origin as a zero-size rect in SCREEN points — the
/// `__brepGizmoHit` blob, which merges under the panel name `gizmo`
/// (`gizmo/anchor`). That point carries the transform gizmo's orange CENTRE
/// free-move handle, or — in dimension mode — the ORIGIN sphere that toggles
/// back to it, so one key addresses the ◎ toggle both ways.
///
/// ITS OWN BLOB, not the history panel's, for the two reasons that split
/// `__brepSketchHit` in two:
///   * SPACE — every rect in a hit blob is a SCREEN point, because that is what
///     a host clicks. This one is the VIEWPORT's widget, so it is the viewport
///     rect's origin (`view`) plus the engine's viewport-local projection;
///     published among the history panel's rects it was the one value in that
///     map that needed `__brepView` added before it meant anything on screen.
///   * CLIP — `click_widget` scrolls a key's panel until the rect is inside that
///     panel's `panel:clip`. The gizmo sits in the viewport, which does not
///     scroll and publishes no `panel:clip`, so the scroll-into-view step is
///     skipped and the rect is clicked where it is. Under the history panel it
///     was outside that pane's clip rect and a host wheeled the feature list
///     trying to reach it.
///
/// Beside the anchor, the transform widget's AXIS ARROWS and ROTATION GRABS are
/// published the same way (`gizmo/axis:x|y|z`, `gizmo/ring:x|y|z`), each at a
/// point [`EngineState::transform_pick`] answers with that handle — dragging
/// an arrow edits `position` along it, dragging a grab edits `rotationEuler`
/// about the axis its ring turns about. A handle whose point another handle
/// would win (an arrow looking straight at the camera) is not published.
///
/// Empty when no gizmo is armed, or before the viewport has a rect.
pub(super) fn gizmo_anchor_hits_json(view: Option<egui::Rect>, state: &EngineState) -> String {
    let Some(view) = view else {
        return "{}".to_string();
    };
    let at = |(x, y): (f64, f64)| {
        egui::Rect::from_min_size(egui::pos2(view.min.x + x as f32, view.min.y + y as f32), egui::Vec2::ZERO)
    };
    // The anchor, then each arrow and rotation grab the drawn transform widget
    // offers (`gizmo/axis:x`, `gizmo/ring:y`, …), then each ARROWHEAD the
    // DIMENSION gizmo offers (`gizmo/dim:sizeX`) — each at a point the engine's
    // own pick answers with that handle, so `drag_widget` on the key grabs it.
    //
    // The anchor is an OPTION rather than the gate it used to be: the two modes
    // are mutually exclusive but their handle lists are not the same shape, and
    // a dimension gizmo whose frame origin does not project is still a gizmo
    // with draggable arrowheads.
    let hits: Vec<(String, egui::Rect)> = state
        .transform_gizmo_anchor()
        .map(|point| ("anchor".to_string(), at(point)))
        .into_iter()
        .chain(
            state
                .transform_handle_points()
                .into_iter()
                .map(|(key, point)| (key.to_string(), at(point))),
        )
        .chain(
            state
                .dimension_handle_points()
                .into_iter()
                .map(|(key, point)| (format!("dim:{key}"), at(point))),
        )
        .chain(
            state
                .dimension_origin_points()
                .into_iter()
                .map(|(key, point)| (format!("ball:{key}"), at(point))),
        )
        .collect();
    if hits.is_empty() {
        return "{}".to_string();
    }
    crate::automation::hit_rects::hits_json(hits.iter().map(|(key, rect)| (key, rect)))
}

/// The open NURBS Solid's control-cage DOTS as zero-size rects in SCREEN
/// points — the `__brepCageHit` blob, which merges under the panel name
/// `cage` (`cage/point:<index>`, the cage's linear point index: `0` the
/// bottom pole, then the rings bottom-up, the top pole last).
///
/// ITS OWN BLOB for the same two reasons as `gizmo/anchor`: the rects are the
/// VIEWPORT's, and there is no `panel:clip` to scroll a dot into. A click on
/// one is what `EngineState::cage_point_pick_at` answers with that point
/// (the nearest dot within its pick radius), so `click_widget` on the key
/// selects it, and with `modifiers_set {shift: true}` first toggles it into
/// the selection.
///
/// Empty while no cage editor is open, or before the viewport has a rect.
pub(super) fn cage_point_hits_json(view: Option<egui::Rect>, state: &EngineState) -> String {
    let Some(view) = view else {
        return "{}".to_string();
    };
    let hits: Vec<(String, egui::Rect)> = state
        .cage_point_screen_points()
        .into_iter()
        .map(|(index, (x, y))| {
            (
                format!("point:{index}"),
                egui::Rect::from_min_size(
                    egui::pos2(view.min.x + x as f32, view.min.y + y as f32),
                    egui::Vec2::ZERO,
                ),
            )
        })
        .collect();
    if hits.is_empty() {
        return "{}".to_string();
    }
    crate::automation::hit_rects::hits_json(hits.iter().map(|(key, rect)| (key, rect)))
}

/// The DRAGGABLE assembly-constraint handles as zero-size rects in SCREEN
/// points — the `__brepConstraintHit` blob, which merges under the panel name
/// `constraint`, one key per constraint id (`constraint/DIST1`).
///
/// ITS OWN BLOB for the same two reasons as `gizmo/anchor`: the rects are the
/// VIEWPORT's, so they are the viewport rect's origin plus the engine's
/// viewport-local projection, and there is no `panel:clip` to scroll a handle
/// into — a host clicks it where it is. The point is the one
/// [`EngineState::constraint_arrow_pick`] matches at distance zero, so
/// `drag_widget {key: "constraint/<id>"}` presses the arrow rather than
/// near it.
///
/// Empty when no constraint publishes a draggable handle, or before the
/// viewport has a rect.
pub(super) fn constraint_handle_hits_json(view: Option<egui::Rect>, state: &EngineState) -> String {
    let Some(view) = view else {
        return "{}".to_string();
    };
    let Ok(serde_json::Value::Object(points)) =
        serde_json::from_str::<serde_json::Value>(&state.constraint_handle_points_json())
    else {
        return "{}".to_string();
    };
    let hits: Vec<(String, egui::Rect)> = points
        .iter()
        .filter_map(|(id, point)| {
            let point = point.as_array()?;
            let x = point.first()?.as_f64()? as f32;
            let y = point.get(1)?.as_f64()? as f32;
            Some((
                id.clone(),
                egui::Rect::from_min_size(
                    egui::pos2(view.min.x + x, view.min.y + y),
                    egui::Vec2::ZERO,
                ),
            ))
        })
        .collect();
    crate::automation::hit_rects::hits_json(hits.iter().map(|(key, rect)| (key, rect)))
}

/// 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()
    }
}