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                    add_constraint_from_selection(state, type_id);
298                }
299            }
300            Some(key) if key.starts_with("feature:") => {
301                let code = &key["feature:".len()..];
302                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
303                    outcome.focus = create_feature_from_selection(state, offer, &sel);
304                }
305            }
306            _ => {}
307        }
308        outcome
309    }
310
311    /// The published widget hit-rects (egui points) for the headed verifier —
312    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
313    #[cfg(target_arch = "wasm32")]
314    pub fn hits_json(&self) -> String {
315        let map: serde_json::Map<String, Value> = self
316            .hits
317            .iter()
318            .map(|(k, r)| {
319                (
320                    k.clone(),
321                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
322                )
323            })
324            .collect();
325        Value::Object(map).to_string()
326    }
327
328    /// The bar's LOGICAL state for the verifier: whether it is shown + which
329    /// generic actions, feature type-codes, and component actions it offered
330    /// this frame (and the single component the latter target).
331    #[cfg(target_arch = "wasm32")]
332    pub fn state_json(&self) -> String {
333        serde_json::json!({
334            "shown": !self.hits.is_empty(),
335            "actions": self.shown_actions,
336            "features": self.shown_features,
337            "constraints": self.shown_constraints,
338            "componentActions": self.shown_component_actions,
339            "componentTarget": self.shown_component_target,
340        })
341        .to_string()
342    }
343}
344
345/// The COMPONENT view of the current selection: which ACOMP instances own the
346/// selected entities, and whether the selection qualifies for the component
347/// action set / the feature-offer fence.
348struct ComponentSelection {
349    /// Unique owning ACOMP ids across every selected NAMED entity, selection
350    /// order.
351    ids: Vec<String>,
352    /// Whether EVERY selected named entity is component-owned (and at least one
353    /// is selected; vertices carry no names, so any vertex disqualifies).
354    all_component: bool,
355    /// Whether the selection is member SOLIDS only (the shape a viewport
356    /// component click produces).
357    solids_only: bool,
358}
359
360impl ComponentSelection {
361    /// The feature FENCE: suppress modeling-feature creation offers when the
362    /// whole selection is component geometry.
363    fn suppress_features(&self) -> bool {
364        self.all_component && !self.ids.is_empty()
365    }
366
367    /// The single component the ACTION SET targets: exactly one owning
368    /// component, selected via its member solid(s) alone.
369    fn sole_target(&self) -> Option<&str> {
370        (self.suppress_features() && self.solids_only && self.ids.len() == 1)
371            .then(|| self.ids[0].as_str())
372    }
373}
374
375/// Resolve the selection's component ownership through the engine's namespace
376/// parse (`component_of_solid` accepts any namespaced entity name — solid,
377/// face, or edge).
378fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
379    let mut ids: Vec<String> = Vec::new();
380    let mut all = true;
381    let mut any = false;
382    for name in sel.all_names() {
383        any = true;
384        match state.component_of_solid(&name) {
385            Some(id) => {
386                if !ids.contains(&id) {
387                    ids.push(id);
388                }
389            }
390            None => all = false,
391        }
392    }
393    if sel.vertices > 0 {
394        all = false;
395    }
396    ComponentSelection {
397        ids,
398        all_component: all && any,
399        solids_only: !sel.solids.is_empty()
400            && sel.sketches.is_empty()
401            && sel.faces.is_empty()
402            && sel.edges.is_empty()
403            && sel.vertices == 0,
404    }
405}
406
407/// The current selection, resolved once per frame from `selection_json`, plus the
408/// single-selection owning feature (for **Edit owning feature**).
409struct Selection {
410    solids: Vec<String>,
411    /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
412    /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
413    /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
414    /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
415    /// `["SOLID"]` field like SM Cutout's `sheet`).
416    sketches: Vec<String>,
417    faces: Vec<String>,
418    edges: Vec<String>,
419    /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
420    /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
421    /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
422    /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
423    /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
424    /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
425    /// a planar face (the kernel resolves either).
426    planes: Vec<String>,
427    vertices: usize,
428    /// The producer feature id of a SINGLE-entity selection with a known producer.
429    owning_feature: Option<String>,
430}
431
432impl Selection {
433    fn read(state: &EngineState) -> Self {
434        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
435        let names = |key: &str| -> Vec<String> {
436            v[key]
437                .as_array()
438                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
439                .unwrap_or_default()
440        };
441        // Partition the selected `solids` into REAL solids vs committed sketches: a
442        // selected solid is a sketch iff its name is a committed sketch (the
443        // sketch's selectable name is its id; visibility is irrelevant here).
444        let sketch_ids: std::collections::HashSet<String> = state
445            .committed_sketches()
446            .into_iter()
447            .map(|(id, _visible)| id)
448            .collect();
449        let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
450            .into_iter()
451            .partition(|name| sketch_ids.contains(name));
452        let faces = names("faces");
453        let edges = names("edges");
454        // Construction planes / datum planes arrive under `datums` — a SEPARATE
455        // bucket (never merged into `faces`): the scene-solid consumers cannot
456        // resolve a datum frame name (see the `planes` field doc).
457        let planes = names("datums");
458        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
459
460        // A single selected entity → its owning feature (the old app's
461        // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
462        // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
463        // its `D`/`P` feature). Datum planes count toward the single-selection
464        // total too, else picking one shows no Edit-owning-feature button.
465        let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
466        let single = if total == 1 && vertices == 0 {
467            faces
468                .first()
469                .or_else(|| edges.first())
470                .or_else(|| solids.first())
471                .or_else(|| sketches.first())
472                .or_else(|| planes.first())
473                .cloned()
474        } else {
475            None
476        };
477        let owning_feature = single
478            .as_deref()
479            .and_then(|name| state.creating_feature(name))
480            .map(|(id, _ty)| id);
481
482        Self {
483            solids,
484            sketches,
485            faces,
486            edges,
487            planes,
488            vertices,
489            owning_feature,
490        }
491    }
492
493    /// The selectable KINDS currently present (vertices carry no names, and no
494    /// primary reference is vertex-only, so they never drive feature actions).
495    fn kinds_present(&self) -> Vec<&'static str> {
496        let mut kinds = Vec::new();
497        if !self.solids.is_empty() {
498            kinds.push("SOLID");
499        }
500        if !self.sketches.is_empty() {
501            kinds.push("SKETCH");
502        }
503        if !self.faces.is_empty() {
504            kinds.push("FACE");
505        }
506        if !self.edges.is_empty() {
507            kinds.push("EDGE");
508        }
509        // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
510        // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
511        if !self.planes.is_empty() {
512            kinds.push("PLANE");
513        }
514        kinds
515    }
516
517    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
518    /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
519    /// frames, `COMPONENT` to selected solids (the picker never yields a bare
520    /// component here).
521    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
522        let mut out: Vec<String> = Vec::new();
523        let push = |src: &[String], out: &mut Vec<String>| {
524            for name in src {
525                if !out.iter().any(|n| n == name) {
526                    out.push(name.clone());
527                }
528            }
529        };
530        for f in filter {
531            match f.as_str() {
532                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
533                "SKETCH" => push(&self.sketches, &mut out),
534                "FACE" => push(&self.faces, &mut out),
535                // A `["PLANE","FACE"]` field prefills from EITHER a selected face
536                // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
537                // is an alias for the same planes bucket.
538                "PLANE" | "DATUM" => push(&self.planes, &mut out),
539                "EDGE" => push(&self.edges, &mut out),
540                _ => {}
541            }
542        }
543        out
544    }
545
546    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
547    /// pinned Info window the Info action opens. Vertices carry no name, and datum
548    /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
549    /// (Info, Hide via `hide_selected`, `component_selection` via
550    /// `component_of_solid`), none of which can resolve a datum frame name. A datum
551    /// plane can be selected (`has_selection` now counts it, so the bar shows and
552    /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
553    /// the probe — never this list.
554    fn all_names(&self) -> Vec<String> {
555        let mut out: Vec<String> = Vec::new();
556        for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
557            for name in src {
558                if !name.is_empty() && !out.iter().any(|n| n == name) {
559                    out.push(name.clone());
560                }
561            }
562        }
563        out
564    }
565
566    fn summary(&self) -> String {
567        format!(
568            "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
569            self.solids.len(),
570            self.sketches.len(),
571            self.faces.len(),
572            self.edges.len(),
573            self.planes.len(),
574            self.vertices,
575        )
576    }
577}
578
579/// One reference field of an offered feature, in schema order — the pre-fill
580/// targets [`prefill_references`] consumes the selection into.
581struct OfferField {
582    /// The JSON path of the `References`-group field.
583    path: Vec<String>,
584    /// That field's `selectionFilter` (which selected kinds map into it).
585    filter: Vec<String>,
586    /// Whether the field takes a list (vs a single name).
587    multiple: bool,
588}
589
590/// One offered feature action.
591struct Offer {
592    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
593    type_code: String,
594    /// The button label (the feature's long name).
595    label: String,
596    /// Every `References`-group field whose filter accepts a selected kind
597    /// (schema order) — the create pre-fills them under a consumed set.
598    fields: Vec<OfferField>,
599}
600
601/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
602/// the selection's kind counts, its component view, and whether it sits entirely
603/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
604fn selection_probe(
605    sel: &Selection,
606    comp: &ComponentSelection,
607    all_sheet_metal: bool,
608) -> SelectionProbe {
609    SelectionProbe {
610        solids: sel.solids.len(),
611        sketches: sel.sketches.len(),
612        faces: sel.faces.len(),
613        edges: sel.edges.len(),
614        planes: sel.planes.len(),
615        vertices: sel.vertices,
616        components: comp.ids.len(),
617        all_component: comp.all_component,
618        all_sheet_metal,
619    }
620}
621
622/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
623/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
624/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
625/// vertex convention: a vertex carries no name to resolve, so any vertex in the
626/// selection disqualifies it.
627fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
628    let names = sel.all_names();
629    !names.is_empty()
630        && sel.vertices == 0
631        && names.iter().all(|name| state.is_sheet_metal_object(name))
632}
633
634/// The feature actions to offer: every catalogue feature whose OWN
635/// `context_applicable` predicate (kernel-defined, next to its schema —
636/// `feature_pipeline::context_offer`) accepts the current selection probe. The
637/// `workbench` argument only FURTHER RESTRICTS that set to the features the
638/// active workbench includes; like the palette filter it is a pure UI trim over
639/// CREATION and never affects the existing history / execution.
640///
641/// The pre-fill stays schema-derived: each offer carries EVERY
642/// `References`-group `reference_selection` field whose `selectionFilter`
643/// intersects a selected kind (schema order), and the create consumes the
644/// selection into them ([`prefill_references`]).
645fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
646    let kinds = sel.kinds_present();
647    if kinds.is_empty() {
648        return Vec::new();
649    }
650    let catalogue = features::feature_catalogue();
651    let mut out = Vec::new();
652    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
653        for feature in list {
654            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
655                continue;
656            };
657            if ty.is_empty() {
658                continue;
659            }
660            // Workbench UI filter: skip features this workbench does not include
661            // (classified off the type code).
662            if !crate::workbench::includes_feature(workbench, ty) {
663                continue;
664            }
665            // The feature's own answer to "does this selection make me
666            // meaningful?" — nuance (Revolve wants profile AND axis) lives in
667            // the kernel predicate, not here.
668            if !brep_kernel::feature_context_applicable(ty, probe) {
669                continue;
670            }
671            // The pre-fill targets: every `References`-group reference field
672            // accepting a selected kind. Primitives only carry the boolean-op
673            // `targets` Reference (group `Boolean`), so they never collect any
674            // (their predicates return false anyway).
675            let fields: Vec<OfferField> = features::feature_form_fields(ty)
676                .iter()
677                .filter(|field| field.group == "References")
678                .filter_map(|field| {
679                    let FieldKind::Reference { filter, multiple } = &field.kind else {
680                        return None;
681                    };
682                    filter
683                        .iter()
684                        .any(|f| kinds.iter().any(|k| *k == f.as_str()))
685                        .then(|| OfferField {
686                            path: field.path.clone(),
687                            filter: filter.clone(),
688                            multiple: *multiple,
689                        })
690                })
691                .collect();
692            out.push(Offer {
693                type_code: ty.to_string(),
694                label: features::feature_long_name(ty),
695                fields,
696            });
697        }
698    }
699    out
700}
701
702/// The single component the context bar's COMPONENT action set targets, or
703/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
704/// sole_target`]) OR the active workbench hides the assembly structure panel
705/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
706/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
707/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
708/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
709/// kernel rejects component references in every workbench.
710fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
711    // The BOM is the assembly workbench's component list (it absorbed the
712    // Structure panel): component actions target a selection only where that
713    // list is on screen.
714    let list_shown = crate::workbench::panel_visible(
715        workbench,
716        crate::workbench::assembly::BOM_PANEL_ID,
717    );
718    list_shown.then(|| comp.sole_target()).flatten()
719}
720
721/// The constraint actions to offer: every constraint type whose `applicable`
722/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
723/// accepts the probe — gated on the Assembly Constraints panel being available
724/// in the active workbench (claim-based visibility: Assembly + All).
725fn constraint_offers(
726    probe: &SelectionProbe,
727    workbench: &str,
728) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
729    if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
730    {
731        return Vec::new();
732    }
733    brep_kernel::CONSTRAINT_TYPES
734        .iter()
735        .filter(|def| (def.applicable)(probe))
736        .collect()
737}
738
739/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
740/// through the constraints panel's seeding helper (filtered + capped by the
741/// type's own schema), then the new row opened so the panel shows its dialog.
742/// The engine's mutation path handles auto-solve exactly like a panel add.
743fn add_constraint_from_selection(state: &mut EngineState, type_id: &str) {
744    let catalogue = brep_kernel::constraint_schema_catalogue();
745    let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
746    let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
747    if let Ok(id) = state.assembly_add_constraint(type_id, &seed.to_string()) {
748        let _ = state.assembly_set_constraint_open(&id, true);
749    }
750}
751
752/// Consume the selection into an offer's reference fields, schema order: each
753/// field takes the selected names its filter accepts that NO EARLIER field
754/// consumed (first name for a single field, all remaining for a multiple) — so
755/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
756/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
757/// Returns `(path, value)` writes for [`form::set_at`].
758fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
759    let mut consumed: HashSet<String> = HashSet::new();
760    let mut writes = Vec::new();
761    for field in fields {
762        let names: Vec<String> = sel
763            .names_for_filter(&field.filter)
764            .into_iter()
765            .filter(|name| !consumed.contains(name))
766            .collect();
767        if names.is_empty() {
768            continue;
769        }
770        let value = if field.multiple {
771            consumed.extend(names.iter().cloned());
772            Value::Array(names.into_iter().map(Value::String).collect())
773        } else {
774            let name = names.into_iter().next().unwrap_or_default();
775            consumed.insert(name.clone());
776            Value::String(name)
777        };
778        writes.push((field.path.clone(), value));
779    }
780    writes
781}
782
783/// Create a feature of `offer.type_code` referencing the selection: build a
784/// fresh descriptor whose `inputParams` are the schema defaults with an
785/// engine-unique `id` and the matched reference fields pre-filled
786/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
787/// Returns the new feature id (for the shell to expand its node).
788fn create_feature_from_selection(
789    state: &mut EngineState,
790    offer: &Offer,
791    sel: &Selection,
792) -> Option<String> {
793    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
794    let mut params = features::feature_default_params(&offer.type_code);
795    if let Value::Object(map) = &mut params {
796        map.insert("id".into(), Value::String(id.clone()));
797    }
798
799    for (path, value) in prefill_references(&offer.fields, sel) {
800        form::set_at(&mut params, &path, value);
801    }
802
803    let feature = serde_json::json!({
804        "type": offer.type_code,
805        "inputParams": params,
806        "persistentData": {},
807    });
808    if state.add_feature(&feature.to_string()).is_ok() {
809        Some(id)
810    } else {
811        None
812    }
813}
814
815/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
816fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
817    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    fn sel_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
825        Selection {
826            solids: solids.iter().map(|s| s.to_string()).collect(),
827            sketches: sketches.iter().map(|s| s.to_string()).collect(),
828            faces: faces.iter().map(|s| s.to_string()).collect(),
829            edges: edges.iter().map(|s| s.to_string()).collect(),
830            planes: Vec::new(),
831            vertices: 0,
832            owning_feature: None,
833        }
834    }
835
836    /// A selection of construction PLANES / DATUM planes only (their frame names).
837    fn sel_planes(planes: &[&str]) -> Selection {
838        Selection {
839            solids: Vec::new(),
840            sketches: Vec::new(),
841            faces: Vec::new(),
842            edges: Vec::new(),
843            planes: planes.iter().map(|s| s.to_string()).collect(),
844            vertices: 0,
845            owning_feature: None,
846        }
847    }
848
849    fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
850        sel_full(solids, &[], faces, edges)
851    }
852
853    /// Offers for a NON-COMPONENT selection (the plain modeling shape).
854    fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
855        let comp = ComponentSelection {
856            ids: Vec::new(),
857            all_component: false,
858            solids_only: false,
859        };
860        // Plain-geometry test selections carry no scene, so they are never on
861        // sheet metal (the SM edit features are covered separately).
862        feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
863    }
864
865    #[test]
866    fn face_selection_offers_face_features_not_solid_ones() {
867        // "all" workbench so the expected sets below are unfiltered.
868        let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
869        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
870        // Face-primary features are offered…
871        for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
872            assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
873        }
874        // …features whose ONLY reference kind is SOLID (or SKETCH/EDGE) are NOT.
875        for nope in ["B", "XFORM", "RIB"] {
876            assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
877        }
878        // …but a feature that takes a face/plane as a SECONDARY reference IS now
879        // offered, keyed on that field (any-field matching — the same rule that
880        // lets a sketch drive a cutout): Mirror about a face, Split by it, Pattern
881        // along its normal.
882        for want in ["M", "PATTERN", "SPL"] {
883            assert!(
884                codes.contains(&want),
885                "FACE should offer {want} via its plane/face field: {codes:?}"
886            );
887        }
888        // Primitives (only a boolean `targets` Reference) never appear.
889        assert!(!codes.contains(&"P.CU"));
890        // Revolve's kernel predicate wants a profile AND an axis edge — a lone
891        // face no longer offers it.
892        assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
893    }
894
895    #[test]
896    fn edge_selection_offers_fillet_chamfer_tube() {
897        let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
898        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
899        for want in ["F", "CH", "TU"] {
900            assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
901        }
902        assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
903        assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
904    }
905
906    /// The sheet-metal EDIT features gate on `all_sheet_metal` end to end: an edge
907    /// that sits on a sheet-metal body offers SM Flange / Fillet / Chamfer; the
908    /// same edge on plain geometry offers none of them (the flag plumbs through
909    /// `selection_probe` → `feature_offers`).
910    #[test]
911    fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
912        let sel = sel_of(&[], &[], &["Wall_E0"]);
913        let comp = ComponentSelection {
914            ids: Vec::new(),
915            all_component: false,
916            solids_only: false,
917        };
918        let codes = |all_sheet_metal: bool| -> Vec<String> {
919            feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
920                .iter()
921                .map(|o| o.type_code.clone())
922                .collect()
923        };
924        let on_sm = codes(true);
925        for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
926            assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
927        }
928        let plain = codes(false);
929        for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
930            assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
931        }
932    }
933
934    #[test]
935    fn solid_selection_offers_solid_features() {
936        let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
937        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
938        for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
939            assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
940        }
941        assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
942    }
943
944    #[test]
945    fn empty_selection_offers_nothing() {
946        assert!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
947    }
948
949    /// The user-specified nuance end to end: profile + axis edge offers Revolve,
950    /// and the consumed pre-fill routes the face into `profile` and the edge
951    /// into `axis` (one field each, nothing echoed).
952    #[test]
953    fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
954        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
955        let offers = offers_for(&sel, "all");
956        let revolve = offers
957            .iter()
958            .find(|o| o.type_code == "R")
959            .expect("face+edge offers Revolve");
960        let writes = prefill_references(&revolve.fields, &sel);
961        assert_eq!(
962            writes,
963            vec![
964                (vec!["profile".to_string()], Value::String("Box_PZ".into())),
965                (vec!["axis".to_string()], Value::String("Box_E0".into())),
966            ]
967        );
968        // A committed sketch as the profile works the same way.
969        let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
970        let offers = offers_for(&sel, "all");
971        assert!(
972            offers.iter().any(|o| o.type_code == "R"),
973            "sketch+edge offers Revolve"
974        );
975    }
976
977    /// The consumed set: a name lands in at most ONE field, schema order —
978    /// Pattern's edge fills `directionRef` and does NOT echo into `axisRef`;
979    /// Fillet's multiple `edges` field takes faces and edges together.
980    #[test]
981    fn prefill_consumes_each_name_once() {
982        let sel = sel_of(&["Box"], &[], &["Box_E0"]);
983        let offers = offers_for(&sel, "all");
984        let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
985        let writes = prefill_references(&pattern.fields, &sel);
986        assert_eq!(
987            writes,
988            vec![
989                (vec!["solids".to_string()], serde_json::json!(["Box"])),
990                (vec!["directionRef".to_string()], Value::String("Box_E0".into())),
991            ]
992        );
993
994        let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
995        let offers = offers_for(&sel, "all");
996        let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
997        let writes = prefill_references(&fillet.fields, &sel);
998        assert_eq!(
999            writes,
1000            vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
1001        );
1002    }
1003
1004    #[test]
1005    fn workbench_filters_the_context_offers() {
1006        // A face selection under different workbenches: the workbench only FURTHER
1007        // restricts the schema-declared offers (it adds no new trigger channel).
1008        let sel = sel_of(&[], &["Box_PZ"], &[]);
1009        let codes = |wb: &str| -> Vec<String> {
1010            offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
1011        };
1012        let all = codes("all");
1013        let modeling = codes("modeling");
1014        let sheet = codes("sheetMetal");
1015        // Modeling keeps the modeling face-feature Extrude, and drops every
1016        // sheet-metal (`SM.*`) offer.
1017        assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
1018        assert!(
1019            !modeling.iter().any(|c| c.starts_with("SM.")),
1020            "modeling must not offer any SM.* feature: {modeling:?}"
1021        );
1022        // Sheet Metal drops the pure-modeling Extrude.
1023        assert!(
1024            !sheet.iter().any(|c| c == "E"),
1025            "sheet metal must not offer Extrude: {sheet:?}"
1026        );
1027        // All is the superset: every modeling offer is present in All.
1028        for c in &modeling {
1029            assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
1030        }
1031    }
1032
1033    #[test]
1034    fn extrude_primary_reference_is_single_profile() {
1035        let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
1036        let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
1037        assert_eq!(extrude.fields.len(), 1, "one matched reference field");
1038        assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
1039        assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
1040        assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
1041    }
1042
1043    #[test]
1044    fn sketch_kind_is_distinct_from_solid() {
1045        // A committed sketch (partitioned out of the solids bucket) presents as
1046        // SKETCH — NOT SOLID — so it never satisfies a SOLID-only field…
1047        assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
1048        // …a real solid presents as SOLID…
1049        assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
1050        // …and a mixed selection carries both.
1051        let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
1052        assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
1053    }
1054
1055    #[test]
1056    fn sketch_selection_offers_cutout_and_profile_features() {
1057        // A committed-sketch selection offers the profile-driven features and, in
1058        // particular, SM Cutout. (Revolve now also wants an axis edge, so it is
1059        // deliberately absent here.)
1060        let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
1061        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1062        for want in ["E", "SM.CUTOUT"] {
1063            assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
1064        }
1065        assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
1066        // SM Cutout matches on `profile` only (its `["SOLID"]` `sheet` field does
1067        // not match a sketch), so the create pre-fills the profile field.
1068        let cutout = offers
1069            .iter()
1070            .find(|o| o.type_code == "SM.CUTOUT")
1071            .expect("cutout offered for a sketch");
1072        assert_eq!(cutout.fields.len(), 1);
1073        assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
1074        assert!(
1075            cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
1076            "profile filter: {:?}",
1077            cutout.fields[0].filter
1078        );
1079        assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
1080    }
1081
1082    #[test]
1083    fn solid_selection_offers_cutout_via_sheet() {
1084        // A real-solid selection still offers SM Cutout, matched on `sheet`.
1085        let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
1086        let cutout = offers
1087            .iter()
1088            .find(|o| o.type_code == "SM.CUTOUT")
1089            .expect("cutout offered for a solid");
1090        assert_eq!(cutout.fields.len(), 1);
1091        assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
1092        assert!(
1093            cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
1094            "sheet filter: {:?}",
1095            cutout.fields[0].filter
1096        );
1097        // Solid + sketch matches BOTH fields — the create fills sheet AND profile.
1098        let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
1099        let offers = offers_for(&sel, "all");
1100        let cutout = offers
1101            .iter()
1102            .find(|o| o.type_code == "SM.CUTOUT")
1103            .expect("cutout offered for solid+sketch");
1104        let writes = prefill_references(&cutout.fields, &sel);
1105        assert_eq!(
1106            writes,
1107            vec![
1108                (vec!["sheet".to_string()], Value::String("Plate".into())),
1109                (vec!["profile".to_string()], Value::String("Sk".into())),
1110            ]
1111        );
1112    }
1113
1114    /// The constraint-offer CLICK path end to end: seed `elements` from the
1115    /// selection, add through the engine (the auto-solve mutation lane), and
1116    /// open the new row so the panel shows its dialog.
1117    #[test]
1118    fn add_constraint_from_selection_seeds_adds_and_opens() {
1119        use crate::panels::component_actions::tests::assembly_engine;
1120        let mut state = assembly_engine();
1121        state.select_component("ACOMP2");
1122        add_constraint_from_selection(&mut state, "fixed");
1123        let constraints = state.assembly_state_value();
1124        let entry = constraints["constraints"]
1125            .as_array()
1126            .and_then(|list| list.last())
1127            .cloned()
1128            .expect("constraint added");
1129        assert_eq!(entry["type"], "fixed");
1130        assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
1131        assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
1132    }
1133
1134    /// Constraint offers: the per-type `applicable` predicates against the
1135    /// probe, gated on the constraints panel's workbench visibility.
1136    #[test]
1137    fn constraint_offers_follow_predicates_and_workbench() {
1138        let one_component = SelectionProbe {
1139            solids: 1,
1140            components: 1,
1141            all_component: true,
1142            ..Default::default()
1143        };
1144        let pair = SelectionProbe {
1145            faces: 2,
1146            components: 2,
1147            all_component: true,
1148            ..Default::default()
1149        };
1150        let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
1151            constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
1152        };
1153
1154        // ONE component's solid → Fixed only.
1155        assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
1156        // Two faces across two components → every face-pair type, no Fixed.
1157        let pair_ids = ids(&pair, "assembly");
1158        for want in [
1159            "coincident",
1160            "touch_align",
1161            "parallel",
1162            "distance",
1163            "angle",
1164            "concentric",
1165            "perpendicular",
1166            "tangent",
1167        ] {
1168            assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
1169        }
1170        assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
1171        // "All" sees the claimed constraints panel too; Modeling does not.
1172        assert!(!ids(&pair, "all").is_empty());
1173        assert!(ids(&pair, "modeling").is_empty());
1174        // A non-component selection never offers constraints.
1175        let plain = SelectionProbe { faces: 2, ..Default::default() };
1176        assert!(ids(&plain, "assembly").is_empty());
1177    }
1178
1179    #[test]
1180    fn names_for_filter_maps_sketch_kind() {
1181        // A `["FACE","SKETCH"]` profile field pre-fills from the selected sketches.
1182        let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
1183        assert_eq!(
1184            sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
1185            ["Box_PZ", "Sk1", "Sk2"]
1186        );
1187        assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
1188        // A SOLID-only field never picks up a sketch.
1189        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1190    }
1191
1192    #[test]
1193    fn all_names_gathers_every_named_entity_for_info_windows() {
1194        // A multi-select of a solid + two faces + an edge → four Info-window targets
1195        // (solids → faces → edges order, de-duplicated).
1196        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1197        assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
1198        // Nothing selected → no windows.
1199        assert!(sel_of(&[], &[], &[]).all_names().is_empty());
1200    }
1201
1202    #[test]
1203    fn component_selection_detects_single_component_and_fences_features() {
1204        use crate::panels::component_actions::tests::assembly_engine;
1205        let engine = assembly_engine();
1206
1207        // ONE member solid selected → the sole action target, features fenced.
1208        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1209        let comp = component_selection(&sel, &engine);
1210        assert!(comp.suppress_features());
1211        assert_eq!(comp.sole_target(), Some("ACOMP2"));
1212
1213        // TWO components selected → fence holds, but no single action target.
1214        let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
1215        let comp = component_selection(&sel, &engine);
1216        assert!(comp.suppress_features());
1217        assert_eq!(comp.sole_target(), None);
1218
1219        // A component FACE selection fences features (the kernel would reject
1220        // the reference anyway) but is not the solid-click action shape.
1221        let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
1222        let comp = component_selection(&sel, &engine);
1223        assert!(comp.suppress_features());
1224        assert_eq!(comp.sole_target(), None);
1225
1226        // A non-component solid (no ACOMP prefix) keeps the feature offers.
1227        let sel = sel_of(&["Box"], &[], &[]);
1228        let comp = component_selection(&sel, &engine);
1229        assert!(!comp.suppress_features());
1230        assert_eq!(comp.sole_target(), None);
1231
1232        // MIXED component + ordinary solid: not all-component → no fence, no
1233        // action target (the kernel enforces the reference fence at execution).
1234        let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
1235        let comp = component_selection(&sel, &engine);
1236        assert!(!comp.suppress_features());
1237        assert_eq!(comp.sole_target(), None);
1238
1239        // An ACOMP-shaped prefix with no matching feature is NOT a component.
1240        let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
1241        assert!(!component_selection(&sel, &engine).suppress_features());
1242    }
1243
1244    /// The workbench fence on the COMPONENT action set: a qualifying selection
1245    /// (one component's member solid) only yields an action target in a
1246    /// workbench that shows the assembly structure panel — Assembly + All —
1247    /// so Move / Edit-in-place / Open-Part / Fix / Delete never bleed into the
1248    /// Modeling (or Sheet Metal) context bar. The feature FENCE is workbench-
1249    /// independent: component geometry suppresses feature offers everywhere.
1250    #[test]
1251    fn component_actions_are_workbench_gated() {
1252        use crate::panels::component_actions::tests::assembly_engine;
1253        let engine = assembly_engine();
1254        let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1255        let comp = component_selection(&sel, &engine);
1256        assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");
1257
1258        for wb in ["assembly", "all"] {
1259            assert_eq!(
1260                component_action_target(&comp, wb),
1261                Some("ACOMP2"),
1262                "component actions offered under `{wb}`"
1263            );
1264        }
1265        for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
1266            assert_eq!(
1267                component_action_target(&comp, wb),
1268                None,
1269                "component actions must not bleed into `{wb}`"
1270            );
1271            // The kernel-enforced fence still suppresses feature offers there.
1272            assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
1273        }
1274    }
1275
1276    #[test]
1277    fn names_for_filter_maps_kinds() {
1278        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1279        assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
1280        assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
1281        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1282        // A multi-kind filter (fillet's FACE+EDGE) gathers both.
1283        assert_eq!(
1284            sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
1285            ["Box_PZ", "Box_NZ", "Box_E0"]
1286        );
1287    }
1288
1289    #[test]
1290    fn plane_selection_present_kind_is_plane() {
1291        // A datum/plane-only selection presents the ONE kind `PLANE` (never DATUM).
1292        assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
1293        // A face-only selection is unchanged (no PLANE leaks in).
1294        assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
1295    }
1296
1297    #[test]
1298    fn names_for_filter_maps_planes_separately_from_faces() {
1299        // A `["PLANE","FACE"]` field (the sketchPlane filter) prefills from the
1300        // selected DATUM frame when only a plane is selected…
1301        let planes = sel_planes(&["Datum:XY"]);
1302        assert_eq!(
1303            planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
1304            ["Datum:XY"]
1305        );
1306        // …`DATUM` is an alias for the same planes bucket…
1307        assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
1308        // …and a datum plane never lands in a FACE-only field (buckets are split).
1309        assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
1310        // A FACE-only selection still fills a `["PLANE","FACE"]` field with the
1311        // face (the FACE arm), and never yields the plane bucket.
1312        let faces = sel_of(&[], &["Box_PZ"], &[]);
1313        assert_eq!(
1314            faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
1315            ["Box_PZ"]
1316        );
1317        assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
1318    }
1319
1320    #[test]
1321    fn plane_selection_offers_sketch_and_prefills_the_plane() {
1322        // A datum/plane-only selection offers Sketch (kernel predicate keys on
1323        // `probe.planes`), and the create routes the frame name into `sketchPlane`.
1324        let sel = sel_planes(&["Datum:XY"]);
1325        let offers = offers_for(&sel, "all");
1326        let sketch = offers
1327            .iter()
1328            .find(|o| o.type_code == "S")
1329            .expect("a plane-only selection offers Sketch");
1330        let writes = prefill_references(&sketch.fields, &sel);
1331        assert!(
1332            writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
1333            "sketchPlane prefilled with the datum frame: {writes:?}"
1334        );
1335        // A bare plane drives no profile/solid/edge feature.
1336        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1337        for nope in ["E", "F", "CH", "B", "XFORM"] {
1338            assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
1339        }
1340    }
1341}