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: the schema `longName` (else the type
47/// itself), with the feature's icon character PREPENDED. Every consumer that
48/// shows a feature name (add-feature palette, history tree, context-bar offers,
49/// dialogs) reads it through here, so the icon appears everywhere with no
50/// per-caller wiring — see [`feature_icon`].
51pub fn feature_long_name(feature_type: &str) -> String {
52    let name = feature_plain_name(feature_type);
53    match feature_icon(feature_type) {
54        Some(glyph) => format!("{glyph} {name}"),
55        None => name,
56    }
57}
58
59/// The same display name WITHOUT the leading glyph — the schema `longName`, else
60/// the type itself.
61///
62/// For the one kind of caller that draws the icon as ARTWORK rather than as a
63/// character: the history tree gives the icon its own column
64/// (`brep_app::panels::tree::TreeRow::glyph`), so its label must not carry the
65/// glyph a second time. Everything that shows a feature name as plain text
66/// keeps reading [`feature_long_name`], which is still where the icon is
67/// prepended.
68pub fn feature_plain_name(feature_type: &str) -> String {
69    feature_schema(feature_type)
70        .and_then(|f| f.get("longName").and_then(Value::as_str).map(String::from))
71        .unwrap_or_else(|| feature_type.to_string())
72}
73
74/// A feature type's SHORT name (`shortName`, e.g. `P.CU`, `S`, `E`), else the
75/// type code itself. This is the BASE for a new feature's id: the engine appends
76/// the part history's persistent global counter to it (`P.CU` → `P.CU7`,
77/// `S` → `S8`) in [`crate::history::History::next_feature_id`].
78pub fn feature_short_name(feature_type: &str) -> String {
79    feature_schema(feature_type)
80        .and_then(|f| f.get("shortName").and_then(Value::as_str).map(String::from))
81        .unwrap_or_else(|| feature_type.to_string())
82}
83
84/// The icon character for a feature type, if one exists. Every kernel
85/// feature type has a FreeCAD-inspired monoline glyph in the font's Private-Use
86/// block U+E030-E059 (edited as `BREP_app/assets/glyphs/*.svg`, which are also
87/// the source of the inline SVG icon catalog — see `BREP_app/src/icons.rs`;
88/// `every_feature_type_has_catalogued_artwork` there checks each of these
89/// codepoints actually HAS a glyph, which this module's own test cannot). The font is
90/// installed as family `"brep_icons"` at the HEAD of egui's fallback chain
91/// ([`crate`]-side `fonts.rs`), so a bare returned `char` renders as the icon.
92///
93/// Matches the SAME alias set the kernel dispatch does (short codes AND the
94/// long-/class-name strings saved files carry — e.g. `"CHAMFER"`, the misspelt
95/// `"DATIUM"`, `"PUSH FACE"`), uppercased, so a type loaded from disk resolves.
96pub fn feature_icon(kind: &str) -> Option<char> {
97    let cp: u32 = match kind.trim().to_ascii_uppercase().as_str() {
98        "D" | "DATUM" | "DATIUM" => 0xE030,
99        "P" | "PLANE" => 0xE031,
100        "P.CU" | "CUBE" => 0xE032,
101        "P.CY" | "CYLINDER" => 0xE033,
102        "P.CO" | "CONE" => 0xE034,
103        "P.S" | "SPHERE" => 0xE035,
104        "P.T" | "TORUS" => 0xE036,
105        "P.PY" | "PYRAMID" => 0xE037,
106        "IMPORT3D" => 0xE038,
107        "S" | "SKETCH" => 0xE039,
108        "SP" | "SPLINE" => 0xE03A,
109        "PORT" => 0xE03B,
110        "HX" | "HELIX" => 0xE03C,
111        "E" | "EXTRUDE" => 0xE03D,
112        "B" | "BOOLEAN" => 0xE03E,
113        "F" | "FILLET" => 0xE03F,
114        "CH" | "CHAMFER" => 0xE040,
115        "O.S" | "OFFSET SHELL" | "OFFSETSHELL" => 0xE041,
116        "O.F" | "OFFSET FACE" | "OFFSETFACE" => 0xE042,
117        "PF" | "PUSHFACE" | "PUSH FACE" => 0xE043,
118        "DF" | "DELETE FACE" | "DELETEFACE" => 0xE044,
119        "THK" | "THICKEN" => 0xE045,
120        "SM.TAB" => 0xE046,
121        "SM.CF" => 0xE047,
122        "SM.F" => 0xE048,
123        "SM.HEM" => 0xE049,
124        "SM.FILLET" | "SM.CFIL" => 0xE04A,
125        "SM.CHAMFER" | "SM.CCHM" => 0xE04B,
126        "SM.CUTOUT" => 0xE04C,
127        "LOFT" => 0xE04D,
128        "M" | "MIRROR" => 0xE04E,
129        "SPL" | "SPLIT" => 0xE04F,
130        "R" | "REVOLVE" => 0xE050,
131        "RIB" => 0xE051,
132        "SW" | "SWEEP" => 0xE052,
133        "SWP" | "PATH SWEEP" | "PATHSWEEP" => 0xE053,
134        "H" | "HOLE" => 0xE054,
135        "TU" | "TUBE" => 0xE055,
136        "XFORM" | "TRANSFORM" => 0xE056,
137        "PATTERN" => 0xE057,
138        "ACOMP" | "ASSEMBLY COMPONENT" => 0xE058,
139        "SM.UNFOLD" => 0xE059,
140        _ => return None,
141    };
142    char::from_u32(cp)
143}
144
145/// A feature type's DEFAULT `inputParams`, built from its schema: one entry per
146/// `inputParamsSchema` param seeded with that param's `default_value` (missing →
147/// `null`). The caller assigns the unique `id` afterwards (the schema's `id`
148/// default is `null`). Additive, engine-owned, and reusable by any "add feature"
149/// path so a new feature's params always track the kernel schema. Unknown types
150/// yield an empty object.
151pub fn feature_default_params(feature_type: &str) -> Value {
152    let mut params = serde_json::Map::new();
153    if let Some(props) = feature_schema(feature_type)
154        .as_ref()
155        .and_then(|s| s.get("inputParamsSchema"))
156        .and_then(Value::as_object)
157    {
158        for (name, spec) in props {
159            let default = spec.get("default_value").cloned().unwrap_or(Value::Null);
160            params.insert(name.clone(), default);
161        }
162    }
163    Value::Object(params)
164}
165
166/// The boolean operation choices (a fixed kernel enum — the schema stores only a
167/// default operation, not the variant set, so the known set rides here).
168const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
169
170/// Map a feature type's `inputParamsSchema` into the general form fields, in
171/// schema (insertion) order — the kernel builds schemas with serde_json's
172/// `preserve_order`, so grouped fields stay contiguous for the group headers.
173pub fn feature_form_fields(feature_type: &str) -> Vec<FormField> {
174    let Some(schema) = feature_schema(feature_type) else {
175        return Vec::new();
176    };
177    form_fields_from_schema(&schema)
178}
179
180/// Map ANY schema entry carrying an `inputParamsSchema` object into form fields
181/// — the shared engine behind [`feature_form_fields`] AND the assembly
182/// constraint dialogs (whose schemas come from the kernel's
183/// `constraint_schema_catalogue`, same shape, different catalogue). One mapping,
184/// two catalogues (the schema-driven-dialog principle).
185pub fn form_fields_from_schema(schema: &Value) -> Vec<FormField> {
186    let Some(params) = schema
187        .get("inputParamsSchema")
188        .and_then(Value::as_object)
189    else {
190        return Vec::new();
191    };
192
193    let mut fields = Vec::new();
194    for (name, spec) in params {
195        let ty = spec.get("type").and_then(Value::as_str).unwrap_or("");
196        match ty {
197            "number" => fields.push(FormField {
198                path: vec![name.clone()],
199                label: prettify(name),
200                group: "Parameters".into(),
201                kind: FieldKind::Scalar { step: 0.5 },
202            }),
203            "string" => fields.push(FormField {
204                path: vec![name.clone()],
205                label: prettify(name),
206                group: "Parameters".into(),
207                // The `id` is the feature's identity; editing it must cascade to
208                // references, so it is shown read-only for now.
209                kind: FieldKind::Text {
210                    read_only: name == "id",
211                },
212            }),
213            "transform" => {
214                // TWO transform param shapes share the `transform` schema type,
215                // discriminated by the schema's own `default_value` (the shape a
216                // fresh feature is seeded with, so it can never lie):
217                //   * the ACOMP rigid instance pose `{translate, rotateEulerDeg}`
218                //     (assemblies spec §2.2 — no scale, degrees, intrinsic XYZ);
219                //   * the modeling `{position, rotationEuler, scale}` triple every
220                //     modeling feature uses.
221                // The mapping lives HERE (the form-engine altitude) so no dialog
222                // ever special-cases a feature type.
223                let is_rigid_pose = spec
224                    .get("default_value")
225                    .map(|d| d.get("translate").is_some() || d.get("rotateEulerDeg").is_some())
226                    .unwrap_or(false);
227                if is_rigid_pose {
228                    fields.push(vec3_field(name, "translate", "Translate", 0.5));
229                    fields.push(vec3_field(name, "rotateEulerDeg", "Rotation (deg)", 1.0));
230                } else {
231                    fields.push(vec3_field(name, "position", "Position", 0.5));
232                    fields.push(vec3_field(name, "rotationEuler", "Rotation (deg)", 1.0));
233                    fields.push(vec3_field(name, "scale", "Scale", 0.1));
234                }
235            }
236            "boolean" => {
237                // A plain checkbox param (e.g. ACOMP `isFixed`, constraint
238                // `reverse`/`opposeNormals`/`exteriorAngle`). Label from the
239                // schema's `label` when present, else the prettified key.
240                fields.push(FormField {
241                    path: vec![name.clone()],
242                    label: field_label(spec, name),
243                    group: "Parameters".into(),
244                    kind: FieldKind::Bool,
245                });
246            }
247            "boolean_operation" => {
248                fields.push(FormField {
249                    path: vec![name.clone(), "operation".into()],
250                    label: "Operation".into(),
251                    group: "Boolean".into(),
252                    kind: FieldKind::Enum {
253                        variants: BOOLEAN_OPS.iter().map(|s| s.to_string()).collect(),
254                    },
255                });
256                fields.push(FormField {
257                    path: vec![name.clone(), "targets".into()],
258                    label: "Tool solids".into(),
259                    group: "Boolean".into(),
260                    kind: FieldKind::Reference {
261                        filter: vec!["SOLID".into()],
262                        multiple: true,
263                    },
264                });
265                fields.push(FormField {
266                    path: vec![name.clone(), "mergeCoplanarFaces".into()],
267                    label: "Merge coplanar faces".into(),
268                    group: "Boolean".into(),
269                    kind: FieldKind::Bool,
270                });
271            }
272            "button" => {
273                // An action button (e.g. Edit Sketch): binds to no value. Its
274                // `key` (the schema key) identifies the click to the host. The
275                // caption is the schema `label` (falling back to the key).
276                let label = spec
277                    .get("label")
278                    .and_then(Value::as_str)
279                    .map(String::from)
280                    .unwrap_or_else(|| prettify(name));
281                fields.push(FormField {
282                    path: vec![name.clone()],
283                    label: label.clone(),
284                    group: "Parameters".into(),
285                    kind: FieldKind::Button { label },
286                });
287            }
288            "reference_selection" => {
289                let filter = spec
290                    .get("selectionFilter")
291                    .and_then(Value::as_array)
292                    .map(|a| {
293                        a.iter()
294                            .filter_map(|v| v.as_str().map(String::from))
295                            .collect()
296                    })
297                    .unwrap_or_default();
298                let multiple = spec
299                    .get("multiple")
300                    .and_then(Value::as_bool)
301                    .unwrap_or(false);
302                // `"References"` is a SEMANTIC TAG, not a rendered wrapper: the
303                // history tree inlines this group (each reference renders as its
304                // own self-titled node — no "References" parent), and the context
305                // bar keys on it to find a feature's PRE-FILL fields (top-level
306                // `reference_selection`s, vs a boolean-op `targets` in `Boolean`;
307                // WHETHER a feature is offered is the kernel predicate's call —
308                // `feature_pipeline::context_offer`).
309                fields.push(FormField {
310                    path: vec![name.clone()],
311                    label: prettify(name),
312                    group: "References".into(),
313                    kind: FieldKind::Reference { filter, multiple },
314                });
315            }
316            "options" => {
317                // A single-choice enum: render as a dropdown of the schema's
318                // `options`, using the field's `label` when present (e.g.
319                // flangeLengthReference → "Length reference", inset → "Flange
320                // position") else the prettified key.
321                let variants = spec
322                    .get("options")
323                    .and_then(Value::as_array)
324                    .map(|a| {
325                        a.iter()
326                            .filter_map(|v| v.as_str().map(String::from))
327                            .collect()
328                    })
329                    .unwrap_or_default();
330                fields.push(FormField {
331                    path: vec![name.clone()],
332                    label: field_label(spec, name),
333                    group: "Parameters".into(),
334                    kind: FieldKind::Enum { variants },
335                });
336            }
337            // Unmapped kernel param types (vec3-array/etc. arrive as more
338            // features are wired) are skipped rather than mis-rendered.
339            _ => {}
340        }
341    }
342    fields
343}
344
345fn vec3_field(param: &str, sub: &str, label: &str, step: f64) -> FormField {
346    FormField {
347        path: vec![param.to_string(), sub.to_string()],
348        label: label.to_string(),
349        group: "Transform".into(),
350        kind: FieldKind::Vec3 { step },
351    }
352}
353
354/// A field's display label: the schema `label` when set, else the prettified key.
355fn field_label(spec: &Value, name: &str) -> String {
356    spec.get("label")
357        .and_then(Value::as_str)
358        .map(String::from)
359        .unwrap_or_else(|| prettify(name))
360}
361
362/// `sizeX` → `Size X`, `rotationEuler` → `Rotation euler`, `id` → `Id`.
363fn prettify(key: &str) -> String {
364    let mut out = String::new();
365    for (i, ch) in key.chars().enumerate() {
366        if i == 0 {
367            out.extend(ch.to_uppercase());
368        } else if ch.is_ascii_uppercase() {
369            out.push(' ');
370            out.extend(ch.to_lowercase());
371        } else {
372            out.push(ch);
373        }
374    }
375    out
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn catalogue_is_reachable_from_native_rust() {
384        let cat = feature_catalogue();
385        assert!(cat.get("features").and_then(Value::as_array).is_some());
386        assert!(feature_schema("P.CU").is_some());
387        assert!(feature_schema("P.CY").is_some());
388        assert!(feature_schema("B").is_some());
389        // Glyph is prepended; the name still ends with the schema longName.
390        assert!(feature_long_name("P.CU").ends_with("Primitive Cube"));
391        assert!(feature_long_name("P.CU").starts_with(feature_icon("P.CU").unwrap()));
392    }
393
394    #[test]
395    fn every_catalogue_feature_type_has_an_icon() {
396        // The palette + history tree prefix every feature with its glyph, so a
397        // missing mapping would leave a bare feature with no icon. Guard the
398        // whole catalogue against a future feature landing without a glyph.
399        let cat = feature_catalogue();
400        for feature in cat["features"].as_array().expect("features") {
401            let ty = feature["type"].as_str().expect("type");
402            assert!(
403                feature_icon(ty).is_some(),
404                "feature type {ty} has no icon (add one under BREP_app/assets/glyphs/ + features::feature_icon)"
405            );
406        }
407        // Alias resolution: the long-name / misspelt strings saved files use.
408        assert_eq!(feature_icon("CHAMFER"), feature_icon("CH"));
409        assert_eq!(feature_icon("DATIUM"), feature_icon("D"));
410        assert_eq!(feature_icon("push face"), feature_icon("PF"));
411        assert!(feature_icon("NOPE").is_none());
412    }
413
414    #[test]
415    fn short_name_is_the_schema_code_or_the_type_fallback() {
416        // The base for a new feature's id: the schema shortName (incl. dotted
417        // primitive codes), falling back to the type code for an unknown type.
418        assert_eq!(feature_short_name("P.CU"), "P.CU");
419        assert_eq!(feature_short_name("P.S"), "P.S");
420        assert_eq!(feature_short_name("S"), "S");
421        assert_eq!(feature_short_name("E"), "E");
422        assert_eq!(feature_short_name("NOPE"), "NOPE");
423    }
424
425    #[test]
426    fn cube_form_fields_map_types_correctly() {
427        let fields = feature_form_fields("P.CU");
428        // id (read-only Text), sizeX/Y/Z (Scalar), transform → 3× Vec3,
429        // boolean → Enum + Reference + Bool.
430        let by_key = |k: &str| fields.iter().find(|f| f.key() == k).cloned();
431
432        assert!(matches!(
433            by_key("id").unwrap().kind,
434            FieldKind::Text { read_only: true }
435        ));
436        assert!(matches!(
437            by_key("sizeX").unwrap().kind,
438            FieldKind::Scalar { .. }
439        ));
440        // transform expands into three Vec3 sub-fields on nested paths.
441        let position = fields
442            .iter()
443            .find(|f| f.path == ["transform", "position"])
444            .expect("transform.position vec3");
445        assert!(matches!(position.kind, FieldKind::Vec3 { .. }));
446        assert_eq!(
447            fields
448                .iter()
449                .filter(|f| matches!(f.kind, FieldKind::Vec3 { .. }))
450                .count(),
451            3
452        );
453        // boolean_operation expands: operation Enum, targets Reference, merge Bool.
454        let op = fields
455            .iter()
456            .find(|f| f.path == ["boolean", "operation"])
457            .expect("boolean.operation");
458        assert!(matches!(op.kind, FieldKind::Enum { .. }));
459        let targets = fields
460            .iter()
461            .find(|f| f.path == ["boolean", "targets"])
462            .expect("boolean.targets");
463        assert!(matches!(targets.kind, FieldKind::Reference { multiple: true, .. }));
464    }
465
466    #[test]
467    fn flange_options_and_boolean_fields_render() {
468        // Regression: `options` and plain `boolean` schema fields fell into the
469        // `_ => {}` catch-all and were silently skipped, so the flange's
470        // length-reference / position dropdowns (and Reverse-direction toggle)
471        // never appeared in the dialog.
472        let fields = feature_form_fields("SM.F");
473        let by_key = |k: &str| fields.iter().find(|f| f.key() == k).cloned();
474
475        let length_ref = by_key("flangeLengthReference").expect("flangeLengthReference renders");
476        assert_eq!(length_ref.label, "Length reference");
477        if let FieldKind::Enum { variants } = &length_ref.kind {
478            assert!(variants.iter().any(|v| v.as_str() == "Inner Virtual Sharp"));
479            assert!(variants.iter().any(|v| v.as_str() == "Outer Virtual Sharp"));
480            assert!(variants.iter().any(|v| v.as_str() == "Tangent to Bend"));
481        } else {
482            panic!("flangeLengthReference should render as an Enum dropdown");
483        }
484
485        let inset = by_key("inset").expect("inset (Flange position) renders");
486        assert_eq!(inset.label, "Flange position");
487        assert!(matches!(inset.kind, FieldKind::Enum { .. }));
488
489        // plain `boolean` now renders too, with its schema label.
490        let reverse = by_key("useOppositeCenterline").expect("useOppositeCenterline renders");
491        assert_eq!(reverse.label, "Reverse direction");
492        assert!(matches!(reverse.kind, FieldKind::Bool));
493    }
494
495    #[test]
496    fn default_params_seed_from_schema_defaults() {
497        // A primitive's defaults are COMPLETE (build-ready) straight from schema.
498        let cube = feature_default_params("P.CU");
499        assert_eq!(cube["sizeX"], 10.0);
500        assert_eq!(cube["sizeY"], 10.0);
501        assert_eq!(cube["transform"]["scale"], serde_json::json!([1, 1, 1]));
502        assert_eq!(cube["boolean"]["operation"], "NONE");
503        // The `id` default is null — the caller assigns a unique one.
504        assert_eq!(cube["id"], Value::Null);
505        // A feature whose reference default is null still yields the key.
506        let boolean = feature_default_params("B");
507        assert!(boolean.as_object().unwrap().contains_key("targetSolid"));
508        assert_eq!(boolean["boolean"]["operation"], "UNION");
509        // Unknown types → empty object (no panic).
510        assert_eq!(feature_default_params("NOPE"), serde_json::json!({}));
511    }
512
513    #[test]
514    fn boolean_feature_target_is_a_reference_field() {
515        let fields = feature_form_fields("B");
516        let target = fields
517            .iter()
518            .find(|f| f.key() == "targetSolid")
519            .expect("targetSolid field");
520        match &target.kind {
521            FieldKind::Reference { filter, multiple } => {
522                assert_eq!(filter, &["SOLID".to_string()]);
523                assert!(!multiple);
524            }
525            other => panic!("targetSolid should be a Reference, got {other:?}"),
526        }
527    }
528
529    #[test]
530    fn sketch_feature_maps_button_fields() {
531        // The SKETCH schema's `editSketch` (`type:"button"`)
532        // become `FieldKind::Button` fields carrying the schema label + their key.
533        let fields = feature_form_fields("S");
534        let edit = fields
535            .iter()
536            .find(|f| f.key() == "editSketch")
537            .expect("editSketch button field");
538        assert!(
539            matches!(&edit.kind, FieldKind::Button { label } if label == "Edit Sketch"),
540            "editSketch should be Button('Edit Sketch'), got {:?}",
541            edit.kind
542        );
543        assert_eq!(edit.path, ["editSketch"], "button binds to its own key");
544        // `dumpSketchDiagnostics` was a debug button whose hint promised a download
545        // it never performed — it wrote to a JS global / stderr and showed the user
546        // nothing. It is gone from the schema; the engine-side
547        // `sketch_diagnostics_dump_json` it called stays as the developer API.
548        assert!(
549            fields.iter().all(|f| f.key() != "dumpSketchDiagnostics"),
550            "the dead Dump Diagnostics button must not come back"
551        );
552    }
553
554    #[test]
555    fn acomp_form_fields_map_rigid_pose_and_is_fixed() {
556        // The ACOMP schema's `transform` default is the RIGID POSE shape
557        // `{translate, rotateEulerDeg}` — the form mapping must bind those two
558        // nested paths (NOT the legacy position/rotationEuler/scale triple), and
559        // its `isFixed` boolean must surface as a checkbox (dialog-visible per
560        // the insert-flow contract).
561        let fields = feature_form_fields("ACOMP");
562        let translate = fields
563            .iter()
564            .find(|f| f.path == ["transform", "translate"])
565            .expect("transform.translate vec3");
566        assert!(matches!(translate.kind, FieldKind::Vec3 { .. }));
567        let rotate = fields
568            .iter()
569            .find(|f| f.path == ["transform", "rotateEulerDeg"])
570            .expect("transform.rotateEulerDeg vec3");
571        assert!(matches!(rotate.kind, FieldKind::Vec3 { .. }));
572        assert!(
573            !fields.iter().any(|f| f.path == ["transform", "scale"]),
574            "a rigid pose has no scale row"
575        );
576        let fixed = fields
577            .iter()
578            .find(|f| f.key() == "isFixed")
579            .expect("isFixed checkbox");
580        assert!(matches!(fixed.kind, FieldKind::Bool));
581        assert_eq!(fixed.label, "Fixed", "schema label wins over the prettified key");
582        // The legacy shape is untouched: a cube still maps the full triple.
583        let cube = feature_form_fields("P.CU");
584        assert!(cube.iter().any(|f| f.path == ["transform", "scale"]));
585    }
586
587    #[test]
588    fn constraint_schemas_map_through_the_shared_field_engine() {
589        // The ten assembly-constraint schemas share the feature-schema shape, so
590        // `form_fields_from_schema` renders them with the SAME mapping: elements →
591        // Reference (filter + multiplicity from the schema), numbers → Scalar,
592        // booleans → Bool, id → read-only Text.
593        let catalogue = brep_kernel::constraint_schema_catalogue();
594        let distance = catalogue
595            .as_array()
596            .unwrap()
597            .iter()
598            .find(|s| s.get("type").and_then(Value::as_str) == Some("distance"))
599            .expect("distance schema in the catalogue");
600        let fields = form_fields_from_schema(distance);
601        let elements = fields
602            .iter()
603            .find(|f| f.key() == "elements")
604            .expect("elements reference field");
605        match &elements.kind {
606            FieldKind::Reference { filter, multiple } => {
607                assert_eq!(filter, &["FACE".to_string(), "VERTEX".into(), "EDGE".into()]);
608                assert!(multiple, "two-element constraints take a list");
609            }
610            other => panic!("elements should be a Reference, got {other:?}"),
611        }
612        assert!(matches!(
613            fields.iter().find(|f| f.key() == "distance").unwrap().kind,
614            FieldKind::Scalar { .. }
615        ));
616        assert!(matches!(
617            fields.iter().find(|f| f.key() == "opposeNormals").unwrap().kind,
618            FieldKind::Bool
619        ));
620        assert!(matches!(
621            fields.iter().find(|f| f.key() == "id").unwrap().kind,
622            FieldKind::Text { read_only: true }
623        ));
624    }
625
626    #[test]
627    fn reference_selection_field_is_a_self_titled_references_tagged_node() {
628        // The history tree renders a `reference_selection` field as its OWN
629        // top-level-under-the-feature node (no "References" wrapper): the node
630        // title is the field's LABEL and it carries the Select button. This pins
631        // the descriptor contract that the tree's inlining relies on — the field's
632        // `label` is the prettified param name ("Sketch plane"), and its `group`
633        // stays `"References"` as the semantic tag (tree inline marker + the
634        // context bar's primary-reference discriminator).
635        let sketch_plane = feature_form_fields("S")
636            .into_iter()
637            .find(|f| f.key() == "sketchPlane")
638            .expect("sketch has a sketchPlane reference_selection field");
639        assert_eq!(sketch_plane.label, "Sketch plane", "node title = field label");
640        assert_eq!(sketch_plane.group, "References", "kept as a semantic tag");
641        assert_eq!(sketch_plane.path, ["sketchPlane"], "a top-level (primary) reference");
642        match &sketch_plane.kind {
643            FieldKind::Reference { filter, multiple } => {
644                assert_eq!(filter, &["PLANE".to_string(), "FACE".to_string()]);
645                assert!(!multiple, "the sketch plane is a single reference");
646            }
647            other => panic!("sketchPlane should be a Reference, got {other:?}"),
648        }
649    }
650}