BREP_app 0.2.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
//! Context action toolbar — the **selection-driven** action bar (the engine-
//! native successor to the old app's floating selection action bar,
//! `SelectionFilter._syncSelectionActions` + `_getHistoryContextActionSpecs`).
//!
//! It is shown ONLY while something is selected (hidden otherwise) and its
//! buttons depend on the CURRENT selection (kinds + count read from
//! `selection_json`):
//!
//! * **Generic actions** (mirror the old selection action bar):
//!   - **Clear** — `clear_selection`.
//!   - **Hide** — `hide_selected` (toggles the visibility of EXACTLY what is
//!     selected: a selected face/edge/vertex hides just that sub-entity, a
//!     selected solid the whole solid; a second click shows it again).
//!   - **Edit owning feature** — for a SINGLE selected entity with a known
//!     producer, `creating_feature(name)` resolves the feature that built it;
//!     clicking rolls the model to that step (`roll_to`) and asks the shell to
//!     EXPAND that feature's inline dialog in the history tree.
//! * **Feature-from-selection** — WHICH features a selection offers is answered
//!   by the KERNEL, per feature: each feature module defines `context_applicable`
//!   (aggregated in `feature_pipeline::context_offer`), a predicate over the
//!   [`SelectionProbe`] kind-counts this bar builds each frame. That is where
//!   nuance lives — e.g. Revolve wants a profile AND an axis edge, so a lone
//!   face no longer offers it. The pre-fill stays schema-derived: an offered
//!   feature's `References`-group `reference_selection` fields are filled from
//!   the selection in schema order under a CONSUMED set (each selected name
//!   lands in at most ONE field — face+edge → Revolve fills `profile` and
//!   `axis`). Clicking creates the feature (`add_feature`) with those fields
//!   pre-filled, then asks the shell to expand the new node for tweaking.
//! * **Constraint-from-selection** — the assembly-constraint mirror of the
//!   feature offers, shown when the Assembly Constraints panel is available in
//!   the active workbench (claim-based visibility). Each constraint type's
//!   `applicable` predicate ([`brep_kernel::ConstraintTypeDef`]) runs against
//!   the same probe: all-component selections only (the kernel rejects anything
//!   else), ONE component's solid(s) for Fixed, a two-element pair across TWO
//!   distinct components for the pairing types. Clicking adds the constraint
//!   with `elements` pre-seeded from the selection (the constraints panel's
//!   seeding helper) and opens its row in the panel.
//!
//! Like the other panels this owns NO model state — the selection + history live
//! in [`EngineState`], borrowed in; it only holds the per-frame `hits` map (widget
//! screen rects) + the last-drawn action ids the headed verifier reads.

use super::action_rail::{action_rail, ActionItem};
use super::component_actions::{run_component_action, ComponentAction, ComponentActionRequest};
use crate::form;
use brep_render::brep_kernel::{self, SelectionProbe};
use brep_render::engine_state::EngineState;
use brep_render::features;
use brep_render::style::FieldKind;
use eframe::egui;
use serde_json::Value;
use std::collections::{HashMap, HashSet};

/// A request bubbled back to the shell after a context action ran: EXPAND (open
/// the inline dialog of) the feature with this id in the history tree. The
/// context bar mutates the engine directly but cannot reach the history panel's
/// private "expanded" state, so it returns the id for the shell to focus.
pub type FocusRequest = Option<String>;

/// What a context-bar frame hands back to the shell. The bar mutates the engine
/// directly, but two effects it cannot reach itself:
/// * `focus` — the history feature to EXPAND after a create / edit-owning action
///   (the history panel's expand state is private to it); and
/// * `info_targets` — the entity names to open PINNED Info windows for after the
///   Info action (the Info-window manager is shell-owned). One name per selected
///   entity, so a multi-select opens one window each.
#[derive(Default)]
pub struct ContextOutcome {
    pub focus: FocusRequest,
    pub info_targets: Vec<String>,
    /// A COMPONENT document-level flow the shell must run (Edit in place /
    /// Open Part) — set when the matching component action was clicked; the
    /// engine-mutating component actions (Move / Fix-Unfix / Delete) already
    /// applied inside the bar.
    pub component: Option<ComponentActionRequest>,
}

/// The context bar's transient UI state (the model lives in the engine).
#[derive(Default)]
pub struct ContextBarPanel {
    /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
    /// each frame (there is no DOM — egui draws on the canvas).
    hits: HashMap<String, egui::Rect>,
    /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
    /// — published so the verifier can assert WHICH actions the selection offered.
    shown_actions: Vec<String>,
    /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
    shown_features: Vec<String>,
    /// The constraint TYPE ids offered THIS frame (`fixed`, `distance`, …).
    shown_constraints: Vec<String>,
    /// The COMPONENT action ids offered THIS frame (`move`, `edit-in-place`, …)
    /// — non-empty exactly when the selection is a single component's members.
    shown_component_actions: Vec<String>,
    /// The single component the actions target this frame (its ACOMP id).
    shown_component_target: Option<String>,
}

impl ContextBarPanel {
    pub fn new() -> Self {
        Self::default()
    }

