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];