Skip to main content

brep_kernel/feature_pipeline/
context_offer.rs

1//! Selection-context applicability — kernel-owned show/no-show for the app's
2//! selection context bar.
3//!
4//! Each feature module exposes `context_applicable(&SelectionProbe) -> bool`
5//! next to its `schema()`: the ONE per-feature answer to "does the current
6//! selection make creating me meaningful?". This module only AGGREGATES those
7//! functions (the `schema.rs` pattern) into [`FEATURE_APPLICABILITY`], keyed by
8//! the catalogue `type` string, and the assembly-constraint equivalents live as
9//! `applicable` on each [`super::assembly::constraints::ConstraintTypeDef`].
10//!
11//! The probe is a KIND-LEVEL summary — per-kind counts plus a few app-derived
12//! roll-up booleans (`all_component`, `all_sheet_metal`) that answer ownership
13//! questions the raw names would; never the names themselves. Predicates decide
14//! eligibility; the app still derives WHICH reference fields to pre-fill from
15//! the schema `selectionFilter`s. A feature whose references a selection can
16//! never satisfy (primitives, datum-driven splines) simply returns `false`.
17
18/// Kind-level summary of the user's current selection, handed to each
19/// feature's / constraint's `context_applicable` test.
20///
21/// `solids` counts REAL solids only — committed sketches present in the scene
22/// as solids, but their reference kind is `SKETCH`, so the app partitions them
23/// into `sketches` before probing (the context bar's established split).
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct SelectionProbe {
26    pub solids: usize,
27    pub sketches: usize,
28    pub faces: usize,
29    pub edges: usize,
30    /// Selected construction PLANES / DATUM planes (their scene FRAME names, e.g.
31    /// `Datum:XY` or a `P` plane feature's id). A datum/plane can seat a sketch's
32    /// `sketchPlane` (the kernel resolves a frame name there), so Sketch offers on
33    /// a plane-only selection just like it does on a planar face.
34    pub planes: usize,
35    /// Selected vertices (position-keyed, no names — no feature offer keys on
36    /// them, but they disqualify component-only shapes via `all_component`).
37    pub vertices: usize,
38    /// Distinct owning ACOMP components across the selected named entities.
39    pub components: usize,
40    /// Every selected named entity is component-owned (and ≥1 is selected).
41    /// Feature creation is fenced on component geometry, so feature predicates
42    /// never see it; constraint predicates REQUIRE it (`mapping::resolve_element`
43    /// rejects non-component references).
44    pub all_component: bool,
45    /// Every selected named entity sits on a SHEET-METAL body (and ≥1 is
46    /// selected) — the app resolves each pick's owning solid and checks its
47    /// `SheetTree` marker. The sheet-metal EDIT features (SM Flange / Fillet /
48    /// Chamfer) require it: they mutate an existing sheet-metal body, so they
49    /// are meaningless on plain geometry.
50    pub all_sheet_metal: bool,
51}
52
53impl SelectionProbe {
54    /// A profile source is selected (the extrude/revolve/sweep family's
55    /// `["SKETCH","FACE"]` fields).
56    pub fn has_profile(&self) -> bool {
57        self.faces > 0 || self.sketches > 0
58    }
59}
60
61/// Every catalogue feature's applicability predicate, catalogue order, keyed by
62/// the schema `type` string ([`super::schema::feature_schema_catalogue`] —
63/// `sheet_metal_corner` contributes two entries from one module). The paired
64/// test keeps this 1:1 with the catalogue.
65pub static FEATURE_APPLICABILITY: &[(&str, fn(&SelectionProbe) -> bool)] = &[
66    ("D", super::features::datum::context_applicable),
67    ("P", super::features::plane::context_applicable),
68    ("P.CU", super::features::cube::context_applicable),
69    ("P.CY", super::features::cylinder::context_applicable),
70    ("P.CO", super::features::cone::context_applicable),
71    ("P.S", super::features::sphere::context_applicable),
72    ("P.T", super::features::torus::context_applicable),
73    ("P.PY", super::features::pyramid::context_applicable),
74    ("IMPORT3D", super::features::import3d::context_applicable),
75    ("S", super::features::sketch::context_applicable),
76    ("SP", super::features::spline::context_applicable),
77    ("PORT", super::features::port::context_applicable),
78    ("HX", super::features::helix::context_applicable),
79    ("E", super::features::extrude::context_applicable),
80    ("B", super::features::boolean::context_applicable),
81    ("F", super::features::fillet::context_applicable),
82    ("CH", super::features::chamfer::context_applicable),
83    ("O.S", super::features::offset_shell::context_applicable),
84    ("O.F", super::features::offset_face::context_applicable),
85    ("PF", super::features::push_face::context_applicable),
86    ("DF", super::features::delete_face::context_applicable),
87    ("THK", super::features::thicken::context_applicable),
88    ("SM.TAB", super::features::sheet_metal_tab::context_applicable),
89    ("SM.CF", super::features::sheet_metal_contour_flange::context_applicable),
90    ("SM.F", super::features::sheet_metal_flange::context_applicable),
91    ("SM.HEM", super::features::sheet_metal_hem::context_applicable),
92    ("SM.FILLET", super::features::sheet_metal_corner::context_applicable),
93    ("SM.CHAMFER", super::features::sheet_metal_corner::context_applicable),
94    ("SM.CUTOUT", super::features::sheet_metal_cutout::context_applicable),
95    ("LOFT", super::features::loft::context_applicable),
96    ("M", super::features::mirror::context_applicable),
97    ("SPL", super::features::split::context_applicable),
98    ("R", super::features::revolve::context_applicable),
99    ("RIB", super::features::rib::context_applicable),
100    ("SW", super::features::sweep::context_applicable),
101    ("SWP", super::features::path_sweep::context_applicable),
102    ("H", super::features::hole::context_applicable),
103    ("TU", super::features::tube::context_applicable),
104    ("XFORM", super::features::transform::context_applicable),
105    ("PATTERN", super::features::pattern::context_applicable),
106    ("ACOMP", super::features::assembly_component::context_applicable),
107];
108
109/// Run feature `type_id`'s applicability predicate. Unknown type → `false`
110/// (nothing is offered for a feature the registry does not know).
111pub fn feature_context_applicable(type_id: &str, probe: &SelectionProbe) -> bool {
112    FEATURE_APPLICABILITY
113        .iter()
114        .find(|(id, _)| *id == type_id)
115        .is_some_and(|(_, applicable)| applicable(probe))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    /// The registry stays 1:1 with the schema catalogue: every catalogue `type`
123    /// has exactly one predicate, and no registry row points at a type outside
124    /// the catalogue.
125    #[test]
126    fn applicability_registry_matches_the_catalogue() {
127        let catalogue = crate::feature_pipeline::feature_schema_catalogue();
128        let types: Vec<String> = catalogue["features"]
129            .as_array()
130            .expect("features array")
131            .iter()
132            .map(|f| f["type"].as_str().expect("type string").to_string())
133            .collect();
134        let registry: Vec<&str> = FEATURE_APPLICABILITY.iter().map(|(id, _)| *id).collect();
135        for ty in &types {
136            assert!(
137                registry.contains(&ty.as_str()),
138                "catalogue feature {ty} has no applicability predicate"
139            );
140        }
141        for id in &registry {
142            assert!(
143                types.iter().any(|ty| ty == id),
144                "registry row {id} is not a catalogue feature"
145            );
146        }
147        assert_eq!(registry.len(), types.len(), "duplicate registry rows");
148    }
149
150    /// The one user-specified nuance: Revolve needs a profile AND an axis edge
151    /// — a face or sketch alone no longer offers it.
152    #[test]
153    fn revolve_requires_profile_and_axis() {
154        let face_only = SelectionProbe { faces: 1, ..Default::default() };
155        assert!(!feature_context_applicable("R", &face_only));
156        let sketch_only = SelectionProbe { sketches: 1, ..Default::default() };
157        assert!(!feature_context_applicable("R", &sketch_only));
158        let edge_only = SelectionProbe { edges: 1, ..Default::default() };
159        assert!(!feature_context_applicable("R", &edge_only));
160        let face_and_axis = SelectionProbe { faces: 1, edges: 1, ..Default::default() };
161        assert!(feature_context_applicable("R", &face_and_axis));
162        let sketch_and_axis = SelectionProbe { sketches: 1, edges: 1, ..Default::default() };
163        assert!(feature_context_applicable("R", &sketch_and_axis));
164    }
165
166    /// The sheet-metal EDIT features (SM Flange / SM Fillet / SM Chamfer) only
167    /// offer when the selection sits on an existing sheet-metal body: without
168    /// `all_sheet_metal`, a plain face/edge/solid offers none of them; with it,
169    /// each offers on the kind it accepts.
170    #[test]
171    fn sheet_metal_edits_require_a_sheet_metal_selection() {
172        for probe in [
173            SelectionProbe { faces: 1, ..Default::default() },
174            SelectionProbe { edges: 1, ..Default::default() },
175            SelectionProbe { solids: 1, ..Default::default() },
176        ] {
177            for ty in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
178                assert!(
179                    !feature_context_applicable(ty, &probe),
180                    "{ty} must not offer on non-sheet-metal geometry: {probe:?}"
181                );
182            }
183        }
184        // On a sheet-metal body: Flange takes an edge or a side face; SM Fillet /
185        // Chamfer additionally take the whole solid (a corner of the flat).
186        let sm_face = SelectionProbe { faces: 1, all_sheet_metal: true, ..Default::default() };
187        let sm_edge = SelectionProbe { edges: 1, all_sheet_metal: true, ..Default::default() };
188        let sm_solid = SelectionProbe { solids: 1, all_sheet_metal: true, ..Default::default() };
189        for probe in [&sm_face, &sm_edge] {
190            for ty in ["SM.F", "SM.FILLET", "SM.CHAMFER"] {
191                assert!(feature_context_applicable(ty, probe), "{ty} offers on sheet metal");
192            }
193        }
194        // A lone sheet-metal SOLID: SM Fillet / Chamfer accept it, Flange (edge/
195        // face only) does not.
196        assert!(feature_context_applicable("SM.FILLET", &sm_solid));
197        assert!(feature_context_applicable("SM.CHAMFER", &sm_solid));
198        assert!(!feature_context_applicable("SM.F", &sm_solid));
199    }
200
201    /// A construction PLANE / DATUM plane selection (its scene frame name) offers
202    /// Sketch: the `sketchPlane` field resolves a frame name just like a planar
203    /// face, so a plane-only selection is a valid sketch seat.
204    #[test]
205    fn plane_selection_offers_sketch() {
206        let plane_only = SelectionProbe { planes: 1, ..Default::default() };
207        assert!(
208            feature_context_applicable("S", &plane_only),
209            "a plane/datum-only selection should offer Sketch"
210        );
211        // …and it drives nothing else (no profile/solid/edge feature keys on a
212        // bare plane).
213        for ty in ["E", "F", "CH", "B", "XFORM", "R"] {
214            assert!(
215                !feature_context_applicable(ty, &plane_only),
216                "{ty} must not offer on a plane-only selection"
217            );
218        }
219    }
220
221    /// Parity spot-checks: the schema-intersection semantics the predicates
222    /// preserve for everything but revolve.
223    #[test]
224    fn parity_predicates_match_reference_filters() {
225        let face = SelectionProbe { faces: 1, ..Default::default() };
226        let edge = SelectionProbe { edges: 1, ..Default::default() };
227        let solid = SelectionProbe { solids: 1, ..Default::default() };
228        let sketch = SelectionProbe { sketches: 1, ..Default::default() };
229
230        // FACE drives the face-primary features…
231        for ty in ["E", "O.F", "PF", "O.S", "THK", "DF", "F", "CH", "M", "SPL", "PATTERN", "S"] {
232            assert!(feature_context_applicable(ty, &face), "FACE should offer {ty}");
233        }
234        // …EDGE the edge-primary ones…
235        for ty in ["F", "CH", "TU", "HX", "RIB", "S"] {
236            assert!(feature_context_applicable(ty, &edge), "EDGE should offer {ty}");
237        }
238        assert!(!feature_context_applicable("E", &edge), "EDGE must not offer Extrude");
239        // …SOLID the solid-primary ones…
240        for ty in ["B", "M", "XFORM", "PATTERN", "SPL", "RIB", "SM.CUTOUT"] {
241            assert!(feature_context_applicable(ty, &solid), "SOLID should offer {ty}");
242        }
243        assert!(!feature_context_applicable("F", &solid), "SOLID must not offer Fillet");
244        // …SKETCH the profile-driven ones (hole places on a sketch)…
245        for ty in ["E", "H", "SM.CUTOUT", "SM.TAB", "SW", "SWP", "LOFT"] {
246            assert!(feature_context_applicable(ty, &sketch), "SKETCH should offer {ty}");
247        }
248        // …and no selection ever offers a primitive or reference-less feature.
249        let all = SelectionProbe {
250            solids: 1,
251            sketches: 1,
252            faces: 1,
253            edges: 1,
254            ..Default::default()
255        };
256        for ty in ["P.CU", "P.CY", "P.CO", "P.S", "P.T", "P.PY", "D", "IMPORT3D", "SP", "ACOMP"] {
257            assert!(!feature_context_applicable(ty, &all), "{ty} must never be offered");
258        }
259        // Unknown type → false, never a panic.
260        assert!(!feature_context_applicable("NOPE", &all));
261    }
262}