Skip to main content

brep_render/
features.rs

1//! Feature-schema access + the feature-schema → [`FormField`] mapping.
2//!
3//! The kernel OWNS the feature definitions (`feature_pipeline/schema.rs`; each
4//! `features/<feat>.rs` declares its own `schema()` with an `inputParamsSchema`).
5//! This module reaches that catalogue from native Rust (re-exported by the
6//! kernel as [`brep_kernel::feature_schema_catalogue`]) and maps ONE feature's
7//! parameter schema into the general [`FormField`] list the shared egui form
8//! engine renders — the SAME `FormField`s the settings dialog uses.
9//!
10//! The map classifies each kernel param `type`:
11//!   * `number`             → [`FieldKind::Scalar`]      (unbounded drag value)
12//!   * `string`             → [`FieldKind::Text`]        (`id` is read-only)
13//!   * `transform`          → three [`FieldKind::Vec3`]  (position/rotation/scale)
14//!   * `boolean_operation`  → an [`FieldKind::Enum`] (operation) + a
15//!                            [`FieldKind::Reference`] (tools) + a
16//!                            [`FieldKind::Bool`] (mergeCoplanarFaces)
17//!   * `reference_selection`→ [`FieldKind::Reference`]   (the #42 seam)
18//!
19//! REFERENCE-SELECTION field types (rendered as the disabled placeholder — the
20//! seam for the next slice's real picker): the top-level `reference_selection`
21//! params, and the `targets` list inside a `boolean_operation`. (`transform`'s
22//! optional start `reference` is not surfaced here yet.)
23
24use crate::style::{FieldKind, FormField};
25use serde_json::Value;
26
27/// The kernel-owned feature-schema catalogue (`{ version, features: [...] }`).
28pub fn feature_catalogue() -> Value {
29    brep_kernel::feature_schema_catalogue()
30}
31
32/// One feature's full schema entry, by its `type` / `shortName` (e.g. `"P.CU"`,
33/// `"B"`). `None` if the type is not in the catalogue.
34pub fn feature_schema(feature_type: &str) -> Option<Value> {
35    feature_catalogue()
36        .get("features")?
37        .as_array()?
38        .iter()
39        .find(|f| {
40            f.get("type").and_then(Value::as_str) == Some(feature_type)
41                || f.get("shortName").and_then(Value::as_str) == Some(feature_type)
42        })
43        .cloned()
44}
45
46/// Human display name for a feature type (`longName`, else the type itself).
47pub fn feature_long_name(feature_type: &str) -> String {
48    feature_schema(feature_type)
49        .and_then(|f| f.get("longName").and_then(Value::as_str).map(String::from))
50        .unwrap_or_else(|| feature_type.to_string())
51}
52
53/// A feature type's SHORT name (`shortName`, e.g. `P.CU`, `S`, `E`), else the
54/// type code itself. This is the BASE for a new feature's id: the engine appends
55/// the part history's persistent global counter to it (`P.CU` → `P.CU7`,
56/// `S` → `S8`) in [`crate::history::History::next_feature_id`].
57pub fn feature_short_name(feature_type: &str) -> String {
58    feature_schema(feature_type)
59        .and_then(|f| f.get("shortName").and_then(Value::as_str).map(String::from))
60        .unwrap_or_else(|| feature_type.to_string())
61}
62
63/// A feature type's DEFAULT `inputParams`, built from its schema: one entry per
64/// `inputParamsSchema` param seeded with that param's `default_value` (missing →
65/// `null`). The caller assigns the unique `id` afterwards (the schema's `id`
66/// default is `null`). Additive, engine-owned, and reusable by any "add feature"
67/// path so a new feature's params always track the kernel schema. Unknown types
68/// yield an empty object.
69pub fn feature_default_params(feature_type: &str) -> Value {
70    let mut params = serde_json::Map::new();
71    if let Some(props) = feature_schema(feature_type)
72        .as_ref()
73        .and_then(|s| s.get("inputParamsSchema"))
74        .and_then(Value::as_object)
75    {
76        for (name, spec) in props {
77            let default = spec.get("default_value").cloned().unwrap_or(Value::Null);
78            params.insert(name.clone(), default);
79        }
80    }
81    Value::Object(params)
82}
83
84/// The boolean operation choices (a fixed kernel enum — the schema stores only a
85/// default operation, not the variant set, so the known set rides here).
86const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
87
88/// Map a feature type's `inputParamsSchema` into the general form fields, in
89/// schema (insertion) order — the kernel builds schemas with serde_json's
90/// `preserve_order`, so grouped fields stay contiguous for the group headers.
91pub fn feature_form_fields(feature_type: &str) -> Vec<FormField> {
92    let Some(schema) = feature_schema(feature_type) else {
93        return Vec::new();
94    };
95    let Some(params) = schema
96        .get("inputParamsSchema")
97        .and_then(Value::as_object)
98    else {
99        return Vec::new();
100    };
101
102    let mut fields = Vec::new();
103    for (name, spec) in params {
104        let ty = spec.get("type").and_then(Value::as_str).unwrap_or("");
105        match ty {
106            "number" => fields.push(FormField {
107                path: vec![name.clone()],
108                label: prettify(name),
109                group: "Parameters".into(),
110                kind: FieldKind::Scalar { step: 0.5 },
111            }),
112            "string" => fields.push(FormField {
113                path: vec![name.clone()],
114                label: prettify(name),
115                group: "Parameters".into(),
116                // The `id` is the feature's identity; editing it must cascade to
117                // references, so it is shown read-only for now.
118                kind: FieldKind::Text {
119                    read_only: name == "id",
120                },
121            }),
122            "transform" => {
123                fields.push(vec3_field(name, "position", "Position", 0.5));
124                fields.push(vec3_field(name, "rotationEuler", "Rotation (deg)", 1.0));
125                fields.push(vec3_field(name, "scale", "Scale", 0.1));
126            }
127            "boolean_operation" => {
128                fields.push(FormField {
129                    path: vec![name.clone(), "operation".into()],
130                    label: "Operation".into(),
131                    group: "Boolean".into(),
132                    kind: FieldKind::Enum {
133                        variants: BOOLEAN_OPS.to_vec(),
134                    },
135                });
136                fields.push(FormField {
137                    path: vec![name.clone(), "targets".into()],
138                    label: "Tool solids".into(),
139                    group: "Boolean".into(),
140                    kind: FieldKind::Reference {
141                        filter: vec!["SOLID".into()],
142                        multiple: true,
143                    },
144                });
145                fields.push(FormField {
146                    path: vec![name.clone(), "mergeCoplanarFaces".into()],
147                    label: "Merge coplanar faces".into(),
148                    group: "Boolean".into(),
149                    kind: FieldKind::Bool,
150                });
151            }
152            "button" => {
153                // An action button (e.g. Edit Sketch): binds to no value. Its
154                // `key` (the schema key) identifies the click to the host. The
155                // caption is the schema `label` (falling back to the key).
156                let label = spec
157                    .get("label")
158                    .and_then(Value::as_str)
159                    .map(String::from)
160                    .unwrap_or_else(|| prettify(name));
161                fields.push(FormField {
162                    path: vec![name.clone()],
163                    label: label.clone(),
164                    group: "Parameters".into(),
165                    kind: FieldKind::Button { label },
166                });
167            }
168            "reference_selection" => {
169                let filter = spec
170                    .get("selectionFilter")
171                    .and_then(Value::as_array)
172                    .map(|a| {
173                        a.iter()
174                            .filter_map(|v| v.as_str().map(String::from))
175                            .collect()
176                    })
177                    .unwrap_or_default();
178                let multiple = spec
179                    .get("multiple")
180                    .and_then(Value::as_bool)
181                    .unwrap_or(false);
182                // `"References"` is a SEMANTIC TAG, not a rendered wrapper: the
183                // history tree inlines this group (each reference renders as its
184                // own self-titled node — no "References" parent), and the context
185                // bar keys on it to find a feature's PRIMARY reference (a top-level
186                // `reference_selection`, vs a boolean-op `targets` in `Boolean`).
187                fields.push(FormField {
188                    path: vec![name.clone()],
189                    label: prettify(name),
190                    group: "References".into(),
191                    kind: FieldKind::Reference { filter, multiple },
192                });
193            }
194            // Unmapped kernel param types (bool/vec3-array/etc. arrive as more
195            // features are wired) are skipped rather than mis-rendered.
196            _ => {}
197        }
198    }
199    fields
200}
201
202fn vec3_field(param: &str, sub: &str, label: &str, step: f64) -> FormField {
203    FormField {
204        path: vec![param.to_string(), sub.to_string()],
205        label: label.to_string(),
206        group: "Transform".into(),
207        kind: FieldKind::Vec3 { step },
208    }
209}
210
211/// `sizeX` → `Size X`, `rotationEuler` → `Rotation euler`, `id` → `Id`.
212fn prettify(key: &str) -> String {
213    let mut out = String::new();
214    for (i, ch) in key.chars().enumerate() {
215        if i == 0 {
216            out.extend(ch.to_uppercase());
217        } else if ch.is_ascii_uppercase() {
218            out.push(' ');
219            out.extend(ch.to_lowercase());
220        } else {
221            out.push(ch);
222        }
223    }
224    out
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn catalogue_is_reachable_from_native_rust() {
233        let cat = feature_catalogue();
234        assert!(cat.get("features").and_then(Value::as_array).is_some());
235        assert!(feature_schema("P.CU").is_some());
236        assert!(feature_schema("P.CY").is_some());
237        assert!(feature_schema("B").is_some());
238        assert_eq!(feature_long_name("P.CU"), "Primitive Cube");
239    }
240
241    #[test]
242    fn short_name_is_the_schema_code_or_the_type_fallback() {
243        // The base for a new feature's id: the schema shortName (incl. dotted
244        // primitive codes), falling back to the type code for an unknown type.
245        assert_eq!(feature_short_name("P.CU"), "P.CU");
246        assert_eq!(feature_short_name("P.S"), "P.S");
247        assert_eq!(feature_short_name("S"), "S");
248        assert_eq!(feature_short_name("E"), "E");
249        assert_eq!(feature_short_name("NOPE"), "NOPE");
250    }
251
252    #[test]
253    fn cube_form_fields_map_types_correctly() {
254        let fields = feature_form_fields("P.CU");
255        // id (read-only Text), sizeX/Y/Z (Scalar), transform → 3× Vec3,
256        // boolean → Enum + Reference + Bool.
257        let by_key = |k: &str| fields.iter().find(|f| f.key() == k).cloned();
258
259        assert!(matches!(
260            by_key("id").unwrap().kind,
261            FieldKind::Text { read_only: true }
262        ));
263        assert!(matches!(
264            by_key("sizeX").unwrap().kind,
265            FieldKind::Scalar { .. }
266        ));
267        // transform expands into three Vec3 sub-fields on nested paths.
268        let position = fields
269            .iter()
270            .find(|f| f.path == ["transform", "position"])
271            .expect("transform.position vec3");
272        assert!(matches!(position.kind, FieldKind::Vec3 { .. }));
273        assert_eq!(
274            fields
275                .iter()
276                .filter(|f| matches!(f.kind, FieldKind::Vec3 { .. }))
277                .count(),
278            3
279        );
280        // boolean_operation expands: operation Enum, targets Reference, merge Bool.
281        let op = fields
282            .iter()
283            .find(|f| f.path == ["boolean", "operation"])
284            .expect("boolean.operation");
285        assert!(matches!(op.kind, FieldKind::Enum { .. }));
286        let targets = fields
287            .iter()
288            .find(|f| f.path == ["boolean", "targets"])
289            .expect("boolean.targets");
290        assert!(matches!(targets.kind, FieldKind::Reference { multiple: true, .. }));
291    }
292
293    #[test]
294    fn default_params_seed_from_schema_defaults() {
295        // A primitive's defaults are COMPLETE (build-ready) straight from schema.
296        let cube = feature_default_params("P.CU");
297        assert_eq!(cube["sizeX"], 10.0);
298        assert_eq!(cube["sizeY"], 10.0);
299        assert_eq!(cube["transform"]["scale"], serde_json::json!([1, 1, 1]));
300        assert_eq!(cube["boolean"]["operation"], "NONE");
301        // The `id` default is null — the caller assigns a unique one.
302        assert_eq!(cube["id"], Value::Null);
303        // A feature whose reference default is null still yields the key.
304        let boolean = feature_default_params("B");
305        assert!(boolean.as_object().unwrap().contains_key("targetSolid"));
306        assert_eq!(boolean["boolean"]["operation"], "UNION");
307        // Unknown types → empty object (no panic).
308        assert_eq!(feature_default_params("NOPE"), serde_json::json!({}));
309    }
310
311    #[test]
312    fn boolean_feature_target_is_a_reference_field() {
313        let fields = feature_form_fields("B");
314        let target = fields
315            .iter()
316            .find(|f| f.key() == "targetSolid")
317            .expect("targetSolid field");
318        match &target.kind {
319            FieldKind::Reference { filter, multiple } => {
320                assert_eq!(filter, &["SOLID".to_string()]);
321                assert!(!multiple);
322            }
323            other => panic!("targetSolid should be a Reference, got {other:?}"),
324        }
325    }
326
327    #[test]
328    fn sketch_feature_maps_button_fields() {
329        // The SKETCH schema's `editSketch` / `dumpSketchDiagnostics` (`type:"button"`)
330        // become `FieldKind::Button` fields carrying the schema label + their key.
331        let fields = feature_form_fields("S");
332        let edit = fields
333            .iter()
334            .find(|f| f.key() == "editSketch")
335            .expect("editSketch button field");
336        assert!(
337            matches!(&edit.kind, FieldKind::Button { label } if label == "Edit Sketch"),
338            "editSketch should be Button('Edit Sketch'), got {:?}",
339            edit.kind
340        );
341        assert_eq!(edit.path, ["editSketch"], "button binds to its own key");
342        let dump = fields
343            .iter()
344            .find(|f| f.key() == "dumpSketchDiagnostics")
345            .expect("dumpSketchDiagnostics button field");
346        assert!(matches!(dump.kind, FieldKind::Button { .. }));
347    }
348
349    #[test]
350    fn reference_selection_field_is_a_self_titled_references_tagged_node() {
351        // The history tree renders a `reference_selection` field as its OWN
352        // top-level-under-the-feature node (no "References" wrapper): the node
353        // title is the field's LABEL and it carries the Select button. This pins
354        // the descriptor contract that the tree's inlining relies on — the field's
355        // `label` is the prettified param name ("Sketch plane"), and its `group`
356        // stays `"References"` as the semantic tag (tree inline marker + the
357        // context bar's primary-reference discriminator).
358        let sketch_plane = feature_form_fields("S")
359            .into_iter()
360            .find(|f| f.key() == "sketchPlane")
361            .expect("sketch has a sketchPlane reference_selection field");
362        assert_eq!(sketch_plane.label, "Sketch plane", "node title = field label");
363        assert_eq!(sketch_plane.group, "References", "kept as a semantic tag");
364        assert_eq!(sketch_plane.path, ["sketchPlane"], "a top-level (primary) reference");
365        match &sketch_plane.kind {
366            FieldKind::Reference { filter, multiple } => {
367                assert_eq!(filter, &["PLANE".to_string(), "FACE".to_string()]);
368                assert!(!multiple, "the sketch plane is a single reference");
369            }
370            other => panic!("sketchPlane should be a Reference, got {other:?}"),
371        }
372    }
373}