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 crate::automation::hit_keys::HitKeyDoc;
44use super::action_rail::{action_rail, ActionItem};
45use super::component_actions::{run_component_action, ComponentAction, ComponentActionRequest};
46use crate::form;
47use brep_render::brep_kernel::{self, SelectionProbe};
48use brep_render::engine_state::EngineState;
49use brep_render::features;
50use brep_render::style::FieldKind;
51use eframe::egui;
52use serde_json::Value;
53use std::collections::{HashMap, HashSet};
54
55/// A request bubbled back to the shell after a context action ran: EXPAND (open
56/// the inline dialog of) the feature with this id in the history tree. The
57/// context bar mutates the engine directly but cannot reach the history panel's
58/// private "expanded" state, so it returns the id for the shell to focus.
59pub type FocusRequest = Option<String>;
60
61/// What a context-bar frame hands back to the shell. The bar mutates the engine
62/// directly, but two effects it cannot reach itself:
63/// * `focus` — the history feature to EXPAND after a create / edit-owning action
64///   (the history panel's expand state is private to it); and
65/// * `info_targets` — the entity names to open PINNED Info windows for after the
66///   Info action (the Info-window manager is shell-owned). One name per selected
67///   entity, so a multi-select opens one window each.
68#[derive(Default)]
69pub struct ContextOutcome {
70    pub focus: FocusRequest,
71    pub info_targets: Vec<String>,
72    /// A COMPONENT document-level flow the shell must run (Edit in place /
73    /// Open Part) — set when the matching component action was clicked; the
74    /// engine-mutating component actions (Move / Fix-Unfix / Delete) already
75    /// applied inside the bar.
76    pub component: Option<ComponentActionRequest>,
77    /// A PMI annotation was added from the selection (the shell surfaces the
78    /// PMI pane so its open form is seen).
79    pub pmi_added: bool,
80}
81
82/// The context bar's transient UI state (the model lives in the engine).
83#[derive(Default)]
84pub struct ContextBarPanel {
85    /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
86    /// each frame (there is no DOM — egui draws on the canvas).
87    hits: HashMap<String, egui::Rect>,
88    /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
89    /// — published so the verifier can assert WHICH actions the selection offered.
90    shown_actions: Vec<String>,
91    /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
92    shown_features: Vec<String>,
93    /// The constraint TYPE ids offered THIS frame (`fixed`, `distance`, …).
94    shown_constraints: Vec<String>,
95    /// The PMI annotation type ids offered THIS frame (`linear`, `datum`, …).
96    shown_pmi: Vec<String>,
97    /// The COMPONENT action ids offered THIS frame (`move`, `open-part`, …)
98    /// — non-empty exactly when the selection is a single component's members.
99    shown_component_actions: Vec<String>,
100    /// The single component the actions target this frame (its ACOMP id).
101    shown_component_target: Option<String>,
102}
103
104impl ContextBarPanel {
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Draw the context bar as a FLOATING panel over the viewport (nothing when
110    /// nothing is selected — like the old app's floating selection action bar).
111    /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
112    /// are always reachable regardless of side-panel scroll. Returns a
113    /// [`ContextOutcome`] — the feature id the shell should expand in the history
114    /// tree (after a create-from-selection or edit-owning action) plus any entity
115    /// names the shell should open pinned Info windows for (after the Info action).
116    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
117        self.hits.clear();
118        self.shown_actions.clear();
119        self.shown_features.clear();
120        self.shown_constraints.clear();
121        self.shown_pmi.clear();
122        self.shown_component_actions.clear();
123        self.shown_component_target = None;
124
125        // Modeling context actions ONLY. Hidden with no selection (geometry OR a
126        // label-selected constraint), and never during reference-selection (the
127        // picker owns the selection) or in sketch mode (the sketch context rail
128        // replaces this one). Rendered through the SHARED single-column rail —
129        // see [`super::action_rail`] — so it and the sketch context bar stay
130        // identical. The constraint selection only counts (and only offers its
131        // Delete action) in a workbench that shows the constraints panel — the
132        // same claim gate as the constraint offers.
133        let has_geometry = state.has_selection();
134        let constraint_target = state.selected_constraint().filter(|_| {
135            crate::workbench::panel_visible(
136                &state.settings.workbench,
137                crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
138            )
139        });
140        if (!has_geometry && constraint_target.is_none())
141            || state.ref_select_active()
142            || state.sketch_mode()
143        {
144            return ContextOutcome::default();
145        }
146
147        let sel = Selection::read(state);
148        let comp = component_selection(&sel, state);
149        let probe = selection_probe(&sel, &comp, all_on_sheet_metal(&sel, state));
150        // The feature FENCE (build-spec §3): a selection made ENTIRELY of
151        // component geometry offers NO modeling-feature creation (the kernel
152        // rejects component references anyway — don't offer dead ends). The
153        // constraint offers are the complement: their predicates REQUIRE an
154        // all-component selection, so the two sets never coexist.
155        let offers = if comp.suppress_features() {
156            Vec::new()
157        } else {
158            feature_offers(&probe, &sel, &state.settings.workbench)
159        };
160        let constraint_types = constraint_offers(&probe, &state.settings.workbench);
161        // PMI annotation offers: the third applicability family — each type's
162        // own predicate on the probe, plain and component geometry alike —
163        // gated on the PMI panel's workbench visibility AND an active view
164        // (creation is gated on a view to annotate).
165        let pmi_types = pmi_offers(&probe, &state.settings.workbench, state.pmi_active_view().is_some());
166        // A single component's member solid(s) selected → the COMPONENT action
167        // set replaces the feature-creation offers (spec §8.5 / §8.1) — but ONLY
168        // in a workbench that shows the assembly structure panel (claim-based:
169        // Assembly + All). The component actions are that panel's row actions,
170        // so they follow its visibility and never bleed into Modeling / Sheet
171        // Metal; the STANDARD actions (Clear / Hide / Info / Edit owning) still
172        // apply to a component selection in every workbench.
173        let component_target = component_action_target(&comp, &state.settings.workbench).map(|id| {
174            let fixed = state.component_info(id).map(|info| info.fixed).unwrap_or(false);
175            (id.to_string(), fixed)
176        });
177
178        // Build the action items: the generic actions, then feature-from-selection.
179        // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
180        // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
181        // window per selected entity — unlike the other actions it drives no engine
182        // mutation; the shell opens the windows from the returned targets.
183        let mut items = vec![ActionItem::new(
184            "action:clear",
185            "\u{2716} Clear",
186            "Clear the selection",
187        )];
188        self.shown_actions.push("clear".into());
189        // Hide + Info act on selected GEOMETRY — with only a constraint
190        // selected they would be no-ops, so they are not offered.
191        if has_geometry {
192            items.push(ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"));
193            items.push(ActionItem::new(
194                "action:info",
195                "\u{1f575} Info",
196                "Open a pinned Info window per selected entity",
197            ));
198            self.shown_actions.push("hide".into());
199            self.shown_actions.push("info".into());
200        }
201        // The label-selected CONSTRAINT's action: delete it (the panel's row ✕,
202        // reachable from the viewport).
203        if let Some(cid) = &constraint_target {
204            items.push(ActionItem::new(
205                "action:delete-constraint",
206                "\u{2715} Delete constraint",
207                format!("Delete constraint {cid}"),
208            ));
209            self.shown_actions.push("delete-constraint".into());
210        }
211        if sel.owning_feature.is_some() {
212            items.push(ActionItem::new(
213                "action:edit-owning",
214                "Edit owning feature",
215                "Roll to and edit the feature that created this",
216            ));
217            self.shown_actions.push("edit-owning".into());
218        }
219        // Component actions (spec §8.5): shown INSTEAD of the feature offers
220        // when the selection is exactly one component's member solid(s).
221        if let Some((target, fixed)) = &component_target {
222            for action in ComponentAction::ALL {
223                items.push(ActionItem::new(
224                    format!("component:{}", action.id()),
225                    action.label(*fixed),
226                    action.tooltip(),
227                ));
228                self.shown_component_actions.push(action.id().to_string());
229            }
230            self.shown_component_target = Some(target.clone());
231        }
232        // Constraint offers (all-component selections in a workbench that shows
233        // the constraints panel): one button per applicable constraint type.
234        for def in &constraint_types {
235            items.push(ActionItem::new(
236                format!("constraint:{}", def.type_id),
237                def.long_name,
238                format!("Add a {} constraint from the selection", def.label),
239            ));
240            self.shown_constraints.push(def.type_id.to_string());
241        }
242        for offer in &offers {
243            items.push(ActionItem::new(
244                format!("feature:{}", offer.type_code),
245                offer.label.clone(),
246                format!("Create {} from the selection", offer.label),
247            ));
248            self.shown_features.push(offer.type_code.clone());
249        }
250        for def in &pmi_types {
251            items.push(ActionItem::new(
252                format!("pmi:{}", def.type_id),
253                def.long_name,
254                format!("Add a {} to the active PMI view from the selection", def.label),
255            ));
256            self.shown_pmi.push(def.type_id.to_string());
257        }
258
259        // With only a constraint selected the geometry summary would read all
260        // zeros — name the constraint instead.
261        let summary = match (&constraint_target, has_geometry) {
262            (Some(cid), false) => format!("Selected: constraint {cid}"),
263            _ => sel.summary(),
264        };
265        let clicked = egui::Frame::popup(ui.style())
266            .show(ui, |ui| {
267                action_rail(
268                    ui,
269                    Some("Selection actions"),
270                    Some(&summary),
271                    &items,
272                    &mut self.hits,
273                )
274            })
275            .inner;
276
277        // --- apply the intent (one engine mutation per frame) -----------------
278        let mut outcome = ContextOutcome::default();
279        match clicked.as_deref() {
280            Some("action:clear") => {
281                // Also drops a label-selected constraint (clear_selection folds
282                // the constraint selection in).
283                state.clear_selection();
284            }
285            Some("action:delete-constraint") => {
286                if let Some(cid) = &constraint_target {
287                    let _ = state.assembly_remove_constraint(cid);
288                    state.constraint_deselect();
289                }
290            }
291            Some("action:hide") => {
292                state.hide_selected();
293            }
294            Some("action:info") => {
295                // No engine mutation — hand the shell one target per selected entity
296                // so it opens (or, on dedup, keeps) a pinned Info window for each.
297                outcome.info_targets = sel.all_names();
298            }
299            Some("action:edit-owning") => {
300                if let Some(fid) = sel.owning_feature.clone() {
301                    if let Some(index) = feature_index(state, &fid) {
302                        state.roll_to(index);
303                    }
304                    outcome.focus = Some(fid);
305                }
306            }
307            Some(key) if key.starts_with("component:") => {
308                if let Some((target, _)) = &component_target {
309                    if let Some(action) = ComponentAction::from_id(&key["component:".len()..]) {
310                        outcome.component = run_component_action(state, action, target);
311                    }
312                }
313            }
314            Some(key) if key.starts_with("constraint:") => {
315                let type_id = &key["constraint:".len()..];
316                if constraint_types.iter().any(|def| def.type_id == type_id) {
317                    if let Err(error) = add_constraint_from_selection(state, type_id) {
318                        state.push_notice(format!("Add constraint: {error}"));
319                    }
320                }
321            }
322            Some(key) if key.starts_with("feature:") => {
323                let code = &key["feature:".len()..];
324                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
325                    outcome.focus = create_feature_from_selection(state, offer, &sel);
326                }
327            }
328            Some(key) if key.starts_with("pmi:") => {
329                let type_id = &key["pmi:".len()..];
330                if pmi_types.iter().any(|def| def.type_id == type_id) {
331                    match add_pmi_from_selection(state, type_id) {
332                        Ok(_) => outcome.pmi_added = true,
333                        Err(error) => state.push_notice(format!("Add PMI: {error}")),
334                    }
335                }
336            }
337            _ => {}
338        }
339        outcome
340    }
341
342    /// The published widget hit-rects (egui points) for the headed verifier —
343    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
344    pub fn hits_json(&self) -> String {
345        crate::automation::hit_rects::hits_json(&self.hits)
346    }
347
348    /// The bar's LOGICAL state for the verifier: whether it is shown + which
349    /// generic actions, feature type-codes, and component actions it offered
350    /// this frame (and the single component the latter target).
351    pub fn state_json(&self) -> String {
352        serde_json::json!({
353            "shown": !self.hits.is_empty(),
354            "actions": self.shown_actions,
355            "features": self.shown_features,
356            "constraints": self.shown_constraints,
357            "pmi": self.shown_pmi,
358            "componentActions": self.shown_component_actions,
359            "componentTarget": self.shown_component_target,
360        })
361        .to_string()
362    }
363}
364
365/// The COMPONENT view of the current selection: which ACOMP instances own the
366/// selected entities, and whether the selection qualifies for the component
367/// action set / the feature-offer fence.
368struct ComponentSelection {
369    /// Unique owning ACOMP ids across every selected NAMED entity, selection
370    /// order.
371    ids: Vec<String>,
372    /// Whether EVERY selected named entity is component-owned (and at least one
373    /// is selected; vertices carry no names, so any vertex disqualifies).
374    all_component: bool,
375    /// Whether the selection is member SOLIDS only (the shape a viewport
376    /// component click produces).
377    solids_only: bool,
378}
379
380impl ComponentSelection {
381    /// The feature FENCE: suppress modeling-feature creation offers when the
382    /// whole selection is component geometry.
383    fn suppress_features(&self) -> bool {
384        self.all_component && !self.ids.is_empty()
385    }
386
387    /// The single component the ACTION SET targets: exactly one owning
388    /// component, selected via its member solid(s) alone.
389    fn sole_target(&self) -> Option<&str> {
390        (self.suppress_features() && self.solids_only && self.ids.len() == 1)
391            .then(|| self.ids[0].as_str())
392    }
393}
394
395/// Resolve the selection's component ownership through the engine's namespace
396/// parse (`component_of_solid` accepts any namespaced entity name — solid,
397/// face, or edge).
398fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
399    let mut ids: Vec<String> = Vec::new();
400    let mut all = true;
401    let mut any = false;
402    for name in sel.all_names() {
403        any = true;
404        match state.component_of_solid(&name) {
405            Some(id) => {
406                if !ids.contains(&id) {
407                    ids.push(id);
408                }
409            }
410            None => all = false,
411        }
412    }
413    if sel.vertices > 0 {
414        all = false;
415    }
416    ComponentSelection {
417        ids,
418        all_component: all && any,
419        solids_only: !sel.solids.is_empty()
420            && sel.sketches.is_empty()
421            && sel.faces.is_empty()
422            && sel.edges.is_empty()
423            && sel.vertices == 0,
424    }
425}
426
427/// The current selection, resolved once per frame from `selection_json`, plus the
428/// single-selection owning feature (for **Edit owning feature**).
429struct Selection {
430    solids: Vec<String>,
431    /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
432    /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
433    /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
434    /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
435    /// `["SOLID"]` field like SM Cutout's `sheet`).
436    sketches: Vec<String>,
437    faces: Vec<String>,
438    edges: Vec<String>,
439    /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
440    /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
441    /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
442    /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
443    /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
444    /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
445    /// a planar face (the kernel resolves either).
446    planes: Vec<String>,
447    vertices: usize,
448    /// The producer feature id of a SINGLE-entity selection with a known producer.
449    owning_feature: Option<String>,
450}
451
452impl Selection {
453    fn read(state: &EngineState) -> Self {
454        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
455        let names = |key: &str| -> Vec<String> {
456            v[key]
457                .as_array()
458                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
459                .unwrap_or_default()
460        };
461        // Partition the selected `solids` into REAL solids vs committed sketches: a
462        // selected solid is a sketch iff its name is a committed sketch (the
463        // sketch's selectable name is its id; visibility is irrelevant here).
464        let sketch_ids: std::collections::HashSet<String> = state
465            .committed_sketches()
466            .into_iter()
467            .map(|(id, _visible)| id)
468            .collect();
469        let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
470            .into_iter()
471            .partition(|name| sketch_ids.contains(name));
472        let faces = names("faces");
473        let edges = names("edges");
474        // Construction planes / datum planes arrive under `datums` — a SEPARATE
475        // bucket (never merged into `faces`): the scene-solid consumers cannot
476        // resolve a datum frame name (see the `planes` field doc).
477        let planes = names("datums");
478        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
479
480        // A single selected entity → its owning feature (the old app's
481        // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
482        // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
483        // its `D`/`P` feature). Datum planes count toward the single-selection
484        // total too, else picking one shows no Edit-owning-feature button.
485        let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
486        let single = if total == 1 && vertices == 0 {
487            faces
488                .first()
489                .or_else(|| edges.first())
490                .or_else(|| solids.first())
491                .or_else(|| sketches.first())
492                .or_else(|| planes.first())
493                .cloned()
494        } else {
495            None
496        };
497        let owning_feature = single
498            .as_deref()
499            .and_then(|name| state.creating_feature(name))
500            .map(|(id, _ty)| id);
501
502        Self {
503            solids,
504            sketches,
505            faces,
506            edges,
507            planes,
508            vertices,
509            owning_feature,
510        }
511    }
512
513    /// The selectable KINDS currently present (vertices carry no names, and no
514    /// primary reference is vertex-only, so they never drive feature actions).
515    fn kinds_present(&self) -> Vec<&'static str> {
516        let mut kinds = Vec::new();
517        if !self.solids.is_empty() {
518            kinds.push("SOLID");
519        }
520        if !self.sketches.is_empty() {
521            kinds.push("SKETCH");
522        }
523        if !self.faces.is_empty() {
524            kinds.push("FACE");
525        }
526        if !self.edges.is_empty() {
527            kinds.push("EDGE");
528        }
529        // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
530        // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
531        if !self.planes.is_empty() {
532            kinds.push("PLANE");
533        }
534        kinds
535    }
536
537    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
538    /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
539    /// frames, `COMPONENT` to selected solids (the picker never yields a bare
540    /// component here).
541    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
542        let mut out: Vec<String> = Vec::new();
543        let push = |src: &[String], out: &mut Vec<String>| {
544            for name in src {
545                if !out.iter().any(|n| n == name) {
546                    out.push(name.clone());
547                }
548            }
549        };
550        for f in filter {
551            match f.as_str() {
552                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
553                "SKETCH" => push(&self.sketches, &mut out),
554                "FACE" => push(&self.faces, &mut out),
555                // A `["PLANE","FACE"]` field prefills from EITHER a selected face
556                // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
557                // is an alias for the same planes bucket.
558                "PLANE" | "DATUM" => push(&self.planes, &mut out),
559                "EDGE" => push(&self.edges, &mut out),
560                _ => {}
561            }
562        }
563        out
564    }
565
566    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
567    /// pinned Info window the Info action opens. Vertices carry no name, and datum
568    /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
569    /// (Info, Hide via `hide_selected`, `component_selection` via
570    /// `component_of_solid`), none of which can resolve a datum frame name. A datum
571    /// plane can be selected (`has_selection` now counts it, so the bar shows and
572    /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
573    /// the probe — never this list.
574    fn all_names(&self) -> Vec<String> {
575        let mut out: Vec<String> = Vec::new();
576        for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
577            for name in src {
578                if !name.is_empty() && !out.iter().any(|n| n == name) {
579                    out.push(name.clone());
580                }
581            }
582        }
583        out
584    }
585
586    fn summary(&self) -> String {
587        format!(
588            "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
589            self.solids.len(),
590            self.sketches.len(),
591            self.faces.len(),
592            self.edges.len(),
593            self.planes.len(),
594            self.vertices,
595        )
596    }
597}
598
599/// One reference field of an offered feature, in schema order — the pre-fill
600/// targets [`prefill_references`] consumes the selection into.
601struct OfferField {
602    /// The JSON path of the `References`-group field.
603    path: Vec<String>,
604    /// That field's `selectionFilter` (which selected kinds map into it).
605    filter: Vec<String>,
606    /// Whether the field takes a list (vs a single name).
607    multiple: bool,
608}
609
610/// One offered feature action.
611struct Offer {
612    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
613    type_code: String,
614    /// The button label (the feature's long name).
615    label: String,
616    /// Every `References`-group field whose filter accepts a selected kind
617    /// (schema order) — the create pre-fills them under a consumed set.
618    fields: Vec<OfferField>,
619}
620
621/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
622/// the selection's kind counts, its component view, and whether it sits entirely
623/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
624fn selection_probe(
625    sel: &Selection,
626    comp: &ComponentSelection,
627    all_sheet_metal: bool,
628) -> SelectionProbe {
629    SelectionProbe {
630        solids: sel.solids.len(),
631        sketches: sel.sketches.len(),
632        faces: sel.faces.len(),
633        edges: sel.edges.len(),
634        planes: sel.planes.len(),
635        vertices: sel.vertices,
636        components: comp.ids.len(),
637        all_component: comp.all_component,
638        all_sheet_metal,
639    }
640}
641
642/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
643/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
644/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
645/// vertex convention: a vertex carries no name to resolve, so any vertex in the
646/// selection disqualifies it.
647fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
648    let names = sel.all_names();
649    !names.is_empty()
650        && sel.vertices == 0
651        && names.iter().all(|name| state.is_sheet_metal_object(name))
652}
653
654/// The feature actions to offer: every catalogue feature whose OWN
655/// `context_applicable` predicate (kernel-defined, next to its schema —
656/// `feature_pipeline::context_offer`) accepts the current selection probe. The
657/// `workbench` argument only FURTHER RESTRICTS that set to the features the
658/// active workbench includes; like the palette filter it is a pure UI trim over
659/// CREATION and never affects the existing history / execution.
660///
661/// The pre-fill stays schema-derived: each offer carries EVERY
662/// `References`-group `reference_selection` field whose `selectionFilter`
663/// intersects a selected kind (schema order), and the create consumes the
664/// selection into them ([`prefill_references`]).
665fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
666    let kinds = sel.kinds_present();
667    if kinds.is_empty() {
668        return Vec::new();
669    }
670    let catalogue = features::feature_catalogue();
671    let mut out = Vec::new();
672    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
673        for feature in list {
674            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
675                continue;
676            };
677            if ty.is_empty() {
678                continue;
679            }
680            // Workbench UI filter: skip features this workbench does not include
681            // (classified off the type code).
682            if !crate::workbench::includes_feature(workbench, ty) {
683                continue;
684            }
685            // The feature's own answer to "does this selection make me
686            // meaningful?" — nuance (Revolve wants profile AND axis) lives in
687            // the kernel predicate, not here.
688            if !brep_kernel::feature_context_applicable(ty, probe) {
689                continue;
690            }
691            // The pre-fill targets: every `References`-group reference field
692            // accepting a selected kind. Primitives only carry the boolean-op
693            // `targets` Reference (group `Boolean`), so they never collect any
694            // (their predicates return false anyway).
695            let fields: Vec<OfferField> = features::feature_form_fields(ty)
696                .iter()
697                .filter(|field| field.group == "References")
698                .filter_map(|field| {
699                    let FieldKind::Reference { filter, multiple } = &field.kind else {
700                        return None;
701                    };
702                    filter
703                        .iter()
704                        .any(|f| kinds.iter().any(|k| *k == f.as_str()))
705                        .then(|| OfferField {
706                            path: field.path.clone(),
707                            filter: filter.clone(),
708                            multiple: *multiple,
709                        })
710                })
711                .collect();
712            out.push(Offer {
713                type_code: ty.to_string(),
714                label: features::feature_long_name(ty),
715                fields,
716            });
717        }
718    }
719    out
720}
721
722/// The single component the context bar's COMPONENT action set targets, or
723/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
724/// sole_target`]) OR the active workbench hides the assembly structure panel
725/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
726/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
727/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
728/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
729/// kernel rejects component references in every workbench.
730fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
731    // The BOM is the assembly workbench's component list (it absorbed the
732    // Structure panel): component actions target a selection only where that
733    // list is on screen.
734    let list_shown = crate::workbench::panel_visible(
735        workbench,
736        crate::workbench::assembly::BOM_PANEL_ID,
737    );
738    list_shown.then(|| comp.sole_target()).flatten()
739}
740
741/// The PMI annotation actions to offer: every type whose `applicable`
742/// predicate ([`brep_kernel::PMI_TYPES`]) accepts the probe — gated on the PMI
743/// panel being available in the active workbench (PMI + All) and on an active
744/// view (no view ⇒ no offers).
745fn pmi_offers(
746    probe: &SelectionProbe,
747    workbench: &str,
748    view_active: bool,
749) -> Vec<&'static brep_kernel::PmiTypeDef> {
750    if !view_active || !crate::workbench::panel_visible(workbench, crate::workbench::pmi::PANEL_ID) {
751        return Vec::new();
752    }
753    brep_kernel::PMI_TYPES
754        .iter()
755        .filter(|def| (def.applicable)(probe))
756        .collect()
757}
758
759/// Add a PMI annotation of `type_id` to the active view, its reference
760/// fields pre-seeded from the selection by the schema's `selectionFilter`s
761/// (faces / edges / datum planes / solids by name, vertices as `{solid}@x,y,z`
762/// world refs). The annotation plane is never seeded — a selected face is
763/// the geometry being annotated; the plane is picked in the form. The new
764/// annotation's form opens (the engine sets the open annotation).
765pub(crate) fn add_pmi_from_selection(state: &mut EngineState, type_id: &str) -> Result<String, String> {
766    let catalogue = brep_kernel::pmi_schema_catalogue();
767    let schema = catalogue
768        .as_array()
769        .and_then(|entries| entries.iter().find(|e| e.get("type").and_then(Value::as_str) == Some(type_id)))
770        .cloned()
771        .ok_or_else(|| format!("no schema for PMI type '{type_id}'"))?;
772    let selection: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
773    let names = |key: &str| -> Vec<String> {
774        selection[key]
775            .as_array()
776            .map(|items| items.iter().filter_map(|item| item.as_str().map(String::from)).collect())
777            .unwrap_or_default()
778    };
779    let vertices: Vec<String> = state
780        .emphasis
781        .selected_vertices
782        .iter()
783        .map(|vertex| brep_render::engine_state::world_vertex_ref(&vertex.solid, vertex.position))
784        .collect();
785    let mut seeded = serde_json::Map::new();
786    let mut consumed: Vec<String> = Vec::new();
787    if let Some(fields) = schema.get("inputParamsSchema").and_then(Value::as_object) {
788        for (key, spec) in fields {
789            if spec.get("type").and_then(Value::as_str) != Some("reference_selection") || key == "plane" {
790                continue;
791            }
792            let filter: Vec<String> = spec
793                .get("selectionFilter")
794                .and_then(Value::as_array)
795                .map(|kinds| kinds.iter().filter_map(|k| k.as_str().map(String::from)).collect())
796                .unwrap_or_default();
797            let multiple = spec.get("multiple").and_then(Value::as_bool).unwrap_or(false);
798            let cap = spec.get("maxSelections").and_then(Value::as_u64).unwrap_or(if multiple { 64 } else { 1 }) as usize;
799            let mut picked: Vec<String> = Vec::new();
800            for kind in &filter {
801                let source: Vec<String> = match kind.to_ascii_uppercase().as_str() {
802                    "FACE" => names("faces"),
803                    "EDGE" => names("edges"),
804                    "PLANE" | "DATUM" => names("datums"),
805                    "SOLID" | "COMPONENT" => names("solids"),
806                    "VERTEX" => vertices.clone(),
807                    _ => Vec::new(),
808                };
809                for name in source {
810                    if picked.len() < cap && !picked.contains(&name) && !consumed.contains(&name) {
811                        picked.push(name);
812                    }
813                }
814            }
815            if picked.is_empty() {
816                continue;
817            }
818            consumed.extend(picked.iter().cloned());
819            let value = if multiple {
820                Value::Array(picked.into_iter().map(Value::String).collect())
821            } else {
822                Value::String(picked.remove(0))
823            };
824            seeded.insert(key.clone(), value);
825        }
826    }
827    state.pmi_add_annotation(None, type_id, &Value::Object(seeded).to_string())
828}
829
830/// The constraint actions to offer: every constraint type whose `applicable`
831/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
832/// accepts the probe — gated on the Assembly Constraints panel being available
833/// in the active workbench (claim-based visibility: Assembly + All).
834fn constraint_offers(
835    probe: &SelectionProbe,
836    workbench: &str,
837) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
838    if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
839    {
840        return Vec::new();
841    }
842    brep_kernel::CONSTRAINT_TYPES
843        .iter()
844        .filter(|def| (def.applicable)(probe))
845        .collect()
846}
847
848/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
849/// through the constraints panel's seeding helper (filtered + capped by the
850/// type's own schema), then the new row opened so the panel shows its dialog.
851/// The engine's mutation path handles auto-solve exactly like a panel add.
852pub(crate) fn add_constraint_from_selection(
853    state: &mut EngineState,
854    type_id: &str,
855) -> Result<String, String> {
856    let catalogue = brep_kernel::constraint_schema_catalogue();
857    let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
858    let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
859    let id = state.assembly_add_constraint(type_id, &seed.to_string())?;
860    let _ = state.assembly_set_constraint_open(&id, true);
861    Ok(id)
862}
863
864/// Consume the selection into an offer's reference fields, schema order: each
865/// field takes the selected names its filter accepts that NO EARLIER field
866/// consumed (first name for a single field, all remaining for a multiple) — so
867/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
868/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
869/// Returns `(path, value)` writes for [`form::set_at`].
870fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
871    let mut consumed: HashSet<String> = HashSet::new();
872    let mut writes = Vec::new();
873    for field in fields {
874        let names: Vec<String> = sel
875            .names_for_filter(&field.filter)
876            .into_iter()
877            .filter(|name| !consumed.contains(name))
878            .collect();
879        if names.is_empty() {
880            continue;
881        }
882        let value = if field.multiple {
883            consumed.extend(names.iter().cloned());
884            Value::Array(names.into_iter().map(Value::String).collect())
885        } else {
886            let name = names.into_iter().next().unwrap_or_default();
887            consumed.insert(name.clone());
888            Value::String(name)
889        };
890        writes.push((field.path.clone(), value));
891    }
892    writes
893}
894
895/// Create a feature of `offer.type_code` referencing the selection: build a
896/// fresh descriptor whose `inputParams` are the schema defaults with an
897/// engine-unique `id` and the matched reference fields pre-filled
898/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
899/// Returns the new feature id (for the shell to expand its node).
900fn create_feature_from_selection(
901    state: &mut EngineState,
902    offer: &Offer,
903    sel: &Selection,
904) -> Option<String> {
905    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
906    let mut params = features::feature_default_params(&offer.type_code);
907    if let Value::Object(map) = &mut params {
908        map.insert("id".into(), Value::String(id.clone()));
909    }
910
911    for (path, value) in prefill_references(&offer.fields, sel) {
912        form::set_at(&mut params, &path, value);
913    }
914
915    let feature = serde_json::json!({
916        "type": offer.type_code,
917        "inputParams": params,
918        "persistentData": {},
919    });
920    if state.add_feature(&feature.to_string()).is_ok() {
921        Some(id)
922    } else {
923        None
924    }
925}
926
927/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
928fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
929    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
930}
931
932// BREP private tests: 0b666fd904aeda94
933
934/// The hit keys this panel publishes (see `automation::hit_keys`).
935pub static HIT_KEYS: &[HitKeyDoc] = &[
936    HitKeyDoc { panel: "context", prefix: "feature:", meaning: "add the offered feature from the selection (feature:type)", command: None },
937    HitKeyDoc { panel: "context", prefix: "component:", meaning: "a component action", command: None },
938    HitKeyDoc { panel: "context", prefix: "constraint:", meaning: "add the offered assembly constraint", command: None },
939    HitKeyDoc { panel: "context", prefix: "pmi:", meaning: "add the offered PMI annotation (pmi:type)", command: None },
940    HitKeyDoc { panel: "context", prefix: "Selected:", meaning: "the selection summary chip", command: None },
941];