    /// Draw the context bar as a FLOATING panel over the viewport (nothing when
    /// nothing is selected — like the old app's floating selection action bar).
    /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
    /// are always reachable regardless of side-panel scroll. Returns a
    /// [`ContextOutcome`] — the feature id the shell should expand in the history
    /// tree (after a create-from-selection or edit-owning action) plus any entity
    /// names the shell should open pinned Info windows for (after the Info action).
    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
        self.hits.clear();
        self.shown_actions.clear();
        self.shown_features.clear();
        self.shown_constraints.clear();
        self.shown_component_actions.clear();
        self.shown_component_target = None;

        // Modeling context actions ONLY. Hidden with no selection (geometry OR a
        // label-selected constraint), and never during reference-selection (the
        // picker owns the selection) or in sketch mode (the sketch context rail
        // replaces this one). Rendered through the SHARED single-column rail —
        // see [`super::action_rail`] — so it and the sketch context bar stay
        // identical. The constraint selection only counts (and only offers its
        // Delete action) in a workbench that shows the constraints panel — the
        // same claim gate as the constraint offers.
        let has_geometry = state.has_selection();
        let constraint_target = state.selected_constraint().filter(|_| {
            crate::workbench::panel_visible(
                &state.settings.workbench,
                crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
            )
        });
        if (!has_geometry && constraint_target.is_none())
            || state.ref_select_active()
            || state.sketch_mode()
        {
            return ContextOutcome::default();
        }

        let sel = Selection::read(state);
        let comp = component_selection(&sel, state);
        let probe = selection_probe(&sel, &comp, all_on_sheet_metal(&sel, state));
        // The feature FENCE (build-spec §3): a selection made ENTIRELY of
        // component geometry offers NO modeling-feature creation (the kernel
        // rejects component references anyway — don't offer dead ends). The
        // constraint offers are the complement: their predicates REQUIRE an
        // all-component selection, so the two sets never coexist.
        let offers = if comp.suppress_features() {
            Vec::new()
        } else {
            feature_offers(&probe, &sel, &state.settings.workbench)
        };
        let constraint_types = constraint_offers(&probe, &state.settings.workbench);
        // A single component's member solid(s) selected → the COMPONENT action
        // set replaces the feature-creation offers (spec §8.5 / §8.1) — but ONLY
        // in a workbench that shows the assembly structure panel (claim-based:
        // Assembly + All). The component actions are that panel's row actions,
        // so they follow its visibility and never bleed into Modeling / Sheet
        // Metal; the STANDARD actions (Clear / Hide / Info / Edit owning) still
        // apply to a component selection in every workbench.
        let component_target = component_action_target(&comp, &state.settings.workbench).map(|id| {
            let fixed = state.component_info(id).map(|info| info.fixed).unwrap_or(false);
            (id.to_string(), fixed)
        });

