Skip to main content

brep_render/
features.rs

1//! Map kernel feature schemas to the shared [`FormField`] representation.
2//!
3//! Feature definitions come from [`brep_kernel::feature_schema_catalogue`].
4//! Parameters map to scalar, text, vector, choice, boolean, and reference fields;
5//! a boolean operation expands into operation, tool-selection, and merge fields.
6//! Transform start references are not surfaced here.
7
8use crate::style::{FieldKind, FormField};
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13/// The catalogue, built ONCE per process. Every entry is static kernel data
14/// (each feature's `schema()` is a literal), so the first call pays the 40-odd
15/// `json!` constructions and every later read is a pointer. Before this cache
16/// the history tree rebuilt the whole catalogue for EVERY ROW it drew — a
17/// 633-component assembly spent ~1.7 s per frame on it, which is what made an
18/// imported STEP assembly impossible to orbit.
19fn cached_catalogue() -> &'static Value {
20    static CATALOGUE: OnceLock<Value> = OnceLock::new();
21    CATALOGUE.get_or_init(brep_kernel::feature_schema_catalogue)
22}
23
24/// `type` AND `shortName` → position in the catalogue's `features` array. Built
25/// in array order with first-match-wins, so a lookup answers exactly what a
26/// linear `find` over the array did.
27fn schema_index() -> &'static HashMap<String, usize> {
28    static INDEX: OnceLock<HashMap<String, usize>> = OnceLock::new();
29    INDEX.get_or_init(|| {
30        let mut index = HashMap::new();
31        if let Some(list) = cached_catalogue().get("features").and_then(Value::as_array) {
32            for (position, feature) in list.iter().enumerate() {
33                for key in ["type", "shortName"] {
34                    if let Some(name) = feature.get(key).and_then(Value::as_str) {
35                        index.entry(name.to_string()).or_insert(position);
36                    }
37                }
38            }
39        }
40        index
41    })
42}
43
44/// One feature's schema entry, borrowed from the cached catalogue.
45fn schema_entry(feature_type: &str) -> Option<&'static Value> {
46    let position = *schema_index().get(feature_type)?;
47    cached_catalogue().get("features")?.as_array()?.get(position)
48}
49
50/// The kernel-owned feature-schema catalogue (`{ version, features: [...] }`).
51/// A clone of the process-wide cache: callers that only READ it (the palette,
52/// the workbench toolbar, the context bar) pay one clone per call instead of
53/// the whole construction.
54pub fn feature_catalogue() -> Value {
55    cached_catalogue().clone()
56}
57
58/// One feature's full schema entry, by its `type` / `shortName` (e.g. `"P.CU"`,
59/// `"B"`). `None` if the type is not in the catalogue.
60pub fn feature_schema(feature_type: &str) -> Option<Value> {
61    schema_entry(feature_type).cloned()
62}
63
64/// The param keys this feature's dialog should HIDE for the given CURRENT
65/// `values` — the kernel's field-visibility hook ([`brep_kernel::feature_hidden_params`]),
66/// returned as a set so the form engine can skip a field in O(1). Empty when the
67/// feature declares no hook, so every field stays visible. The form engine
68/// recomputes this each frame from the live params, which is what makes fields
69/// appear and disappear as their governing field changes.
70pub fn feature_hidden_params(feature_type: &str, values: &Value) -> HashSet<String> {
71    brep_kernel::feature_hidden_params(feature_type, values)
72        .into_iter()
73        .collect()
74}
75
76/// Human display name for a feature type: the schema `longName` (else the type
77/// itself), with the feature's icon character PREPENDED. Every consumer that
78/// shows a feature name (add-feature palette, history tree, context-bar offers,
79/// dialogs) reads it through here, so the icon appears everywhere with no
80/// per-caller wiring — see [`feature_icon`].
81pub fn feature_long_name(feature_type: &str) -> String {
82    let name = feature_plain_name(feature_type);
83    match feature_icon(feature_type) {
84        Some(glyph) => format!("{glyph} {name}"),
85        None => name,
86    }
87}
88
89/// The same display name WITHOUT the leading glyph — the schema `longName`, else
90/// the type itself.
91///
92/// For the one kind of caller that draws the icon as ARTWORK rather than as a
93/// character: the history tree gives the icon its own column
94/// (`brep_app::panels::tree::TreeRow::glyph`), so its label must not carry the
95/// glyph a second time. Everything that shows a feature name as plain text
96/// keeps reading [`feature_long_name`], which is still where the icon is
97/// prepended.
98pub fn feature_plain_name(feature_type: &str) -> String {
99    schema_entry(feature_type)
100        .and_then(|f| f.get("longName").and_then(Value::as_str).map(String::from))
101        .unwrap_or_else(|| feature_type.to_string())
102}
103
104/// A feature type's SHORT name (`shortName`, e.g. `P.CU`, `S`, `E`), else the
105/// type code itself. This is the BASE for a new feature's id: the engine appends
106/// the part history's persistent global counter to it (`P.CU` → `P.CU7`,
107/// `S` → `S8`) in [`crate::history::History::next_feature_id`].
108pub fn feature_short_name(feature_type: &str) -> String {
109    schema_entry(feature_type)
110        .and_then(|f| f.get("shortName").and_then(Value::as_str).map(String::from))
111        .unwrap_or_else(|| feature_type.to_string())
112}
113
114/// The icon character for a feature type, if one exists. Every kernel
115/// feature type has a FreeCAD-inspired monoline glyph in the font's Private-Use
116/// block U+E030-E059 (edited as `BREP_app/assets/glyphs/*.svg`, which are also
117/// the source of the inline SVG icon catalog — see `BREP_app/src/icons.rs`;
118/// `every_feature_type_has_catalogued_artwork` there checks each of these
119/// codepoints actually HAS a glyph, which this module's own test cannot). The font is
120/// installed as family `"brep_icons"` at the HEAD of egui's fallback chain
121/// ([`crate`]-side `fonts.rs`), so a bare returned `char` renders as the icon.
122///
123/// Matches the SAME alias set the kernel dispatch does (short codes AND the
124/// long-/class-name strings saved files carry — e.g. `"CHAMFER"`, the misspelt
125/// `"DATIUM"`, `"PUSH FACE"`), uppercased, so a type loaded from disk resolves.
126pub fn feature_icon(kind: &str) -> Option<char> {
127    let cp: u32 = match kind.trim().to_ascii_uppercase().as_str() {
128        "D" | "DATUM" | "DATIUM" => 0xE030,
129        "P" | "PLANE" => 0xE031,
130        "P.CU" | "CUBE" => 0xE032,
131        "P.CY" | "CYLINDER" => 0xE033,
132        "P.CO" | "CONE" => 0xE034,
133        "P.S" | "SPHERE" => 0xE035,
134        "P.T" | "TORUS" => 0xE036,
135        "P.PY" | "PYRAMID" => 0xE037,
136        "NS" | "NURBS SOLID" | "NURBSSOLID" => 0xE05F,
137        "IMPORT3D" => 0xE038,
138        "S" | "SKETCH" => 0xE039,
139        "S3D" | "SKETCH3D" | "3D SKETCH" => 0xE0C6,
140        "SP" | "SPLINE" => 0xE03A,
141        "WP" | "WAYPOINT" => 0xE03B,
142        "HX" | "HELIX" => 0xE03C,
143        "E" | "EXTRUDE" => 0xE03D,
144        "B" | "BOOLEAN" => 0xE03E,
145        "F" | "FILLET" => 0xE03F,
146        "CH" | "CHAMFER" => 0xE040,
147        "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => 0xE041,
148        "O.F" | "OFFSET FACE" | "OFFSETFACE" => 0xE042,
149        "PF" | "PUSHFACE" | "PUSH FACE" => 0xE043,
150        "DF" | "DELETE FACE" | "DELETEFACE" => 0xE044,
151        "THK" | "THICKEN" => 0xE045,
152        "SM.TAB" => 0xE046,
153        "SM.CF" => 0xE047,
154        "SM.F" | "SM.CLOSED_CORNER" | "SM.CC" => 0xE048,
155        "SM.HEM" => 0xE049,
156        "SM.FILLET" | "SM.CFIL" => 0xE04A,
157        "SM.CHAMFER" | "SM.CCHM" => 0xE04B,
158        "SM.CUTOUT" => 0xE04C,
159        "LOFT" => 0xE04D,
160        "M" | "MIRROR" => 0xE04E,
161        "SPL" | "SPLIT" => 0xE04F,
162        "R" | "REVOLVE" => 0xE050,
163        "RIB" => 0xE051,
164        "SW" | "SWEEP" => 0xE052,
165        "SWP" | "PATH SWEEP" | "PATHSWEEP" => 0xE053,
166        "H" | "HOLE" => 0xE054,
167        "TU" | "TUBE" => 0xE055,
168        "XFORM" | "TRANSFORM" => 0xE056,
169        "PATTERN" => 0xE057,
170        // The component pattern draws the part Pattern's artwork.
171        "APAT" | "PATTERN COMPONENT" => 0xE057,
172        "ACOMP" | "ASSEMBLY COMPONENT" => 0xE058,
173        "SM.UNFOLD" => 0xE059,
174        "TF" | "TRANSFORM FACE" | "TRANSFORMFACE" => 0xE05D,
175        "RFS" | "REFIT FACES" | "REFITFACES" => 0xE05E,
176        _ => return None,
177    };
178    char::from_u32(cp)
179}
180
181/// A feature type's DEFAULT `inputParams`, built from its schema: one entry per
182/// `inputParamsSchema` param seeded with that param's `default_value` (missing →
183/// `null`). The caller assigns the unique `id` afterwards (the schema's `id`
184/// default is `null`). Additive, engine-owned, and reusable by any "add feature"
185/// path so a new feature's params always track the kernel schema. Unknown types
186/// yield an empty object.
187pub fn feature_default_params(feature_type: &str) -> Value {
188    let mut params = serde_json::Map::new();
189    if let Some(props) = feature_schema(feature_type)
190        .as_ref()
191        .and_then(|s| s.get("inputParamsSchema"))
192        .and_then(Value::as_object)
193    {
194        for (name, spec) in props {
195            let default = spec.get("default_value").cloned().unwrap_or(Value::Null);
196            params.insert(name.clone(), default);
197        }
198    }
199    Value::Object(params)
200}
201
202/// The boolean operation choices (a fixed kernel enum — the schema stores only a
203/// default operation, not the variant set, so the known set rides here).
204const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
205
206/// Map a feature type's `inputParamsSchema` into the general form fields, in
207/// schema (insertion) order — the kernel builds schemas with serde_json's
208/// `preserve_order`, so grouped fields stay contiguous for the group headers.
209pub fn feature_form_fields(feature_type: &str) -> Vec<FormField> {
210    let Some(schema) = feature_schema(feature_type) else {
211        return Vec::new();
212    };
213    form_fields_from_schema(&schema)
214}
215
216/// Plugin numeric bounds are enforced when the user commits a scalar edit.
217/// Keep built-in expression fields on their existing scalar path; neither
218/// kind quantizes an authored value just because its form is displayed.
219pub fn plugin_form_fields_from_schema(schema: &Value) -> Vec<FormField> {
220    let mut fields = form_fields_from_schema(schema);
221    for field in &mut fields {
222        let Some(name) = field.path.first() else { continue };
223        let spec = &schema["inputParamsSchema"][name];
224        if spec["type"] == "number" && (spec.get("min").is_some() || spec.get("max").is_some()) {
225            field.kind = FieldKind::BoundedScalar {
226                min: spec["min"].as_f64().unwrap_or(-f64::MAX),
227                max: spec["max"].as_f64().unwrap_or(f64::MAX),
228                step: spec["step"].as_f64().unwrap_or(0.5),
229            };
230        }
231    }
232    fields
233}
234
235/// Map ANY schema entry carrying an `inputParamsSchema` object into form fields
236/// — the shared engine behind [`feature_form_fields`] AND the assembly
237/// constraint dialogs (whose schemas come from the kernel's
238/// `constraint_schema_catalogue`, same shape, different catalogue). One mapping,
239/// two catalogues (the schema-driven-dialog principle).
240pub fn form_fields_from_schema(schema: &Value) -> Vec<FormField> {
241    let Some(params) = schema
242        .get("inputParamsSchema")
243        .and_then(Value::as_object)
244    else {
245        return Vec::new();
246    };
247
248    let mut fields = Vec::new();
249    for (name, spec) in params {
250        let ty = spec.get("type").and_then(Value::as_str).unwrap_or("");
251        match ty {
252            "number" => fields.push(FormField {
253                path: vec![name.clone()],
254                label: field_label(spec, name),
255                group: "Parameters".into(),
256                kind: FieldKind::Scalar { step: 0.5 },
257            }),
258            "string" => fields.push(FormField {
259                path: vec![name.clone()],
260                label: field_label(spec, name),
261                group: "Parameters".into(),
262                // The `id` is the feature's identity; editing it must cascade to
263                // references, so it is shown read-only for now.
264                kind: FieldKind::Text {
265                    read_only: name == "id",
266                },
267            }),
268            "vec3" => {
269                // A BARE three-component vector param (Transform Face's
270                // `pivot`), as against the `transform` GROUP below: one
271                // labelled row of three expression-capable components, no
272                // rotation and no scale. The kernel reads it with the same
273                // `common::vec3_from_value` the transform components use, so a
274                // component may be a number or an expression either way.
275                fields.push(FormField {
276                    path: vec![name.clone()],
277                    label: field_label(spec, name),
278                    group: "Parameters".into(),
279                    kind: FieldKind::Vec3 { step: 0.5 },
280                });
281            }
282            "transform" => {
283                // TWO transform param shapes share the `transform` schema type,
284                // discriminated by the schema's own `default_value` (the shape a
285                // fresh feature is seeded with, so it can never lie):
286                //   * the ACOMP rigid instance pose `{translate, rotateEulerDeg}`
287                //     (no scale, degrees, intrinsic XYZ);
288                //   * the modeling `{position, rotationEuler, scale}` triple every
289                //     modeling feature uses — WITHOUT its Scale row when the
290                //     default carries no `scale`, which is how a rigid-motion
291                //     feature (Transform Face) says scale means nothing to it.
292                // The mapping lives HERE (the form-engine altitude) so no dialog
293                // ever special-cases a feature type.
294                let is_rigid_pose = spec
295                    .get("default_value")
296                    .map(|d| d.get("translate").is_some() || d.get("rotateEulerDeg").is_some())
297                    .unwrap_or(false);
298                if is_rigid_pose {
299                    fields.push(vec3_field(name, "translate", "Translate", 0.5));
300                    fields.push(vec3_field(name, "rotateEulerDeg", "Rotation (deg)", 1.0));
301                } else {
302                    fields.push(vec3_field(name, "position", "Position", 0.5));
303                    fields.push(vec3_field(name, "rotationEuler", "Rotation (deg)", 1.0));
304                    let scales = spec
305                        .get("default_value")
306                        .map(|d| d.get("scale").is_some())
307                        .unwrap_or(true);
308                    if scales {
309                        fields.push(vec3_field(name, "scale", "Scale", 0.1));
310                    }
311                }
312            }
313            "boolean" => {
314                // A plain checkbox param (e.g. ACOMP `isFixed`, constraint
315                // `reverse`/`opposeNormals`/`exteriorAngle`). Label from the
316                // schema's `label` when present, else the prettified key.
317                fields.push(FormField {
318                    path: vec![name.clone()],
319                    label: field_label(spec, name),
320                    group: "Parameters".into(),
321                    kind: FieldKind::Bool,
322                });
323            }
324            "boolean_operation" => {
325                fields.push(FormField {
326                    path: vec![name.clone(), "operation".into()],
327                    label: "Operation".into(),
328                    group: "Boolean".into(),
329                    kind: FieldKind::Enum {
330                        variants: BOOLEAN_OPS.iter().map(|s| s.to_string()).collect(),
331                    },
332                });
333                fields.push(FormField {
334                    path: vec![name.clone(), "targets".into()],
335                    label: "Tool solids".into(),
336                    group: "Boolean".into(),
337                    kind: FieldKind::Reference {
338                        filter: vec!["SOLID".into()],
339                        multiple: true,
340                    },
341                });
342                fields.push(FormField {
343                    path: vec![name.clone(), "mergeCoplanarFaces".into()],
344                    label: "Merge coplanar faces".into(),
345                    group: "Boolean".into(),
346                    kind: FieldKind::Bool,
347                });
348            }
349            "button" => {
350                // An action button (e.g. Edit Sketch): binds to no value. Its
351                // `key` (the schema key) identifies the click to the host. The
352                // caption is the schema `label` (falling back to the key).
353                let label = field_label(spec, name);
354                fields.push(FormField {
355                    path: vec![name.clone()],
356                    label: label.clone(),
357                    group: "Parameters".into(),
358                    kind: FieldKind::Button { label },
359                });
360            }
361            "reference_selection" => {
362                let filter = crate::json_support::string_values(spec.get("selectionFilter"))
363                    .map(String::from)
364                    .collect();
365                let multiple = spec
366                    .get("multiple")
367                    .and_then(Value::as_bool)
368                    .unwrap_or(false);
369                // `"References"` is a SEMANTIC TAG, not a rendered wrapper: the
370                // history tree inlines this group (each reference renders as its
371                // own self-titled node — no "References" parent), and the context
372                // bar keys on it to find a feature's PRE-FILL fields (top-level
373                // `reference_selection`s, vs a boolean-op `targets` in `Boolean`;
374                // WHETHER a feature is offered is the kernel predicate's call —
375                // `feature_pipeline::context_offer`).
376                // The label is the schema's own, like every other kind: it is
377                // the form row's title AND, carried by the Select intent, the
378                // picker's prompt.
379                fields.push(FormField {
380                    path: vec![name.clone()],
381                    label: field_label(spec, name),
382                    group: "References".into(),
383                    kind: FieldKind::Reference { filter, multiple },
384                });
385            }
386            "options" => {
387                // A single-choice enum: render as a dropdown of the schema's
388                // `options`, using the field's `label` when present (e.g.
389                // flangeLengthReference → "Length reference", inset → "Flange
390                // position") else the prettified key.
391                let variants = crate::json_support::string_values(spec.get("options"))
392                    .map(String::from)
393                    .collect();
394                fields.push(FormField {
395                    path: vec![name.clone()],
396                    label: field_label(spec, name),
397                    group: "Parameters".into(),
398                    kind: FieldKind::Enum { variants },
399                });
400            }
401            // Unmapped kernel param types (vec3-array/etc. arrive as more
402            // features are wired) are skipped rather than mis-rendered.
403            _ => {}
404        }
405    }
406    fields
407}
408
409fn vec3_field(param: &str, sub: &str, label: &str, step: f64) -> FormField {
410    FormField {
411        path: vec![param.to_string(), sub.to_string()],
412        label: label.to_string(),
413        group: "Transform".into(),
414        kind: FieldKind::Vec3 { step },
415    }
416}
417
418/// A field's display label: the schema `label` when set, else the prettified key.
419///
420/// The ONE place a form label is made, for every field kind that has a schema
421/// key. The form row shows it and a reference field's Select carries it into the
422/// picker's prompt. The labels the engine writes itself (a transform's
423/// "Position", a boolean group's "Tool solids") have no schema key to read.
424fn field_label(spec: &Value, name: &str) -> String {
425    spec.get("label")
426        .and_then(Value::as_str)
427        .map(String::from)
428        .unwrap_or_else(|| prettify(name))
429}
430
431/// `sizeX` → `Size X`, `rotationEuler` → `Rotation euler`, `id` → `Id`.
432fn prettify(key: &str) -> String {
433    let mut out = String::new();
434    for (i, ch) in key.chars().enumerate() {
435        if i == 0 {
436            out.extend(ch.to_uppercase());
437        } else if ch.is_ascii_uppercase() {
438            out.push(' ');
439            out.extend(ch.to_lowercase());
440        } else {
441            out.push(ch);
442        }
443    }
444    out
445}
446