Skip to main content

brep_app/panels/
context_bar.rs

1//! Context action toolbar — the **selection-driven** action bar (the engine-
2//! native successor to the old app's floating selection action bar,
3//! `SelectionFilter._syncSelectionActions` + `_getHistoryContextActionSpecs`).
4//!
5//! It is shown ONLY while something is selected (hidden otherwise) and its
6//! buttons depend on the CURRENT selection (kinds + count read from
7//! `selection_json`):
8//!
9//! * **Generic actions** (mirror the old selection action bar):
10//!   - **Clear** — `clear_selection`.
11//!   - **Hide** — `hide_selected` (toggles the visibility of EXACTLY what is
12//!     selected: a selected face/edge/vertex hides just that sub-entity, a
13//!     selected solid the whole solid; a second click shows it again).
14//!   - **Edit owning feature** — for a SINGLE selected entity with a known
15//!     producer, `creating_feature(name)` resolves the feature that built it;
16//!     clicking rolls the model to that step (`roll_to`) and asks the shell to
17//!     EXPAND that feature's inline dialog in the history tree.
18//! * **Feature-from-selection** — WHICH features a selection offers is answered
19//!   by the KERNEL, per feature: each feature module defines `context_applicable`
20//!   (aggregated in `feature_pipeline::context_offer`), a predicate over the
21//!   [`SelectionProbe`] kind-counts this bar builds each frame. That is where
22//!   nuance lives — e.g. Revolve wants a profile AND an axis edge, so a lone
23//!   face no longer offers it. The pre-fill stays schema-derived: an offered
24//!   feature's `References`-group `reference_selection` fields are filled from
25//!   the selection in schema order under a CONSUMED set (each selected name
26//!   lands in at most ONE field — face+edge → Revolve fills `profile` and
27//!   `axis`). Clicking creates the feature (`add_feature`) with those fields
28//!   pre-filled, then asks the shell to expand the new node for tweaking.
29//! * **Constraint-from-selection** — the assembly-constraint mirror of the
30//!   feature offers, shown when the Assembly Constraints panel is available in
31//!   the active workbench (claim-based visibility). Each constraint type's
32//!   `applicable` predicate ([`brep_kernel::ConstraintTypeDef`]) runs against
33//!   the same probe: all-component selections only (the kernel rejects anything
34//!   else), ONE component's solid(s) for Fixed, a two-element pair across TWO
35//!   distinct components for the pairing types. Clicking adds the constraint
36//!   with `elements` pre-seeded from the selection (the constraints panel's
37//!   seeding helper) and opens its row in the panel.
38//!
39//! Like the other panels this owns NO model state — the selection + history live
40//! in [`EngineState`], borrowed in; it only holds the per-frame `hits` map (widget
41//! screen rects) + the last-drawn action ids the headed verifier reads.
42
43use super::action_rail::{action_rail, ActionItem};
44use super::component_actions::{run_component_action, ComponentAction, ComponentActionRequest};
45use crate::form;
46use brep_render::brep_kernel::{self, SelectionProbe};
47use brep_render::engine_state::EngineState;
48use brep_render::features;
49use brep_render::style::FieldKind;
50use eframe::egui;
51use serde_json::Value;
52use std::collections::{HashMap, HashSet};
53
54/// A request bubbled back to the shell after a context action ran: EXPAND (open
55/// the inline dialog of) the feature with this id in the history tree. The
56/// context bar mutates the engine directly but cannot reach the history panel's
57/// private "expanded" state, so it returns the id for the shell to focus.
58pub type FocusRequest = Option<String>;
59
60/// What a context-bar frame hands back to the shell. The bar mutates the engine
61/// directly, but two effects it cannot reach itself:
62/// * `focus` — the history feature to EXPAND after a create / edit-owning action
63///   (the history panel's expand state is private to it); and
64/// * `info_targets` — the entity names to open PINNED Info windows for after the
65///   Info action (the Info-window manager is shell-owned). One name per selected
66///   entity, so a multi-select opens one window each.
67#[derive(Default)]
68pub struct ContextOutcome {
69    pub focus: FocusRequest,
70    pub info_targets: Vec<String>,
71    /// A COMPONENT document-level flow the shell must run (Edit in place /
72    /// Open Part) — set when the matching component action was clicked; the
73    /// engine-mutating component actions (Move / Fix-Unfix / Delete) already
74    /// applied inside the bar.
75    pub component: Option<ComponentActionRequest>,
76}
77
78/// The context bar's transient UI state (the model lives in the engine).
79#[derive(Default)]
80pub struct ContextBarPanel {
81    /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
82    /// each frame (there is no DOM — egui draws on the canvas).
83    hits: HashMap<String, egui::Rect>,
84    /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
85    /// — published so the verifier can assert WHICH actions the selection offered.
86    shown_actions: Vec<String>,
87    /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
88    shown_features: Vec<String>,
89    /// The constraint TYPE ids offered THIS frame (`fixed`, `distance`, …).
90    shown_constraints: Vec<String>,
91    /// The COMPONENT action ids offered THIS frame (`move`, `open-part`, …)
92    /// — non-empty exactly when the selection is a single component's members.
93    shown_component_actions: Vec<String>,
94    /// The single component the actions target this frame (its ACOMP id).
95    shown_component_target: Option<String>,
96}
97
98impl ContextBarPanel {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Draw the context bar as a FLOATING panel over the viewport (nothing when
104    /// nothing is selected — like the old app's floating selection action bar).
105    /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
106    /// are always reachable regardless of side-panel scroll. Returns a
107    /// [`ContextOutcome`] — the feature id the shell should expand in the history
108    /// tree (after a create-from-selection or edit-owning action) plus any entity
109    /// names the shell should open pinned Info windows for (after the Info action).
110    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
111        self.hits.clear();
112        self.shown_actions.clear();
113        self.shown_features.clear();
114        self.shown_constraints.clear();
115        self.shown_component_actions.clear();
116        self.shown_component_target = None;
117
118        // Modeling context actions ONLY. Hidden with no selection (geometry OR a
119        // label-selected constraint), and never during reference-selection (the
120        // picker owns the selection) or in sketch mode (the sketch context rail
121        // replaces this one). Rendered through the SHARED single-column rail —
122        // see [`super::action_rail`] — so it and the sketch context bar stay
123        // identical. The constraint selection only counts (and only offers its
124        // Delete action) in a workbench that shows the constraints panel — the
125        // same claim gate as the constraint offers.
126        let has_geometry = state.has_selection();
127        let constraint_target = state.selected_constraint().filter(|_| {
128            crate::workbench::panel_visible(
129                &state.settings.workbench,
130                crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
131            )
132        });
133        if (!has_geometry && constraint_target.is_none())
134            || state.ref_select_active()
135            || state.sketch_mode()
136        {
137            return ContextOutcome::default();
138        }
139
140        let sel = Selection::read(state);
141        let comp = component_selection(&sel, state);
142        let probe = selection_probe(&sel, &comp, all_on_sheet_metal(&sel, state));
143        // The feature FENCE (build-spec §3): a selection made ENTIRELY of
144        // component geometry offers NO modeling-feature creation (the kernel
145        // rejects component references anyway — don't offer dead ends). The
146        // constraint offers are the complement: their predicates REQUIRE an
147        // all-component selection, so the two sets never coexist.
148        let offers = if comp.suppress_features() {
149            Vec::new()
150        } else {
151            feature_offers(&probe, &sel, &state.settings.workbench)
152        };
153        let constraint_types = constraint_offers(&probe, &state.settings.workbench);
154        // A single component's member solid(s) selected → the COMPONENT action
155        // set replaces the feature-creation offers (spec §8.5 / §8.1) — but ONLY
156        // in a workbench that shows the assembly structure panel (claim-based:
157        // Assembly + All). The component actions are that panel's row actions,
158        // so they follow its visibility and never bleed into Modeling / Sheet
159        // Metal; the STANDARD actions (Clear / Hide / Info / Edit owning) still
160        // apply to a component selection in every workbench.
161        let component_target = component_action_target(&comp, &state.settings.workbench).map(|id| {
162            let fixed = state.component_info(id).map(|info| info.fixed).unwrap_or(false);
163            (id.to_string(), fixed)
164        });
165
166        // Build the action items: the generic actions, then feature-from-selection.
167        // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
168        // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
169        // window per selected entity — unlike the other actions it drives no engine
170        // mutation; the shell opens the windows from the returned targets.
171        let mut items = vec![ActionItem::new(
172            "action:clear",
173            "\u{2716} Clear",
174            "Clear the selection",
175        )];
176        self.shown_actions.push("clear".into());
177        // Hide + Info act on selected GEOMETRY — with only a constraint
178        // selected they would be no-ops, so they are not offered.
179        if has_geometry {
180            items.push(ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"));
181            items.push(ActionItem::new(
182                "action:info",
183                "\u{1f575} Info",
184                "Open a pinned Info window per selected entity",
185            ));
186            self.shown_actions.push("hide".into());
187            self.shown_actions.push("info".into());
188        }
189        // The label-selected CONSTRAINT's action: delete it (the panel's row ✕,
190        // reachable from the viewport).
191        if let Some(cid) = &constraint_target {
192            items.push(ActionItem::new(
193                "action:delete-constraint",
194                "\u{2715} Delete constraint",
195                format!("Delete constraint {cid}"),
196            ));
197            self.shown_actions.push("delete-constraint".into());
198        }
199        if sel.owning_feature.is_some() {
200            items.push(ActionItem::new(
201                "action:edit-owning",
202                "Edit owning feature",
203                "Roll to and edit the feature that created this",
204            ));
205            self.shown_actions.push("edit-owning".into());
206        }
207        // Component actions (spec §8.5): shown INSTEAD of the feature offers
208        // when the selection is exactly one component's member solid(s).
209        if let Some((target, fixed)) = &component_target {
210            for action in ComponentAction::ALL {
211                items.push(ActionItem::new(
212                    format!("component:{}", action.id()),
213                    action.label(*fixed),
214                    action.tooltip(),
215                ));
216                self.shown_component_actions.push(action.id().to_string());
217            }
218            self.shown_component_target = Some(target.clone());
219        }
220        // Constraint offers (all-component selections in a workbench that shows
221        // the constraints panel): one button per applicable constraint type.
222        for def in &constraint_types {
223            items.push(ActionItem::new(
224                format!("constraint:{}", def.type_id),
225                def.long_name,
226                format!("Add a {} constraint from the selection", def.label),
227            ));
228            self.shown_constraints.push(def.type_id.to_string());
229        }
230        for offer in &offers {
231            items.push(ActionItem::new(
232                format!("feature:{}", offer.type_code),
233                offer.label.clone(),
234                format!("Create {} from the selection", offer.label),
235            ));
236            self.shown_features.push(offer.type_code.clone());
237        }
238
239        // With only a constraint selected the geometry summary would read all
240        // zeros — name the constraint instead.
241        let summary = match (&constraint_target, has_geometry) {
242            (Some(cid), false) => format!("Selected: constraint {cid}"),
243            _ => sel.summary(),
244        };
245        let clicked = egui::Frame::popup(ui.style())
246            .show(ui, |ui| {
247                action_rail(
248                    ui,
249                    Some("Selection actions"),
250                    Some(&summary),
251                    &items,
252                    &mut self.hits,
253                )
254            })
255            .inner;
256
257        // --- apply the intent (one engine mutation per frame) -----------------
258        let mut outcome = ContextOutcome::default();
259        match clicked.as_deref() {
260            Some("action:clear") => {
261                // Also drops a label-selected constraint (clear_selection folds
262                // the constraint selection in).
263                state.clear_selection();
264            }
265            Some("action:delete-constraint") => {
266                if let Some(cid) = &constraint_target {
267                    let _ = state.assembly_remove_constraint(cid);
268                    state.constraint_deselect();
269                }
270            }
271            Some("action:hide") => {
272                state.hide_selected();
273            }
274            Some("action:info") => {
275                // No engine mutation — hand the shell one target per selected entity
276                // so it opens (or, on dedup, keeps) a pinned Info window for each.
277                outcome.info_targets = sel.all_names();
278            }
279            Some("action:edit-owning") => {
280                if let Some(fid) = sel.owning_feature.clone() {
281                    if let Some(index) = feature_index(state, &fid) {
282                        state.roll_to(index);
283                    }
284                    outcome.focus = Some(fid);
285                }
286            }
287            Some(key) if key.starts_with("component:") => {
288                if let Some((target, _)) = &component_target {
289                    if let Some(action) = ComponentAction::from_id(&key["component:".len()..]) {
290                        outcome.component = run_component_action(state, action, target);
291                    }
292                }
293            }
294            Some(key) if key.starts_with("constraint:") => {
295                let type_id = &key["constraint:".len()..];
296                if constraint_types.iter().any(|def| def.type_id == type_id) {
297                    if let Err(error) = add_constraint_from_selection(state, type_id) {
298                        state.push_notice(format!("Add constraint: {error}"));
299                    }
300                }
301            }
302            Some(key) if key.starts_with("feature:") => {
303                let code = &key["feature:".len()..];
304                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
305                    outcome.focus = create_feature_from_selection(state, offer, &sel);
306                }
307            }
308            _ => {}
309        }
310        outcome
311    }
312
313    /// The published widget hit-rects (egui points) for the headed verifier —
314    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
315    #[cfg(target_arch = "wasm32")]
316    pub fn hits_json(&self) -> String {
317        let map: serde_json::Map<String, Value> = self
318            .hits
319            .iter()
320            .map(|(k, r)| {
321                (
322                    k.clone(),
323                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
324                )
325            })
326            .collect();
327        Value::Object(map).to_string()
328    }
329
330    /// The bar's LOGICAL state for the verifier: whether it is shown + which
331    /// generic actions, feature type-codes, and component actions it offered
332    /// this frame (and the single component the latter target).
333    #[cfg(target_arch = "wasm32")]
334    pub fn state_json(&self) -> String {
335        serde_json::json!({
336            "shown": !self.hits.is_empty(),
337            "actions": self.shown_actions,
338            "features": self.shown_features,
339            "constraints": self.shown_constraints,
340            "componentActions": self.shown_component_actions,
341            "componentTarget": self.shown_component_target,
342        })
343        .to_string()
344    }
345}
346
347/// The COMPONENT view of the current selection: which ACOMP instances own the
348/// selected entities, and whether the selection qualifies for the component
349/// action set / the feature-offer fence.
350struct ComponentSelection {
351    /// Unique owning ACOMP ids across every selected NAMED entity, selection
352    /// order.
353    ids: Vec<String>,
354    /// Whether EVERY selected named entity is component-owned (and at least one
355    /// is selected; vertices carry no names, so any vertex disqualifies).
356    all_component: bool,
357    /// Whether the selection is member SOLIDS only (the shape a viewport
358    /// component click produces).
359    solids_only: bool,
360}
361
362impl ComponentSelection {
363    /// The feature FENCE: suppress modeling-feature creation offers when the
364    /// whole selection is component geometry.
365    fn suppress_features(&self) -> bool {
366        self.all_component && !self.ids.is_empty()
367    }
368
369    /// The single component the ACTION SET targets: exactly one owning
370    /// component, selected via its member solid(s) alone.
371    fn sole_target(&self) -> Option<&str> {
372        (self.suppress_features() && self.solids_only && self.ids.len() == 1)
373            .then(|| self.ids[0].as_str())
374    }
375}
376
377/// Resolve the selection's component ownership through the engine's namespace
378/// parse (`component_of_solid` accepts any namespaced entity name — solid,
379/// face, or edge).
380fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
381    let mut ids: Vec<String> = Vec::new();
382    let mut all = true;
383    let mut any = false;
384    for name in sel.all_names() {
385        any = true;
386        match state.component_of_solid(&name) {
387            Some(id) => {
388                if !ids.contains(&id) {
389                    ids.push(id);
390                }
391            }
392            None => all = false,
393        }
394    }
395    if sel.vertices > 0 {
396        all = false;
397    }
398    ComponentSelection {
399        ids,
400        all_component: all && any,
401        solids_only: !sel.solids.is_empty()
402            && sel.sketches.is_empty()
403            && sel.faces.is_empty()
404            && sel.edges.is_empty()
405            && sel.vertices == 0,
406    }
407}
408
409/// The current selection, resolved once per frame from `selection_json`, plus the
410/// single-selection owning feature (for **Edit owning feature**).
411struct Selection {
412    solids: Vec<String>,
413    /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
414    /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
415    /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
416    /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
417    /// `["SOLID"]` field like SM Cutout's `sheet`).
418    sketches: Vec<String>,
419    faces: Vec<String>,
420    edges: Vec<String>,
421    /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
422    /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
423    /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
424    /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
425    /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
426    /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
427    /// a planar face (the kernel resolves either).
428    planes: Vec<String>,
429    vertices: usize,
430    /// The producer feature id of a SINGLE-entity selection with a known producer.
431    owning_feature: Option<String>,
432}
433
434impl Selection {
435    fn read(state: &EngineState) -> Self {
436        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
437        let names = |key: &str| -> Vec<String> {
438            v[key]
439                .as_array()
440                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
441                .unwrap_or_default()
442        };
443        // Partition the selected `solids` into REAL solids vs committed sketches: a
444        // selected solid is a sketch iff its name is a committed sketch (the
445        // sketch's selectable name is its id; visibility is irrelevant here).
446        let sketch_ids: std::collections::HashSet<String> = state
447            .committed_sketches()
448            .into_iter()
449            .map(|(id, _visible)| id)
450            .collect();
451        let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
452            .into_iter()
453            .partition(|name| sketch_ids.contains(name));
454        let faces = names("faces");
455        let edges = names("edges");
456        // Construction planes / datum planes arrive under `datums` — a SEPARATE
457        // bucket (never merged into `faces`): the scene-solid consumers cannot
458        // resolve a datum frame name (see the `planes` field doc).
459        let planes = names("datums");
460        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
461
462        // A single selected entity → its owning feature (the old app's
463        // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
464        // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
465        // its `D`/`P` feature). Datum planes count toward the single-selection
466        // total too, else picking one shows no Edit-owning-feature button.
467        let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
468        let single = if total == 1 && vertices == 0 {
469            faces
470                .first()
471                .or_else(|| edges.first())
472                .or_else(|| solids.first())
473                .or_else(|| sketches.first())
474                .or_else(|| planes.first())
475                .cloned()
476        } else {
477            None
478        };
479        let owning_feature = single
480            .as_deref()
481            .and_then(|name| state.creating_feature(name))
482            .map(|(id, _ty)| id);
483
484        Self {
485            solids,
486            sketches,
487            faces,
488            edges,
489            planes,
490            vertices,
491            owning_feature,
492        }
493    }
494
495    /// The selectable KINDS currently present (vertices carry no names, and no
496    /// primary reference is vertex-only, so they never drive feature actions).
497    fn kinds_present(&self) -> Vec<&'static str> {
498        let mut kinds = Vec::new();
499        if !self.solids.is_empty() {
500            kinds.push("SOLID");
501        }
502        if !self.sketches.is_empty() {
503            kinds.push("SKETCH");
504        }
505        if !self.faces.is_empty() {
506            kinds.push("FACE");
507        }
508        if !self.edges.is_empty() {
509            kinds.push("EDGE");
510        }
511        // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
512        // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
513        if !self.planes.is_empty() {
514            kinds.push("PLANE");
515        }
516        kinds
517    }
518
519    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
520    /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
521    /// frames, `COMPONENT` to selected solids (the picker never yields a bare
522    /// component here).
523    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
524        let mut out: Vec<String> = Vec::new();
525        let push = |src: &[String], out: &mut Vec<String>| {
526            for name in src {
527                if !out.iter().any(|n| n == name) {
528                    out.push(name.clone());
529                }
530            }
531        };
532        for f in filter {
533            match f.as_str() {
534                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
535                "SKETCH" => push(&self.sketches, &mut out),
536                "FACE" => push(&self.faces, &mut out),
537                // A `["PLANE","FACE"]` field prefills from EITHER a selected face
538                // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
539                // is an alias for the same planes bucket.
540                "PLANE" | "DATUM" => push(&self.planes, &mut out),
541                "EDGE" => push(&self.edges, &mut out),
542                _ => {}
543            }
544        }
545        out
546    }
547
548    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
549    /// pinned Info window the Info action opens. Vertices carry no name, and datum
550    /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
551    /// (Info, Hide via `hide_selected`, `component_selection` via
552    /// `component_of_solid`), none of which can resolve a datum frame name. A datum
553    /// plane can be selected (`has_selection` now counts it, so the bar shows and
554    /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
555    /// the probe — never this list.
556    fn all_names(&self) -> Vec<String> {
557        let mut out: Vec<String> = Vec::new();
558        for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
559            for name in src {
560                if !name.is_empty() && !out.iter().any(|n| n == name) {
561                    out.push(name.clone());
562                }
563            }
564        }
565        out
566    }
567
568    fn summary(&self) -> String {
569        format!(
570            "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
571            self.solids.len(),
572            self.sketches.len(),
573            self.faces.len(),
574            self.edges.len(),
575            self.planes.len(),
576            self.vertices,
577        )
578    }
579}
580
581/// One reference field of an offered feature, in schema order — the pre-fill
582/// targets [`prefill_references`] consumes the selection into.
583struct OfferField {
584    /// The JSON path of the `References`-group field.
585    path: Vec<String>,
586    /// That field's `selectionFilter` (which selected kinds map into it).
587    filter: Vec<String>,
588    /// Whether the field takes a list (vs a single name).
589    multiple: bool,
590}
591
592/// One offered feature action.
593struct Offer {
594    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
595    type_code: String,
596    /// The button label (the feature's long name).
597    label: String,
598    /// Every `References`-group field whose filter accepts a selected kind
599    /// (schema order) — the create pre-fills them under a consumed set.
600    fields: Vec<OfferField>,
601}
602
603/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
604/// the selection's kind counts, its component view, and whether it sits entirely
605/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
606fn selection_probe(
607    sel: &Selection,
608    comp: &ComponentSelection,
609    all_sheet_metal: bool,
610) -> SelectionProbe {
611    SelectionProbe {
612        solids: sel.solids.len(),
613        sketches: sel.sketches.len(),
614        faces: sel.faces.len(),
615        edges: sel.edges.len(),
616        planes: sel.planes.len(),
617        vertices: sel.vertices,
618        components: comp.ids.len(),
619        all_component: comp.all_component,
620        all_sheet_metal,
621    }
622}
623
624/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
625/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
626/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
627/// vertex convention: a vertex carries no name to resolve, so any vertex in the
628/// selection disqualifies it.
629fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
630    let names = sel.all_names();
631    !names.is_empty()
632        && sel.vertices == 0
633        && names.iter().all(|name| state.is_sheet_metal_object(name))
634}
635
636/// The feature actions to offer: every catalogue feature whose OWN
637/// `context_applicable` predicate (kernel-defined, next to its schema —
638/// `feature_pipeline::context_offer`) accepts the current selection probe. The
639/// `workbench` argument only FURTHER RESTRICTS that set to the features the
640/// active workbench includes; like the palette filter it is a pure UI trim over
641/// CREATION and never affects the existing history / execution.
642///
643/// The pre-fill stays schema-derived: each offer carries EVERY
644/// `References`-group `reference_selection` field whose `selectionFilter`
645/// intersects a selected kind (schema order), and the create consumes the
646/// selection into them ([`prefill_references`]).
647fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
648    let kinds = sel.kinds_present();
649    if kinds.is_empty() {
650        return Vec::new();
651    }
652    let catalogue = features::feature_catalogue();
653    let mut out = Vec::new();
654    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
655        for feature in list {
656            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
657                continue;
658            };
659            if ty.is_empty() {
660                continue;
661            }
662            // Workbench UI filter: skip features this workbench does not include
663            // (classified off the type code).
664            if !crate::workbench::includes_feature(workbench, ty) {
665                continue;
666            }
667            // The feature's own answer to "does this selection make me
668            // meaningful?" — nuance (Revolve wants profile AND axis) lives in
669            // the kernel predicate, not here.
670            if !brep_kernel::feature_context_applicable(ty, probe) {
671                continue;
672            }
673            // The pre-fill targets: every `References`-group reference field
674            // accepting a selected kind. Primitives only carry the boolean-op
675            // `targets` Reference (group `Boolean`), so they never collect any
676            // (their predicates return false anyway).
677            let fields: Vec<OfferField> = features::feature_form_fields(ty)
678                .iter()
679                .filter(|field| field.group == "References")
680                .filter_map(|field| {
681                    let FieldKind::Reference { filter, multiple } = &field.kind else {
682                        return None;
683                    };
684                    filter
685                        .iter()
686                        .any(|f| kinds.iter().any(|k| *k == f.as_str()))
687                        .then(|| OfferField {
688                            path: field.path.clone(),
689                            filter: filter.clone(),
690                            multiple: *multiple,
691                        })
692                })
693                .collect();
694            out.push(Offer {
695                type_code: ty.to_string(),
696                label: features::feature_long_name(ty),
697                fields,
698            });
699        }
700    }
701    out
702}
703
704/// The single component the context bar's COMPONENT action set targets, or
705/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
706/// sole_target`]) OR the active workbench hides the assembly structure panel
707/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
708/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
709/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
710/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
711/// kernel rejects component references in every workbench.
712fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
713    // The BOM is the assembly workbench's component list (it absorbed the
714    // Structure panel): component actions target a selection only where that
715    // list is on screen.
716    let list_shown = crate::workbench::panel_visible(
717        workbench,
718        crate::workbench::assembly::BOM_PANEL_ID,
719    );
720    list_shown.then(|| comp.sole_target()).flatten()
721}
722
723/// The constraint actions to offer: every constraint type whose `applicable`
724/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
725/// accepts the probe — gated on the Assembly Constraints panel being available
726/// in the active workbench (claim-based visibility: Assembly + All).
727fn constraint_offers(
728    probe: &SelectionProbe,
729    workbench: &str,
730) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
731    if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
732    {
733        return Vec::new();
734    }
735    brep_kernel::CONSTRAINT_TYPES
736        .iter()
737        .filter(|def| (def.applicable)(probe))
738        .collect()
739}
740
741/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
742/// through the constraints panel's seeding helper (filtered + capped by the
743/// type's own schema), then the new row opened so the panel shows its dialog.
744/// The engine's mutation path handles auto-solve exactly like a panel add.
745pub(crate) fn add_constraint_from_selection(
746    state: &mut EngineState,
747    type_id: &str,
748) -> Result<String, String> {
749    let catalogue = brep_kernel::constraint_schema_catalogue();
750    let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
751    let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
752    let id = state.assembly_add_constraint(type_id, &seed.to_string())?;
753    let _ = state.assembly_set_constraint_open(&id, true);
754    Ok(id)
755}
756
757/// Consume the selection into an offer's reference fields, schema order: each
758/// field takes the selected names its filter accepts that NO EARLIER field
759/// consumed (first name for a single field, all remaining for a multiple) — so
760/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
761/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
762/// Returns `(path, value)` writes for [`form::set_at`].
763fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
764    let mut consumed: HashSet<String> = HashSet::new();
765    let mut writes = Vec::new();
766    for field in fields {
767        let names: Vec<String> = sel
768            .names_for_filter(&field.filter)
769            .into_iter()
770            .filter(|name| !consumed.contains(name))
771            .collect();
772        if names.is_empty() {
773            continue;
774        }
775        let value = if field.multiple {
776            consumed.extend(names.iter().cloned());
777            Value::Array(names.into_iter().map(Value::String).collect())
778        } else {
779            let name = names.into_iter().next().unwrap_or_default();
780            consumed.insert(name.clone());
781            Value::String(name)
782        };
783        writes.push((field.path.clone(), value));
784    }
785    writes
786}
787
788/// Create a feature of `offer.type_code` referencing the selection: build a
789/// fresh descriptor whose `inputParams` are the schema defaults with an
790/// engine-unique `id` and the matched reference fields pre-filled
791/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
792/// Returns the new feature id (for the shell to expand its node).
793fn create_feature_from_selection(
794    state: &mut EngineState,
795    offer: &Offer,
796    sel: &Selection,
797) -> Option<String> {
798    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
799    let mut params = features::feature_default_params(&offer.type_code);
800    if let Value::Object(map) = &mut params {
801        map.insert("id".into(), Value::String(id.clone()));
802    }
803
804    for (path, value) in prefill_references(&offer.fields, sel) {
805        form::set_at(&mut params, &path, value);
806    }
807
808    let feature = serde_json::json!({
809        "type": offer.type_code,
810        "inputParams": params,
811        "persistentData": {},
812    });
813    if state.add_feature(&feature.to_string()).is_ok() {
814        Some(id)
815    } else {
816        None
817    }
818}
819
820/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
821fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
822    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn sel_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
830        Selection {
831            solids: solids.iter().map(|s| s.to_string()).collect(),
832            sketches: sketches.iter().map(|s| s.to_string()).collect(),
833            faces: faces.iter().map(|s| s.to_string()).collect(),
834            edges: edges.iter().map(|s| s.to_string()).collect(),
835            planes: Vec::new(),
836            vertices: 0,
837            owning_feature: None,
838        }
839    }
840
841    /// A selection of construction PLANES / DATUM planes only (their frame names).
842    fn sel_planes(planes: &[&str]) -> Selection {
843        Selection {
844            solids: Vec::new(),
845            sketches: Vec::new(),
846            faces: Vec::new(),
847            edges: Vec::new(),
848            planes: planes.iter().map(|s| s.to_string()).collect(),
849            vertices: 0,
850            owning_feature: None,
851        }
852    }
853
854    fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
855        sel_full(solids, &[], faces, edges)
856    }
857
858    /// Offers for a NON-COMPONENT selection (the plain modeling shape).
859    fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
860        let comp = ComponentSelection {
861            ids: Vec::new(),
862            all_component: false,
863            solids_only: false,
864        };
865        // Plain-geometry test selections carry no scene, so they are never on
866        // sheet metal (the SM edit features are covered separately).
867        feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
868    }
869
870    #[test]
871    fn face_selection_offers_face_features_not_solid_ones() {
872        // "all" workbench so the expected sets below are unfiltered.
873        let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
874        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
875        // Face-primary features are offered…
876        for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
877            assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
878        }
879        // …features whose ONLY reference kind is SOLID (or SKETCH/EDGE) are NOT.
880        for nope in ["B", "XFORM", "RIB"] {
881            assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
882        }
883        // …but a feature that takes a face/plane as a SECONDARY reference IS now
884        // offered, keyed on that field (any-field matching — the same rule that
885        // lets a sketch drive a cutout): Mirror about a face, Split by it, Pattern
886        // along its normal.
887        for want in ["M", "PATTERN", "SPL"] {
888            assert!(
889                codes.contains(&want),
890                "FACE should offer {want} via its plane/face field: {codes:?}"
891            );
892        }
893        // Primitives (only a boolean `targets` Reference) never appear.
894        assert!(!codes.contains(&"P.CU"));
895        // Revolve's kernel predicate wants a profile AND an axis edge — a lone
896        // face no longer offers it.
897        assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
898    }
899
900    #[test]
901    fn edge_selection_offers_fillet_chamfer_tube() {
902        let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
903        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
904        for want in ["F", "CH", "TU"] {
905            assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
906        }
907        assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
908        assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
909    }
910
911    /// The sheet-metal EDIT features gate on `all_sheet_metal` end to end: an edge
912    /// that sits on a sheet-metal body offers SM Flange / Fillet / Chamfer; the
913    /// same edge on plain geometry offers none of them (the flag plumbs through
914    /// `selection_probe` → `feature_offers`).
915    #[test]
916    fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
917        let sel = sel_of(&[], &[], &["Wall_E0"]);
918        let comp = ComponentSelection {
919            ids: Vec::new(),
920            all_component: false,
921            solids_only: false,
922        };
923        let codes = |all_sheet_metal: bool| -> Vec<String> {
924            feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
925                .iter()
926                .map(|o| o.type_code.clone())
927                .collect()
928        };
929        let on_sm = codes(true);
930        for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
931            assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
932        }
933        let plain = codes(false);
934        for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
935            assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
936        }
937    }
938
939    #[test]
940    fn solid_selection_offers_solid_features() {
941        let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
942        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
943        for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
944            assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
945        }
946        assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
947    }
948
949    #[test]
950    fn empty_selection_offers_nothing() {
951        assert!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
952    }
953
954    /// The user-specified nuance end to end: profile + axis edge offers Revolve,
955    /// and the consumed pre-fill routes the face into `profile` and the edge
956    /// into `axis` (one field each, nothing echoed).
957    #[test]
958    fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
959        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
960        let offers = offers_for(&sel, "all");
961        let revolve = offers
962            .iter()
963            .find(|o| o.type_code == "R")
964            .expect("face+edge offers Revolve");
965        let writes = prefill_references(&revolve.fields, &sel);
966        assert_eq!(
967            writes,
968            vec![
969                (vec!["profile".to_string()], Value::String("Box_PZ".into())),
970                (vec!["axis".to_string()], Value::String("Box_E0".into())),
971            ]
972        );
973        // A committed sketch as the profile works the same way.
974        let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
975        let offers = offers_for(&sel, "all");
976        assert!(
977            offers.iter().any(|o| o.type_code == "R"),
978            "sketch+edge offers Revolve"
979        );
980    }
981
982    /// The consumed set: a name lands in at most ONE field, schema order —
983    /// Pattern's edge fills `directionRef` and does NOT echo into `axisRef`;
984    /// Fillet's multiple `edges` field takes faces and edges together.
985    #[test]
986    fn prefill_consumes_each_name_once() {
987        let sel = sel_of(&["Box"], &[], &["Box_E0"]);
988        let offers = offers_for(&sel, "all");
989        let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
990        let writes = prefill_references(&pattern.fields, &sel);
991        assert_eq!(
992            writes,
993            vec![
994                (vec!["solids".to_string()], serde_json::json!(["Box"])),
995                (vec!["directionRef".to_string()], Value::String("Box_E0".into())),
996            ]
997        );
998
999        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
1000        let offers = offers_for(&sel, "all");
1001        let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
1002        let writes = prefill_references(&fillet.fields, &sel);
1003        assert_eq!(
1004            writes,
1005            vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
1006        );
1007    }
1008
1009    #[test]
1010    fn workbench_filters_the_context_offers() {
1011        // A face selection under different workbenches: the workbench only FURTHER
1012        // restricts the schema-declared offers (it adds no new trigger channel).
1013        let sel = sel_of(&[], &["Box_PZ"], &[]);
1014        let codes = |wb: &str| -> Vec<String> {
1015            offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
1016        };
1017        let all = codes("all");
1018        let modeling = codes("modeling");
1019        let sheet = codes("sheetMetal");
1020        // Modeling keeps the modeling face-feature Extrude, and drops every
1021        // sheet-metal (`SM.*`) offer.
1022        assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
1023        assert!(
1024            !modeling.iter().any(|c| c.starts_with("SM.")),
1025            "modeling must not offer any SM.* feature: {modeling:?}"
1026        );
1027        // Sheet Metal drops the pure-modeling Extrude.
1028        assert!(
1029            !sheet.iter().any(|c| c == "E"),
1030            "sheet metal must not offer Extrude: {sheet:?}"
1031        );
1032        // All is the superset: every modeling offer is present in All.
1033        for c in &modeling {
1034            assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
1035        }
1036    }
1037
1038    #[test]
1039    fn extrude_primary_reference_is_single_profile() {
1040        let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
1041        let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
1042        assert_eq!(extrude.fields.len(), 1, "one matched reference field");
1043        assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
1044        assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
1045        assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
1046    }
1047
1048    #[test]
1049    fn sketch_kind_is_distinct_from_solid() {
1050        // A committed sketch (partitioned out of the solids bucket) presents as
1051        // SKETCH — NOT SOLID — so it never satisfies a SOLID-only field…
1052        assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
1053        // …a real solid presents as SOLID…
1054        assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
1055        // …and a mixed selection carries both.
1056        let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
1057        assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
1058    }
1059
1060    #[test]
1061    fn sketch_selection_offers_cutout_and_profile_features() {
1062        // A committed-sketch selection offers the profile-driven features and, in
1063        // particular, SM Cutout. (Revolve now also wants an axis edge, so it is
1064        // deliberately absent here.)
1065        let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
1066        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1067        for want in ["E", "SM.CUTOUT"] {
1068            assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
1069        }
1070        assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
1071        // SM Cutout matches on `profile` only (its `["SOLID"]` `sheet` field does
1072        // not match a sketch), so the create pre-fills the profile field.
1073        let cutout = offers
1074            .iter()
1075            .find(|o| o.type_code == "SM.CUTOUT")
1076            .expect("cutout offered for a sketch");
1077        assert_eq!(cutout.fields.len(), 1);
1078        assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
1079        assert!(
1080            cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
1081            "profile filter: {:?}",
1082            cutout.fields[0].filter
1083        );
1084        assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
1085    }
1086
1087    #[test]
1088    fn solid_selection_offers_cutout_via_sheet() {
1089        // A real-solid selection still offers SM Cutout, matched on `sheet`.
1090        let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
1091        let cutout = offers
1092            .iter()
1093            .find(|o| o.type_code == "SM.CUTOUT")
1094            .expect("cutout offered for a solid");
1095        assert_eq!(cutout.fields.len(), 1);
1096        assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
1097        assert!(
1098            cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
1099            "sheet filter: {:?}",
1100            cutout.fields[0].filter
1101        );
1102        // Solid + sketch matches BOTH fields — the create fills sheet AND profile.
1103        let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
1104        let offers = offers_for(&sel, "all");
1105        let cutout = offers
1106            .iter()
1107            .find(|o| o.type_code == "SM.CUTOUT")
1108            .expect("cutout offered for solid+sketch");
1109        let writes = prefill_references(&cutout.fields, &sel);
1110        assert_eq!(
1111            writes,
1112            vec![
1113                (vec!["sheet".to_string()], Value::String("Plate".into())),
1114                (vec!["profile".to_string()], Value::String("Sk".into())),
1115            ]
1116        );
1117    }
1118
1119    /// The constraint-offer CLICK path end to end: seed `elements` from the
1120    /// selection, add through the engine (the auto-solve mutation lane), and
1121    /// open the new row so the panel shows its dialog.
1122    #[test]
1123    fn add_constraint_from_selection_seeds_adds_and_opens() {
1124        use crate::panels::component_actions::tests::assembly_engine;
1125        let mut state = assembly_engine();
1126        state.select_component("ACOMP2");
1127        add_constraint_from_selection(&mut state, "fixed").expect("adds");
1128        let constraints = state.assembly_state_value();
1129        let entry = constraints["constraints"]
1130            .as_array()
1131            .and_then(|list| list.last())
1132            .cloned()
1133            .expect("constraint added");
1134        assert_eq!(entry["type"], "fixed");
1135        assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
1136        assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
1137    }
1138
1139    /// Constraint offers: the per-type `applicable` predicates against the
1140    /// probe, gated on the constraints panel's workbench visibility.
1141    #[test]
1142    fn constraint_offers_follow_predicates_and_workbench() {
1143        let one_component = SelectionProbe {
1144            solids: 1,
1145            components: 1,
1146            all_component: true,
1147            ..Default::default()
1148        };
1149        let pair = SelectionProbe {
1150            faces: 2,
1151            components: 2,
1152            all_component: true,
1153            ..Default::default()
1154        };
1155        let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
1156            constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
1157        };
1158
1159        // ONE component's solid → Fixed only.
1160        assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
1161        // Two faces across two components → every face-pair type, no Fixed.
1162        let pair_ids = ids(&pair, "assembly");
1163        for want in [
1164            "coincident",
1165            "touch_align",
1166            "parallel",
1167            "distance",
1168            "angle",
1169            "concentric",
1170            "perpendicular",
1171            "tangent",
1172        ] {
1173            assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
1174        }
1175        assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
1176        // "All" sees the claimed constraints panel too; Modeling does not.
1177        assert!(!ids(&pair, "all").is_empty());
1178        assert!(ids(&pair, "modeling").is_empty());
1179        // A non-component selection never offers constraints.
1180        let plain = SelectionProbe { faces: 2, ..Default::default() };
1181        assert!(ids(&plain, "assembly").is_empty());
1182    }
1183
1184    #[test]
1185    fn names_for_filter_maps_sketch_kind() {
1186        // A `["FACE","SKETCH"]` profile field pre-fills from the selected sketches.
1187        let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
1188        assert_eq!(
1189            sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
1190            ["Box_PZ", "Sk1", "Sk2"]
1191        );
1192        assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
1193        // A SOLID-only field never picks up a sketch.
1194        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1195    }
1196
1197    #[test]
1198    fn all_names_gathers_every_named_entity_for_info_windows() {
1199        // A multi-select of a solid + two faces + an edge → four Info-window targets
1200        // (solids → faces → edges order, de-duplicated).
1201        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1202        assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
1203        // Nothing selected → no windows.
1204        assert!(sel_of(&[], &[], &[]).all_names().is_empty());
1205    }
1206
1207    #[test]
1208    fn component_selection_detects_single_component_and_fences_features() {
1209        use crate::panels::component_actions::tests::assembly_engine;
1210        let engine = assembly_engine();
1211
1212        // ONE member solid selected → the sole action target, features fenced.
1213        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1214        let comp = component_selection(&sel, &engine);
1215        assert!(comp.suppress_features());
1216        assert_eq!(comp.sole_target(), Some("ACOMP2"));
1217
1218        // TWO components selected → fence holds, but no single action target.
1219        let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
1220        let comp = component_selection(&sel, &engine);
1221        assert!(comp.suppress_features());
1222        assert_eq!(comp.sole_target(), None);
1223
1224        // A component FACE selection fences features (the kernel would reject
1225        // the reference anyway) but is not the solid-click action shape.
1226        let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
1227        let comp = component_selection(&sel, &engine);
1228        assert!(comp.suppress_features());
1229        assert_eq!(comp.sole_target(), None);
1230
1231        // A non-component solid (no ACOMP prefix) keeps the feature offers.
1232        let sel = sel_of(&["Box"], &[], &[]);
1233        let comp = component_selection(&sel, &engine);
1234        assert!(!comp.suppress_features());
1235        assert_eq!(comp.sole_target(), None);
1236
1237        // MIXED component + ordinary solid: not all-component → no fence, no
1238        // action target (the kernel enforces the reference fence at execution).
1239        let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
1240        let comp = component_selection(&sel, &engine);
1241        assert!(!comp.suppress_features());
1242        assert_eq!(comp.sole_target(), None);
1243
1244        // An ACOMP-shaped prefix with no matching feature is NOT a component.
1245        let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
1246        assert!(!component_selection(&sel, &engine).suppress_features());
1247    }
1248
1249    /// The workbench fence on the COMPONENT action set: a qualifying selection
1250    /// (one component's member solid) only yields an action target in a
1251    /// workbench that shows the assembly structure panel — Assembly + All —
1252    /// so Move / Edit-in-place / Open-Part / Fix / Delete never bleed into the
1253    /// Modeling (or Sheet Metal) context bar. The feature FENCE is workbench-
1254    /// independent: component geometry suppresses feature offers everywhere.
1255    #[test]
1256    fn component_actions_are_workbench_gated() {
1257        use crate::panels::component_actions::tests::assembly_engine;
1258        let engine = assembly_engine();
1259        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1260        let comp = component_selection(&sel, &engine);
1261        assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");
1262
1263        for wb in ["assembly", "all"] {
1264            assert_eq!(
1265                component_action_target(&comp, wb),
1266                Some("ACOMP2"),
1267                "component actions offered under `{wb}`"
1268            );
1269        }
1270        for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
1271            assert_eq!(
1272                component_action_target(&comp, wb),
1273                None,
1274                "component actions must not bleed into `{wb}`"
1275            );
1276            // The kernel-enforced fence still suppresses feature offers there.
1277            assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
1278        }
1279    }
1280
1281    #[test]
1282    fn names_for_filter_maps_kinds() {
1283        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1284        assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
1285        assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
1286        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1287        // A multi-kind filter (fillet's FACE+EDGE) gathers both.
1288        assert_eq!(
1289            sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
1290            ["Box_PZ", "Box_NZ", "Box_E0"]
1291        );
1292    }
1293
1294    #[test]
1295    fn plane_selection_present_kind_is_plane() {
1296        // A datum/plane-only selection presents the ONE kind `PLANE` (never DATUM).
1297        assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
1298        // A face-only selection is unchanged (no PLANE leaks in).
1299        assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
1300    }
1301
1302    #[test]
1303    fn names_for_filter_maps_planes_separately_from_faces() {
1304        // A `["PLANE","FACE"]` field (the sketchPlane filter) prefills from the
1305        // selected DATUM frame when only a plane is selected…
1306        let planes = sel_planes(&["Datum:XY"]);
1307        assert_eq!(
1308            planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
1309            ["Datum:XY"]
1310        );
1311        // …`DATUM` is an alias for the same planes bucket…
1312        assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
1313        // …and a datum plane never lands in a FACE-only field (buckets are split).
1314        assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
1315        // A FACE-only selection still fills a `["PLANE","FACE"]` field with the
1316        // face (the FACE arm), and never yields the plane bucket.
1317        let faces = sel_of(&[], &["Box_PZ"], &[]);
1318        assert_eq!(
1319            faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
1320            ["Box_PZ"]
1321        );
1322        assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
1323    }
1324
1325    #[test]
1326    fn plane_selection_offers_sketch_and_prefills_the_plane() {
1327        // A datum/plane-only selection offers Sketch (kernel predicate keys on
1328        // `probe.planes`), and the create routes the frame name into `sketchPlane`.
1329        let sel = sel_planes(&["Datum:XY"]);
1330        let offers = offers_for(&sel, "all");
1331        let sketch = offers
1332            .iter()
1333            .find(|o| o.type_code == "S")
1334            .expect("a plane-only selection offers Sketch");
1335        let writes = prefill_references(&sketch.fields, &sel);
1336        assert!(
1337            writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
1338            "sketchPlane prefilled with the datum frame: {writes:?}"
1339        );
1340        // A bare plane drives no profile/solid/edge feature.
1341        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1342        for nope in ["E", "F", "CH", "B", "XFORM"] {
1343            assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
1344        }
1345    }
1346}