        // Build the action items: the generic actions, then feature-from-selection.
        // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
        // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
        // window per selected entity — unlike the other actions it drives no engine
        // mutation; the shell opens the windows from the returned targets.
        let mut items = vec![ActionItem::new(
            "action:clear",
            "\u{2716} Clear",
            "Clear the selection",
        )];
        self.shown_actions.push("clear".into());
        // Hide + Info act on selected GEOMETRY — with only a constraint
        // selected they would be no-ops, so they are not offered.
        if has_geometry {
            items.push(ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"));
            items.push(ActionItem::new(
                "action:info",
                "\u{1f575} Info",
                "Open a pinned Info window per selected entity",
            ));
            self.shown_actions.push("hide".into());
            self.shown_actions.push("info".into());
        }
        // The label-selected CONSTRAINT's action: delete it (the panel's row ✕,
        // reachable from the viewport).
        if let Some(cid) = &constraint_target {
            items.push(ActionItem::new(
                "action:delete-constraint",
                "\u{2715} Delete constraint",
                format!("Delete constraint {cid}"),
            ));
            self.shown_actions.push("delete-constraint".into());
        }
        if sel.owning_feature.is_some() {
            items.push(ActionItem::new(
                "action:edit-owning",
                "Edit owning feature",
                "Roll to and edit the feature that created this",
            ));
            self.shown_actions.push("edit-owning".into());
        }
        // Component actions (spec §8.5): shown INSTEAD of the feature offers
        // when the selection is exactly one component's member solid(s).
        if let Some((target, fixed)) = &component_target {
            for action in ComponentAction::ALL {
                items.push(ActionItem::new(
                    format!("component:{}", action.id()),
                    action.label(*fixed),
                    action.tooltip(),
                ));
                self.shown_component_actions.push(action.id().to_string());
            }
            self.shown_component_target = Some(target.clone());
        }
        // Constraint offers (all-component selections in a workbench that shows
        // the constraints panel): one button per applicable constraint type.
        for def in &constraint_types {
            items.push(ActionItem::new(
                format!("constraint:{}", def.type_id),
                def.long_name,
                format!("Add a {} constraint from the selection", def.label),
            ));
            self.shown_constraints.push(def.type_id.to_string());
        }
        for offer in &offers {
            items.push(ActionItem::new(
                format!("feature:{}", offer.type_code),
                offer.label.clone(),
                format!("Create {} from the selection", offer.label),
            ));
            self.shown_features.push(offer.type_code.clone());
        }

        // With only a constraint selected the geometry summary would read all
        // zeros — name the constraint instead.
        let summary = match (&constraint_target, has_geometry) {
            (Some(cid), false) => format!("Selected: constraint {cid}"),
            _ => sel.summary(),
        };
        let clicked = egui::Frame::popup(ui.style())
            .show(ui, |ui| {
                action_rail(
                    ui,
                    Some("Selection actions"),
                    Some(&summary),
                    &items,
                    &mut self.hits,
                )
            })
            .inner;

        // --- apply the intent (one engine mutation per frame) -----------------
        let mut outcome = ContextOutcome::default();
        match clicked.as_deref() {
            Some("action:clear") => {
                // Also drops a label-selected constraint (clear_selection folds
                // the constraint selection in).
                state.clear_selection();
            }
            Some("action:delete-constraint") => {
                if let Some(cid) = &constraint_target {
                    let _ = state.assembly_remove_constraint(cid);
                    state.constraint_deselect();
                }
            }
            Some("action:hide") => {
                state.hide_selected();
            }
            Some("action:info") => {
                // No engine mutation — hand the shell one target per selected entity
                // so it opens (or, on dedup, keeps) a pinned Info window for each.
                outcome.info_targets = sel.all_names();
            }
            Some("action:edit-owning") => {
                if let Some(fid) = sel.owning_feature.clone() {
                    if let Some(index) = feature_index(state, &fid) {
                        state.roll_to(index);
                    }
                    outcome.focus = Some(fid);
                }
            }
            Some(key) if key.starts_with("component:") => {
                if let Some((target, _)) = &component_target {
                    if let Some(action) = ComponentAction::from_id(&key["component:".len()..]) {
                        outcome.component = run_component_action(state, action, target);
                    }
                }
            }
            Some(key) if key.starts_with("constraint:") => {
                let type_id = &key["constraint:".len()..];
                if constraint_types.iter().any(|def| def.type_id == type_id) {
                    add_constraint_from_selection(state, type_id);
                }
            }
            Some(key) if key.starts_with("feature:") => {
                let code = &key["feature:".len()..];
                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
                    outcome.focus = create_feature_from_selection(state, offer, &sel);
                }
            }
            _ => {}
        }
        outcome
    }

    /// The published widget hit-rects (egui points) for the headed verifier —
    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
    #[cfg(target_arch = "wasm32")]
    pub fn hits_json(&self) -> String {
        let map: serde_json::Map<String, Value> = self
            .hits
            .iter()
            .map(|(k, r)| {
                (
                    k.clone(),
                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
                )
            })
            .collect();
        Value::Object(map).to_string()
    }

    /// The bar's LOGICAL state for the verifier: whether it is shown + which
    /// generic actions, feature type-codes, and component actions it offered
    /// this frame (and the single component the latter target).
    #[cfg(target_arch = "wasm32")]
    pub fn state_json(&self) -> String {
        serde_json::json!({
            "shown": !self.hits.is_empty(),
            "actions": self.shown_actions,
            "features": self.shown_features,
            "constraints": self.shown_constraints,
            "componentActions": self.shown_component_actions,
            "componentTarget": self.shown_component_target,
        })
        .to_string()
    }
}

/// The COMPONENT view of the current selection: which ACOMP instances own the
/// selected entities, and whether the selection qualifies for the component
/// action set / the feature-offer fence.
struct ComponentSelection {
    /// Unique owning ACOMP ids across every selected NAMED entity, selection
    /// order.
    ids: Vec<String>,
    /// Whether EVERY selected named entity is component-owned (and at least one
    /// is selected; vertices carry no names, so any vertex disqualifies).
    all_component: bool,
    /// Whether the selection is member SOLIDS only (the shape a viewport
    /// component click produces).
    solids_only: bool,
}

impl ComponentSelection {
    /// The feature FENCE: suppress modeling-feature creation offers when the
    /// whole selection is component geometry.
    fn suppress_features(&self) -> bool {
        self.all_component && !self.ids.is_empty()
    }

    /// The single component the ACTION SET targets: exactly one owning
    /// component, selected via its member solid(s) alone.
    fn sole_target(&self) -> Option<&str> {
        (self.suppress_features() && self.solids_only && self.ids.len() == 1)
            .then(|| self.ids[0].as_str())
    }
}

/// Resolve the selection's component ownership through the engine's namespace
/// parse (`component_of_solid` accepts any namespaced entity name — solid,
/// face, or edge).
fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
    let mut ids: Vec<String> = Vec::new();
    let mut all = true;
    let mut any = false;
    for name in sel.all_names() {
        any = true;
        match state.component_of_solid(&name) {
            Some(id) => {
                if !ids.contains(&id) {
                    ids.push(id);
                }
            }
            None => all = false,
        }
    }
    if sel.vertices > 0 {
        all = false;
    }
    ComponentSelection {
        ids,
        all_component: all && any,
        solids_only: !sel.solids.is_empty()
            && sel.sketches.is_empty()
            && sel.faces.is_empty()
            && sel.edges.is_empty()
            && sel.vertices == 0,
    }
}

/// The current selection, resolved once per frame from `selection_json`, plus the
/// single-selection owning feature (for **Edit owning feature**).
struct Selection {
    solids: Vec<String>,
    /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
    /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
    /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
    /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
    /// `["SOLID"]` field like SM Cutout's `sheet`).
    sketches: Vec<String>,
    faces: Vec<String>,
    edges: Vec<String>,
    /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
    /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
    /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
    /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
    /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
    /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
    /// a planar face (the kernel resolves either).
    planes: Vec<String>,
    vertices: usize,
    /// The producer feature id of a SINGLE-entity selection with a known producer.
    owning_feature: Option<String>,
}

