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_schema(feature_type)
53 .and_then(|f| f.get("longName").and_then(Value::as_str).map(String::from))
54 .unwrap_or_else(|| feature_type.to_string());
55 match feature_icon(feature_type) {
56 Some(glyph) => format!("{glyph} {name}"),
57 None => name,
58 }
59}
60
61pub fn feature_short_name(feature_type: &str) -> String {
66 feature_schema(feature_type)
67 .and_then(|f| f.get("shortName").and_then(Value::as_str).map(String::from))
68 .unwrap_or_else(|| feature_type.to_string())
69}
70
71pub 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
129pub 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
150const BOOLEAN_OPS: &[&str] = &["NONE", "UNION", "SUBTRACT", "INTERSECT"];
153
154pub 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
164pub 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 kind: FieldKind::Text {
194 read_only: name == "id",
195 },
196 }),
197 "transform" => {
198 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 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 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 = spec
274 .get("selectionFilter")
275 .and_then(Value::as_array)
276 .map(|a| {
277 a.iter()
278 .filter_map(|v| v.as_str().map(String::from))
279 .collect()
280 })
281 .unwrap_or_default();
282 let multiple = spec
283 .get("multiple")
284 .and_then(Value::as_bool)
285 .unwrap_or(false);
286 fields.push(FormField {
294 path: vec![name.clone()],
295 label: prettify(name),
296 group: "References".into(),
297 kind: FieldKind::Reference { filter, multiple },
298 });
299 }
300 "options" => {
301 let variants = spec
306 .get("options")
307 .and_then(Value::as_array)
308 .map(|a| {
309 a.iter()
310 .filter_map(|v| v.as_str().map(String::from))
311 .collect()
312 })
313 .unwrap_or_default();
314 fields.push(FormField {
315 path: vec![name.clone()],
316 label: field_label(spec, name),
317 group: "Parameters".into(),
318 kind: FieldKind::Enum { variants },
319 });
320 }
321 _ => {}
324 }
325 }
326 fields
327}
328
329fn vec3_field(param: &str, sub: &str, label: &str, step: f64) -> FormField {
330 FormField {
331 path: vec![param.to_string(), sub.to_string()],
332 label: label.to_string(),
333 group: "Transform".into(),
334 kind: FieldKind::Vec3 { step },
335 }
336}
337
338fn field_label(spec: &Value, name: &str) -> String {
340 spec.get("label")
341 .and_then(Value::as_str)
342 .map(String::from)
343 .unwrap_or_else(|| prettify(name))
344}
345
346fn prettify(key: &str) -> String {
348 let mut out = String::new();
349 for (i, ch) in key.chars().enumerate() {
350 if i == 0 {
351 out.extend(ch.to_uppercase());
352 } else if ch.is_ascii_uppercase() {
353 out.push(' ');
354 out.extend(ch.to_lowercase());
355 } else {
356 out.push(ch);
357 }
358 }
359 out
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn catalogue_is_reachable_from_native_rust() {
368 let cat = feature_catalogue();
369 assert!(cat.get("features").and_then(Value::as_array).is_some());
370 assert!(feature_schema("P.CU").is_some());
371 assert!(feature_schema("P.CY").is_some());
372 assert!(feature_schema("B").is_some());
373 assert!(feature_long_name("P.CU").ends_with("Primitive Cube"));
375 assert!(feature_long_name("P.CU").starts_with(feature_icon("P.CU").unwrap()));
376 }
377
378 #[test]
379 fn every_catalogue_feature_type_has_an_icon() {
380 let cat = feature_catalogue();
384 for feature in cat["features"].as_array().expect("features") {
385 let ty = feature["type"].as_str().expect("type");
386 assert!(
387 feature_icon(ty).is_some(),
388 "feature type {ty} has no BrepIcons glyph (add one under tools/iconfont/glyphs/ + features::feature_icon)"
389 );
390 }
391 assert_eq!(feature_icon("CHAMFER"), feature_icon("CH"));
393 assert_eq!(feature_icon("DATIUM"), feature_icon("D"));
394 assert_eq!(feature_icon("push face"), feature_icon("PF"));
395 assert!(feature_icon("NOPE").is_none());
396 }
397
398 #[test]
399 fn short_name_is_the_schema_code_or_the_type_fallback() {
400 assert_eq!(feature_short_name("P.CU"), "P.CU");
403 assert_eq!(feature_short_name("P.S"), "P.S");
404 assert_eq!(feature_short_name("S"), "S");
405 assert_eq!(feature_short_name("E"), "E");
406 assert_eq!(feature_short_name("NOPE"), "NOPE");
407 }
408
409 #[test]
410 fn cube_form_fields_map_types_correctly() {
411 let fields = feature_form_fields("P.CU");
412 let by_key = |k: &str| fields.iter().find(|f| f.key() == k).cloned();
415
416 assert!(matches!(
417 by_key("id").unwrap().kind,
418 FieldKind::Text { read_only: true }
419 ));
420 assert!(matches!(
421 by_key("sizeX").unwrap().kind,
422 FieldKind::Scalar { .. }
423 ));
424 let position = fields
426 .iter()
427 .find(|f| f.path == ["transform", "position"])
428 .expect("transform.position vec3");
429 assert!(matches!(position.kind, FieldKind::Vec3 { .. }));
430 assert_eq!(
431 fields
432 .iter()
433 .filter(|f| matches!(f.kind, FieldKind::Vec3 { .. }))
434 .count(),
435 3
436 );
437 let op = fields
439 .iter()
440 .find(|f| f.path == ["boolean", "operation"])
441 .expect("boolean.operation");
442 assert!(matches!(op.kind, FieldKind::Enum { .. }));
443 let targets = fields
444 .iter()
445 .find(|f| f.path == ["boolean", "targets"])
446 .expect("boolean.targets");
447 assert!(matches!(targets.kind, FieldKind::Reference { multiple: true, .. }));
448 }
449
450 #[test]
451 fn flange_options_and_boolean_fields_render() {
452 let fields = feature_form_fields("SM.F");
457 let by_key = |k: &str| fields.iter().find(|f| f.key() == k).cloned();
458
459 let length_ref = by_key("flangeLengthReference").expect("flangeLengthReference renders");
460 assert_eq!(length_ref.label, "Length reference");
461 if let FieldKind::Enum { variants } = &length_ref.kind {
462 assert!(variants.iter().any(|v| v.as_str() == "Inner Virtual Sharp"));
463 assert!(variants.iter().any(|v| v.as_str() == "Outer Virtual Sharp"));
464 assert!(variants.iter().any(|v| v.as_str() == "Tangent to Bend"));
465 } else {
466 panic!("flangeLengthReference should render as an Enum dropdown");
467 }
468
469 let inset = by_key("inset").expect("inset (Flange position) renders");
470 assert_eq!(inset.label, "Flange position");
471 assert!(matches!(inset.kind, FieldKind::Enum { .. }));
472
473 let reverse = by_key("useOppositeCenterline").expect("useOppositeCenterline renders");
475 assert_eq!(reverse.label, "Reverse direction");
476 assert!(matches!(reverse.kind, FieldKind::Bool));
477 }
478
479 #[test]
480 fn default_params_seed_from_schema_defaults() {
481 let cube = feature_default_params("P.CU");
483 assert_eq!(cube["sizeX"], 10.0);
484 assert_eq!(cube["sizeY"], 10.0);
485 assert_eq!(cube["transform"]["scale"], serde_json::json!([1, 1, 1]));
486 assert_eq!(cube["boolean"]["operation"], "NONE");
487 assert_eq!(cube["id"], Value::Null);
489 let boolean = feature_default_params("B");
491 assert!(boolean.as_object().unwrap().contains_key("targetSolid"));
492 assert_eq!(boolean["boolean"]["operation"], "UNION");
493 assert_eq!(feature_default_params("NOPE"), serde_json::json!({}));
495 }
496
497 #[test]
498 fn boolean_feature_target_is_a_reference_field() {
499 let fields = feature_form_fields("B");
500 let target = fields
501 .iter()
502 .find(|f| f.key() == "targetSolid")
503 .expect("targetSolid field");
504 match &target.kind {
505 FieldKind::Reference { filter, multiple } => {
506 assert_eq!(filter, &["SOLID".to_string()]);
507 assert!(!multiple);
508 }
509 other => panic!("targetSolid should be a Reference, got {other:?}"),
510 }
511 }
512
513 #[test]
514 fn sketch_feature_maps_button_fields() {
515 let fields = feature_form_fields("S");
518 let edit = fields
519 .iter()
520 .find(|f| f.key() == "editSketch")
521 .expect("editSketch button field");
522 assert!(
523 matches!(&edit.kind, FieldKind::Button { label } if label == "Edit Sketch"),
524 "editSketch should be Button('Edit Sketch'), got {:?}",
525 edit.kind
526 );
527 assert_eq!(edit.path, ["editSketch"], "button binds to its own key");
528 assert!(
533 fields.iter().all(|f| f.key() != "dumpSketchDiagnostics"),
534 "the dead Dump Diagnostics button must not come back"
535 );
536 }
537
538 #[test]
539 fn acomp_form_fields_map_rigid_pose_and_is_fixed() {
540 let fields = feature_form_fields("ACOMP");
546 let translate = fields
547 .iter()
548 .find(|f| f.path == ["transform", "translate"])
549 .expect("transform.translate vec3");
550 assert!(matches!(translate.kind, FieldKind::Vec3 { .. }));
551 let rotate = fields
552 .iter()
553 .find(|f| f.path == ["transform", "rotateEulerDeg"])
554 .expect("transform.rotateEulerDeg vec3");
555 assert!(matches!(rotate.kind, FieldKind::Vec3 { .. }));
556 assert!(
557 !fields.iter().any(|f| f.path == ["transform", "scale"]),
558 "a rigid pose has no scale row"
559 );
560 let fixed = fields
561 .iter()
562 .find(|f| f.key() == "isFixed")
563 .expect("isFixed checkbox");
564 assert!(matches!(fixed.kind, FieldKind::Bool));
565 assert_eq!(fixed.label, "Fixed", "schema label wins over the prettified key");
566 let cube = feature_form_fields("P.CU");
568 assert!(cube.iter().any(|f| f.path == ["transform", "scale"]));
569 }
570
571 #[test]
572 fn constraint_schemas_map_through_the_shared_field_engine() {
573 let catalogue = brep_kernel::constraint_schema_catalogue();
578 let distance = catalogue
579 .as_array()
580 .unwrap()
581 .iter()
582 .find(|s| s.get("type").and_then(Value::as_str) == Some("distance"))
583 .expect("distance schema in the catalogue");
584 let fields = form_fields_from_schema(distance);
585 let elements = fields
586 .iter()
587 .find(|f| f.key() == "elements")
588 .expect("elements reference field");
589 match &elements.kind {
590 FieldKind::Reference { filter, multiple } => {
591 assert_eq!(filter, &["FACE".to_string(), "VERTEX".into(), "EDGE".into()]);
592 assert!(multiple, "two-element constraints take a list");
593 }
594 other => panic!("elements should be a Reference, got {other:?}"),
595 }
596 assert!(matches!(
597 fields.iter().find(|f| f.key() == "distance").unwrap().kind,
598 FieldKind::Scalar { .. }
599 ));
600 assert!(matches!(
601 fields.iter().find(|f| f.key() == "opposeNormals").unwrap().kind,
602 FieldKind::Bool
603 ));
604 assert!(matches!(
605 fields.iter().find(|f| f.key() == "id").unwrap().kind,
606 FieldKind::Text { read_only: true }
607 ));
608 }
609
610 #[test]
611 fn reference_selection_field_is_a_self_titled_references_tagged_node() {
612 let sketch_plane = feature_form_fields("S")
620 .into_iter()
621 .find(|f| f.key() == "sketchPlane")
622 .expect("sketch has a sketchPlane reference_selection field");
623 assert_eq!(sketch_plane.label, "Sketch plane", "node title = field label");
624 assert_eq!(sketch_plane.group, "References", "kept as a semantic tag");
625 assert_eq!(sketch_plane.path, ["sketchPlane"], "a top-level (primary) reference");
626 match &sketch_plane.kind {
627 FieldKind::Reference { filter, multiple } => {
628 assert_eq!(filter, &["PLANE".to_string(), "FACE".to_string()]);
629 assert!(!multiple, "the sketch plane is a single reference");
630 }
631 other => panic!("sketchPlane should be a Reference, got {other:?}"),
632 }
633 }
634}