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** — the feature types whose PRIMARY reference
19//!   accepts the selected kind. The mapping is DERIVED FROM THE KERNEL SCHEMAS,
20//!   not hard-coded: for each feature we take its first top-level
21//!   `reference_selection` field (a [`FieldKind::Reference`] in the `References`
22//!   group — this excludes primitives, whose only reference is the boolean-op
23//!   `targets`), and offer it when that field's `selectionFilter` intersects a
24//!   currently-selected kind. So a FACE selected → Extrude / Offset Face / Push
25//!   Face / Offset Shell / Thicken / Delete Face / Fillet / Chamfer / Revolve /
26//!   Sweep / Path Sweep / Loft; an EDGE → Fillet / Chamfer / Tube; a SOLID →
27//!   Boolean / Mirror / Transform / Pattern / Split / Rib. Clicking a feature
28//!   action creates it (`add_feature`) with its primary reference PRE-FILLED with
29//!   the selected entity name(s) matching the field filter, then asks the shell
30//!   to expand the new node for tweaking.
31//!
32//! Like the other panels this owns NO model state — the selection + history live
33//! in [`EngineState`], borrowed in; it only holds the per-frame `hits` map (widget
34//! screen rects) + the last-drawn action ids the headed verifier reads.
35
36use super::action_rail::{action_rail, ActionItem};
37use crate::form;
38use brep_render::engine_state::EngineState;
39use brep_render::features;
40use brep_render::style::FieldKind;
41use eframe::egui;
42use serde_json::Value;
43use std::collections::HashMap;
44
45/// A request bubbled back to the shell after a context action ran: EXPAND (open
46/// the inline dialog of) the feature with this id in the history tree. The
47/// context bar mutates the engine directly but cannot reach the history panel's
48/// private "expanded" state, so it returns the id for the shell to focus.
49pub type FocusRequest = Option<String>;
50
51/// What a context-bar frame hands back to the shell. The bar mutates the engine
52/// directly, but two effects it cannot reach itself:
53/// * `focus` — the history feature to EXPAND after a create / edit-owning action
54///   (the history panel's expand state is private to it); and
55/// * `info_targets` — the entity names to open PINNED Info windows for after the
56///   Info action (the Info-window manager is shell-owned). One name per selected
57///   entity, so a multi-select opens one window each.
58#[derive(Default)]
59pub struct ContextOutcome {
60    pub focus: FocusRequest,
61    pub info_targets: Vec<String>,
62}
63
64/// The context bar's transient UI state (the model lives in the engine).
65#[derive(Default)]
66pub struct ContextBarPanel {
67    /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
68    /// each frame (there is no DOM — egui draws on the canvas).
69    hits: HashMap<String, egui::Rect>,
70    /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
71    /// — published so the verifier can assert WHICH actions the selection offered.
72    shown_actions: Vec<String>,
73    /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
74    shown_features: Vec<String>,
75}
76
77impl ContextBarPanel {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Draw the context bar as a FLOATING panel over the viewport (nothing when
83    /// nothing is selected — like the old app's floating selection action bar).
84    /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
85    /// are always reachable regardless of side-panel scroll. Returns a
86    /// [`ContextOutcome`] — the feature id the shell should expand in the history
87    /// tree (after a create-from-selection or edit-owning action) plus any entity
88    /// names the shell should open pinned Info windows for (after the Info action).
89    pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
90        self.hits.clear();
91        self.shown_actions.clear();
92        self.shown_features.clear();
93
94        // Modeling context actions ONLY. Hidden with no selection, and never
95        // during reference-selection (the picker owns the selection) or in sketch
96        // mode (the sketch context rail replaces this one). Rendered through the
97        // SHARED single-column rail — see [`super::action_rail`] — so it and the
98        // sketch context bar stay identical.
99        if !state.has_selection() || state.ref_select_active() || state.sketch_mode() {
100            return ContextOutcome::default();
101        }
102
103        let sel = Selection::read(state);
104        let offers = feature_offers(&sel);
105
106        // Build the action items: the generic actions, then feature-from-selection.
107        // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
108        // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
109        // window per selected entity — unlike the other actions it drives no engine
110        // mutation; the shell opens the windows from the returned targets.
111        let mut items = vec![
112            ActionItem::new("action:clear", "\u{2716} Clear", "Clear the selection"),
113            ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"),
114            ActionItem::new(
115                "action:info",
116                "\u{1f575} Info",
117                "Open a pinned Info window per selected entity",
118            ),
119        ];
120        self.shown_actions.push("clear".into());
121        self.shown_actions.push("hide".into());
122        self.shown_actions.push("info".into());
123        if sel.owning_feature.is_some() {
124            items.push(ActionItem::new(
125                "action:edit-owning",
126                "Edit owning feature",
127                "Roll to and edit the feature that created this",
128            ));
129            self.shown_actions.push("edit-owning".into());
130        }
131        for offer in &offers {
132            items.push(ActionItem::new(
133                format!("feature:{}", offer.type_code),
134                offer.label.clone(),
135                format!("Create {} from the selection", offer.label),
136            ));
137            self.shown_features.push(offer.type_code.clone());
138        }
139
140        let summary = sel.summary();
141        let clicked = egui::Frame::popup(ui.style())
142            .show(ui, |ui| {
143                action_rail(
144                    ui,
145                    Some("Selection actions"),
146                    Some(&summary),
147                    &items,
148                    &mut self.hits,
149                )
150            })
151            .inner;
152
153        // --- apply the intent (one engine mutation per frame) -----------------
154        let mut outcome = ContextOutcome::default();
155        match clicked.as_deref() {
156            Some("action:clear") => {
157                state.clear_selection();
158            }
159            Some("action:hide") => {
160                state.hide_selected();
161            }
162            Some("action:info") => {
163                // No engine mutation — hand the shell one target per selected entity
164                // so it opens (or, on dedup, keeps) a pinned Info window for each.
165                outcome.info_targets = sel.all_names();
166            }
167            Some("action:edit-owning") => {
168                if let Some(fid) = sel.owning_feature.clone() {
169                    if let Some(index) = feature_index(state, &fid) {
170                        state.roll_to(index);
171                    }
172                    outcome.focus = Some(fid);
173                }
174            }
175            Some(key) if key.starts_with("feature:") => {
176                let code = &key["feature:".len()..];
177                if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
178                    outcome.focus = create_feature_from_selection(state, offer, &sel);
179                }
180            }
181            _ => {}
182        }
183        outcome
184    }
185
186    /// The published widget hit-rects (egui points) for the headed verifier —
187    /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
188    #[cfg(target_arch = "wasm32")]
189    pub fn hits_json(&self) -> String {
190        let map: serde_json::Map<String, Value> = self
191            .hits
192            .iter()
193            .map(|(k, r)| {
194                (
195                    k.clone(),
196                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
197                )
198            })
199            .collect();
200        Value::Object(map).to_string()
201    }
202
203    /// The bar's LOGICAL state for the verifier: whether it is shown + which
204    /// generic actions and feature type-codes it offered this frame.
205    #[cfg(target_arch = "wasm32")]
206    pub fn state_json(&self) -> String {
207        serde_json::json!({
208            "shown": !self.hits.is_empty(),
209            "actions": self.shown_actions,
210            "features": self.shown_features,
211        })
212        .to_string()
213    }
214}
215
216/// The current selection, resolved once per frame from `selection_json`, plus the
217/// single-selection owning feature (for **Edit owning feature**).
218struct Selection {
219    solids: Vec<String>,
220    faces: Vec<String>,
221    edges: Vec<String>,
222    vertices: usize,
223    /// The producer feature id of a SINGLE-entity selection with a known producer.
224    owning_feature: Option<String>,
225}
226
227impl Selection {
228    fn read(state: &EngineState) -> Self {
229        let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
230        let names = |key: &str| -> Vec<String> {
231            v[key]
232                .as_array()
233                .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
234                .unwrap_or_default()
235        };
236        let solids = names("solids");
237        let faces = names("faces");
238        let edges = names("edges");
239        let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
240
241        // A single selected entity → its owning feature (the old app's
242        // Edit-owning-feature, generalized from FACE/PLANE to any single entity).
243        let total = solids.len() + faces.len() + edges.len();
244        let single = if total == 1 && vertices == 0 {
245            faces
246                .first()
247                .or_else(|| edges.first())
248                .or_else(|| solids.first())
249                .cloned()
250        } else {
251            None
252        };
253        let owning_feature = single
254            .as_deref()
255            .and_then(|name| state.creating_feature(name))
256            .map(|(id, _ty)| id);
257
258        Self {
259            solids,
260            faces,
261            edges,
262            vertices,
263            owning_feature,
264        }
265    }
266
267    /// The selectable KINDS currently present (vertices carry no names, and no
268    /// primary reference is vertex-only, so they never drive feature actions).
269    fn kinds_present(&self) -> Vec<&'static str> {
270        let mut kinds = Vec::new();
271        if !self.solids.is_empty() {
272            kinds.push("SOLID");
273        }
274        if !self.faces.is_empty() {
275            kinds.push("FACE");
276        }
277        if !self.edges.is_empty() {
278            kinds.push("EDGE");
279        }
280        kinds
281    }
282
283    /// The selected names whose kind the reference `filter` accepts (de-duplicated,
284    /// in solid→face→edge order). `PLANE` maps to selected faces, `COMPONENT` to
285    /// selected solids (the picker never yields a bare component here).
286    fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
287        let mut out: Vec<String> = Vec::new();
288        let push = |src: &[String], out: &mut Vec<String>| {
289            for name in src {
290                if !out.iter().any(|n| n == name) {
291                    out.push(name.clone());
292                }
293            }
294        };
295        for f in filter {
296            match f.as_str() {
297                "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
298                "FACE" | "PLANE" => push(&self.faces, &mut out),
299                "EDGE" => push(&self.edges, &mut out),
300                _ => {}
301            }
302        }
303        out
304    }
305
306    /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
307    /// pinned Info window the Info action opens. Vertices carry no name; datums are
308    /// not part of this bar's model (a datum-only selection never shows the context
309    /// bar — `has_selection` ignores datums).
310    fn all_names(&self) -> Vec<String> {
311        let mut out: Vec<String> = Vec::new();
312        for src in [&self.solids, &self.faces, &self.edges] {
313            for name in src {
314                if !name.is_empty() && !out.iter().any(|n| n == name) {
315                    out.push(name.clone());
316                }
317            }
318        }
319        out
320    }
321
322    fn summary(&self) -> String {
323        format!(
324            "Selected: {} solid, {} face, {} edge, {} vertex",
325            self.solids.len(),
326            self.faces.len(),
327            self.edges.len(),
328            self.vertices,
329        )
330    }
331}
332
333/// One offered feature action, resolved from a feature's schema.
334struct Offer {
335    /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
336    type_code: String,
337    /// The button label (the feature's long name).
338    label: String,
339    /// The JSON path of the feature's PRIMARY reference field to pre-fill.
340    ref_path: Vec<String>,
341    /// That field's `selectionFilter` (which selected kinds map into it).
342    filter: Vec<String>,
343    /// Whether that field takes a list (vs a single name).
344    multiple: bool,
345}
346
347/// The feature actions to offer for `sel`: every registered feature whose PRIMARY
348/// reference (its first `References`-group reference field — NOT the boolean-op
349/// `targets`, which every primitive carries) accepts a currently-selected kind.
350/// The whole mapping is derived from the kernel feature schemas at run time.
351fn feature_offers(sel: &Selection) -> Vec<Offer> {
352    let kinds = sel.kinds_present();
353    if kinds.is_empty() {
354        return Vec::new();
355    }
356    let catalogue = features::feature_catalogue();
357    let mut out = Vec::new();
358    if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
359        for feature in list {
360            let Some(ty) = feature.get("type").and_then(Value::as_str) else {
361                continue;
362            };
363            if ty.is_empty() {
364                continue;
365            }
366            // Primary reference = the first Reference field in the `References`
367            // group (a top-level `reference_selection` param). Primitives only
368            // carry the boolean-op `targets` Reference (group `Boolean`), so they
369            // are correctly skipped.
370            let fields = features::feature_form_fields(ty);
371            let Some(primary) = fields
372                .iter()
373                .find(|f| matches!(f.kind, FieldKind::Reference { .. }) && f.group == "References")
374            else {
375                continue;
376            };
377            let FieldKind::Reference { filter, multiple } = &primary.kind else {
378                continue;
379            };
380            if !filter
381                .iter()
382                .any(|f| kinds.iter().any(|k| *k == f.as_str()))
383            {
384                continue;
385            }
386            out.push(Offer {
387                type_code: ty.to_string(),
388                label: features::feature_long_name(ty),
389                ref_path: primary.path.clone(),
390                filter: filter.clone(),
391                multiple: *multiple,
392            });
393        }
394    }
395    out
396}
397
398/// Create a feature of `offer.type_code` referencing the selection: build a fresh
399/// descriptor whose `inputParams` are the schema defaults with an engine-unique
400/// `id` and the PRIMARY reference pre-filled with the selected names matching its
401/// filter, then append it (`add_feature`, which rolls to it). Returns the new
402/// feature id (for the shell to expand its node).
403fn create_feature_from_selection(
404    state: &mut EngineState,
405    offer: &Offer,
406    sel: &Selection,
407) -> Option<String> {
408    let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
409    let mut params = features::feature_default_params(&offer.type_code);
410    if let Value::Object(map) = &mut params {
411        map.insert("id".into(), Value::String(id.clone()));
412    }
413
414    let names = sel.names_for_filter(&offer.filter);
415    let value = if offer.multiple {
416        Value::Array(names.into_iter().map(Value::String).collect())
417    } else {
418        Value::String(names.into_iter().next().unwrap_or_default())
419    };
420    form::set_at(&mut params, &offer.ref_path, value);
421
422    let feature = serde_json::json!({
423        "type": offer.type_code,
424        "inputParams": params,
425        "persistentData": {},
426    });
427    if state.add_feature(&feature.to_string()).is_ok() {
428        Some(id)
429    } else {
430        None
431    }
432}
433
434/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
435fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
436    (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
444        Selection {
445            solids: solids.iter().map(|s| s.to_string()).collect(),
446            faces: faces.iter().map(|s| s.to_string()).collect(),
447            edges: edges.iter().map(|s| s.to_string()).collect(),
448            vertices: 0,
449            owning_feature: None,
450        }
451    }
452
453    #[test]
454    fn face_selection_offers_face_features_not_solid_ones() {
455        let offers = feature_offers(&sel_of(&[], &["Box_PZ"], &[]));
456        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
457        // Face-primary features are offered…
458        for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
459            assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
460        }
461        // …and solid-only-primary features are NOT.
462        for nope in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
463            assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
464        }
465        // Primitives (only a boolean `targets` Reference) never appear.
466        assert!(!codes.contains(&"P.CU"));
467    }
468
469    #[test]
470    fn edge_selection_offers_fillet_chamfer_tube() {
471        let offers = feature_offers(&sel_of(&[], &[], &["Box_E0"]));
472        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
473        for want in ["F", "CH", "TU"] {
474            assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
475        }
476        assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
477    }
478
479    #[test]
480    fn solid_selection_offers_solid_features() {
481        let offers = feature_offers(&sel_of(&["Box"], &[], &[]));
482        let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
483        for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
484            assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
485        }
486        assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
487    }
488
489    #[test]
490    fn empty_selection_offers_nothing() {
491        assert!(feature_offers(&sel_of(&[], &[], &[])).is_empty());
492    }
493
494    #[test]
495    fn extrude_primary_reference_is_single_profile() {
496        let offers = feature_offers(&sel_of(&[], &["F1"], &[]));
497        let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
498        assert_eq!(extrude.ref_path, vec!["profile".to_string()]);
499        assert!(!extrude.multiple, "extrude profile is a single reference");
500        assert!(extrude.filter.iter().any(|f| f == "FACE"));
501    }
502
503    #[test]
504    fn all_names_gathers_every_named_entity_for_info_windows() {
505        // A multi-select of a solid + two faces + an edge → four Info-window targets
506        // (solids → faces → edges order, de-duplicated).
507        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
508        assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
509        // Nothing selected → no windows.
510        assert!(sel_of(&[], &[], &[]).all_names().is_empty());
511    }
512
513    #[test]
514    fn names_for_filter_maps_kinds() {
515        let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
516        assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
517        assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
518        assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
519        // A multi-kind filter (fillet's FACE+EDGE) gathers both.
520        assert_eq!(
521            sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
522            ["Box_PZ", "Box_NZ", "Box_E0"]
523        );
524    }
525}