impl Selection {
    fn read(state: &EngineState) -> Self {
        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
        let names = |key: &str| -> Vec<String> {
            v[key]
                .as_array()
                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
                .unwrap_or_default()
        };
        // Partition the selected `solids` into REAL solids vs committed sketches: a
        // selected solid is a sketch iff its name is a committed sketch (the
        // sketch's selectable name is its id; visibility is irrelevant here).
        let sketch_ids: std::collections::HashSet<String> = state
            .committed_sketches()
            .into_iter()
            .map(|(id, _visible)| id)
            .collect();
        let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
            .into_iter()
            .partition(|name| sketch_ids.contains(name));
        let faces = names("faces");
        let edges = names("edges");
        // Construction planes / datum planes arrive under `datums` — a SEPARATE
        // bucket (never merged into `faces`): the scene-solid consumers cannot
        // resolve a datum frame name (see the `planes` field doc).
        let planes = names("datums");
        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;

        // A single selected entity → its owning feature (the old app's
        // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
        // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
        // its `D`/`P` feature). Datum planes count toward the single-selection
        // total too, else picking one shows no Edit-owning-feature button.
        let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
        let single = if total == 1 && vertices == 0 {
            faces
                .first()
                .or_else(|| edges.first())
                .or_else(|| solids.first())
                .or_else(|| sketches.first())
                .or_else(|| planes.first())
                .cloned()
        } else {
            None
        };
        let owning_feature = single
            .as_deref()
            .and_then(|name| state.creating_feature(name))
            .map(|(id, _ty)| id);

        Self {
            solids,
            sketches,
            faces,
            edges,
            planes,
            vertices,
            owning_feature,
        }
    }

    /// The selectable KINDS currently present (vertices carry no names, and no
    /// primary reference is vertex-only, so they never drive feature actions).
    fn kinds_present(&self) -> Vec<&'static str> {
        let mut kinds = Vec::new();
        if !self.solids.is_empty() {
            kinds.push("SOLID");
        }
        if !self.sketches.is_empty() {
            kinds.push("SKETCH");
        }
        if !self.faces.is_empty() {
            kinds.push("FACE");
        }
        if !self.edges.is_empty() {
            kinds.push("EDGE");
        }
        // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
        // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
        if !self.planes.is_empty() {
            kinds.push("PLANE");
        }
        kinds
    }

    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
    /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
    /// frames, `COMPONENT` to selected solids (the picker never yields a bare
    /// component here).
    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        let push = |src: &[String], out: &mut Vec<String>| {
            for name in src {
                if !out.iter().any(|n| n == name) {
                    out.push(name.clone());
                }
            }
        };
        for f in filter {
            match f.as_str() {
                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
                "SKETCH" => push(&self.sketches, &mut out),
                "FACE" => push(&self.faces, &mut out),
                // A `["PLANE","FACE"]` field prefills from EITHER a selected face
                // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
                // is an alias for the same planes bucket.
                "PLANE" | "DATUM" => push(&self.planes, &mut out),
                "EDGE" => push(&self.edges, &mut out),
                _ => {}
            }
        }
        out
    }

    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
    /// pinned Info window the Info action opens. Vertices carry no name, and datum
    /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
    /// (Info, Hide via `hide_selected`, `component_selection` via
    /// `component_of_solid`), none of which can resolve a datum frame name. A datum
    /// plane can be selected (`has_selection` now counts it, so the bar shows and
    /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
    /// the probe — never this list.
    fn all_names(&self) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
            for name in src {
                if !name.is_empty() && !out.iter().any(|n| n == name) {
                    out.push(name.clone());
                }
            }
        }
        out
    }

    fn summary(&self) -> String {
        format!(
            "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
            self.solids.len(),
            self.sketches.len(),
            self.faces.len(),
            self.edges.len(),
            self.planes.len(),
            self.vertices,
        )
    }
}

/// One reference field of an offered feature, in schema order — the pre-fill
/// targets [`prefill_references`] consumes the selection into.
struct OfferField {
    /// The JSON path of the `References`-group field.
    path: Vec<String>,
    /// That field's `selectionFilter` (which selected kinds map into it).
    filter: Vec<String>,
    /// Whether the field takes a list (vs a single name).
    multiple: bool,
}

/// One offered feature action.
struct Offer {
    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
    type_code: String,
    /// The button label (the feature's long name).
    label: String,
    /// Every `References`-group field whose filter accepts a selected kind
    /// (schema order) — the create pre-fills them under a consumed set.
    fields: Vec<OfferField>,
}

/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
/// the selection's kind counts, its component view, and whether it sits entirely
/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
fn selection_probe(
    sel: &Selection,
    comp: &ComponentSelection,
    all_sheet_metal: bool,
) -> SelectionProbe {
    SelectionProbe {
        solids: sel.solids.len(),
        sketches: sel.sketches.len(),
        faces: sel.faces.len(),
        edges: sel.edges.len(),
        planes: sel.planes.len(),
        vertices: sel.vertices,
        components: comp.ids.len(),
        all_component: comp.all_component,
        all_sheet_metal,
    }
}

/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
/// vertex convention: a vertex carries no name to resolve, so any vertex in the
/// selection disqualifies it.
fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
    let names = sel.all_names();
    !names.is_empty()
        && sel.vertices == 0
        && names.iter().all(|name| state.is_sheet_metal_object(name))
}

