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 if let Err(error) = add_constraint_from_selection(state, type_id) {
298 state.push_notice(format!("Add constraint: {error}"));
299 }
300 }
301 }
302 Some(key) if key.starts_with("feature:") => {
303 let code = &key["feature:".len()..];
304 if let Some(offer) = offers.iter().find(|o| o.type_code == code) {
305 outcome.focus = create_feature_from_selection(state, offer, &sel);
306 }
307 }
308 _ => {}
309 }
310 outcome
311 }
312
313 /// The published widget hit-rects (egui points) for the headed verifier —
314 /// `action:clear|action:hide|action:edit-owning` + `feature:<TYPE>`.
315 #[cfg(target_arch = "wasm32")]
316 pub fn hits_json(&self) -> String {
317 let map: serde_json::Map<String, Value> = self
318 .hits
319 .iter()
320 .map(|(k, r)| {
321 (
322 k.clone(),
323 serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
324 )
325 })
326 .collect();
327 Value::Object(map).to_string()
328 }
329
330 /// The bar's LOGICAL state for the verifier: whether it is shown + which
331 /// generic actions, feature type-codes, and component actions it offered
332 /// this frame (and the single component the latter target).
333 #[cfg(target_arch = "wasm32")]
334 pub fn state_json(&self) -> String {
335 serde_json::json!({
336 "shown": !self.hits.is_empty(),
337 "actions": self.shown_actions,
338 "features": self.shown_features,
339 "constraints": self.shown_constraints,
340 "componentActions": self.shown_component_actions,
341 "componentTarget": self.shown_component_target,
342 })
343 .to_string()
344 }
345}
346
347/// The COMPONENT view of the current selection: which ACOMP instances own the
348/// selected entities, and whether the selection qualifies for the component
349/// action set / the feature-offer fence.
350struct ComponentSelection {
351 /// Unique owning ACOMP ids across every selected NAMED entity, selection
352 /// order.
353 ids: Vec<String>,
354 /// Whether EVERY selected named entity is component-owned (and at least one
355 /// is selected; vertices carry no names, so any vertex disqualifies).
356 all_component: bool,
357 /// Whether the selection is member SOLIDS only (the shape a viewport
358 /// component click produces).
359 solids_only: bool,
360}
361
362impl ComponentSelection {
363 /// The feature FENCE: suppress modeling-feature creation offers when the
364 /// whole selection is component geometry.
365 fn suppress_features(&self) -> bool {
366 self.all_component && !self.ids.is_empty()
367 }
368
369 /// The single component the ACTION SET targets: exactly one owning
370 /// component, selected via its member solid(s) alone.
371 fn sole_target(&self) -> Option<&str> {
372 (self.suppress_features() && self.solids_only && self.ids.len() == 1)
373 .then(|| self.ids[0].as_str())
374 }
375}
376
377/// Resolve the selection's component ownership through the engine's namespace
378/// parse (`component_of_solid` accepts any namespaced entity name — solid,
379/// face, or edge).
380fn component_selection(sel: &Selection, state: &EngineState) -> ComponentSelection {
381 let mut ids: Vec<String> = Vec::new();
382 let mut all = true;
383 let mut any = false;
384 for name in sel.all_names() {
385 any = true;
386 match state.component_of_solid(&name) {
387 Some(id) => {
388 if !ids.contains(&id) {
389 ids.push(id);
390 }
391 }
392 None => all = false,
393 }
394 }
395 if sel.vertices > 0 {
396 all = false;
397 }
398 ComponentSelection {
399 ids,
400 all_component: all && any,
401 solids_only: !sel.solids.is_empty()
402 && sel.sketches.is_empty()
403 && sel.faces.is_empty()
404 && sel.edges.is_empty()
405 && sel.vertices == 0,
406 }
407}
408
409/// The current selection, resolved once per frame from `selection_json`, plus the
410/// single-selection owning feature (for **Edit owning feature**).
411struct Selection {
412 solids: Vec<String>,
413 /// Selected COMMITTED SKETCHES. A committed sketch presents in the scene as a
414 /// solid (`is_sketch`), so it arrives in `selection_json`'s `solids` array; we
415 /// partition it out here because its reference KIND is `SKETCH`, not `SOLID`
416 /// (it must satisfy a `["FACE","SKETCH"]` profile field, and must NOT satisfy a
417 /// `["SOLID"]` field like SM Cutout's `sheet`).
418 sketches: Vec<String>,
419 faces: Vec<String>,
420 edges: Vec<String>,
421 /// Selected construction PLANES / DATUM planes (their scene FRAME names, from
422 /// `selection_json`'s `datums` array). Kept a SEPARATE bucket from `faces`:
423 /// only [`kinds_present`](Self::kinds_present) / [`names_for_filter`](Self::
424 /// names_for_filter) / the probe read it — NEVER the scene-solid consumers
425 /// (`all_names`, Info, Hide, `component_selection`), which cannot resolve a
426 /// datum frame name. A datum plane seats a sketch's `sketchPlane` exactly like
427 /// a planar face (the kernel resolves either).
428 planes: Vec<String>,
429 vertices: usize,
430 /// The producer feature id of a SINGLE-entity selection with a known producer.
431 owning_feature: Option<String>,
432}
433
434impl Selection {
435 fn read(state: &EngineState) -> Self {
436 let v: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
437 let names = |key: &str| -> Vec<String> {
438 v[key]
439 .as_array()
440 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
441 .unwrap_or_default()
442 };
443 // Partition the selected `solids` into REAL solids vs committed sketches: a
444 // selected solid is a sketch iff its name is a committed sketch (the
445 // sketch's selectable name is its id; visibility is irrelevant here).
446 let sketch_ids: std::collections::HashSet<String> = state
447 .committed_sketches()
448 .into_iter()
449 .map(|(id, _visible)| id)
450 .collect();
451 let (sketches, solids): (Vec<String>, Vec<String>) = names("solids")
452 .into_iter()
453 .partition(|name| sketch_ids.contains(name));
454 let faces = names("faces");
455 let edges = names("edges");
456 // Construction planes / datum planes arrive under `datums` — a SEPARATE
457 // bucket (never merged into `faces`): the scene-solid consumers cannot
458 // resolve a datum frame name (see the `planes` field doc).
459 let planes = names("datums");
460 let vertices = v["vertices"].as_u64().unwrap_or(0) as usize;
461
462 // A single selected entity → its owning feature (the old app's
463 // Edit-owning-feature, generalized from FACE/PLANE to any single entity —
464 // a lone selected sketch rolls to its `S` feature, a lone datum/plane to
465 // its `D`/`P` feature). Datum planes count toward the single-selection
466 // total too, else picking one shows no Edit-owning-feature button.
467 let total = solids.len() + sketches.len() + faces.len() + edges.len() + planes.len();
468 let single = if total == 1 && vertices == 0 {
469 faces
470 .first()
471 .or_else(|| edges.first())
472 .or_else(|| solids.first())
473 .or_else(|| sketches.first())
474 .or_else(|| planes.first())
475 .cloned()
476 } else {
477 None
478 };
479 let owning_feature = single
480 .as_deref()
481 .and_then(|name| state.creating_feature(name))
482 .map(|(id, _ty)| id);
483
484 Self {
485 solids,
486 sketches,
487 faces,
488 edges,
489 planes,
490 vertices,
491 owning_feature,
492 }
493 }
494
495 /// The selectable KINDS currently present (vertices carry no names, and no
496 /// primary reference is vertex-only, so they never drive feature actions).
497 fn kinds_present(&self) -> Vec<&'static str> {
498 let mut kinds = Vec::new();
499 if !self.solids.is_empty() {
500 kinds.push("SOLID");
501 }
502 if !self.sketches.is_empty() {
503 kinds.push("SKETCH");
504 }
505 if !self.faces.is_empty() {
506 kinds.push("FACE");
507 }
508 if !self.edges.is_empty() {
509 kinds.push("EDGE");
510 }
511 // Datum planes and `P` planes both present as ONE kind, `PLANE` — the
512 // schema filters spell it `["PLANE","FACE"]`, and both resolve as frames.
513 if !self.planes.is_empty() {
514 kinds.push("PLANE");
515 }
516 kinds
517 }
518
519 /// The selected names whose kind the reference `filter` accepts (de-duplicated,
520 /// in solid→face→edge order). `PLANE`/`DATUM` map to selected datum/plane
521 /// frames, `COMPONENT` to selected solids (the picker never yields a bare
522 /// component here).
523 fn names_for_filter(&self, filter: &[String]) -> Vec<String> {
524 let mut out: Vec<String> = Vec::new();
525 let push = |src: &[String], out: &mut Vec<String>| {
526 for name in src {
527 if !out.iter().any(|n| n == name) {
528 out.push(name.clone());
529 }
530 }
531 };
532 for f in filter {
533 match f.as_str() {
534 "SOLID" | "COMPONENT" => push(&self.solids, &mut out),
535 "SKETCH" => push(&self.sketches, &mut out),
536 "FACE" => push(&self.faces, &mut out),
537 // A `["PLANE","FACE"]` field prefills from EITHER a selected face
538 // (via the FACE arm) or a selected datum/plane frame here; `DATUM`
539 // is an alias for the same planes bucket.
540 "PLANE" | "DATUM" => push(&self.planes, &mut out),
541 "EDGE" => push(&self.edges, &mut out),
542 _ => {}
543 }
544 }
545 out
546 }
547
548 /// Every NAMED selected entity (solids → faces → edges), de-duplicated — one per
549 /// pinned Info window the Info action opens. Vertices carry no name, and datum
550 /// PLANES are deliberately EXCLUDED: this list feeds the scene-solid consumers
551 /// (Info, Hide via `hide_selected`, `component_selection` via
552 /// `component_of_solid`), none of which can resolve a datum frame name. A datum
553 /// plane can be selected (`has_selection` now counts it, so the bar shows and
554 /// offers Sketch), but it only reaches `kinds_present` / `names_for_filter` /
555 /// the probe — never this list.
556 fn all_names(&self) -> Vec<String> {
557 let mut out: Vec<String> = Vec::new();
558 for src in [&self.solids, &self.sketches, &self.faces, &self.edges] {
559 for name in src {
560 if !name.is_empty() && !out.iter().any(|n| n == name) {
561 out.push(name.clone());
562 }
563 }
564 }
565 out
566 }
567
568 fn summary(&self) -> String {
569 format!(
570 "Selected: {} solid, {} sketch, {} face, {} edge, {} plane, {} vertex",
571 self.solids.len(),
572 self.sketches.len(),
573 self.faces.len(),
574 self.edges.len(),
575 self.planes.len(),
576 self.vertices,
577 )
578 }
579}
580
581/// One reference field of an offered feature, in schema order — the pre-fill
582/// targets [`prefill_references`] consumes the selection into.
583struct OfferField {
584 /// The JSON path of the `References`-group field.
585 path: Vec<String>,
586 /// That field's `selectionFilter` (which selected kinds map into it).
587 filter: Vec<String>,
588 /// Whether the field takes a list (vs a single name).
589 multiple: bool,
590}
591
592/// One offered feature action.
593struct Offer {
594 /// The feature TYPE CODE (e.g. `E`, `F`, `CH`).
595 type_code: String,
596 /// The button label (the feature's long name).
597 label: String,
598 /// Every `References`-group field whose filter accepts a selected kind
599 /// (schema order) — the create pre-fills them under a consumed set.
600 fields: Vec<OfferField>,
601}
602
603/// Build the [`SelectionProbe`] the kernel applicability predicates run on:
604/// the selection's kind counts, its component view, and whether it sits entirely
605/// on sheet metal ([`all_on_sheet_metal`], the gate for the SM edit features).
606fn selection_probe(
607 sel: &Selection,
608 comp: &ComponentSelection,
609 all_sheet_metal: bool,
610) -> SelectionProbe {
611 SelectionProbe {
612 solids: sel.solids.len(),
613 sketches: sel.sketches.len(),
614 faces: sel.faces.len(),
615 edges: sel.edges.len(),
616 planes: sel.planes.len(),
617 vertices: sel.vertices,
618 components: comp.ids.len(),
619 all_component: comp.all_component,
620 all_sheet_metal,
621 }
622}
623
624/// Whether the selection sits ENTIRELY on sheet-metal bodies (and names at least
625/// one entity) — the gate the SM edit features (Flange / Fillet / Chamfer) key
626/// on. Mirrors [`component_selection`]'s all-or-nothing rule, including its
627/// vertex convention: a vertex carries no name to resolve, so any vertex in the
628/// selection disqualifies it.
629fn all_on_sheet_metal(sel: &Selection, state: &EngineState) -> bool {
630 let names = sel.all_names();
631 !names.is_empty()
632 && sel.vertices == 0
633 && names.iter().all(|name| state.is_sheet_metal_object(name))
634}
635
636/// The feature actions to offer: every catalogue feature whose OWN
637/// `context_applicable` predicate (kernel-defined, next to its schema —
638/// `feature_pipeline::context_offer`) accepts the current selection probe. The
639/// `workbench` argument only FURTHER RESTRICTS that set to the features the
640/// active workbench includes; like the palette filter it is a pure UI trim over
641/// CREATION and never affects the existing history / execution.
642///
643/// The pre-fill stays schema-derived: each offer carries EVERY
644/// `References`-group `reference_selection` field whose `selectionFilter`
645/// intersects a selected kind (schema order), and the create consumes the
646/// selection into them ([`prefill_references`]).
647fn feature_offers(probe: &SelectionProbe, sel: &Selection, workbench: &str) -> Vec<Offer> {
648 let kinds = sel.kinds_present();
649 if kinds.is_empty() {
650 return Vec::new();
651 }
652 let catalogue = features::feature_catalogue();
653 let mut out = Vec::new();
654 if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
655 for feature in list {
656 let Some(ty) = feature.get("type").and_then(Value::as_str) else {
657 continue;
658 };
659 if ty.is_empty() {
660 continue;
661 }
662 // Workbench UI filter: skip features this workbench does not include
663 // (classified off the type code).
664 if !crate::workbench::includes_feature(workbench, ty) {
665 continue;
666 }
667 // The feature's own answer to "does this selection make me
668 // meaningful?" — nuance (Revolve wants profile AND axis) lives in
669 // the kernel predicate, not here.
670 if !brep_kernel::feature_context_applicable(ty, probe) {
671 continue;
672 }
673 // The pre-fill targets: every `References`-group reference field
674 // accepting a selected kind. Primitives only carry the boolean-op
675 // `targets` Reference (group `Boolean`), so they never collect any
676 // (their predicates return false anyway).
677 let fields: Vec<OfferField> = features::feature_form_fields(ty)
678 .iter()
679 .filter(|field| field.group == "References")
680 .filter_map(|field| {
681 let FieldKind::Reference { filter, multiple } = &field.kind else {
682 return None;
683 };
684 filter
685 .iter()
686 .any(|f| kinds.iter().any(|k| *k == f.as_str()))
687 .then(|| OfferField {
688 path: field.path.clone(),
689 filter: filter.clone(),
690 multiple: *multiple,
691 })
692 })
693 .collect();
694 out.push(Offer {
695 type_code: ty.to_string(),
696 label: features::feature_long_name(ty),
697 fields,
698 });
699 }
700 }
701 out
702}
703
704/// The single component the context bar's COMPONENT action set targets, or
705/// `None` when the selection shape doesn't qualify ([`ComponentSelection::
706/// sole_target`]) OR the active workbench hides the assembly structure panel
707/// (claim-based visibility, [`crate::workbench::panel_visible`]: Assembly +
708/// All). The workbench gate is what keeps the Move / Edit-in-place / Open-Part
709/// / Fix / Delete buttons — assembly UI — out of the Modeling context bar; the
710/// feature FENCE (`suppress_features`) is intentionally NOT gated, since the
711/// kernel rejects component references in every workbench.
712fn component_action_target<'a>(comp: &'a ComponentSelection, workbench: &str) -> Option<&'a str> {
713 // The BOM is the assembly workbench's component list (it absorbed the
714 // Structure panel): component actions target a selection only where that
715 // list is on screen.
716 let list_shown = crate::workbench::panel_visible(
717 workbench,
718 crate::workbench::assembly::BOM_PANEL_ID,
719 );
720 list_shown.then(|| comp.sole_target()).flatten()
721}
722
723/// The constraint actions to offer: every constraint type whose `applicable`
724/// predicate ([`brep_kernel::CONSTRAINT_TYPES`], defined with the type table)
725/// accepts the probe — gated on the Assembly Constraints panel being available
726/// in the active workbench (claim-based visibility: Assembly + All).
727fn constraint_offers(
728 probe: &SelectionProbe,
729 workbench: &str,
730) -> Vec<&'static brep_kernel::ConstraintTypeDef> {
731 if !crate::workbench::panel_visible(workbench, crate::workbench::assembly::CONSTRAINTS_PANEL_ID)
732 {
733 return Vec::new();
734 }
735 brep_kernel::CONSTRAINT_TYPES
736 .iter()
737 .filter(|def| (def.applicable)(probe))
738 .collect()
739}
740
741/// Add a constraint of `type_id` from the selection: `elements` pre-seeded
742/// through the constraints panel's seeding helper (filtered + capped by the
743/// type's own schema), then the new row opened so the panel shows its dialog.
744/// The engine's mutation path handles auto-solve exactly like a panel add.
745pub(crate) fn add_constraint_from_selection(
746 state: &mut EngineState,
747 type_id: &str,
748) -> Result<String, String> {
749 let catalogue = brep_kernel::constraint_schema_catalogue();
750 let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
751 let seed = super::assembly_constraints::seeded_elements(state, &schemas, type_id);
752 let id = state.assembly_add_constraint(type_id, &seed.to_string())?;
753 let _ = state.assembly_set_constraint_open(&id, true);
754 Ok(id)
755}
756
757/// Consume the selection into an offer's reference fields, schema order: each
758/// field takes the selected names its filter accepts that NO EARLIER field
759/// consumed (first name for a single field, all remaining for a multiple) — so
760/// face+edge → Revolve fills `profile` with the face and `axis` with the edge,
761/// and Pattern's edge lands in `directionRef` without echoing into `axisRef`.
762/// Returns `(path, value)` writes for [`form::set_at`].
763fn prefill_references(fields: &[OfferField], sel: &Selection) -> Vec<(Vec<String>, Value)> {
764 let mut consumed: HashSet<String> = HashSet::new();
765 let mut writes = Vec::new();
766 for field in fields {
767 let names: Vec<String> = sel
768 .names_for_filter(&field.filter)
769 .into_iter()
770 .filter(|name| !consumed.contains(name))
771 .collect();
772 if names.is_empty() {
773 continue;
774 }
775 let value = if field.multiple {
776 consumed.extend(names.iter().cloned());
777 Value::Array(names.into_iter().map(Value::String).collect())
778 } else {
779 let name = names.into_iter().next().unwrap_or_default();
780 consumed.insert(name.clone());
781 Value::String(name)
782 };
783 writes.push((field.path.clone(), value));
784 }
785 writes
786}
787
788/// Create a feature of `offer.type_code` referencing the selection: build a
789/// fresh descriptor whose `inputParams` are the schema defaults with an
790/// engine-unique `id` and the matched reference fields pre-filled
791/// ([`prefill_references`]), then append it (`add_feature`, which rolls to it).
792/// Returns the new feature id (for the shell to expand its node).
793fn create_feature_from_selection(
794 state: &mut EngineState,
795 offer: &Offer,
796 sel: &Selection,
797) -> Option<String> {
798 let id = state.next_feature_id(&features::feature_short_name(&offer.type_code));
799 let mut params = features::feature_default_params(&offer.type_code);
800 if let Value::Object(map) = &mut params {
801 map.insert("id".into(), Value::String(id.clone()));
802 }
803
804 for (path, value) in prefill_references(&offer.fields, sel) {
805 form::set_at(&mut params, &path, value);
806 }
807
808 let feature = serde_json::json!({
809 "type": offer.type_code,
810 "inputParams": params,
811 "persistentData": {},
812 });
813 if state.add_feature(&feature.to_string()).is_ok() {
814 Some(id)
815 } else {
816 None
817 }
818}
819
820/// The feature index carrying id `id` (the engine exposes index→id, so we scan).
821fn feature_index(state: &EngineState, id: &str) -> Option<usize> {
822 (0..state.history_len()).find(|&i| state.feature_id_at(i).as_deref() == Some(id))
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 fn sel_full(solids: &[&str], sketches: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
830 Selection {
831 solids: solids.iter().map(|s| s.to_string()).collect(),
832 sketches: sketches.iter().map(|s| s.to_string()).collect(),
833 faces: faces.iter().map(|s| s.to_string()).collect(),
834 edges: edges.iter().map(|s| s.to_string()).collect(),
835 planes: Vec::new(),
836 vertices: 0,
837 owning_feature: None,
838 }
839 }
840
841 /// A selection of construction PLANES / DATUM planes only (their frame names).
842 fn sel_planes(planes: &[&str]) -> Selection {
843 Selection {
844 solids: Vec::new(),
845 sketches: Vec::new(),
846 faces: Vec::new(),
847 edges: Vec::new(),
848 planes: planes.iter().map(|s| s.to_string()).collect(),
849 vertices: 0,
850 owning_feature: None,
851 }
852 }
853
854 fn sel_of(solids: &[&str], faces: &[&str], edges: &[&str]) -> Selection {
855 sel_full(solids, &[], faces, edges)
856 }
857
858 /// Offers for a NON-COMPONENT selection (the plain modeling shape).
859 fn offers_for(sel: &Selection, workbench: &str) -> Vec<Offer> {
860 let comp = ComponentSelection {
861 ids: Vec::new(),
862 all_component: false,
863 solids_only: false,
864 };
865 // Plain-geometry test selections carry no scene, so they are never on
866 // sheet metal (the SM edit features are covered separately).
867 feature_offers(&selection_probe(sel, &comp, false), sel, workbench)
868 }
869
870 #[test]
871 fn face_selection_offers_face_features_not_solid_ones() {
872 // "all" workbench so the expected sets below are unfiltered.
873 let offers = offers_for(&sel_of(&[], &["Box_PZ"], &[]), "all");
874 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
875 // Face-primary features are offered…
876 for want in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH"] {
877 assert!(codes.contains(&want), "FACE should offer {want}: {codes:?}");
878 }
879 // …features whose ONLY reference kind is SOLID (or SKETCH/EDGE) are NOT.
880 for nope in ["B", "XFORM", "RIB"] {
881 assert!(!codes.contains(&nope), "FACE must not offer {nope}: {codes:?}");
882 }
883 // …but a feature that takes a face/plane as a SECONDARY reference IS now
884 // offered, keyed on that field (any-field matching — the same rule that
885 // lets a sketch drive a cutout): Mirror about a face, Split by it, Pattern
886 // along its normal.
887 for want in ["M", "PATTERN", "SPL"] {
888 assert!(
889 codes.contains(&want),
890 "FACE should offer {want} via its plane/face field: {codes:?}"
891 );
892 }
893 // Primitives (only a boolean `targets` Reference) never appear.
894 assert!(!codes.contains(&"P.CU"));
895 // Revolve's kernel predicate wants a profile AND an axis edge — a lone
896 // face no longer offers it.
897 assert!(!codes.contains(&"R"), "FACE alone must not offer Revolve: {codes:?}");
898 }
899
900 #[test]
901 fn edge_selection_offers_fillet_chamfer_tube() {
902 let offers = offers_for(&sel_of(&[], &[], &["Box_E0"]), "all");
903 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
904 for want in ["F", "CH", "TU"] {
905 assert!(codes.contains(&want), "EDGE should offer {want}: {codes:?}");
906 }
907 assert!(!codes.contains(&"E"), "EDGE must not offer Extrude: {codes:?}");
908 assert!(!codes.contains(&"R"), "EDGE alone must not offer Revolve: {codes:?}");
909 }
910
911 /// The sheet-metal EDIT features gate on `all_sheet_metal` end to end: an edge
912 /// that sits on a sheet-metal body offers SM Flange / Fillet / Chamfer; the
913 /// same edge on plain geometry offers none of them (the flag plumbs through
914 /// `selection_probe` → `feature_offers`).
915 #[test]
916 fn sheet_metal_edits_offer_only_on_a_sheet_metal_selection() {
917 let sel = sel_of(&[], &[], &["Wall_E0"]);
918 let comp = ComponentSelection {
919 ids: Vec::new(),
920 all_component: false,
921 solids_only: false,
922 };
923 let codes = |all_sheet_metal: bool| -> Vec<String> {
924 feature_offers(&selection_probe(&sel, &comp, all_sheet_metal), &sel, "sheetMetal")
925 .iter()
926 .map(|o| o.type_code.clone())
927 .collect()
928 };
929 let on_sm = codes(true);
930 for want in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
931 assert!(on_sm.iter().any(|c| c == want), "sheet-metal edge offers {want}: {on_sm:?}");
932 }
933 let plain = codes(false);
934 for nope in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
935 assert!(!plain.iter().any(|c| c == nope), "plain edge must not offer {nope}: {plain:?}");
936 }
937 }
938
939 #[test]
940 fn solid_selection_offers_solid_features() {
941 let offers = offers_for(&sel_of(&["Box"], &[], &[]), "all");
942 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
943 for want in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB"] {
944 assert!(codes.contains(&want), "SOLID should offer {want}: {codes:?}");
945 }
946 assert!(!codes.contains(&"F"), "SOLID must not offer Fillet: {codes:?}");
947 }
948
949 #[test]
950 fn empty_selection_offers_nothing() {
951 assert!(offers_for(&sel_of(&[], &[], &[]), "all").is_empty());
952 }
953
954 /// The user-specified nuance end to end: profile + axis edge offers Revolve,
955 /// and the consumed pre-fill routes the face into `profile` and the edge
956 /// into `axis` (one field each, nothing echoed).
957 #[test]
958 fn revolve_offer_needs_profile_and_axis_and_prefills_both() {
959 let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
960 let offers = offers_for(&sel, "all");
961 let revolve = offers
962 .iter()
963 .find(|o| o.type_code == "R")
964 .expect("face+edge offers Revolve");
965 let writes = prefill_references(&revolve.fields, &sel);
966 assert_eq!(
967 writes,
968 vec![
969 (vec!["profile".to_string()], Value::String("Box_PZ".into())),
970 (vec!["axis".to_string()], Value::String("Box_E0".into())),
971 ]
972 );
973 // A committed sketch as the profile works the same way.
974 let sel = sel_full(&[], &["Sk"], &[], &["Box_E0"]);
975 let offers = offers_for(&sel, "all");
976 assert!(
977 offers.iter().any(|o| o.type_code == "R"),
978 "sketch+edge offers Revolve"
979 );
980 }
981
982 /// The consumed set: a name lands in at most ONE field, schema order —
983 /// Pattern's edge fills `directionRef` and does NOT echo into `axisRef`;
984 /// Fillet's multiple `edges` field takes faces and edges together.
985 #[test]
986 fn prefill_consumes_each_name_once() {
987 let sel = sel_of(&["Box"], &[], &["Box_E0"]);
988 let offers = offers_for(&sel, "all");
989 let pattern = offers.iter().find(|o| o.type_code == "PATTERN").expect("pattern");
990 let writes = prefill_references(&pattern.fields, &sel);
991 assert_eq!(
992 writes,
993 vec![
994 (vec!["solids".to_string()], serde_json::json!(["Box"])),
995 (vec!["directionRef".to_string()], Value::String("Box_E0".into())),
996 ]
997 );
998
999 let sel = sel_of(&[], &["Box_PZ"], &["Box_E0"]);
1000 let offers = offers_for(&sel, "all");
1001 let fillet = offers.iter().find(|o| o.type_code == "F").expect("fillet");
1002 let writes = prefill_references(&fillet.fields, &sel);
1003 assert_eq!(
1004 writes,
1005 vec![(vec!["edges".to_string()], serde_json::json!(["Box_PZ", "Box_E0"]))]
1006 );
1007 }
1008
1009 #[test]
1010 fn workbench_filters_the_context_offers() {
1011 // A face selection under different workbenches: the workbench only FURTHER
1012 // restricts the schema-declared offers (it adds no new trigger channel).
1013 let sel = sel_of(&[], &["Box_PZ"], &[]);
1014 let codes = |wb: &str| -> Vec<String> {
1015 offers_for(&sel, wb).iter().map(|o| o.type_code.clone()).collect()
1016 };
1017 let all = codes("all");
1018 let modeling = codes("modeling");
1019 let sheet = codes("sheetMetal");
1020 // Modeling keeps the modeling face-feature Extrude, and drops every
1021 // sheet-metal (`SM.*`) offer.
1022 assert!(modeling.iter().any(|c| c == "E"), "modeling should offer Extrude: {modeling:?}");
1023 assert!(
1024 !modeling.iter().any(|c| c.starts_with("SM.")),
1025 "modeling must not offer any SM.* feature: {modeling:?}"
1026 );
1027 // Sheet Metal drops the pure-modeling Extrude.
1028 assert!(
1029 !sheet.iter().any(|c| c == "E"),
1030 "sheet metal must not offer Extrude: {sheet:?}"
1031 );
1032 // All is the superset: every modeling offer is present in All.
1033 for c in &modeling {
1034 assert!(all.contains(c), "All should contain modeling offer {c}: {all:?}");
1035 }
1036 }
1037
1038 #[test]
1039 fn extrude_primary_reference_is_single_profile() {
1040 let offers = offers_for(&sel_of(&[], &["F1"], &[]), "all");
1041 let extrude = offers.iter().find(|o| o.type_code == "E").expect("extrude offered");
1042 assert_eq!(extrude.fields.len(), 1, "one matched reference field");
1043 assert_eq!(extrude.fields[0].path, vec!["profile".to_string()]);
1044 assert!(!extrude.fields[0].multiple, "extrude profile is a single reference");
1045 assert!(extrude.fields[0].filter.iter().any(|f| f == "FACE"));
1046 }
1047
1048 #[test]
1049 fn sketch_kind_is_distinct_from_solid() {
1050 // A committed sketch (partitioned out of the solids bucket) presents as
1051 // SKETCH — NOT SOLID — so it never satisfies a SOLID-only field…
1052 assert_eq!(sel_full(&[], &["Sk"], &[], &[]).kinds_present(), ["SKETCH"]);
1053 // …a real solid presents as SOLID…
1054 assert_eq!(sel_full(&["Box"], &[], &[], &[]).kinds_present(), ["SOLID"]);
1055 // …and a mixed selection carries both.
1056 let mixed = sel_full(&["Box"], &["Sk"], &[], &[]).kinds_present();
1057 assert!(mixed.contains(&"SOLID") && mixed.contains(&"SKETCH"), "mixed: {mixed:?}");
1058 }
1059
1060 #[test]
1061 fn sketch_selection_offers_cutout_and_profile_features() {
1062 // A committed-sketch selection offers the profile-driven features and, in
1063 // particular, SM Cutout. (Revolve now also wants an axis edge, so it is
1064 // deliberately absent here.)
1065 let offers = offers_for(&sel_full(&[], &["Sk"], &[], &[]), "all");
1066 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1067 for want in ["E", "SM.CUTOUT"] {
1068 assert!(codes.contains(&want), "SKETCH should offer {want}: {codes:?}");
1069 }
1070 assert!(!codes.contains(&"R"), "SKETCH alone must not offer Revolve: {codes:?}");
1071 // SM Cutout matches on `profile` only (its `["SOLID"]` `sheet` field does
1072 // not match a sketch), so the create pre-fills the profile field.
1073 let cutout = offers
1074 .iter()
1075 .find(|o| o.type_code == "SM.CUTOUT")
1076 .expect("cutout offered for a sketch");
1077 assert_eq!(cutout.fields.len(), 1);
1078 assert_eq!(cutout.fields[0].path, vec!["profile".to_string()]);
1079 assert!(
1080 cutout.fields[0].filter.iter().any(|f| f == "SKETCH"),
1081 "profile filter: {:?}",
1082 cutout.fields[0].filter
1083 );
1084 assert!(!cutout.fields[0].multiple, "cutout profile is a single reference");
1085 }
1086
1087 #[test]
1088 fn solid_selection_offers_cutout_via_sheet() {
1089 // A real-solid selection still offers SM Cutout, matched on `sheet`.
1090 let offers = offers_for(&sel_of(&["Plate"], &[], &[]), "all");
1091 let cutout = offers
1092 .iter()
1093 .find(|o| o.type_code == "SM.CUTOUT")
1094 .expect("cutout offered for a solid");
1095 assert_eq!(cutout.fields.len(), 1);
1096 assert_eq!(cutout.fields[0].path, vec!["sheet".to_string()]);
1097 assert!(
1098 cutout.fields[0].filter.iter().any(|f| f == "SOLID"),
1099 "sheet filter: {:?}",
1100 cutout.fields[0].filter
1101 );
1102 // Solid + sketch matches BOTH fields — the create fills sheet AND profile.
1103 let sel = sel_full(&["Plate"], &["Sk"], &[], &[]);
1104 let offers = offers_for(&sel, "all");
1105 let cutout = offers
1106 .iter()
1107 .find(|o| o.type_code == "SM.CUTOUT")
1108 .expect("cutout offered for solid+sketch");
1109 let writes = prefill_references(&cutout.fields, &sel);
1110 assert_eq!(
1111 writes,
1112 vec![
1113 (vec!["sheet".to_string()], Value::String("Plate".into())),
1114 (vec!["profile".to_string()], Value::String("Sk".into())),
1115 ]
1116 );
1117 }
1118
1119 /// The constraint-offer CLICK path end to end: seed `elements` from the
1120 /// selection, add through the engine (the auto-solve mutation lane), and
1121 /// open the new row so the panel shows its dialog.
1122 #[test]
1123 fn add_constraint_from_selection_seeds_adds_and_opens() {
1124 use crate::panels::component_actions::tests::assembly_engine;
1125 let mut state = assembly_engine();
1126 state.select_component("ACOMP2");
1127 add_constraint_from_selection(&mut state, "fixed").expect("adds");
1128 let constraints = state.assembly_state_value();
1129 let entry = constraints["constraints"]
1130 .as_array()
1131 .and_then(|list| list.last())
1132 .cloned()
1133 .expect("constraint added");
1134 assert_eq!(entry["type"], "fixed");
1135 assert_eq!(entry["inputParams"]["elements"], serde_json::json!(["ACOMP2"]));
1136 assert_eq!(entry["open"], serde_json::json!(true), "row opens for editing");
1137 }
1138
1139 /// Constraint offers: the per-type `applicable` predicates against the
1140 /// probe, gated on the constraints panel's workbench visibility.
1141 #[test]
1142 fn constraint_offers_follow_predicates_and_workbench() {
1143 let one_component = SelectionProbe {
1144 solids: 1,
1145 components: 1,
1146 all_component: true,
1147 ..Default::default()
1148 };
1149 let pair = SelectionProbe {
1150 faces: 2,
1151 components: 2,
1152 all_component: true,
1153 ..Default::default()
1154 };
1155 let ids = |probe: &SelectionProbe, wb: &str| -> Vec<&str> {
1156 constraint_offers(probe, wb).iter().map(|d| d.type_id).collect()
1157 };
1158
1159 // ONE component's solid → Fixed only.
1160 assert_eq!(ids(&one_component, "assembly"), ["fixed"]);
1161 // Two faces across two components → every face-pair type, no Fixed.
1162 let pair_ids = ids(&pair, "assembly");
1163 for want in [
1164 "coincident",
1165 "touch_align",
1166 "parallel",
1167 "distance",
1168 "angle",
1169 "concentric",
1170 "perpendicular",
1171 "tangent",
1172 ] {
1173 assert!(pair_ids.contains(&want), "pair should offer {want}: {pair_ids:?}");
1174 }
1175 assert!(!pair_ids.contains(&"fixed"), "pair must not offer fixed");
1176 // "All" sees the claimed constraints panel too; Modeling does not.
1177 assert!(!ids(&pair, "all").is_empty());
1178 assert!(ids(&pair, "modeling").is_empty());
1179 // A non-component selection never offers constraints.
1180 let plain = SelectionProbe { faces: 2, ..Default::default() };
1181 assert!(ids(&plain, "assembly").is_empty());
1182 }
1183
1184 #[test]
1185 fn names_for_filter_maps_sketch_kind() {
1186 // A `["FACE","SKETCH"]` profile field pre-fills from the selected sketches.
1187 let sel = sel_full(&["Box"], &["Sk1", "Sk2"], &["Box_PZ"], &[]);
1188 assert_eq!(
1189 sel.names_for_filter(&["FACE".into(), "SKETCH".into()]),
1190 ["Box_PZ", "Sk1", "Sk2"]
1191 );
1192 assert_eq!(sel.names_for_filter(&["SKETCH".into()]), ["Sk1", "Sk2"]);
1193 // A SOLID-only field never picks up a sketch.
1194 assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1195 }
1196
1197 #[test]
1198 fn all_names_gathers_every_named_entity_for_info_windows() {
1199 // A multi-select of a solid + two faces + an edge → four Info-window targets
1200 // (solids → faces → edges order, de-duplicated).
1201 let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1202 assert_eq!(sel.all_names(), ["Box", "Box_PZ", "Box_NZ", "Box_E0"]);
1203 // Nothing selected → no windows.
1204 assert!(sel_of(&[], &[], &[]).all_names().is_empty());
1205 }
1206
1207 #[test]
1208 fn component_selection_detects_single_component_and_fences_features() {
1209 use crate::panels::component_actions::tests::assembly_engine;
1210 let engine = assembly_engine();
1211
1212 // ONE member solid selected → the sole action target, features fenced.
1213 let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1214 let comp = component_selection(&sel, &engine);
1215 assert!(comp.suppress_features());
1216 assert_eq!(comp.sole_target(), Some("ACOMP2"));
1217
1218 // TWO components selected → fence holds, but no single action target.
1219 let sel = sel_of(&["ACOMP1:Part", "ACOMP2:Part"], &[], &[]);
1220 let comp = component_selection(&sel, &engine);
1221 assert!(comp.suppress_features());
1222 assert_eq!(comp.sole_target(), None);
1223
1224 // A component FACE selection fences features (the kernel would reject
1225 // the reference anyway) but is not the solid-click action shape.
1226 let sel = sel_of(&[], &["ACOMP1:Part_PZ"], &[]);
1227 let comp = component_selection(&sel, &engine);
1228 assert!(comp.suppress_features());
1229 assert_eq!(comp.sole_target(), None);
1230
1231 // A non-component solid (no ACOMP prefix) keeps the feature offers.
1232 let sel = sel_of(&["Box"], &[], &[]);
1233 let comp = component_selection(&sel, &engine);
1234 assert!(!comp.suppress_features());
1235 assert_eq!(comp.sole_target(), None);
1236
1237 // MIXED component + ordinary solid: not all-component → no fence, no
1238 // action target (the kernel enforces the reference fence at execution).
1239 let sel = sel_of(&["ACOMP2:Part", "Box"], &[], &[]);
1240 let comp = component_selection(&sel, &engine);
1241 assert!(!comp.suppress_features());
1242 assert_eq!(comp.sole_target(), None);
1243
1244 // An ACOMP-shaped prefix with no matching feature is NOT a component.
1245 let sel = sel_of(&["ACOMP9:Part"], &[], &[]);
1246 assert!(!component_selection(&sel, &engine).suppress_features());
1247 }
1248
1249 /// The workbench fence on the COMPONENT action set: a qualifying selection
1250 /// (one component's member solid) only yields an action target in a
1251 /// workbench that shows the assembly structure panel — Assembly + All —
1252 /// so Move / Edit-in-place / Open-Part / Fix / Delete never bleed into the
1253 /// Modeling (or Sheet Metal) context bar. The feature FENCE is workbench-
1254 /// independent: component geometry suppresses feature offers everywhere.
1255 #[test]
1256 fn component_actions_are_workbench_gated() {
1257 use crate::panels::component_actions::tests::assembly_engine;
1258 let engine = assembly_engine();
1259 let sel = sel_of(&["ACOMP2:Part"], &[], &[]);
1260 let comp = component_selection(&sel, &engine);
1261 assert_eq!(comp.sole_target(), Some("ACOMP2"), "selection shape qualifies");
1262
1263 for wb in ["assembly", "all"] {
1264 assert_eq!(
1265 component_action_target(&comp, wb),
1266 Some("ACOMP2"),
1267 "component actions offered under `{wb}`"
1268 );
1269 }
1270 for wb in ["modeling", "sheetMetal", "wireHarness", "pmi"] {
1271 assert_eq!(
1272 component_action_target(&comp, wb),
1273 None,
1274 "component actions must not bleed into `{wb}`"
1275 );
1276 // The kernel-enforced fence still suppresses feature offers there.
1277 assert!(comp.suppress_features(), "feature fence holds under `{wb}`");
1278 }
1279 }
1280
1281 #[test]
1282 fn names_for_filter_maps_kinds() {
1283 let sel = sel_of(&["Box"], &["Box_PZ", "Box_NZ"], &["Box_E0"]);
1284 assert_eq!(sel.names_for_filter(&["FACE".into()]), ["Box_PZ", "Box_NZ"]);
1285 assert_eq!(sel.names_for_filter(&["EDGE".into()]), ["Box_E0"]);
1286 assert_eq!(sel.names_for_filter(&["SOLID".into()]), ["Box"]);
1287 // A multi-kind filter (fillet's FACE+EDGE) gathers both.
1288 assert_eq!(
1289 sel.names_for_filter(&["FACE".into(), "EDGE".into()]),
1290 ["Box_PZ", "Box_NZ", "Box_E0"]
1291 );
1292 }
1293
1294 #[test]
1295 fn plane_selection_present_kind_is_plane() {
1296 // A datum/plane-only selection presents the ONE kind `PLANE` (never DATUM).
1297 assert_eq!(sel_planes(&["Datum:XY"]).kinds_present(), ["PLANE"]);
1298 // A face-only selection is unchanged (no PLANE leaks in).
1299 assert_eq!(sel_of(&[], &["Box_PZ"], &[]).kinds_present(), ["FACE"]);
1300 }
1301
1302 #[test]
1303 fn names_for_filter_maps_planes_separately_from_faces() {
1304 // A `["PLANE","FACE"]` field (the sketchPlane filter) prefills from the
1305 // selected DATUM frame when only a plane is selected…
1306 let planes = sel_planes(&["Datum:XY"]);
1307 assert_eq!(
1308 planes.names_for_filter(&["PLANE".into(), "FACE".into()]),
1309 ["Datum:XY"]
1310 );
1311 // …`DATUM` is an alias for the same planes bucket…
1312 assert_eq!(planes.names_for_filter(&["DATUM".into()]), ["Datum:XY"]);
1313 // …and a datum plane never lands in a FACE-only field (buckets are split).
1314 assert!(planes.names_for_filter(&["FACE".into()]).is_empty());
1315 // A FACE-only selection still fills a `["PLANE","FACE"]` field with the
1316 // face (the FACE arm), and never yields the plane bucket.
1317 let faces = sel_of(&[], &["Box_PZ"], &[]);
1318 assert_eq!(
1319 faces.names_for_filter(&["PLANE".into(), "FACE".into()]),
1320 ["Box_PZ"]
1321 );
1322 assert!(faces.names_for_filter(&["PLANE".into()]).is_empty());
1323 }
1324
1325 #[test]
1326 fn plane_selection_offers_sketch_and_prefills_the_plane() {
1327 // A datum/plane-only selection offers Sketch (kernel predicate keys on
1328 // `probe.planes`), and the create routes the frame name into `sketchPlane`.
1329 let sel = sel_planes(&["Datum:XY"]);
1330 let offers = offers_for(&sel, "all");
1331 let sketch = offers
1332 .iter()
1333 .find(|o| o.type_code == "S")
1334 .expect("a plane-only selection offers Sketch");
1335 let writes = prefill_references(&sketch.fields, &sel);
1336 assert!(
1337 writes.contains(&(vec!["sketchPlane".to_string()], Value::String("Datum:XY".into()))),
1338 "sketchPlane prefilled with the datum frame: {writes:?}"
1339 );
1340 // A bare plane drives no profile/solid/edge feature.
1341 let codes: Vec<&str> = offers.iter().map(|o| o.type_code.as_str()).collect();
1342 for nope in ["E", "F", "CH", "B", "XFORM"] {
1343 assert!(!codes.contains(&nope), "plane alone must not offer {nope}: {codes:?}");
1344 }
1345 }
1346}