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}