1use crate::style::{FieldKind, FormField};
25use serde_json::Value;
26
27pub fn feature_catalogue() -> Value {
29 brep_kernel::feature_schema_catalogue()
30}
31
32pub 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
46pub 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
59pub 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
74pub 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
84pub 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
145pub 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
166const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
169
170pub 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
180pub 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 kind: FieldKind::Text {
210 read_only: name == "id",
211 },
212 }),
213 "transform" => {
214 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 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 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 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 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 _ => {}
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
354fn 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
362fn 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(cube["id"], Value::Null);
505 let boolean = feature_default_params("B");
507 assert!(boolean.as_object().unwrap().contains_key("targetSolid"));
508 assert_eq!(boolean["boolean"]["operation"], "UNION");
509 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 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 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 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 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 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 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}