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;
10
11/// The kernel-owned feature-schema catalogue (`{ version, features: [...] }`).
12pub fn feature_catalogue() -> Value {
13    brep_kernel::feature_schema_catalogue()
14}
15
16/// One feature's full schema entry, by its `type` / `shortName` (e.g. `"P.CU"`,
17/// `"B"`). `None` if the type is not in the catalogue.
18pub fn feature_schema(feature_type: &str) -> Option<Value> {
19    feature_catalogue()
20        .get("features")?
21        .as_array()?
22        .iter()
23        .find(|f| {
24            f.get("type").and_then(Value::as_str) == Some(feature_type)
25                || f.get("shortName").and_then(Value::as_str) == Some(feature_type)
26        })
27        .cloned()
28}
29
30/// Human display name for a feature type: the schema `longName` (else the type
31/// itself), with the feature's icon character PREPENDED. Every consumer that
32/// shows a feature name (add-feature palette, history tree, context-bar offers,
33/// dialogs) reads it through here, so the icon appears everywhere with no
34/// per-caller wiring — see [`feature_icon`].
35pub fn feature_long_name(feature_type: &str) -> String {
36    let name = feature_plain_name(feature_type);
37    match feature_icon(feature_type) {
38        Some(glyph) => format!("{glyph} {name}"),
39        None => name,
40    }
41}
42
43/// The same display name WITHOUT the leading glyph — the schema `longName`, else
44/// the type itself.
45///
46/// For the one kind of caller that draws the icon as ARTWORK rather than as a
47/// character: the history tree gives the icon its own column
48/// (`brep_app::panels::tree::TreeRow::glyph`), so its label must not carry the
49/// glyph a second time. Everything that shows a feature name as plain text
50/// keeps reading [`feature_long_name`], which is still where the icon is
51/// prepended.
52pub fn feature_plain_name(feature_type: &str) -> String {
53    feature_schema(feature_type)
54        .and_then(|f| f.get("longName").and_then(Value::as_str).map(String::from))
55        .unwrap_or_else(|| feature_type.to_string())
56}
57
58/// A feature type's SHORT name (`shortName`, e.g. `P.CU`, `S`, `E`), else the
59/// type code itself. This is the BASE for a new feature's id: the engine appends
60/// the part history's persistent global counter to it (`P.CU` → `P.CU7`,
61/// `S` → `S8`) in [`crate::history::History::next_feature_id`].
62pub fn feature_short_name(feature_type: &str) -> String {
63    feature_schema(feature_type)
64        .and_then(|f| f.get("shortName").and_then(Value::as_str).map(String::from))
65        .unwrap_or_else(|| feature_type.to_string())
66}
67
68/// The icon character for a feature type, if one exists. Every kernel
69/// feature type has a FreeCAD-inspired monoline glyph in the font's Private-Use
70/// block U+E030-E059 (edited as `BREP_app/assets/glyphs/*.svg`, which are also
71/// the source of the inline SVG icon catalog — see `BREP_app/src/icons.rs`;
72/// `every_feature_type_has_catalogued_artwork` there checks each of these
73/// codepoints actually HAS a glyph, which this module's own test cannot). The font is
74/// installed as family `"brep_icons"` at the HEAD of egui's fallback chain
75/// ([`crate`]-side `fonts.rs`), so a bare returned `char` renders as the icon.
76///
77/// Matches the SAME alias set the kernel dispatch does (short codes AND the
78/// long-/class-name strings saved files carry — e.g. `"CHAMFER"`, the misspelt
79/// `"DATIUM"`, `"PUSH FACE"`), uppercased, so a type loaded from disk resolves.
80pub fn feature_icon(kind: &str) -> Option<char> {
81    let cp: u32 = match kind.trim().to_ascii_uppercase().as_str() {
82        "D" | "DATUM" | "DATIUM" => 0xE030,
83        "P" | "PLANE" => 0xE031,
84        "P.CU" | "CUBE" => 0xE032,
85        "P.CY" | "CYLINDER" => 0xE033,
86        "P.CO" | "CONE" => 0xE034,
87        "P.S" | "SPHERE" => 0xE035,
88        "P.T" | "TORUS" => 0xE036,
89        "P.PY" | "PYRAMID" => 0xE037,
90        "IMPORT3D" => 0xE038,
91        "S" | "SKETCH" => 0xE039,
92        "SP" | "SPLINE" => 0xE03A,
93        "PORT" => 0xE03B,
94        "HX" | "HELIX" => 0xE03C,
95        "E" | "EXTRUDE" => 0xE03D,
96        "B" | "BOOLEAN" => 0xE03E,
97        "F" | "FILLET" => 0xE03F,
98        "CH" | "CHAMFER" => 0xE040,
99        "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => 0xE041,
100        "O.F" | "OFFSET FACE" | "OFFSETFACE" => 0xE042,
101        "PF" | "PUSHFACE" | "PUSH FACE" => 0xE043,
102        "DF" | "DELETE FACE" | "DELETEFACE" => 0xE044,
103        "THK" | "THICKEN" => 0xE045,
104        "SM.TAB" => 0xE046,
105        "SM.CF" => 0xE047,
106        "SM.F" => 0xE048,
107        "SM.HEM" => 0xE049,
108        "SM.FILLET" | "SM.CFIL" => 0xE04A,
109        "SM.CHAMFER" | "SM.CCHM" => 0xE04B,
110        "SM.CUTOUT" => 0xE04C,
111        "LOFT" => 0xE04D,
112        "M" | "MIRROR" => 0xE04E,
113        "SPL" | "SPLIT" => 0xE04F,
114        "R" | "REVOLVE" => 0xE050,
115        "RIB" => 0xE051,
116        "SW" | "SWEEP" => 0xE052,
117        "SWP" | "PATH SWEEP" | "PATHSWEEP" => 0xE053,
118        "H" | "HOLE" => 0xE054,
119        "TU" | "TUBE" => 0xE055,
120        "XFORM" | "TRANSFORM" => 0xE056,
121        "PATTERN" => 0xE057,
122        "ACOMP" | "ASSEMBLY COMPONENT" => 0xE058,
123        "SM.UNFOLD" => 0xE059,
124        _ => return None,
125    };
126    char::from_u32(cp)
127}
128
129/// A feature type's DEFAULT `inputParams`, built from its schema: one entry per
130/// `inputParamsSchema` param seeded with that param's `default_value` (missing →
131/// `null`). The caller assigns the unique `id` afterwards (the schema's `id`
132/// default is `null`). Additive, engine-owned, and reusable by any "add feature"
133/// path so a new feature's params always track the kernel schema. Unknown types
134/// yield an empty object.
135pub fn feature_default_params(feature_type: &str) -> Value {
136    let mut params = serde_json::Map::new();
137    if let Some(props) = feature_schema(feature_type)
138        .as_ref()
139        .and_then(|s| s.get("inputParamsSchema"))
140        .and_then(Value::as_object)
141    {
142        for (name, spec) in props {
143            let default = spec.get("default_value").cloned().unwrap_or(Value::Null);
144            params.insert(name.clone(), default);
145        }
146    }
147    Value::Object(params)
148}
149
150/// The boolean operation choices (a fixed kernel enum — the schema stores only a
151/// default operation, not the variant set, so the known set rides here).
152const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
153
154/// Map a feature type's `inputParamsSchema` into the general form fields, in
155/// schema (insertion) order — the kernel builds schemas with serde_json's
156/// `preserve_order`, so grouped fields stay contiguous for the group headers.
157pub fn feature_form_fields(feature_type: &str) -> Vec<FormField> {
158    let Some(schema) = feature_schema(feature_type) else {
159        return Vec::new();
160    };
161    form_fields_from_schema(&schema)
162}
163
164/// Map ANY schema entry carrying an `inputParamsSchema` object into form fields
165/// — the shared engine behind [`feature_form_fields`] AND the assembly
166/// constraint dialogs (whose schemas come from the kernel's
167/// `constraint_schema_catalogue`, same shape, different catalogue). One mapping,
168/// two catalogues (the schema-driven-dialog principle).
169pub fn form_fields_from_schema(schema: &Value) -> Vec<FormField> {
170    let Some(params) = schema
171        .get("inputParamsSchema")
172        .and_then(Value::as_object)
173    else {
174        return Vec::new();
175    };
176
177    let mut fields = Vec::new();
178    for (name, spec) in params {
179        let ty = spec.get("type").and_then(Value::as_str).unwrap_or("");
180        match ty {
181            "number" => fields.push(FormField {
182                path: vec![name.clone()],
183                label: prettify(name),
184                group: "Parameters".into(),
185                kind: FieldKind::Scalar { step: 0.5 },
186            }),
187            "string" => fields.push(FormField {
188                path: vec![name.clone()],
189                label: prettify(name),
190                group: "Parameters".into(),
191                // The `id` is the feature's identity; editing it must cascade to
192                // references, so it is shown read-only for now.
193                kind: FieldKind::Text {
194                    read_only: name == "id",
195                },
196            }),
197            "transform" => {
198                // TWO transform param shapes share the `transform` schema type,
199                // discriminated by the schema's own `default_value` (the shape a
200                // fresh feature is seeded with, so it can never lie):
201                //   * the ACOMP rigid instance pose `{translate, rotateEulerDeg}`
202                //     (assemblies spec §2.2 — no scale, degrees, intrinsic XYZ);
203                //   * the modeling `{position, rotationEuler, scale}` triple every
204                //     modeling feature uses.
205                // The mapping lives HERE (the form-engine altitude) so no dialog
206                // ever special-cases a feature type.
207                let is_rigid_pose = spec
208                    .get("default_value")
209                    .map(|d| d.get("translate").is_some() || d.get("rotateEulerDeg").is_some())
210                    .unwrap_or(false);
211                if is_rigid_pose {
212                    fields.push(vec3_field(name, "translate", "Translate", 0.5));
213                    fields.push(vec3_field(name, "rotateEulerDeg", "Rotation (deg)", 1.0));
214                } else {
215                    fields.push(vec3_field(name, "position", "Position", 0.5));
216                    fields.push(vec3_field(name, "rotationEuler", "Rotation (deg)", 1.0));
217                    fields.push(vec3_field(name, "scale", "Scale", 0.1));
218                }
219            }
220            "boolean" => {
221                // A plain checkbox param (e.g. ACOMP `isFixed`, constraint
222                // `reverse`/`opposeNormals`/`exteriorAngle`). Label from the
223                // schema's `label` when present, else the prettified key.
224                fields.push(FormField {
225                    path: vec![name.clone()],
226                    label: field_label(spec, name),
227                    group: "Parameters".into(),
228                    kind: FieldKind::Bool,
229                });
230            }
231            "boolean_operation" => {
232                fields.push(FormField {
233                    path: vec![name.clone(), "operation".into()],
234                    label: "Operation".into(),
235                    group: "Boolean".into(),
236                    kind: FieldKind::Enum {
237                        variants: BOOLEAN_OPS.iter().map(|s| s.to_string()).collect(),
238                    },
239                });
240                fields.push(FormField {
241                    path: vec![name.clone(), "targets".into()],
242                    label: "Tool solids".into(),
243                    group: "Boolean".into(),
244                    kind: FieldKind::Reference {
245                        filter: vec!["SOLID".into()],
246                        multiple: true,
247                    },
248                });
249                fields.push(FormField {
250                    path: vec![name.clone(), "mergeCoplanarFaces".into()],
251                    label: "Merge coplanar faces".into(),
252                    group: "Boolean".into(),
253                    kind: FieldKind::Bool,
254                });
255            }
256            "button" => {
257                // An action button (e.g. Edit Sketch): binds to no value. Its
258                // `key` (the schema key) identifies the click to the host. The
259                // caption is the schema `label` (falling back to the key).
260                let label = spec
261                    .get("label")
262                    .and_then(Value::as_str)
263                    .map(String::from)
264                    .unwrap_or_else(|| prettify(name));
265                fields.push(FormField {
266                    path: vec![name.clone()],
267                    label: label.clone(),
268                    group: "Parameters".into(),
269                    kind: FieldKind::Button { label },
270                });
271            }
272            "reference_selection" => {
273                let filter = crate::json_support::string_values(spec.get("selectionFilter"))
274                    .map(String::from)
275                    .collect();
276                let multiple = spec
277                    .get("multiple")
278                    .and_then(Value::as_bool)
279                    .unwrap_or(false);
280                // `"References"` is a SEMANTIC TAG, not a rendered wrapper: the
281                // history tree inlines this group (each reference renders as its
282                // own self-titled node — no "References" parent), and the context
283                // bar keys on it to find a feature's PRE-FILL fields (top-level
284                // `reference_selection`s, vs a boolean-op `targets` in `Boolean`;
285                // WHETHER a feature is offered is the kernel predicate's call —
286                // `feature_pipeline::context_offer`).
287                fields.push(FormField {
288                    path: vec![name.clone()],
289                    label: prettify(name),
290                    group: "References".into(),
291                    kind: FieldKind::Reference { filter, multiple },
292                });
293            }
294            "options" => {
295                // A single-choice enum: render as a dropdown of the schema's
296                // `options`, using the field's `label` when present (e.g.
297                // flangeLengthReference → "Length reference", inset → "Flange
298                // position") else the prettified key.
299                let variants = crate::json_support::string_values(spec.get("options"))
300                    .map(String::from)
301                    .collect();
302                fields.push(FormField {
303                    path: vec![name.clone()],
304                    label: field_label(spec, name),
305                    group: "Parameters".into(),
306                    kind: FieldKind::Enum { variants },
307                });
308            }
309            // Unmapped kernel param types (vec3-array/etc. arrive as more
310            // features are wired) are skipped rather than mis-rendered.
311            _ => {}
312        }
313    }
314    fields
315}
316
317fn vec3_field(param: &str, sub: &str, label: &str, step: f64) -> FormField {
318    FormField {
319        path: vec![param.to_string(), sub.to_string()],
320        label: label.to_string(),
321        group: "Transform".into(),
322        kind: FieldKind::Vec3 { step },
323    }
324}
325
326/// A field's display label: the schema `label` when set, else the prettified key.
327fn field_label(spec: &Value, name: &str) -> String {
328    spec.get("label")
329        .and_then(Value::as_str)
330        .map(String::from)
331        .unwrap_or_else(|| prettify(name))
332}
333
334/// `sizeX` → `Size X`, `rotationEuler` → `Rotation euler`, `id` → `Id`.
335fn prettify(key: &str) -> String {
336    let mut out = String::new();
337    for (i, ch) in key.chars().enumerate() {
338        if i == 0 {
339            out.extend(ch.to_uppercase());
340        } else if ch.is_ascii_uppercase() {
341            out.push(' ');
342            out.extend(ch.to_lowercase());
343        } else {
344            out.push(ch);
345        }
346    }
347    out
348}
349
350// BREP private tests: 8025e153939dba84