/// The feature actions to offer: every catalogue feature whose OWN
/// `context_applicable` predicate (kernel-defined, next to its schema —
/// `feature_pipeline::context_offer`) accepts the current selection probe. The
/// `workbench` argument only FURTHER RESTRICTS that set to the features the
/// active workbench includes; like the palette filter it is a pure UI trim over
/// CREATION and never affects the existing history / execution.
///
/// The pre-fill stays schema-derived: each offer carries EVERY
/// `References`-group `reference_selection` field whose `selectionFilter`
/// intersects a selected kind (schema order), and the create consumes the
/// selection into them ([`prefill_references`]).
fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
    let kinds = sel.kinds_present();
    if kinds.is_empty() {
        return Vec::new();
    }
    let catalogue = features::feature_catalogue();
    let mut out = Vec::new();
    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
        for feature in list {
            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
                continue;
            };
            if ty.is_empty() {
                continue;
            }
            // Workbench UI filter: skip features this workbench does not include
            // (classified off the type code).
            if !crate::workbench::includes_feature(workbench, ty) {
                continue;
            }
            // The feature's own answer to "does this selection make me
            // meaningful?" — nuance (Revolve wants profile AND axis) lives in
            // the kernel predicate, not here.
            if !brep_kernel::feature_context_applicable(ty, probe) {
                continue;
            }
            // The pre-fill targets: every `References`-group reference field
            // accepting a selected kind. Primitives only carry the boolean-op
            // `targets` Reference (group `Boolean`), so they never collect any
            // (their predicates return false anyway).
            let fields: Vec<OfferField> = features::feature_form_fields(ty)
                .iter()
                .filter(|field| field.group == "References")
                .filter_map(|field| {
                    let FieldKind::Reference { filter, multiple } = &field.kind else {
                        return None;
                    };
                    filter
                        .iter()
                        .any(|f| kinds.iter().any(|k| *k == f.as_str()))
                        .then(|| OfferField {
                            path: field.path.clone(),
                            filter: filter.clone(),
                            multiple: *multiple,
                        })
                })
                .collect();
            out.push(Offer {
                type_code: ty.to_string(),
                label: features::feature_long_name(ty),
                fields,
            });
        }
    }
    out
}

/// The single component the context bar's COMPONENT action set targets, or
/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
/// sole_target`]) OR the active workbench hides the assembly structure panel
/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
/// kernel rejects component references in every workbench.
fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
    // The BOM is the assembly workbench's component list (it absorbed the
    // Structure panel): component actions target a selection only where that
    // list is on screen.
    let list_shown = crate::workbench::panel_visible(
        workbench,
        crate::workbench::assembly::BOM_PANEL_ID,
    );
    list_shown.then(|| comp.sole_target()).flatten()
}

/// The constraint actions to offer: every constraint type whose `applicable`
/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
/// accepts the probe — gated on the Assembly Constraints panel being available
/// in the active workbench (claim-based visibility: Assembly + All).
fn constraint_offers(
    probe: &SelectionProbe,
    workbench: &str,
) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
    if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
    {
        return Vec::new();
    }
    brep_kernel::CONSTRAINT_TYPES
        .iter()
        .filter(|def| (def.applicable)(probe))
        .collect()
}

/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
/// through the constraints panel's seeding helper (filtered + capped by the
/// type's own schema), then the new row opened so the panel shows its dialog.
/// The engine's mutation path handles auto-solve exactly like a panel add.
fn add_constraint_from_selection(state: &mut EngineState, type_id: &str) {
    let catalogue = brep_kernel::constraint_schema_catalogue();
    let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
    let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
    if let Ok(id) = state.assembly_add_constraint(type_id, &seed.to_string()) {
        let _ = state.assembly_set_constraint_open(&id, true);
    }
}

/// Consume the selection into an offer's reference fields, schema order: each
/// field takes the selected names its filter accepts that NO EARLIER field
/// consumed (first name for a single field, all remaining for a multiple) — so
/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
/// Returns `(path, value)` writes for [`form::set_at`].
fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
    let mut consumed: HashSet<String> = HashSet::new();
    let mut writes = Vec::new();
    for field in fields {
        let names: Vec<String> = sel
            .names_for_filter(&field.filter)
            .into_iter()
            .filter(|name| !consumed.contains(name))
            .collect();
        if names.is_empty() {
            continue;
        }
        let value = if field.multiple {
            consumed.extend(names.iter().cloned());
            Value::Array(names.into_iter().map(Value::String).collect())
        } else {
            let name = names.into_iter().next().unwrap_or_default();
            consumed.insert(name.clone());
            Value::String(name)
        };
        writes.push((field.path.clone(), value));
    }
    writes
}

/// Create a feature of `offer.type_code` referencing the selection: build a
/// fresh descriptor whose `inputParams` are the schema defaults with an
/// engine-unique `id` and the matched reference fields pre-filled
/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
/// Returns the new feature id (for the shell to expand its node).
fn create_feature_from_selection(
    state: &mut EngineState,
    offer: &Offer,
    sel: &Selection,
) -> Option<String> {
    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
    let mut params = features::feature_default_params(&offer.type_code);
    if let Value::Object(map) = &mut params {
        map.insert("id".into(), Value::String(id.clone()));
    }

    for (path, value) in prefill_references(&offer.fields, sel) {
        form::set_at(&mut params, &path, value);
    }

    let feature = serde_json::json!({
        "type": offer.type_code,
        "inputParams": params,
        "persistentData": {},
    });
    if state.add_feature(&feature.to_string()).is_ok() {
        Some(id)
    } else {
        None
    }
}

