1use serde_json::Value;
36
37use crate::feature_dimensions::{
38 append_plain_leader, leaders_buffers, FeatureDimAnnotation,
39};
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum ConstraintOverlayKind {
44 Distance,
46 Angle,
48 Leader,
50}
51
52#[derive(Clone, Debug)]
56pub struct ConstraintOverlay {
57 pub id: String,
59 pub constraint_type: String,
61 pub status: String,
64 pub message: String,
66 pub kind: ConstraintOverlayKind,
68 pub anchors: Vec<[f64; 3]>,
71 pub annotation: Option<FeatureDimAnnotation>,
76 pub value: Option<f64>,
79 pub unit: String,
81 pub draggable: bool,
85 pub input_params: Value,
88 pub elements: Vec<String>,
91}
92
93impl ConstraintOverlay {
94 pub fn field_key(&self) -> Option<&'static str> {
96 match self.kind {
97 ConstraintOverlayKind::Distance => Some("distance"),
98 ConstraintOverlayKind::Angle => Some("angle"),
99 ConstraintOverlayKind::Leader => None,
100 }
101 }
102
103 pub fn label_anchor(&self, world_per_pixel: f64) -> Option<[f64; 3]> {
108 if let Some(annotation) = &self.annotation {
109 return Some(match self.kind {
110 ConstraintOverlayKind::Angle => {
111 crate::feature_dimensions::angular_chip_anchor(annotation, world_per_pixel)
112 }
113 _ => annotation.midpoint(),
114 });
115 }
116 match self.anchors.len() {
117 0 => None,
118 1 => Some(self.anchors[0]),
119 _ => {
120 let a = self.anchors[0];
121 let b = self.anchors[1];
122 Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5])
123 }
124 }
125 }
126
127 pub fn label_text(&self) -> String {
130 match self.value {
131 Some(value) => {
132 let n = format!("{value:.2}");
133 let n = n.trim_end_matches('0').trim_end_matches('.');
134 if self.unit == "deg" {
135 format!("{} {}\u{00b0}", self.id, n)
136 } else if self.unit.is_empty() {
137 format!("{} {}", self.id, n)
138 } else {
139 format!("{} {} {}", self.id, n, self.unit)
140 }
141 }
142 None => self.id.clone(),
143 }
144 }
145}
146
147pub fn status_color(status: &str) -> [f32; 3] {
159 let [r, g, b] = crate::assembly_status::status_color_rgb(status);
160 [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
161}
162
163pub fn build_constraint_overlays(
175 overlay_rows: &Value,
176 state_constraints: &Value,
177) -> Vec<ConstraintOverlay> {
178 let Some(rows) = overlay_rows.as_array() else {
179 return Vec::new();
180 };
181 rows.iter()
182 .filter_map(|row| build_row(row, state_constraints))
183 .collect()
184}
185
186fn build_row(row: &Value, state_constraints: &Value) -> Option<ConstraintOverlay> {
187 let id = row.get("id")?.as_str()?.to_string();
188 let constraint_type = row
189 .get("type")
190 .and_then(Value::as_str)
191 .unwrap_or("")
192 .to_string();
193 let status = row
194 .get("status")
195 .and_then(Value::as_str)
196 .unwrap_or("")
197 .to_string();
198 let message = row
199 .get("message")
200 .and_then(Value::as_str)
201 .unwrap_or("")
202 .to_string();
203 let anchors = read_points(row.get("anchors"));
204 let directions = read_dirs(row.get("directions"));
205 let geoms = read_strings(row.get("geoms"));
206 let value = row.get("value").and_then(Value::as_f64);
207 let unit = row
208 .get("unit")
209 .and_then(Value::as_str)
210 .unwrap_or("")
211 .to_string();
212
213 let input_params = state_constraints
215 .as_array()
216 .and_then(|list| {
217 list.iter().find(|entry| {
218 entry
219 .get("inputParams")
220 .and_then(|p| p.get("id"))
221 .and_then(Value::as_str)
222 == Some(id.as_str())
223 })
224 })
225 .and_then(|entry| entry.get("inputParams"))
226 .cloned()
227 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
228 let elements = input_params
229 .get("elements")
230 .and_then(Value::as_array)
231 .map(|list| {
232 list.iter()
233 .filter_map(|v| v.as_str().map(str::to_string))
234 .collect()
235 })
236 .unwrap_or_default();
237
238 let kind = match constraint_type.as_str() {
239 "distance" => ConstraintOverlayKind::Distance,
240 "angle" => ConstraintOverlayKind::Angle,
241 _ => ConstraintOverlayKind::Leader,
242 };
243
244 let draggable = match kind {
248 ConstraintOverlayKind::Leader => false,
249 ConstraintOverlayKind::Distance => param_allows_drag(&input_params, "distance"),
250 ConstraintOverlayKind::Angle => param_allows_drag(&input_params, "angle"),
251 };
252
253 let annotation = match kind {
254 ConstraintOverlayKind::Distance => {
255 build_distance_annotation(&anchors, &directions, &geoms, value)
256 }
257 ConstraintOverlayKind::Angle => build_angle_annotation(&anchors, &directions, value),
258 ConstraintOverlayKind::Leader => None,
259 };
260
261 Some(ConstraintOverlay {
262 id,
263 constraint_type,
264 status,
265 message,
266 kind,
267 anchors,
268 annotation,
269 value: match kind {
270 ConstraintOverlayKind::Leader => None,
271 _ => value,
272 },
273 unit,
274 draggable,
275 input_params,
276 elements,
277 })
278}
279
280fn param_allows_drag(params: &Value, key: &str) -> bool {
284 match params.get(key) {
285 None | Some(Value::Null) => true,
286 Some(Value::Number(_)) => true,
287 Some(Value::String(text)) => text.trim().parse::<f64>().is_ok(),
288 _ => false,
289 }
290}
291
292fn build_distance_annotation(
311 anchors: &[[f64; 3]],
312 directions: &[Option<[f64; 3]>],
313 geoms: &[String],
314 value: Option<f64>,
315) -> Option<FeatureDimAnnotation> {
316 if anchors.len() < 2 {
317 return None;
318 }
319 let base = (0..2).find(|&i| {
321 geoms.get(i).map(String::as_str) == Some("plane")
322 && directions
323 .get(i)
324 .copied()
325 .flatten()
326 .is_some_and(|n| co_norm(n) > 1e-9)
327 });
328 if let Some(base) = base {
329 let n = directions[base].expect("base index checked above");
330 let len = co_norm(n);
331 let n = [n[0] / len, n[1] / len, n[2] / len];
332 let q = anchors[base];
333 let p = anchors[1 - base];
334 let s = co_dot(co_sub(p, q), n);
335 let foot = [p[0] - n[0] * s, p[1] - n[1] * s, p[2] - n[2] * s];
336 let mut annotation =
339 FeatureDimAnnotation::linear("distance", foot, p, value.unwrap_or(s), "D");
340 annotation.axis = n;
341 return Some(annotation);
342 }
343 let a = anchors[0];
344 let b = anchors[1];
345 let value = value.unwrap_or_else(|| co_norm(co_sub(b, a)));
346 Some(FeatureDimAnnotation::linear("distance", a, b, value, "D"))
347}
348
349fn build_angle_annotation(
360 anchors: &[[f64; 3]],
361 directions: &[Option<[f64; 3]>],
362 value: Option<f64>,
363) -> Option<FeatureDimAnnotation> {
364 if anchors.len() < 2 || directions.len() < 2 {
365 return None;
366 }
367 let d0 = directions[0]?;
368 let d1 = directions[1]?;
369 let axis = co_cross(d0, d1);
370 let center = carrier_closest_midpoint(anchors[0], d0, anchors[1], d1);
371 let value = value.unwrap_or(0.0).clamp(-360.0, 360.0);
372 Some(FeatureDimAnnotation::angular(
373 "angle", center, axis, d0, value, "A",
374 ))
375}
376
377fn carrier_closest_midpoint(a: [f64; 3], da: [f64; 3], b: [f64; 3], db: [f64; 3]) -> [f64; 3] {
380 let mid = |p: [f64; 3], q: [f64; 3]| {
381 [(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5]
382 };
383 let da_n = co_norm(da);
384 let db_n = co_norm(db);
385 if da_n < 1e-9 || db_n < 1e-9 {
386 return mid(a, b);
387 }
388 let u = [da[0] / da_n, da[1] / da_n, da[2] / da_n];
389 let v = [db[0] / db_n, db[1] / db_n, db[2] / db_n];
390 let w0 = co_sub(a, b);
391 let b_uv = co_dot(u, v);
392 let denom = 1.0 - b_uv * b_uv;
393 if denom.abs() < 1e-9 {
394 return mid(a, b); }
396 let d = co_dot(u, w0);
397 let e = co_dot(v, w0);
398 let t = (b_uv * e - d) / denom;
399 let s = (e - b_uv * d) / denom;
400 let p = [a[0] + u[0] * t, a[1] + u[1] * t, a[2] + u[2] * t];
401 let q = [b[0] + v[0] * s, b[1] + v[1] * s, b[2] + v[2] * s];
402 mid(p, q)
403}
404
405pub fn constraint_overlay_buffers(
415 overlays: &[ConstraintOverlay],
416 world_per_pixel: f64,
417) -> (Vec<f32>, Vec<f32>) {
418 let annotations: Vec<FeatureDimAnnotation> = overlays
419 .iter()
420 .filter_map(|overlay| overlay.annotation.clone())
421 .collect();
422 let (mut positions, mut colors) = leaders_buffers(&annotations, world_per_pixel);
423 for overlay in overlays {
424 if overlay.kind == ConstraintOverlayKind::Leader && overlay.anchors.len() >= 2 {
425 append_plain_leader(
426 &mut positions,
427 &mut colors,
428 overlay.anchors[0],
429 overlay.anchors[1],
430 world_per_pixel,
431 );
432 }
433 }
434 (positions, colors)
435}
436
437fn read_points(value: Option<&Value>) -> Vec<[f64; 3]> {
442 value
443 .and_then(Value::as_array)
444 .map(|list| list.iter().filter_map(read_point3).collect())
445 .unwrap_or_default()
446}
447
448fn read_dirs(value: Option<&Value>) -> Vec<Option<[f64; 3]>> {
451 value
452 .and_then(Value::as_array)
453 .map(|list| list.iter().map(read_point3).collect())
454 .unwrap_or_default()
455}
456
457fn read_strings(value: Option<&Value>) -> Vec<String> {
461 value
462 .and_then(Value::as_array)
463 .map(|list| {
464 list.iter()
465 .map(|v| v.as_str().unwrap_or("").to_string())
466 .collect()
467 })
468 .unwrap_or_default()
469}
470
471fn read_point3(value: &Value) -> Option<[f64; 3]> {
472 let list = value.as_array()?;
473 Some([
474 list.first()?.as_f64()?,
475 list.get(1)?.as_f64()?,
476 list.get(2)?.as_f64()?,
477 ])
478}
479
480fn co_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
481 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
482}
483
484fn co_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
485 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
486}
487
488fn co_norm(v: [f64; 3]) -> f64 {
489 co_dot(v, v).sqrt()
490}
491
492fn co_cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
493 [
494 a[1] * b[2] - a[2] * b[1],
495 a[2] * b[0] - a[0] * b[2],
496 a[0] * b[1] - a[1] * b[0],
497 ]
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::feature_dimensions::FeatureDimKind;
504 use serde_json::json;
505
506 fn state_with(params: Value) -> Value {
509 json!([{ "type": "constraint", "inputParams": params, "persistentData": {},
510 "enabled": true, "open": false }])
511 }
512
513 #[test]
514 fn distance_row_builds_a_grabbable_linear_annotation() {
515 let rows = json!([{
516 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
517 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
518 "directions": [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
519 "value": 8.0, "unit": "mm", "target": 8.0,
520 }]);
521 let state = state_with(json!({
522 "id": "DIST1", "elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"], "distance": 8.0
523 }));
524 assert_eq!(build_constraint_overlays(&rows, &Value::Null).len(), 1);
526 let overlays = build_constraint_overlays(&rows, &state);
527 assert_eq!(overlays.len(), 1);
528 let o = &overlays[0];
529 assert_eq!(o.kind, ConstraintOverlayKind::Distance);
530 assert!(o.draggable, "numeric distance param is draggable");
531 assert_eq!(o.field_key(), Some("distance"));
532 assert_eq!(o.elements, ["ACOMP1:Part_PX", "ACOMP2:Part_NX"]);
533 let ann = o.annotation.as_ref().expect("linear annotation");
534 assert_eq!(ann.kind, FeatureDimKind::Linear);
535 assert_eq!(ann.point_a, [0.0, 0.0, 0.0]);
536 assert_eq!(ann.point_b, [8.0, 0.0, 0.0]);
537 assert!((ann.value - 8.0).abs() < 1e-12);
538 assert_eq!(o.label_text(), "DIST1 8 mm");
540 assert_eq!(o.label_anchor(0.1), Some([4.0, 0.0, 0.0]));
541 }
542
543 #[test]
544 fn plane_based_distance_draws_the_perpendicular_foot_arrow() {
545 let n = 1.0 / 3.0_f64.sqrt();
551 let q = [1.0, 2.0, 3.0];
552 let p = [4.0, 1.0, 5.0];
553 let s = 4.0 / 3.0_f64.sqrt(); let rows = json!([{
555 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
556 "anchors": [q, p],
557 "directions": [[n, n, n], null],
558 "geoms": ["plane", "point"],
559 "value": s, "unit": "mm",
560 }]);
561 let overlays = build_constraint_overlays(&rows, &Value::Null);
562 let ann = overlays[0].annotation.as_ref().expect("linear annotation");
563 assert_eq!(ann.point_b, p);
565 for i in 0..3 {
566 assert!((ann.axis[i] - n).abs() < 1e-12, "axis = n̂: {:?}", ann.axis);
567 }
568 let d = co_sub(ann.point_b, ann.point_a);
571 assert!(co_norm(co_cross(d, ann.axis)) < 1e-9, "arrow ∥ base normal: {d:?}");
572 assert!((co_dot(d, ann.axis) - s).abs() < 1e-12, "signed length == s");
573 assert!((co_norm(d) - s.abs()).abs() < 1e-12, "world length == |s|");
574 assert!(co_dot(co_sub(ann.point_a, q), ann.axis).abs() < 1e-12, "foot on the plane");
576 assert!((ann.value - s).abs() < 1e-12, "value = the kernel's signed measure");
577 }
578
579 #[test]
580 fn plane_second_and_negative_offsets_flip_the_arrow_behind_the_face() {
581 let rows = json!([{
585 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
586 "anchors": [[2.0, 3.0, 1.0], [0.0, 0.0, 5.0]],
587 "directions": [null, [0.0, 0.0, 1.0]],
588 "geoms": ["point", "plane"],
589 "value": -4.0, "unit": "mm",
590 }]);
591 let overlays = build_constraint_overlays(&rows, &Value::Null);
592 let o = &overlays[0];
593 let ann = o.annotation.as_ref().expect("linear annotation");
594 assert_eq!(ann.point_a, [2.0, 3.0, 5.0], "foot above P, in the plane z=5");
595 assert_eq!(ann.point_b, [2.0, 3.0, 1.0], "tip at the point anchor, behind the face");
596 assert_eq!(ann.axis, [0.0, 0.0, 1.0]);
597 assert!((ann.value + 4.0).abs() < 1e-12, "signed value: {}", ann.value);
598 assert_eq!(o.label_text(), "DIST1 -4 mm");
599 let rows = json!([{
602 "id": "DIST2", "type": "distance", "status": "satisfied", "message": "",
603 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
604 "directions": [[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
605 "geoms": ["line", "line"],
606 "value": 8.0, "unit": "mm",
607 }]);
608 let overlays = build_constraint_overlays(&rows, &Value::Null);
609 let ann = overlays[0].annotation.as_ref().expect("linear annotation");
610 assert_eq!(ann.point_a, [0.0, 0.0, 0.0]);
611 assert_eq!(ann.point_b, [8.0, 0.0, 0.0]);
612 assert_eq!(ann.axis, [0.0; 3], "no base plane → no signed-drag axis");
613 }
614
615 #[test]
616 fn expression_distance_param_disables_the_drag() {
617 let rows = json!([{
618 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
619 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
620 "directions": [null, null],
621 "value": 8.0, "unit": "mm",
622 }]);
623 let state = state_with(json!({ "id": "DIST1", "elements": [], "distance": "gap * 2" }));
624 let overlays = build_constraint_overlays(&rows, &state);
625 assert!(!overlays[0].draggable, "expression param must disable dragging");
626 assert!(overlays[0].annotation.is_some());
628 let state = state_with(json!({ "id": "DIST1", "elements": [], "distance": "8.0" }));
631 assert!(build_constraint_overlays(&rows, &state)[0].draggable);
632 let state = state_with(json!({ "id": "DIST1", "elements": [] }));
633 assert!(build_constraint_overlays(&rows, &state)[0].draggable);
634 }
635
636 #[test]
637 fn angle_row_maps_directions_onto_the_arc_annotation() {
638 let rows = json!([{
643 "id": "ANGL2", "type": "angle", "status": "adjusted", "message": "",
644 "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
645 "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
646 "value": 90.0, "unit": "deg",
647 }]);
648 let state = state_with(json!({ "id": "ANGL2", "elements": [], "angle": 90.0 }));
649 let overlays = build_constraint_overlays(&rows, &state);
650 let o = &overlays[0];
651 assert_eq!(o.kind, ConstraintOverlayKind::Angle);
652 assert!(o.draggable);
653 assert_eq!(o.field_key(), Some("angle"));
654 let ann = o.annotation.as_ref().expect("angular annotation");
655 assert_eq!(ann.kind, FeatureDimKind::Angular);
656 assert!((ann.value - 90.0).abs() < 1e-9);
657 assert!((ann.axis[2] - 1.0).abs() < 1e-9, "axis = d0×d1 = +Z: {:?}", ann.axis);
658 assert!((ann.ref_dir[0] - 1.0).abs() < 1e-9, "ref = d0 = +X: {:?}", ann.ref_dir);
659 assert!(co_norm(ann.center) < 1e-9, "vertex at the carrier intersection: {:?}", ann.center);
660 let end = crate::feature_dimensions::rotate_about_axis(
663 ann.ref_dir,
664 ann.axis,
665 ann.value.to_radians(),
666 );
667 assert!((end[1] - 1.0).abs() < 1e-9, "arc end ≈ d1: {end:?}");
668 assert_eq!(o.label_text(), "ANGL2 90\u{00b0}");
669 }
670
671 #[test]
672 fn non_dimensional_rows_get_leaders_only_and_degenerate_rows_get_nothing() {
673 let rows = json!([
674 { "id": "PARA3", "type": "parallel", "status": "satisfied", "message": "",
676 "anchors": [[0.0, 0.0, 0.0], [0.0, 4.0, 0.0]],
677 "directions": [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]] },
678 { "id": "FIXD1", "type": "fixed", "status": "satisfied", "message": "",
680 "anchors": [[1.0, 2.0, 3.0]], "directions": [null] },
681 { "id": "COIN9", "type": "coincident", "status": "invalid-selection",
683 "message": "unknown element" },
684 ]);
685 let overlays = build_constraint_overlays(&rows, &Value::Null);
686 assert_eq!(overlays.len(), 3);
687 assert!(overlays.iter().all(|o| o.kind == ConstraintOverlayKind::Leader));
688 assert!(overlays.iter().all(|o| !o.draggable && o.annotation.is_none()));
689 assert_eq!(overlays[0].label_anchor(0.1), Some([0.0, 2.0, 0.0]));
690 assert_eq!(overlays[1].label_anchor(0.1), Some([1.0, 2.0, 3.0]));
691 assert_eq!(overlays[2].label_anchor(0.1), None, "no anchors → no label anchor");
692 assert_eq!(overlays[2].label_text(), "COIN9");
693
694 let (pos, col) = constraint_overlay_buffers(&overlays, 0.1);
697 assert!(!pos.is_empty(), "parallel leader emits tris");
698 assert_eq!(pos.len(), col.len());
699 assert_eq!(pos.len() % 9, 0, "whole triangles");
700 let only_first: Vec<ConstraintOverlay> = overlays[1..].to_vec();
701 let (pos2, _) = constraint_overlay_buffers(&only_first, 0.1);
702 assert!(pos2.is_empty(), "one-anchor + unresolved rows draw no leaders");
703 }
704
705 #[test]
706 fn dimensional_rows_bake_through_the_shared_leader_renderer() {
707 let rows = json!([
708 { "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
709 "anchors": [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
710 "directions": [null, null], "value": 10.0, "unit": "mm" },
711 { "id": "ANGL2", "type": "angle", "status": "blocked", "message": "",
712 "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
713 "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
714 "value": 90.0, "unit": "deg" },
715 ]);
716 let overlays = build_constraint_overlays(&rows, &Value::Null);
717 let (pos, col) = constraint_overlay_buffers(&overlays, 0.1);
718 assert!(!pos.is_empty());
719 assert_eq!(pos.len(), col.len());
720 assert_eq!(pos.len() % 9, 0);
721 let has = |rgb: [f32; 3]| {
724 col.chunks_exact(3).any(|c| {
725 (c[0] - rgb[0]).abs() < 1e-3
726 && (c[1] - rgb[1]).abs() < 1e-3
727 && (c[2] - rgb[2]).abs() < 1e-3
728 })
729 };
730 assert!(has([0.80, 0.81, 0.82]), "silver shaft/arc tris");
731 assert!(has([0.961, 0.651, 0.137]), "orange cone/handle tris");
732 assert!(has([0.902, 0.157, 0.157]), "red zero-reference tris");
733 assert!(has([0.204, 0.808, 0.267]), "green axis tris");
734 }
735
736 #[test]
737 fn status_colors_follow_the_requirements_vocabulary() {
738 let hex = |rgb: [f32; 3]| -> u32 {
739 (((rgb[0] * 255.0).round() as u32) << 16)
740 | (((rgb[1] * 255.0).round() as u32) << 8)
741 | ((rgb[2] * 255.0).round() as u32)
742 };
743 assert_eq!(hex(status_color("satisfied")), 0x30d158);
744 assert_eq!(hex(status_color("disabled")), 0x8e8e93);
745 assert_eq!(hex(status_color("adjusted")), 0xffd60a);
746 assert_eq!(hex(status_color("blocked")), 0xff3b30);
747 assert_eq!(hex(status_color("error")), 0xff3b30);
748 assert_eq!(hex(status_color("something-new")), 0xffd60a, "default is amber");
749 }
750}