brep_app/panels/context_bar.rs
1//! Context action toolbar — the **selection-driven** action bar (the engine-
2//! native successor to the old app's floating selection action bar,
3//! `SelectionFilter._syncSelectionActions` + `_getHistoryContextActionSpecs`).
4//!
5//! It is shown ONLY while something is selected (hidden otherwise) and its
6//! buttons depend on the CURRENT selection (kinds + count read from
7//! `selection_json`):
8//!
9//! * **Generic actions** (mirror the old selection action bar):
10//! - **Clear** — `clear_selection`.
11//! - **Hide** — `hide_selected` (toggles the visibility of EXACTLY what is
12//! selected: a selected face/edge/vertex hides just that sub-entity, a
13//! selected solid the whole solid; a second click shows it again).
14//! - **Edit owning feature** — for a SINGLE selected entity with a known
15//! producer, `creating_feature(name)` resolves the feature that built it;
16//! clicking rolls the model to that step (`roll_to`) and asks the shell to
17//! EXPAND that feature's inline dialog in the history tree.
18//! * **Feature-from-selection** — WHICH features a selection offers is answered
19//! by the KERNEL, per feature: each feature module defines `context_applicable`
20//! (aggregated in `feature_pipeline::context_offer`), a predicate over the
21//! [`SelectionProbe`] kind-counts this bar builds each frame. That is where
22//! nuance lives — e.g. Revolve wants a profile AND an axis edge, so a lone
23//! face no longer offers it. The pre-fill stays schema-derived: an offered
24//! feature's `References`-group `reference_selection` fields are filled from
25//! the selection in schema order under a CONSUMED set (each selected name
26//! lands in at most ONE field — face+edge → Revolve fills `profile` and
27//! `axis`). Clicking creates the feature (`add_feature`) with those fields
28//! pre-filled, then asks the shell to expand the new node for tweaking.
29//! * **Constraint-from-selection** — the assembly-constraint mirror of the
30//! feature offers, shown when the Assembly Constraints panel is available in
31//! the active workbench (claim-based visibility). Each constraint type's
32//! `applicable` predicate ([`brep_kernel::ConstraintTypeDef`]) runs against
33//! the same probe: all-component selections only (the kernel rejects anything
34//! else), ONE component's solid(s) for Fixed, a two-element pair across TWO
35//! distinct components for the pairing types. Clicking adds the constraint
36//! with `elements` pre-seeded from the selection (the constraints panel's
37//! seeding helper) and opens its row in the panel.
38//!
39//! Like the other panels this owns NO model state — the selection + history live
40//! in [`EngineState`], borrowed in; it only holds the per-frame `hits` map (widget
41//! screen rects) + the last-drawn action ids the headed verifier reads.
42
43use super::action_rail::{action_rail, ActionItem};
44use super::component_actions::{run_component_action, ComponentAction, ComponentActionRequest};
45use crate::form;
46use brep_render::brep_kernel::{self, SelectionProbe};
47use brep_render::engine_state::EngineState;
48use brep_render::features;
49use brep_render::style::FieldKind;
50use eframe::egui;
51use serde_json::Value;
52use std::collections::{HashMap, HashSet};
53
54/// A request bubbled back to the shell after a context action ran: EXPAND (open
55/// the inline dialog of) the feature with this id in the history tree. The
56/// context bar mutates the engine directly but cannot reach the history panel's
57/// private "expanded" state, so it returns the id for the shell to focus.
58pub type FocusRequest = Option<String>;
59
60/// What a context-bar frame hands back to the shell. The bar mutates the engine
61/// directly, but two effects it cannot reach itself:
62/// * `focus` — the history feature to EXPAND after a create / edit-owning action
63/// (the history panel's expand state is private to it); and
64/// * `info_targets` — the entity names to open PINNED Info windows for after the
65/// Info action (the Info-window manager is shell-owned). One name per selected
66/// entity, so a multi-select opens one window each.
67#[derive(Default)]
68pub struct ContextOutcome {
69 pub focus: FocusRequest,
70 pub info_targets: Vec<String>,
71 /// A COMPONENT document-level flow the shell must run (Edit in place /
72 /// Open Part) — set when the matching component action was clicked; the
73 /// engine-mutating component actions (Move / Fix-Unfix / Delete) already
74 /// applied inside the bar.
75 pub component: Option<ComponentActionRequest>,
76}
77
78/// The context bar's transient UI state (the model lives in the engine).
79#[derive(Default)]
80pub struct ContextBarPanel {
81 /// Per-frame widget screen rects, published for the headed verifier. Rebuilt
82 /// each frame (there is no DOM — egui draws on the canvas).
83 hits: HashMap<String, egui::Rect>,
84 /// The generic action ids drawn THIS frame (`clear` / `hide` / `edit-owning`)
85 /// — published so the verifier can assert WHICH actions the selection offered.
86 shown_actions: Vec<String>,
87 /// The feature TYPE CODES offered THIS frame (`E`, `F`, `CH`, …).
88 shown_features: Vec<String>,
89 /// The constraint TYPE ids offered THIS frame (`fixed`, `distance`, …).
90 shown_constraints: Vec<String>,
91 /// The COMPONENT action ids offered THIS frame (`move`, `open-part`, …)
92 /// — non-empty exactly when the selection is a single component's members.
93 shown_component_actions: Vec<String>,
94 /// The single component the actions target this frame (its ACOMP id).
95 shown_component_target: Option<String>,
96}
97
98impl ContextBarPanel {
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 /// Draw the context bar as a FLOATING panel over the viewport (nothing when
104 /// nothing is selected — like the old app's floating selection action bar).
105 /// Drawn at ctx level (not inside the scrollable side panel) so its buttons
106 /// are always reachable regardless of side-panel scroll. Returns a
107 /// [`ContextOutcome`] — the feature id the shell should expand in the history
108 /// tree (after a create-from-selection or edit-owning action) plus any entity
109 /// names the shell should open pinned Info windows for (after the Info action).
110 pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) -> ContextOutcome {
111 self.hits.clear();
112 self.shown_actions.clear();
113 self.shown_features.clear();
114 self.shown_constraints.clear();
115 self.shown_component_actions.clear();
116 self.shown_component_target = None;
117
118 // Modeling context actions ONLY. Hidden with no selection (geometry OR a
119 // label-selected constraint), and never during reference-selection (the
120 // picker owns the selection) or in sketch mode (the sketch context rail
121 // replaces this one). Rendered through the SHARED single-column rail —
122 // see [`super::action_rail`] — so it and the sketch context bar stay
123 // identical. The constraint selection only counts (and only offers its
124 // Delete action) in a workbench that shows the constraints panel — the
125 // same claim gate as the constraint offers.
126 let has_geometry = state.has_selection();
127 let constraint_target = state.selected_constraint().filter(|_| {
128 crate::workbench::panel_visible(
129 &state.settings.workbench,
130 crate::workbench::assembly::CONSTRAINTS_PANEL_ID,
131 )
132 });
133 if (!has_geometry && constraint_target.is_none())
134 || state.ref_select_active()
135 || state.sketch_mode()
136 {
137 return ContextOutcome::default();
138 }
139
140 let sel = Selection::read(state);
141 let comp = component_selection(&sel, state);
142 let probe = selection_probe(&sel, &comp, all_on_sheet_metal(&sel, state));
143 // The feature FENCE (build-spec §3): a selection made ENTIRELY of
144 // component geometry offers NO modeling-feature creation (the kernel
145 // rejects component references anyway — don't offer dead ends). The
146 // constraint offers are the complement: their predicates REQUIRE an
147 // all-component selection, so the two sets never coexist.
148 let offers = if comp.suppress_features() {
149 Vec::new()
150 } else {
151 feature_offers(&probe, &sel, &state.settings.workbench)
152 };
153 let constraint_types = constraint_offers(&probe, &state.settings.workbench);
154 // A single component's member solid(s) selected → the COMPONENT action
155 // set replaces the feature-creation offers (spec §8.5 / §8.1) — but ONLY
156 // in a workbench that shows the assembly structure panel (claim-based:
157 // Assembly + All). The component actions are that panel's row actions,
158 // so they follow its visibility and never bleed into Modeling / Sheet
159 // Metal; the STANDARD actions (Clear / Hide / Info / Edit owning) still
160 // apply to a component selection in every workbench.
161 let component_target = component_action_target(&comp, &state.settings.workbench).map(|id| {
162 let fixed = state.component_info(id).map(|info| info.fixed).unwrap_or(false);
163 (id.to_string(), fixed)
164 });
165
166 // Build the action items: the generic actions, then feature-from-selection.
167 // Info (🕵 U+1F575, the previous app's "Inspector, Metadata & Mass Properties"
168 // glyph, from the bundled Noto Sans Symbols 2 font) opens one PINNED Info
169 // window per selected entity — unlike the other actions it drives no engine
170 // mutation; the shell opens the windows from the returned targets.
171 let mut items = vec![ActionItem::new(
172 "action:clear",
173 "\u{2716} Clear",
174 "Clear the selection",
175 )];
176 self.shown_actions.push("clear".into());
177 // Hide + Info act on selected GEOMETRY — with only a constraint
178 // selected they would be no-ops, so they are not offered.
179 if has_geometry {
180 items.push(ActionItem::new("action:hide", "\u{1f441} Hide", "Hide/Show selection"));
181 items.push(ActionItem::new(
182 "action:info",
183 "\u{1f575} Info",
184 "Open a pinned Info window per selected entity",
185 ));
186 self.shown_actions.push("hide".into());
187 self.shown_actions.push("info".into());
188 }
189 // The label-selected CONSTRAINT's action: delete it (the panel's row ✕,
190 // reachable from the viewport).
191 if let Some(cid) = &constraint_target {
192 items.push(ActionItem::new(
193 "action:delete-constraint",
194 "\u{2715} Delete constraint",
195 format!("Delete constraint {cid}"),
196 ));
197 self.shown_actions.push("delete-constraint".into());
198 }
199 if sel.owning_feature.is_some() {
200 items.push(ActionItem::new(
201 "action:edit-owning",
202 "Edit owning feature",
203 "Roll to and edit the feature that created this",
204 ));
205 self.shown_actions.push("edit-owning".into());
206 }
207 // Component actions (spec §8.5): shown INSTEAD of the feature offers
208 // when the selection is exactly one component's member solid(s).
209 if let Some((target, fixed)) = &component_target {
210 for action in ComponentAction::ALL {
211 items.push(ActionItem::new(
212 format!("component:{}", action.id()),
213 action.label(*fixed),
214 action.tooltip(),
215 ));
216 self.shown_component_actions.push(action.id().to_string());
217 }
218 self.shown_component_target = Some(target.clone());
219 }
220 // Constraint offers (all-component selections in a workbench that shows
221 // the constraints panel): one button per applicable constraint type.
222 for def in &constraint_types {
223 items.push(ActionItem::new(
224 format!("constraint:{}", def.type_id),
225 def.long_name,
226 format!("Add a {} constraint from the selection", def.label),
227 ));
228 self.shown_constraints.push(def.type_id.to_string());
229 }
230 for offer in &offers {
231 items.push(ActionItem::new(
232 format!("feature:{}", offer.type_code),
233 offer.label.clone(),
234 format!("Create {} from the selection", offer.label),
235 ));
236 self.shown_features.push(offer.type_code.clone());
237 }
238
239 // With only a constraint selected the geometry summary would read all
240 // zeros — name the constraint instead.
241 let summary = match (&constraint_target, has_geometry) {
242 (Some(cid), false) => format!("Selected: constraint {cid}"),
243 _ => sel.summary(),
244 };
245 let clicked = egui::Frame::popup(ui.style())
246 .show(ui, |ui| {
247 action_rail(
248 ui,
249 Some("Selection actions"),
250 Some(&summary),
251 &items,
252 &mut self.hits,
253 )
254 })
255 .inner;
256
257 // --- apply the intent (one engine mutation per frame) -----------------
258 let mut outcome = ContextOutcome::default();
259 match clicked.as_deref() {
260 Some("action:clear") => {
261 // Also drops a label-selected constraint (clear_selection folds
262 // the constraint selection in).
263 state.clear_selection();
264 }
265 Some("action:delete-constraint") => {
266 if let Some(cid) = &constraint_target {
267 let _ = state.assembly_remove_constraint(cid);
268 state.constraint_deselect();
269 }
270 }
271 Some("action:hide") => {
272 state.hide_selected();
273 }
274 Some("action:info") => {
275 // No engine mutation — hand the shell one target per selected entity
276 // so it opens (or, on dedup, keeps) a pinned Info window for each.
277 outcome.info_targets = sel.all_names();
278 }
279 Some("action:edit-owning") => {
280 if let Some(fid) = sel.owning_feature.clone() {
281 if let Some(index) = feature_index(state, &fid) {
282 state.roll_to(index);
283 }
284 outcome.focus = Some(fid);
285 }
286 }
287 Some(key) if key.starts_with("component:") => {
288 if let Some((target, _)) = &component_target {
289 if let Some(action) = ComponentAction::from_id(&key["component:".len()..]) {
290 outcome.component = run_component_action(state, action, target);
291 }
292 }
293 }
294 Some(key) if key.starts_with("constraint:") => {
295 let type_id = &key["constraint:".len()..];
296 if constraint_types.iter().any(|def| def.type_id == type_id) {
297 add_constraint_from_selection(state, type_id);
298 }
299 }
300 Some(key) if key.starts_with("feature:") => {
301 let code = &key["feature:".len()..];
302 if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
303 outcome.focus = create_feature_from_selection(state, offer, &sel);
304 }
305 }
306 _ => {}
307 }
308 outcome
309 }
310
311 /// The published widget hit-rects (egui points) for the headed verifier —
312 /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
313 #[cfg(target_arch = "wasm32")]
314 pub fn hits_json(&self) -> String {
315 let map: serde_json::Map<String, Value> = self
316 .hits
317 .iter()
318 .map(|(k, r)| {
319 (
320 k.clone(),
321 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
322 )
323 })
324 .collect();
325 Value::Object(map).to_string()
326 }
327
328 /// The bar's LOGICAL state for the verifier: whether it is shown + which
329 /// generic actions, feature type-codes, and component actions it offered
330 /// this frame (and the single component the latter target).
331 #[cfg(target_arch = "wasm32")]
332 pub fn state_json(&self) -> String {
333 serde_json::json!({
334 "shown": !self.hits.is_empty(),
335 "actions": self.shown_actions,
336 "features": self.shown_features,
337 "constraints": self.shown_constraints,
338 "componentActions": self.shown_component_actions,
339 "componentTarget": self.shown_component_target,
340 })
341 .to_string()
342 }
343}
344
345/// The COMPONENT view of the current selection: which ACOMP instances own the
346/// selected entities, and whether the selection qualifies for the component
347/// action set / the feature-offer fence.
348struct ComponentSelection {
349 /// Unique owning ACOMP ids across every selected NAMED entity, selection
350 /// order.
351 ids: Vec<String>,
352 /// Whether EVERY selected named entity is component-owned (and at least one
353 /// is selected; vertices carry no names, so any vertex disqualifies).
354 all_component: bool,
355 /// Whether the selection is member SOLIDS only (the shape a viewport
356 /// component click produces).
357 solids_only: bool,
358}
359
360impl ComponentSelection {
361 /// The feature FENCE: suppress modeling-feature creation offers when the
362 /// whole selection is component geometry.
363 fn suppress_features(&self) -> bool {
364 self.all_component && !self.ids.is_empty()
365 }
366
367 /// The single component the ACTION SET targets: exactly one owning
368 /// component, selected via its member solid(s) alone.
369 fn sole_target(&self) -> Option<&str> {
370 (self.suppress_features() && self.solids_only && self.ids.len() == 1)
371 .then(|| self.ids[0].as_str())
372 }
373}
374
375/// Resolve the selection's component ownership through the engine's namespace
376/// parse (`component_of_solid` accepts any namespaced entity name — solid,
377/// face, or edge).
378fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
379 let mut ids: Vec<String> = Vec::new();
380 let mut all = true;
381 let mut any = false;
382 for name in sel.all_names() {
383 any = true;
384 match state.component_of_solid(&name) {
385 Some(id) => {
386 if !ids.contains(&id) {
387 ids.push(id);
388 }
389 }
390 None => all = false,
391 }
392 }
393 if sel.vertices > 0 {
394 all = false;
395 }
396 ComponentSelection {
397 ids,
398 all_component: all && any,
399 solids_only: !sel.solids.is_empty()
400 && sel.sketches.is_empty()
401 && sel.faces.is_empty()
402 && sel.edges.is_empty()
403 && sel.vertices == 0,
404 }
405}
406
407/// The current selection, resolved once per frame from `selection_json`, plus the
408/// single-selection owning feature (for **Edit owning feature**).
409struct Selection {
410 solids: Vec<String>,
411 /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
412 /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
413 /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
414 /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
415 /// `["SOLID"]` field like SM Cutout's `sheet`).
416 sketches: Vec<String>,
417 faces: Vec<String>,
418 edges: Vec<String>,
419 /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
420 /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
421 /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
422 /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
423 /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
424 /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
425 /// a planar face (the kernel resolves either).
426 planes: Vec<String>,
427 vertices: usize,
428 /// The producer feature id of a SINGLE-entity selection with a known producer.
429 owning_feature: Option<String>,
430}
431
432impl Selection {
433 fn read(state: &EngineState) -> Self {
434 let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
435 let names = |key: &str| -> Vec<String> {
436 v[key]
437 .as_array()
438 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
439 .unwrap_or_default()
440 };
441 // Partition the selected `solids` into REAL solids vs committed sketches: a
442 // selected solid is a sketch iff its name is a committed sketch (the
443 // sketch's selectable name is its id; visibility is irrelevant here).
444 let sketch_ids: std::collections::HashSet<String> = state
445 .committed_sketches()
446 .into_iter()
447 .map(|(id, _visible)| id)
448 .collect();
449 let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
450 .into_iter()
451 .partition(|name| sketch_ids.contains(name));
452 let faces = names("faces");
453 let edges = names("edges");
454 // Construction planes / datum planes arrive under `datums` — a SEPARATE
455 // bucket (never merged into `faces`): the scene-solid consumers cannot
456 // resolve a datum frame name (see the `planes` field doc).
457 let planes = names("datums");
458 let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
459
460 // A single selected entity → its owning feature (the old app's
461 // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
462 // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
463 // its `D`/`P` feature). Datum planes count toward the single-selection
464 // total too, else picking one shows no Edit-owning-feature button.
465 let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
466 let single = if total == 1 && vertices == 0 {
467 faces
468 .first()
469 .or_else(|| edges.first())
470 .or_else(|| solids.first())
471 .or_else(|| sketches.first())
472 .or_else(|| planes.first())
473 .cloned()
474 } else {
475 None
476 };
477 let owning_feature = single
478 .as_deref()
479 .and_then(|name| state.creating_feature(name))
480 .map(|(id, _ty)| id);
481
482 Self {
483 solids,
484 sketches,
485 faces,
486 edges,
487 planes,
488 vertices,
489 owning_feature,
490 }
491 }
492
493 /// The selectable KINDS currently present (vertices carry no names, and no
494 /// primary reference is vertex-only, so they never drive feature actions).
495 fn kinds_present(&self) -> Vec<&'static str> {
496 let mut kinds = Vec::new();
497 if !self.solids.is_empty() {
498 kinds.push("SOLID");
499 }
500 if !self.sketches.is_empty() {
501 kinds.push("SKETCH");
502 }
503 if !self.faces.is_empty() {
504 kinds.push("FACE");
505 }
506 if !self.edges.is_empty() {
507 kinds.push("EDGE");
508 }
509 // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
510 // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
511 if !self.planes.is_empty() {
512 kinds.push("PLANE");
513 }
514 kinds
515 }
516
517 /// The selected names whose kind the reference `filter` accepts (de-duplicated,
518 /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
519 /// frames, `COMPONENT` to selected solids (the picker never yields a bare
520 /// component here).
521 fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
522 let mut out: Vec<String> = Vec::new();
523 let push = |src: &[String], out: &mut Vec<String>| {
524 for name in src {
525 if !out.iter().any(|n| n == name) {
526 out.push(name.clone());
527 }
528 }
529 };
530 for f in filter {
531 match f.as_str() {
532 "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
533 "SKETCH" => push(&self.sketches, &mut out),
534 "FACE" => push(&self.faces, &mut out),
535 // A `["PLANE","FACE"]` field prefills from EITHER a selected face
536 // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
537 // is an alias for the same planes bucket.
538 "PLANE" | "DATUM" => push(&self.planes, &mut out),
539 "EDGE" => push(&self.edges, &mut out),
540 _ => {}
541 }
542 }
543 out
544 }
545
546 /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
547 /// pinned Info window the Info action opens. Vertices carry no name, and datum
548 /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
549 /// (Info, Hide via `hide_selected`, `component_selection` via
550 /// `component_of_solid`), none of which can resolve a datum frame name. A datum
551 /// plane can be selected (`has_selection` now counts it, so the bar shows and
552 /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
553 /// the probe — never this list.
554 fn all_names(&self) -> Vec<String> {
555 let mut out: Vec<String> = Vec::new();
556 for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
557 for name in src {
558 if !name.is_empty() && !out.iter().any(|n| n == name) {
559 out.push(name.clone());
560 }
561 }
562 }
563 out
564 }
565
566 fn summary(&self) -> String {
567 format!(
568 "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
569 self.solids.len(),
570 self.sketches.len(),
571 self.faces.len(),
572 self.edges.len(),
573 self.planes.len(),
574 self.vertices,
575 )
576 }
577}
578
579/// One reference field of an offered feature, in schema order — the pre-fill
580/// targets [`prefill_references`] consumes the selection into.
581struct OfferField {
582 /// The JSON path of the `References`-group field.
583 path: Vec<String>,
584 /// That field's `selectionFilter` (which selected kinds map into it).
585 filter: Vec<String>,
586 /// Whether the field takes a list (vs a single name).
587 multiple: bool,
588}
589
590/// One offered feature action.
591struct Offer {
592 /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
593 type_code: String,
594 /// The button label (the feature's long name).
595 label: String,
596 /// Every `References`-group field whose filter accepts a selected kind
597 /// (schema order) — the create pre-fills them under a consumed set.
598 fields: Vec<OfferField>,
599}
600
601/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
602/// the selection's kind counts, its component view, and whether it sits entirely
603/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
604fn selection_probe(
605 sel: &Selection,
606 comp: &ComponentSelection,
607 all_sheet_metal: bool,
608) -> SelectionProbe {
609 SelectionProbe {
610 solids: sel.solids.len(),
611 sketches: sel.sketches.len(),
612 faces: sel.faces.len(),
613 edges: sel.edges.len(),
614 planes: sel.planes.len(),
615 vertices: sel.vertices,
616 components: comp.ids.len(),
617 all_component: comp.all_component,
618 all_sheet_metal,
619 }
620}
621
622/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
623/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
624/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
625/// vertex convention: a vertex carries no name to resolve, so any vertex in the
626/// selection disqualifies it.
627fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
628 let names = sel.all_names();
629 !names.is_empty()
630 && sel.vertices == 0
631 && names.iter().all(|name| state.is_sheet_metal_object(name))
632}
633
634/// The feature actions to offer: every catalogue feature whose OWN
635/// `context_applicable` predicate (kernel-defined, next to its schema —
636/// `feature_pipeline::context_offer`) accepts the current selection probe. The
637/// `workbench` argument only FURTHER RESTRICTS that set to the features the
638/// active workbench includes; like the palette filter it is a pure UI trim over
639/// CREATION and never affects the existing history / execution.
640///
641/// The pre-fill stays schema-derived: each offer carries EVERY
642/// `References`-group `reference_selection` field whose `selectionFilter`
643/// intersects a selected kind (schema order), and the create consumes the
644/// selection into them ([`prefill_references`]).
645fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
646 let kinds = sel.kinds_present();
647 if kinds.is_empty() {
648 return Vec::new();
649 }
650 let catalogue = features::feature_catalogue();
651 let mut out = Vec::new();
652 if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
653 for feature in list {
654 let Some(ty) = feature.get("type").and_then(Value::as_str) else {
655 continue;
656 };
657 if ty.is_empty() {
658 continue;
659 }
660 // Workbench UI filter: skip features this workbench does not include
661 // (classified off the type code).
662 if !crate::workbench::includes_feature(workbench, ty) {
663 continue;
664 }
665 // The feature's own answer to "does this selection make me
666 // meaningful?" — nuance (Revolve wants profile AND axis) lives in
667 // the kernel predicate, not here.
668 if !brep_kernel::feature_context_applicable(ty, probe) {
669 continue;
670 }
671 // The pre-fill targets: every `References`-group reference field
672 // accepting a selected kind. Primitives only carry the boolean-op
673 // `targets` Reference (group `Boolean`), so they never collect any
674 // (their predicates return false anyway).
675 let fields: Vec<OfferField> = features::feature_form_fields(ty)
676 .iter()
677 .filter(|field| field.group == "References")
678 .filter_map(|field| {
679 let FieldKind::Reference { filter, multiple } = &field.kind else {
680 return None;
681 };
682 filter
683 .iter()
684 .any(|f| kinds.iter().any(|k| *k == f.as_str()))
685 .then(|| OfferField {
686 path: field.path.clone(),
687 filter: filter.clone(),
688 multiple: *multiple,
689 })
690 })
691 .collect();
692 out.push(Offer {
693 type_code: ty.to_string(),
694 label: features::feature_long_name(ty),
695 fields,
696 });
697 }
698 }
699 out
700}
701
702/// The single component the context bar's COMPONENT action set targets, or
703/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
704/// sole_target`]) OR the active workbench hides the assembly structure panel
705/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
706/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
707/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
708/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
709/// kernel rejects component references in every workbench.
710fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
711 // The BOM is the assembly workbench's component list (it absorbed the
712 // Structure panel): component actions target a selection only where that
713 // list is on screen.
714 let list_shown = crate::workbench::panel_visible(
715 workbench,
716 crate::workbench::assembly::BOM_PANEL_ID,
717 );
718 list_shown.then(|| comp.sole_target()).flatten()
719}
720
721/// The constraint actions to offer: every constraint type whose `applicable`
722/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
723/// accepts the probe — gated on the Assembly Constraints panel being available
724/// in the active workbench (claim-based visibility: Assembly + All).
725fn constraint_offers(
726 probe: &SelectionProbe,
727 workbench: &str,
728) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
729 if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
730 {
731 return Vec::new();
732 }
733 brep_kernel::CONSTRAINT_TYPES
734 .iter()
735 .filter(|def| (def.applicable)(probe))
736 .collect()
737}
738
739/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
740/// through the constraints panel's seeding helper (filtered + capped by the
741/// type's own schema), then the new row opened so the panel shows its dialog.
742/// The engine's mutation path handles auto-solve exactly like a panel add.
743fn add_constraint_from_selection(state: &mut EngineState, type_id: &str) {
744 let catalogue = brep_kernel::constraint_schema_catalogue();
745 let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
746 let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
747 if let Ok(id) = state.assembly_add_constraint(type_id, &seed.to_string()) {
748 let _ = state.assembly_set_constraint_open(&id, true);
749 }
750}
751
752/// Consume the selection into an offer's reference fields, schema order: each
753/// field takes the selected names its filter accepts that NO EARLIER field
754/// consumed (first name for a single field, all remaining for a multiple) — so
755/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
756/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
757/// Returns `(path, value)` writes for [`form::set_at`].
758fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
759 let mut consumed: HashSet<String> = HashSet::new();
760 let mut writes = Vec::new();
761 for field in fields {
762 let names: Vec<String> = sel
763 .names_for_filter(&field.filter)
764 .into_iter()
765 .filter(|name| !consumed.contains(name))
766 .collect();
767 if names.is_empty() {
768 continue;
769 }
770 let value = if field.multiple {
771 consumed.extend(names.iter().cloned());
772 Value::Array(names.into_iter().map(Value::String).collect())
773 } else {
774 let name = names.into_iter().next().unwrap_or_default();
775 consumed.insert(name.clone());
776 Value::String(name)
777 };
778 writes.push((field.path.clone(), value));
779 }
780 writes
781}
782
783/// Create a feature of `offer.type_code` referencing the selection: build a
784/// fresh descriptor whose `inputParams` are the schema defaults with an
785/// engine-unique `id` and the matched reference fields pre-filled
786/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
787/// Returns the new feature id (for the shell to expand its node).
788fn create_feature_from_selection(
789 state: &mut EngineState,
790 offer: &Offer,
791 sel: &Selection,
792) -> Option<String> {
793 let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
794 let mut params = features::feature_default_params(&offer.type_code);
795 if let Value::Object(map) = &mut params {
796 map.insert("id".into(), Value::String(id.clone()));
797 }
798
799 for (path, value) in prefill_references(&offer.fields, sel) {
800 form::set_at(&mut params, &path, value);
801 }
802
803 let feature = serde_json::json!({
804 "type": offer.type_code,
805 "inputParams": params,
806 "persistentData": {},
807 });
808 if state.add_feature(&feature.to_string()).is_ok() {
809 Some(id)
810 } else {
811 None
812 }
813}
814
815/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
816fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
817 (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 fn sel_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
825 Selection {
826 solids: solids.iter().map(|s| s.to_string()).collect(),
827 sketches: sketches.iter().map(|s| s.to_string()).collect(),
828 faces: faces.iter().map(|s| s.to_string()).collect(),
829 edges: edges.iter().map(|s| s.to_string()).collect(),
830 planes: Vec::new(),
831 vertices: 0,
832 owning_feature: None,
833 }
834 }
835
836 /// A selection of construction PLANES / DATUM planes only (their frame names).
837 fn sel_planes(planes: &[&str]) -> Selection {
838 Selection {
839 solids: Vec::new(),
840 sketches: Vec::new(),
841 faces: Vec::new(),
842 edges: Vec::new(),
843 planes: planes.iter().map(|s| s.to_string()).collect(),
844 vertices: 0,
845 owning_feature: None,
846 }
847 }
848
849 fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
850 sel_full(solids, &[], faces, edges)
851 }
852
853 /// Offers for a NON-COMPONENT selection (the plain modeling shape).
854 fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
855 let comp = ComponentSelection {
856 ids: Vec::new(),
857 all_component: false,
858 solids_only: false,
859 };
860 // Plain-geometry test selections carry no scene, so they are never on
861 // sheet metal (the SM edit features are covered separately).
862 feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
863 }
864
865 #[test]
866 fn face_selection_offers_face_features_not_solid_ones() {
867 // "all" workbench so the expected sets below are unfiltered.
868 let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
869 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
870 // Face-primary features are offered…
871 for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
872 assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
873 }
874 // …features whose ONLY reference kind is SOLID (or SKETCH/EDGE) are NOT.
875 for nope in ["B", "XFORM", "RIB"] {
876 assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
877 }
878 // …but a feature that takes a face/plane as a SECONDARY reference IS now
879 // offered, keyed on that field (any-field matching — the same rule that
880 // lets a sketch drive a cutout): Mirror about a face, Split by it, Pattern
881 // along its normal.
882 for want in ["M", "PATTERN", "SPL"] {
883 assert!(
884 codes.contains(&want),
885 "FACE should offer {want} via its plane/face field: {codes:?}"
886 );
887 }
888 // Primitives (only a boolean `targets` Reference) never appear.
889 assert!(!codes.contains(&"P.CU"));
890 // Revolve's kernel predicate wants a profile AND an axis edge — a lone
891 // face no longer offers it.
892 assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
893 }
894
895 #[test]
896 fn edge_selection_offers_fillet_chamfer_tube() {
897 let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
898 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
899 for want in ["F", "CH", "TU"] {
900 assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
901 }
902 assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
903 assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
904 }
905
906 /// The sheet-metal EDIT features gate on `all_sheet_metal` end to end: an edge
907 /// that sits on a sheet-metal body offers SM Flange / Fillet / Chamfer; the
908 /// same edge on plain geometry offers none of them (the flag plumbs through
909 /// `selection_probe` → `feature_offers`).
910 #[test]
911 fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
912 let sel = sel_of(&[], &[], &["Wall_E0"]);
913 let comp = ComponentSelection {
914 ids: Vec::new(),
915 all_component: false,
916 solids_only: false,
917 };
918 let codes = |all_sheet_metal: bool| -> Vec<String> {
919 feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
920 .iter()
921 .map(|o| o.type_code.clone())
922 .collect()
923 };
924 let on_sm = codes(true);
925 for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
926 assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
927 }
928 let plain = codes(false);
929 for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
930 assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
931 }
932 }
933
934 #[test]
935 fn solid_selection_offers_solid_features() {
936 let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
937 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
938 for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
939 assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
940 }
941 assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
942 }
943
944 #[test]
945 fn empty_selection_offers_nothing() {
946 assert!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
947 }
948
949 /// The user-specified nuance end to end: profile + axis edge offers Revolve,
950 /// and the consumed pre-fill routes the face into `profile` and the edge
951 /// into `axis` (one field each, nothing echoed).
952 #[test]
953 fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
954 let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
955 let offers = offers_for(&sel, "all");
956 let revolve = offers
957 .iter()
958 .find(|o| o.type_code == "R")
959 .expect("face+edge offers Revolve");
960 let writes = prefill_references(&revolve.fields, &sel);
961 assert_eq!(
962 writes,
963 vec![
964 (vec!["profile".to_string()], Value::String("Box_PZ".into())),
965 (vec!["axis".to_string()], Value::String("Box_E0".into())),
966 ]
967 );
968 // A committed sketch as the profile works the same way.
969 let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
970 let offers = offers_for(&sel, "all");
971 assert!(
972 offers.iter().any(|o| o.type_code == "R"),
973 "sketch+edge offers Revolve"
974 );
975 }
976
977 /// The consumed set: a name lands in at most ONE field, schema order —
978 /// Pattern's edge fills `directionRef` and does NOT echo into `axisRef`;
979 /// Fillet's multiple `edges` field takes faces and edges together.
980 #[test]
981 fn prefill_consumes_each_name_once() {
982 let sel = sel_of(&["Box"], &[], &["Box_E0"]);
983 let offers = offers_for(&sel, "all");
984 let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
985 let writes = prefill_references(&pattern.fields, &sel);
986 assert_eq!(
987 writes,
988 vec![
989 (vec!["solids".to_string()], serde_json::json!(["Box"])),
990 (vec!["directionRef".to_string()], Value::String("Box_E0".into())),
991 ]
992 );
993
994 let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
995 let offers = offers_for(&sel, "all");
996 let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
997 let writes = prefill_references(&fillet.fields, &sel);
998 assert_eq!(
999 writes,
1000 vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
1001 );
1002 }
1003
1004 #[test]
1005 fn workbench_filters_the_context_offers() {
1006 // A face selection under different workbenches: the workbench only FURTHER
1007 // restricts the schema-declared offers (it adds no new trigger channel).
1008 let sel = sel_of(&[], &["Box_PZ"], &[]);
1009 let codes = |wb: &str| -> Vec<String> {
1010 offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
1011 };
1012 let all = codes("all");
1013 let modeling = codes("modeling");
1014 let sheet = codes("sheetMetal");
1015 // Modeling keeps the modeling face-feature Extrude, and drops every
1016 // sheet-metal (`SM.*`) offer.
1017 assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
1018 assert!(
1019 !modeling.iter().any(|c| c.starts_with("SM.")),
1020 "modeling must not offer any SM.* feature: {modeling:?}"
1021 );
1022 // Sheet Metal drops the pure-modeling Extrude.
1023 assert!(
1024 !sheet.iter().any(|c| c == "E"),
1025 "sheet metal must not offer Extrude: {sheet:?}"
1026 );
1027 // All is the superset: every modeling offer is present in All.
1028 for c in &modeling {
1029 assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
1030 }
1031 }
1032
1033 #[test]
1034 fn extrude_primary_reference_is_single_profile() {
1035 let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
1036 let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
1037 assert_eq!(extrude.fields.len(), 1, "one matched reference field");
1038 assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
1039 assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
1040 assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
1041 }
1042
1043 #[test]
1044 fn sketch_kind_is_distinct_from_solid() {
1045 // A committed sketch (partitioned out of the solids bucket) presents as
1046 // SKETCH — NOT SOLID — so it never satisfies a SOLID-only field…
1047 assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
1048 // …a real solid presents as SOLID…
1049 assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
1050 // …and a mixed selection carries both.
1051 let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
1052 assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
1053 }
1054
1055 #[test]
1056 fn sketch_selection_offers_cutout_and_profile_features() {
1057 // A committed-sketch selection offers the profile-driven features and, in
1058 // particular, SM Cutout. (Revolve now also wants an axis edge, so it is
1059 // deliberately absent here.)
1060 let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
1061 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1062 for want in ["E", "SM.CUTOUT"] {
1063 assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
1064 }
1065 assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
1066 // SM Cutout matches on `profile` only (its `["SOLID"]` `sheet` field does
1067 // not match a sketch), so the create pre-fills the profile field.
1068 let cutout = offers
1069 .iter()
1070 .find(|o| o.type_code == "SM.CUTOUT")
1071 .expect("cutout offered for a sketch");
1072 assert_eq!(cutout.fields.len(), 1);
1073 assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
1074 assert!(
1075 cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
1076 "profile filter: {:?}",
1077 cutout.fields[0].filter
1078 );
1079 assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
1080 }
1081
1082 #[test]
1083 fn solid_selection_offers_cutout_via_sheet() {
1084 // A real-solid selection still offers SM Cutout, matched on `sheet`.
1085 let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
1086 let cutout = offers
1087 .iter()
1088 .find(|o| o.type_code == "SM.CUTOUT")
1089 .expect("cutout offered for a solid");
1090 assert_eq!(cutout.fields.len(), 1);
1091 assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
1092 assert!(
1093 cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
1094 "sheet filter: {:?}",
1095 cutout.fields[0].filter
1096 );
1097 // Solid + sketch matches BOTH fields — the create fills sheet AND profile.
1098 let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
1099 let offers = offers_for(&sel, "all");
1100 let cutout = offers
1101 .iter()
1102 .find(|o| o.type_code == "SM.CUTOUT")
1103 .expect("cutout offered for solid+sketch");
1104 let writes = prefill_references(&cutout.fields, &sel);
1105 assert_eq!(
1106 writes,
1107 vec![
1108 (vec!["sheet".to_string()], Value::String("Plate".into())),
1109 (vec!["profile".to_string()], Value::String("Sk".into())),
1110 ]
1111 );
1112 }
1113
1114 /// The constraint-offer CLICK path end to end: seed `elements` from the
1115 /// selection, add through the engine (the auto-solve mutation lane), and
1116 /// open the new row so the panel shows its dialog.
1117 #[test]
1118 fn add_constraint_from_selection_seeds_adds_and_opens() {
1119 use crate::panels::component_actions::tests::assembly_engine;
1120 let mut state = assembly_engine();
1121 state.select_component("ACOMP2");
1122 add_constraint_from_selection(&mut state, "fixed");
1123 let constraints = state.assembly_state_value();
1124 let entry = constraints["constraints"]
1125 .as_array()
1126 .and_then(|list| list.last())
1127 .cloned()
1128 .expect("constraint added");
1129 assert_eq!(entry["type"], "fixed");
1130 assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
1131 assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
1132 }
1133
1134 /// Constraint offers: the per-type `applicable` predicates against the
1135 /// probe, gated on the constraints panel's workbench visibility.
1136 #[test]
1137 fn constraint_offers_follow_predicates_and_workbench() {
1138 let one_component = SelectionProbe {
1139 solids: 1,
1140 components: 1,
1141 all_component: true,
1142 ..Default::default()
1143 };
1144 let pair = SelectionProbe {
1145 faces: 2,
1146 components: 2,
1147 all_component: true,
1148 ..Default::default()
1149 };
1150 let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
1151 constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
1152 };
1153
1154 // ONE component's solid → Fixed only.
1155 assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
1156 // Two faces across two components → every face-pair type, no Fixed.
1157 let pair_ids = ids(&pair, "assembly");
1158 for want in [
1159 "coincident",
1160 "touch_align",
1161 "parallel",
1162 "distance",
1163 "angle",
1164 "concentric",
1165 "perpendicular",
1166 "tangent",
1167 ] {
1168 assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
1169 }
1170 assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
1171 // "All" sees the claimed constraints panel too; Modeling does not.
1172 assert!(!ids(&pair, "all").is_empty());
1173 assert!(ids(&pair, "modeling").is_empty());
1174 // A non-component selection never offers constraints.
1175 let plain = SelectionProbe { faces: 2, ..Default::default() };
1176 assert!(ids(&plain, "assembly").is_empty());
1177 }
1178
1179 #[test]
1180 fn names_for_filter_maps_sketch_kind() {
1181 // A `["FACE","SKETCH"]` profile field pre-fills from the selected sketches.
1182 let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
1183 assert_eq!(
1184 sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
1185 ["Box_PZ", "Sk1", "Sk2"]
1186 );
1187 assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
1188 // A SOLID-only field never picks up a sketch.
1189 assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1190 }
1191
1192 #[test]
1193 fn all_names_gathers_every_named_entity_for_info_windows() {
1194 // A multi-select of a solid + two faces + an edge → four Info-window targets
1195 // (solids → faces → edges order, de-duplicated).
1196 let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1197 assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
1198 // Nothing selected → no windows.
1199 assert!(sel_of(&[], &[], &[]).all_names().is_empty());
1200 }
1201
1202 #[test]
1203 fn component_selection_detects_single_component_and_fences_features() {
1204 use crate::panels::component_actions::tests::assembly_engine;
1205 let engine = assembly_engine();
1206
1207 // ONE member solid selected → the sole action target, features fenced.
1208 let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1209 let comp = component_selection(&sel, &engine);
1210 assert!(comp.suppress_features());
1211 assert_eq!(comp.sole_target(), Some("ACOMP2"));
1212
1213 // TWO components selected → fence holds, but no single action target.
1214 let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
1215 let comp = component_selection(&sel, &engine);
1216 assert!(comp.suppress_features());
1217 assert_eq!(comp.sole_target(), None);
1218
1219 // A component FACE selection fences features (the kernel would reject
1220 // the reference anyway) but is not the solid-click action shape.
1221 let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
1222 let comp = component_selection(&sel, &engine);
1223 assert!(comp.suppress_features());
1224 assert_eq!(comp.sole_target(), None);
1225
1226 // A non-component solid (no ACOMP prefix) keeps the feature offers.
1227 let sel = sel_of(&["Box"], &[], &[]);
1228 let comp = component_selection(&sel, &engine);
1229 assert!(!comp.suppress_features());
1230 assert_eq!(comp.sole_target(), None);
1231
1232 // MIXED component + ordinary solid: not all-component → no fence, no
1233 // action target (the kernel enforces the reference fence at execution).
1234 let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
1235 let comp = component_selection(&sel, &engine);
1236 assert!(!comp.suppress_features());
1237 assert_eq!(comp.sole_target(), None);
1238
1239 // An ACOMP-shaped prefix with no matching feature is NOT a component.
1240 let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
1241 assert!(!component_selection(&sel, &engine).suppress_features());
1242 }
1243
1244 /// The workbench fence on the COMPONENT action set: a qualifying selection
1245 /// (one component's member solid) only yields an action target in a
1246 /// workbench that shows the assembly structure panel — Assembly + All —
1247 /// so Move / Edit-in-place / Open-Part / Fix / Delete never bleed into the
1248 /// Modeling (or Sheet Metal) context bar. The feature FENCE is workbench-
1249 /// independent: component geometry suppresses feature offers everywhere.
1250 #[test]
1251 fn component_actions_are_workbench_gated() {
1252 use crate::panels::component_actions::tests::assembly_engine;
1253 let engine = assembly_engine();
1254 let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1255 let comp = component_selection(&sel, &engine);
1256 assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");
1257
1258 for wb in ["assembly", "all"] {
1259 assert_eq!(
1260 component_action_target(&comp, wb),
1261 Some("ACOMP2"),
1262 "component actions offered under `{wb}`"
1263 );
1264 }
1265 for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
1266 assert_eq!(
1267 component_action_target(&comp, wb),
1268 None,
1269 "component actions must not bleed into `{wb}`"
1270 );
1271 // The kernel-enforced fence still suppresses feature offers there.
1272 assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
1273 }
1274 }
1275
1276 #[test]
1277 fn names_for_filter_maps_kinds() {
1278 let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1279 assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
1280 assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
1281 assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1282 // A multi-kind filter (fillet's FACE+EDGE) gathers both.
1283 assert_eq!(
1284 sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
1285 ["Box_PZ", "Box_NZ", "Box_E0"]
1286 );
1287 }
1288
1289 #[test]
1290 fn plane_selection_present_kind_is_plane() {
1291 // A datum/plane-only selection presents the ONE kind `PLANE` (never DATUM).
1292 assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
1293 // A face-only selection is unchanged (no PLANE leaks in).
1294 assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
1295 }
1296
1297 #[test]
1298 fn names_for_filter_maps_planes_separately_from_faces() {
1299 // A `["PLANE","FACE"]` field (the sketchPlane filter) prefills from the
1300 // selected DATUM frame when only a plane is selected…
1301 let planes = sel_planes(&["Datum:XY"]);
1302 assert_eq!(
1303 planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
1304 ["Datum:XY"]
1305 );
1306 // …`DATUM` is an alias for the same planes bucket…
1307 assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
1308 // …and a datum plane never lands in a FACE-only field (buckets are split).
1309 assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
1310 // A FACE-only selection still fills a `["PLANE","FACE"]` field with the
1311 // face (the FACE arm), and never yields the plane bucket.
1312 let faces = sel_of(&[], &["Box_PZ"], &[]);
1313 assert_eq!(
1314 faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
1315 ["Box_PZ"]
1316 );
1317 assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
1318 }
1319
1320 #[test]
1321 fn plane_selection_offers_sketch_and_prefills_the_plane() {
1322 // A datum/plane-only selection offers Sketch (kernel predicate keys on
1323 // `probe.planes`), and the create routes the frame name into `sketchPlane`.
1324 let sel = sel_planes(&["Datum:XY"]);
1325 let offers = offers_for(&sel, "all");
1326 let sketch = offers
1327 .iter()
1328 .find(|o| o.type_code == "S")
1329 .expect("a plane-only selection offers Sketch");
1330 let writes = prefill_references(&sketch.fields, &sel);
1331 assert!(
1332 writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
1333 "sketchPlane prefilled with the datum frame: {writes:?}"
1334 );
1335 // A bare plane drives no profile/solid/edge feature.
1336 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1337 for nope in ["E", "F", "CH", "B", "XFORM"] {
1338 assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
1339 }
1340 }
1341}