/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sel_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
        Selection {
            solids: solids.iter().map(|s| s.to_string()).collect(),
            sketches: sketches.iter().map(|s| s.to_string()).collect(),
            faces: faces.iter().map(|s| s.to_string()).collect(),
            edges: edges.iter().map(|s| s.to_string()).collect(),
            planes: Vec::new(),
            vertices: 0,
            owning_feature: None,
        }
    }

    /// A selection of construction PLANES / DATUM planes only (their frame names).
    fn sel_planes(planes: &[&str]) -> Selection {
        Selection {
            solids: Vec::new(),
            sketches: Vec::new(),
            faces: Vec::new(),
            edges: Vec::new(),
            planes: planes.iter().map(|s| s.to_string()).collect(),
            vertices: 0,
            owning_feature: None,
        }
    }

    fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
        sel_full(solids, &[], faces, edges)
    }

    /// Offers for a NON-COMPONENT selection (the plain modeling shape).
    fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
        let comp = ComponentSelection {
            ids: Vec::new(),
            all_component: false,
            solids_only: false,
        };
        // Plain-geometry test selections carry no scene, so they are never on
        // sheet metal (the SM edit features are covered separately).
        feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
    }

    #[test]
    fn face_selection_offers_face_features_not_solid_ones() {
        // "all" workbench so the expected sets below are unfiltered.
        let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        // Face-primary features are offered…
        for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
            assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
        }
        // …features whose ONLY reference kind is SOLID (or SKETCH/EDGE) are NOT.
        for nope in ["B", "XFORM", "RIB"] {
            assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
        }
        // …but a feature that takes a face/plane as a SECONDARY reference IS now
        // offered, keyed on that field (any-field matching — the same rule that
        // lets a sketch drive a cutout): Mirror about a face, Split by it, Pattern
        // along its normal.
        for want in ["M", "PATTERN", "SPL"] {
            assert!(
                codes.contains(&want),
                "FACE should offer {want} via its plane/face field: {codes:?}"
            );
        }
        // Primitives (only a boolean `targets` Reference) never appear.
        assert!(!codes.contains(&"P.CU"));
        // Revolve's kernel predicate wants a profile AND an axis edge — a lone
        // face no longer offers it.
        assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
    }

    #[test]
    fn edge_selection_offers_fillet_chamfer_tube() {
        let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for want in ["F", "CH", "TU"] {
            assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
        }
        assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
        assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
    }

    /// The sheet-metal EDIT features gate on `all_sheet_metal` end to end: an edge
    /// that sits on a sheet-metal body offers SM Flange / Fillet / Chamfer; the
    /// same edge on plain geometry offers none of them (the flag plumbs through
    /// `selection_probe` → `feature_offers`).
    #[test]
    fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
        let sel = sel_of(&[], &[], &["Wall_E0"]);
        let comp = ComponentSelection {
            ids: Vec::new(),
            all_component: false,
            solids_only: false,
        };
        let codes = |all_sheet_metal: bool| -> Vec<String> {
            feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
                .iter()
                .map(|o| o.type_code.clone())
                .collect()
        };
        let on_sm = codes(true);
        for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
            assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
        }
        let plain = codes(false);
        for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
            assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
        }
    }

    #[test]
    fn solid_selection_offers_solid_features() {
        let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
            assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
        }
        assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
    }

    #[test]
    fn empty_selection_offers_nothing() {
        assert!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
    }

    /// The user-specified nuance end to end: profile + axis edge offers Revolve,
    /// and the consumed pre-fill routes the face into `profile` and the edge
    /// into `axis` (one field each, nothing echoed).
    #[test]
    fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
        let offers = offers_for(&sel, "all");
        let revolve = offers
            .iter()
            .find(|o| o.type_code == "R")
            .expect("face+edge offers Revolve");
        let writes = prefill_references(&revolve.fields, &sel);
        assert_eq!(
            writes,
            vec![
                (vec!["profile".to_string()], Value::String("Box_PZ".into())),
                (vec!["axis".to_string()], Value::String("Box_E0".into())),
            ]
        );
        // A committed sketch as the profile works the same way.
        let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
        let offers = offers_for(&sel, "all");
        assert!(
            offers.iter().any(|o| o.type_code == "R"),
            "sketch+edge offers Revolve"
        );
    }

    /// The consumed set: a name lands in at most ONE field, schema order —
    /// Pattern's edge fills `directionRef` and does NOT echo into `axisRef`;
    /// Fillet's multiple `edges` field takes faces and edges together.
    #[test]
    fn prefill_consumes_each_name_once() {
        let sel = sel_of(&["Box"], &[], &["Box_E0"]);
        let offers = offers_for(&sel, "all");
        let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
        let writes = prefill_references(&pattern.fields, &sel);
        assert_eq!(
            writes,
            vec![
                (vec!["solids".to_string()], serde_json::json!(["Box"])),
                (vec!["directionRef".to_string()], Value::String("Box_E0".into())),
            ]
        );

        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
        let offers = offers_for(&sel, "all");
        let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
        let writes = prefill_references(&fillet.fields, &sel);
        assert_eq!(
            writes,
            vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
        );
    }

    #[test]
    fn workbench_filters_the_context_offers() {
        // A face selection under different workbenches: the workbench only FURTHER
        // restricts the schema-declared offers (it adds no new trigger channel).
        let sel = sel_of(&[], &["Box_PZ"], &[]);
        let codes = |wb: &str| -> Vec<String> {
            offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
        };
        let all = codes("all");
        let modeling = codes("modeling");
        let sheet = codes("sheetMetal");
        // Modeling keeps the modeling face-feature Extrude, and drops every
        // sheet-metal (`SM.*`) offer.
        assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
        assert!(
            !modeling.iter().any(|c| c.starts_with("SM.")),
            "modeling must not offer any SM.* feature: {modeling:?}"
        );
        // Sheet Metal drops the pure-modeling Extrude.
        assert!(
            !sheet.iter().any(|c| c == "E"),
            "sheet metal must not offer Extrude: {sheet:?}"
        );
        // All is the superset: every modeling offer is present in All.
        for c in &modeling {
            assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
        }
    }

    #[test]
    fn extrude_primary_reference_is_single_profile() {
        let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
        let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
        assert_eq!(extrude.fields.len(), 1, "one matched reference field");
        assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
        assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
        assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
    }

    #[test]
    fn sketch_kind_is_distinct_from_solid() {
        // A committed sketch (partitioned out of the solids bucket) presents as
        // SKETCH — NOT SOLID — so it never satisfies a SOLID-only field…
        assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
        // …a real solid presents as SOLID…
        assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
        // …and a mixed selection carries both.
        let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
        assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
    }

    #[test]
    fn sketch_selection_offers_cutout_and_profile_features() {
        // A committed-sketch selection offers the profile-driven features and, in
        // particular, SM Cutout. (Revolve now also wants an axis edge, so it is
        // deliberately absent here.)
        let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for want in ["E", "SM.CUTOUT"] {
            assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
        }
        assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
        // SM Cutout matches on `profile` only (its `["SOLID"]` `sheet` field does
        // not match a sketch), so the create pre-fills the profile field.
        let cutout = offers
            .iter()
            .find(|o| o.type_code == "SM.CUTOUT")
            .expect("cutout offered for a sketch");
        assert_eq!(cutout.fields.len(), 1);
        assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
        assert!(
            cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
            "profile filter: {:?}",
            cutout.fields[0].filter
        );
        assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
    }

    #[test]
    fn solid_selection_offers_cutout_via_sheet() {
        // A real-solid selection still offers SM Cutout, matched on `sheet`.
        let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
        let cutout = offers
            .iter()
            .find(|o| o.type_code == "SM.CUTOUT")
            .expect("cutout offered for a solid");
        assert_eq!(cutout.fields.len(), 1);
        assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
        assert!(
            cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
            "sheet filter: {:?}",
            cutout.fields[0].filter
        );
        // Solid + sketch matches BOTH fields — the create fills sheet AND profile.
        let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
        let offers = offers_for(&sel, "all");
        let cutout = offers
            .iter()
            .find(|o| o.type_code == "SM.CUTOUT")
            .expect("cutout offered for solid+sketch");
        let writes = prefill_references(&cutout.fields, &sel);
        assert_eq!(
            writes,
            vec![
                (vec!["sheet".to_string()], Value::String("Plate".into())),
                (vec!["profile".to_string()], Value::String("Sk".into())),
            ]
        );
    }

    /// The constraint-offer CLICK path end to end: seed `elements` from the
    /// selection, add through the engine (the auto-solve mutation lane), and
    /// open the new row so the panel shows its dialog.
    #[test]
    fn add_constraint_from_selection_seeds_adds_and_opens() {
        use crate::panels::component_actions::tests::assembly_engine;
        let mut state = assembly_engine();
        state.select_component("ACOMP2");
        add_constraint_from_selection(&mut state, "fixed");
        let constraints = state.assembly_state_value();
        let entry = constraints["constraints"]
            .as_array()
            .and_then(|list| list.last())
            .cloned()
            .expect("constraint added");
        assert_eq!(entry["type"], "fixed");
        assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
        assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
    }

    /// Constraint offers: the per-type `applicable` predicates against the
    /// probe, gated on the constraints panel's workbench visibility.
    #[test]
    fn constraint_offers_follow_predicates_and_workbench() {
        let one_component = SelectionProbe {
            solids: 1,
            components: 1,
            all_component: true,
            ..Default::default()
        };
        let pair = SelectionProbe {
            faces: 2,
            components: 2,
            all_component: true,
            ..Default::default()
        };
        let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
            constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
        };

        // ONE component's solid → Fixed only.
        assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
        // Two faces across two components → every face-pair type, no Fixed.
        let pair_ids = ids(&pair, "assembly");
        for want in [
            "coincident",
            "touch_align",
            "parallel",
            "distance",
            "angle",
            "concentric",
            "perpendicular",
            "tangent",
        ] {
            assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
        }
        assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
        // "All" sees the claimed constraints panel too; Modeling does not.
        assert!(!ids(&pair, "all").is_empty());
        assert!(ids(&pair, "modeling").is_empty());
        // A non-component selection never offers constraints.
        let plain = SelectionProbe { faces: 2, ..Default::default() };
        assert!(ids(&plain, "assembly").is_empty());
    }

    #[test]
    fn names_for_filter_maps_sketch_kind() {
        // A `["FACE","SKETCH"]` profile field pre-fills from the selected sketches.
        let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
        assert_eq!(
            sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
            ["Box_PZ", "Sk1", "Sk2"]
        );
        assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
        // A SOLID-only field never picks up a sketch.
        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
    }

    #[test]
    fn all_names_gathers_every_named_entity_for_info_windows() {
        // A multi-select of a solid + two faces + an edge → four Info-window targets
        // (solids → faces → edges order, de-duplicated).
        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
        assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
        // Nothing selected → no windows.
        assert!(sel_of(&[], &[], &[]).all_names().is_empty());
    }

    #[test]
    fn component_selection_detects_single_component_and_fences_features() {
        use crate::panels::component_actions::tests::assembly_engine;
        let engine = assembly_engine();

        // ONE member solid selected → the sole action target, features fenced.
        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
        let comp = component_selection(&sel, &engine);
        assert!(comp.suppress_features());
        assert_eq!(comp.sole_target(), Some("ACOMP2"));

        // TWO components selected → fence holds, but no single action target.
        let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
        let comp = component_selection(&sel, &engine);
        assert!(comp.suppress_features());
        assert_eq!(comp.sole_target(), None);

        // A component FACE selection fences features (the kernel would reject
        // the reference anyway) but is not the solid-click action shape.
        let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
        let comp = component_selection(&sel, &engine);
        assert!(comp.suppress_features());
        assert_eq!(comp.sole_target(), None);

        // A non-component solid (no ACOMP prefix) keeps the feature offers.
        let sel = sel_of(&["Box"], &[], &[]);
        let comp = component_selection(&sel, &engine);
        assert!(!comp.suppress_features());
        assert_eq!(comp.sole_target(), None);

        // MIXED component + ordinary solid: not all-component → no fence, no
        // action target (the kernel enforces the reference fence at execution).
        let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
        let comp = component_selection(&sel, &engine);
        assert!(!comp.suppress_features());
        assert_eq!(comp.sole_target(), None);

        // An ACOMP-shaped prefix with no matching feature is NOT a component.
        let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
        assert!(!component_selection(&sel, &engine).suppress_features());
    }

    /// The workbench fence on the COMPONENT action set: a qualifying selection
    /// (one component's member solid) only yields an action target in a
    /// workbench that shows the assembly structure panel — Assembly + All —
    /// so Move / Edit-in-place / Open-Part / Fix / Delete never bleed into the
    /// Modeling (or Sheet Metal) context bar. The feature FENCE is workbench-
    /// independent: component geometry suppresses feature offers everywhere.
    #[test]
    fn component_actions_are_workbench_gated() {
        use crate::panels::component_actions::tests::assembly_engine;
        let engine = assembly_engine();
        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
        let comp = component_selection(&sel, &engine);
        assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");

        for wb in ["assembly", "all"] {
            assert_eq!(
                component_action_target(&comp, wb),
                Some("ACOMP2"),
                "component actions offered under `{wb}`"
            );
        }
        for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
            assert_eq!(
                component_action_target(&comp, wb),
                None,
                "component actions must not bleed into `{wb}`"
            );
            // The kernel-enforced fence still suppresses feature offers there.
            assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
        }
    }

    #[test]
    fn names_for_filter_maps_kinds() {
        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
        assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
        assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
        // A multi-kind filter (fillet's FACE+EDGE) gathers both.
        assert_eq!(
            sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
            ["Box_PZ", "Box_NZ", "Box_E0"]
        );
    }

    #[test]
    fn plane_selection_present_kind_is_plane() {
        // A datum/plane-only selection presents the ONE kind `PLANE` (never DATUM).
        assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
        // A face-only selection is unchanged (no PLANE leaks in).
        assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
    }

    #[test]
    fn names_for_filter_maps_planes_separately_from_faces() {
        // A `["PLANE","FACE"]` field (the sketchPlane filter) prefills from the
        // selected DATUM frame when only a plane is selected…
        let planes = sel_planes(&["Datum:XY"]);
        assert_eq!(
            planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
            ["Datum:XY"]
        );
        // …`DATUM` is an alias for the same planes bucket…
        assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
        // …and a datum plane never lands in a FACE-only field (buckets are split).
        assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
        // A FACE-only selection still fills a `["PLANE","FACE"]` field with the
        // face (the FACE arm), and never yields the plane bucket.
        let faces = sel_of(&[], &["Box_PZ"], &[]);
        assert_eq!(
            faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
            ["Box_PZ"]
        );
        assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
    }

    #[test]
    fn plane_selection_offers_sketch_and_prefills_the_plane() {
        // A datum/plane-only selection offers Sketch (kernel predicate keys on
        // `probe.planes`), and the create routes the frame name into `sketchPlane`.
        let sel = sel_planes(&["Datum:XY"]);
        let offers = offers_for(&sel, "all");
        let sketch = offers
            .iter()
            .find(|o| o.type_code == "S")
            .expect("a plane-only selection offers Sketch");
        let writes = prefill_references(&sketch.fields, &sel);
        assert!(
            writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
            "sketchPlane prefilled with the datum frame: {writes:?}"
        );
        // A bare plane drives no profile/solid/edge feature.
        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
        for nope in ["E", "F", "CH", "B", "XFORM"] {
            assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
        }
    }
}