1use serde_json::Value;
41
42use crate::feature_dimensions::{
43 append_plain_leader, leaders_buffers, FeatureDimAnnotation,
44};
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ConstraintOverlayKind {
49 Distance,
51 Angle,
53 Leader,
55}
56
57#[derive(Clone, Debug)]
61pub struct ConstraintOverlay {
62 pub id: String,
64 pub constraint_type: String,
66 pub icon: String,
70 pub status: String,
73 pub message: String,
75 pub kind: ConstraintOverlayKind,
77 pub anchors: Vec<[f64; 3]>,
80 pub groups: Vec<Vec<usize>>,
85 pub annotation: Option<FeatureDimAnnotation>,
90 pub value: Option<f64>,
93 pub unit: String,
95 pub draggable: bool,
99 pub input_params: Value,
102 pub elements: Vec<String>,
105}
106
107impl ConstraintOverlay {
108 pub fn field_key(&self) -> Option<&'static str> {
110 match self.kind {
111 ConstraintOverlayKind::Distance => Some("distance"),
112 ConstraintOverlayKind::Angle => Some("angle"),
113 ConstraintOverlayKind::Leader => None,
114 }
115 }
116
117 pub fn label_anchor(&self, world_per_pixel: f64) -> Option<[f64; 3]> {
124 if let Some(annotation) = &self.annotation {
125 return Some(match self.kind {
126 ConstraintOverlayKind::Angle => {
127 crate::feature_dimensions::angular_chip_anchor(annotation, world_per_pixel)
128 }
129 _ => annotation.midpoint(),
130 });
131 }
132 if let Some((span, _)) = self.role_leaders() {
133 return Some(co_mid(span.0, span.1));
134 }
135 match self.anchors.len() {
136 0 => None,
137 1 => Some(self.anchors[0]),
138 _ => {
139 let a = self.anchors[0];
140 let b = self.anchors[1];
141 Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5])
142 }
143 }
144 }
145
146 fn role_leaders(&self) -> Option<(([f64; 3], [f64; 3]), ([f64; 3], [f64; 3]))> {
153 let [width, tab] = self.groups.as_slice() else {
154 return None;
155 };
156 let [w0, w1] = width.as_slice() else {
157 return None;
158 };
159 let (a, b) = (*self.anchors.get(*w0)?, *self.anchors.get(*w1)?);
160 if tab.is_empty() {
161 return None;
162 }
163 let mut centroid = [0.0f64; 3];
164 for &index in tab {
165 let p = self.anchors.get(index)?;
166 for k in 0..3 {
167 centroid[k] += p[k] / tab.len() as f64;
168 }
169 }
170 Some(((a, b), (co_mid(a, b), centroid)))
171 }
172
173 pub fn label_text(&self) -> String {
179 let lead = if self.icon.is_empty() { self.id.as_str() } else { self.icon.as_str() };
180 match self.value {
181 Some(value) => {
182 let n = format!("{value:.2}");
183 let n = n.trim_end_matches('0').trim_end_matches('.');
184 if self.unit == "deg" {
185 format!("{lead} {n}\u{00b0}")
186 } else if self.unit.is_empty() {
187 format!("{lead} {n}")
188 } else {
189 format!("{lead} {n} {}", self.unit)
190 }
191 }
192 None => lead.to_string(),
193 }
194 }
195}
196
197pub fn status_color(status: &str) -> [f32; 3] {
209 let [r, g, b] = crate::assembly_status::status_color_rgb(status);
210 [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
211}
212
213pub fn build_constraint_overlays(
225 overlay_rows: &Value,
226 state_constraints: &Value,
227) -> Vec<ConstraintOverlay> {
228 let Some(rows) = overlay_rows.as_array() else {
229 return Vec::new();
230 };
231 rows.iter()
232 .filter_map(|row| build_row(row, state_constraints))
233 .collect()
234}
235
236fn build_row(row: &Value, state_constraints: &Value) -> Option<ConstraintOverlay> {
237 let id = row.get("id")?.as_str()?.to_string();
238 let constraint_type = row
239 .get("type")
240 .and_then(Value::as_str)
241 .unwrap_or("")
242 .to_string();
243 let icon = brep_kernel::constraint_type(&constraint_type)
244 .map(|def| def.icon.to_string())
245 .unwrap_or_default();
246 let status = row
247 .get("status")
248 .and_then(Value::as_str)
249 .unwrap_or("")
250 .to_string();
251 let message = row
252 .get("message")
253 .and_then(Value::as_str)
254 .unwrap_or("")
255 .to_string();
256 let anchors = read_points(row.get("anchors"));
257 let directions = read_dirs(row.get("directions"));
258 let geoms = read_strings(row.get("geoms"));
259 let groups = read_groups(row.get("groups"));
260 let value = row.get("value").and_then(Value::as_f64);
261 let unit = row
262 .get("unit")
263 .and_then(Value::as_str)
264 .unwrap_or("")
265 .to_string();
266
267 let input_params = state_constraints
269 .as_array()
270 .and_then(|list| {
271 list.iter().find(|entry| {
272 entry
273 .get("inputParams")
274 .and_then(|p| p.get("id"))
275 .and_then(Value::as_str)
276 == Some(id.as_str())
277 })
278 })
279 .and_then(|entry| entry.get("inputParams"))
280 .cloned()
281 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
282 let elements = input_params
283 .get("elements")
284 .and_then(Value::as_array)
285 .map(|list| {
286 list.iter()
287 .filter_map(|v| v.as_str().map(str::to_string))
288 .collect()
289 })
290 .unwrap_or_default();
291
292 let kind = match constraint_type.as_str() {
293 "distance" => ConstraintOverlayKind::Distance,
294 "angle" => ConstraintOverlayKind::Angle,
295 _ => ConstraintOverlayKind::Leader,
296 };
297
298 let draggable = match kind {
302 ConstraintOverlayKind::Leader => false,
303 ConstraintOverlayKind::Distance => param_allows_drag(&input_params, "distance"),
304 ConstraintOverlayKind::Angle => param_allows_drag(&input_params, "angle"),
305 };
306
307 let annotation = match kind {
308 ConstraintOverlayKind::Distance => {
309 build_distance_annotation(&anchors, &directions, &geoms, value)
310 }
311 ConstraintOverlayKind::Angle => build_angle_annotation(&anchors, &directions, value),
312 ConstraintOverlayKind::Leader => None,
313 };
314
315 Some(ConstraintOverlay {
316 id,
317 constraint_type,
318 icon,
319 status,
320 message,
321 kind,
322 anchors,
323 groups,
324 annotation,
325 value: match kind {
326 ConstraintOverlayKind::Leader => None,
327 _ => value,
328 },
329 unit,
330 draggable,
331 input_params,
332 elements,
333 })
334}
335
336fn param_allows_drag(params: &Value, key: &str) -> bool {
340 match params.get(key) {
341 None | Some(Value::Null) => true,
342 Some(Value::Number(_)) => true,
343 Some(Value::String(text)) => text.trim().parse::<f64>().is_ok(),
344 _ => false,
345 }
346}
347
348fn build_distance_annotation(
367 anchors: &[[f64; 3]],
368 directions: &[Option<[f64; 3]>],
369 geoms: &[String],
370 value: Option<f64>,
371) -> Option<FeatureDimAnnotation> {
372 if anchors.len() < 2 {
373 return None;
374 }
375 let base = (0..2).find(|&i| {
377 geoms.get(i).map(String::as_str) == Some("plane")
378 && directions
379 .get(i)
380 .copied()
381 .flatten()
382 .is_some_and(|n| co_norm(n) > 1e-9)
383 });
384 if let Some(base) = base {
385 let n = directions[base].expect("base index checked above");
386 let len = co_norm(n);
387 let n = [n[0] / len, n[1] / len, n[2] / len];
388 let q = anchors[base];
389 let p = anchors[1 - base];
390 let s = co_dot(co_sub(p, q), n);
391 let foot = [p[0] - n[0] * s, p[1] - n[1] * s, p[2] - n[2] * s];
392 let mut annotation =
395 FeatureDimAnnotation::linear("distance", foot, p, value.unwrap_or(s), "D");
396 annotation.axis = n;
397 return Some(annotation);
398 }
399 let a = anchors[0];
400 let b = anchors[1];
401 let value = value.unwrap_or_else(|| co_norm(co_sub(b, a)));
402 Some(FeatureDimAnnotation::linear("distance", a, b, value, "D"))
403}
404
405fn build_angle_annotation(
416 anchors: &[[f64; 3]],
417 directions: &[Option<[f64; 3]>],
418 value: Option<f64>,
419) -> Option<FeatureDimAnnotation> {
420 if anchors.len() < 2 || directions.len() < 2 {
421 return None;
422 }
423 let d0 = directions[0]?;
424 let d1 = directions[1]?;
425 let axis = co_cross(d0, d1);
426 let center = carrier_closest_midpoint(anchors[0], d0, anchors[1], d1);
427 let value = value.unwrap_or(0.0).clamp(-360.0, 360.0);
428 Some(FeatureDimAnnotation::angular(
429 "angle", center, axis, d0, value, "A",
430 ))
431}
432
433fn carrier_closest_midpoint(a: [f64; 3], da: [f64; 3], b: [f64; 3], db: [f64; 3]) -> [f64; 3] {
436 let mid = |p: [f64; 3], q: [f64; 3]| {
437 [(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5]
438 };
439 let da_n = co_norm(da);
440 let db_n = co_norm(db);
441 if da_n < 1e-9 || db_n < 1e-9 {
442 return mid(a, b);
443 }
444 let u = [da[0] / da_n, da[1] / da_n, da[2] / da_n];
445 let v = [db[0] / db_n, db[1] / db_n, db[2] / db_n];
446 let w0 = co_sub(a, b);
447 let b_uv = co_dot(u, v);
448 let denom = 1.0 - b_uv * b_uv;
449 if denom.abs() < 1e-9 {
450 return mid(a, b); }
452 let d = co_dot(u, w0);
453 let e = co_dot(v, w0);
454 let t = (b_uv * e - d) / denom;
455 let s = (e - b_uv * d) / denom;
456 let p = [a[0] + u[0] * t, a[1] + u[1] * t, a[2] + u[2] * t];
457 let q = [b[0] + v[0] * s, b[1] + v[1] * s, b[2] + v[2] * s];
458 mid(p, q)
459}
460
461pub fn constraint_overlay_buffers(
471 overlays: &[ConstraintOverlay],
472 world_per_pixel: f64,
473) -> (Vec<f32>, Vec<f32>) {
474 let annotations: Vec<FeatureDimAnnotation> = overlays
475 .iter()
476 .filter_map(|overlay| overlay.annotation.clone())
477 .collect();
478 let (mut positions, mut colors) = leaders_buffers(&annotations, world_per_pixel);
479 for overlay in overlays {
480 if overlay.kind != ConstraintOverlayKind::Leader {
481 continue;
482 }
483 if let Some((span, tab)) = overlay.role_leaders() {
484 append_plain_leader(&mut positions, &mut colors, span.0, span.1, world_per_pixel);
486 append_plain_leader(&mut positions, &mut colors, tab.0, tab.1, world_per_pixel);
487 } else if overlay.anchors.len() >= 2 {
488 append_plain_leader(
489 &mut positions,
490 &mut colors,
491 overlay.anchors[0],
492 overlay.anchors[1],
493 world_per_pixel,
494 );
495 }
496 }
497 (positions, colors)
498}
499
500fn read_points(value: Option<&Value>) -> Vec<[f64; 3]> {
505 value
506 .and_then(Value::as_array)
507 .map(|list| list.iter().filter_map(read_point3).collect())
508 .unwrap_or_default()
509}
510
511fn read_dirs(value: Option<&Value>) -> Vec<Option<[f64; 3]>> {
514 value
515 .and_then(Value::as_array)
516 .map(|list| list.iter().map(read_point3).collect())
517 .unwrap_or_default()
518}
519
520fn read_strings(value: Option<&Value>) -> Vec<String> {
524 value
525 .and_then(Value::as_array)
526 .map(|list| {
527 list.iter()
528 .map(|v| v.as_str().unwrap_or("").to_string())
529 .collect()
530 })
531 .unwrap_or_default()
532}
533
534fn read_groups(value: Option<&Value>) -> Vec<Vec<usize>> {
536 value
537 .and_then(Value::as_array)
538 .map(|groups| {
539 groups
540 .iter()
541 .map(|group| {
542 group
543 .as_array()
544 .map(|list| {
545 list.iter()
546 .filter_map(Value::as_u64)
547 .map(|index| index as usize)
548 .collect()
549 })
550 .unwrap_or_default()
551 })
552 .collect()
553 })
554 .unwrap_or_default()
555}
556
557fn read_point3(value: &Value) -> Option<[f64; 3]> {
558 let list = value.as_array()?;
559 Some([
560 list.first()?.as_f64()?,
561 list.get(1)?.as_f64()?,
562 list.get(2)?.as_f64()?,
563 ])
564}
565
566fn co_mid(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
567 [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5]
568}
569
570fn co_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
571 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
572}
573
574fn co_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
575 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
576}
577
578fn co_norm(v: [f64; 3]) -> f64 {
579 co_dot(v, v).sqrt()
580}
581
582fn co_cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
583 [
584 a[1] * b[2] - a[2] * b[1],
585 a[2] * b[0] - a[0] * b[2],
586 a[0] * b[1] - a[1] * b[0],
587 ]
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::feature_dimensions::FeatureDimKind;
594 use serde_json::json;
595
596 fn state_with(params: Value) -> Value {
599 json!([{ "type": "constraint", "inputParams": params, "persistentData": {},
600 "enabled": true, "open": false }])
601 }
602
603 #[test]
604 fn distance_row_builds_a_grabbable_linear_annotation() {
605 let rows = json!([{
606 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
607 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
608 "directions": [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
609 "value": 8.0, "unit": "mm", "target": 8.0,
610 }]);
611 let state = state_with(json!({
612 "id": "DIST1", "elements": ["ACOMP1:Part_PX", "ACOMP2:Part_NX"], "distance": 8.0
613 }));
614 assert_eq!(build_constraint_overlays(&rows, &Value::Null).len(), 1);
616 let overlays = build_constraint_overlays(&rows, &state);
617 assert_eq!(overlays.len(), 1);
618 let o = &overlays[0];
619 assert_eq!(o.kind, ConstraintOverlayKind::Distance);
620 assert!(o.draggable, "numeric distance param is draggable");
621 assert_eq!(o.field_key(), Some("distance"));
622 assert_eq!(o.elements, ["ACOMP1:Part_PX", "ACOMP2:Part_NX"]);
623 let ann = o.annotation.as_ref().expect("linear annotation");
624 assert_eq!(ann.kind, FeatureDimKind::Linear);
625 assert_eq!(ann.point_a, [0.0, 0.0, 0.0]);
626 assert_eq!(ann.point_b, [8.0, 0.0, 0.0]);
627 assert!((ann.value - 8.0).abs() < 1e-12);
628 assert_eq!(o.label_text(), "\u{27FA} 8 mm", "icon, not id, leads the chip");
630 assert_eq!(o.label_anchor(0.1), Some([4.0, 0.0, 0.0]));
631 }
632
633 #[test]
634 fn plane_based_distance_draws_the_perpendicular_foot_arrow() {
635 let n = 1.0 / 3.0_f64.sqrt();
641 let q = [1.0, 2.0, 3.0];
642 let p = [4.0, 1.0, 5.0];
643 let s = 4.0 / 3.0_f64.sqrt(); let rows = json!([{
645 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
646 "anchors": [q, p],
647 "directions": [[n, n, n], null],
648 "geoms": ["plane", "point"],
649 "value": s, "unit": "mm",
650 }]);
651 let overlays = build_constraint_overlays(&rows, &Value::Null);
652 let ann = overlays[0].annotation.as_ref().expect("linear annotation");
653 assert_eq!(ann.point_b, p);
655 for i in 0..3 {
656 assert!((ann.axis[i] - n).abs() < 1e-12, "axis = n̂: {:?}", ann.axis);
657 }
658 let d = co_sub(ann.point_b, ann.point_a);
661 assert!(co_norm(co_cross(d, ann.axis)) < 1e-9, "arrow ∥ base normal: {d:?}");
662 assert!((co_dot(d, ann.axis) - s).abs() < 1e-12, "signed length == s");
663 assert!((co_norm(d) - s.abs()).abs() < 1e-12, "world length == |s|");
664 assert!(co_dot(co_sub(ann.point_a, q), ann.axis).abs() < 1e-12, "foot on the plane");
666 assert!((ann.value - s).abs() < 1e-12, "value = the kernel's signed measure");
667 }
668
669 #[test]
670 fn plane_second_and_negative_offsets_flip_the_arrow_behind_the_face() {
671 let rows = json!([{
675 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
676 "anchors": [[2.0, 3.0, 1.0], [0.0, 0.0, 5.0]],
677 "directions": [null, [0.0, 0.0, 1.0]],
678 "geoms": ["point", "plane"],
679 "value": -4.0, "unit": "mm",
680 }]);
681 let overlays = build_constraint_overlays(&rows, &Value::Null);
682 let o = &overlays[0];
683 let ann = o.annotation.as_ref().expect("linear annotation");
684 assert_eq!(ann.point_a, [2.0, 3.0, 5.0], "foot above P, in the plane z=5");
685 assert_eq!(ann.point_b, [2.0, 3.0, 1.0], "tip at the point anchor, behind the face");
686 assert_eq!(ann.axis, [0.0, 0.0, 1.0]);
687 assert!((ann.value + 4.0).abs() < 1e-12, "signed value: {}", ann.value);
688 assert_eq!(o.label_text(), "\u{27FA} -4 mm");
689 let rows = json!([{
692 "id": "DIST2", "type": "distance", "status": "satisfied", "message": "",
693 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
694 "directions": [[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
695 "geoms": ["line", "line"],
696 "value": 8.0, "unit": "mm",
697 }]);
698 let overlays = build_constraint_overlays(&rows, &Value::Null);
699 let ann = overlays[0].annotation.as_ref().expect("linear annotation");
700 assert_eq!(ann.point_a, [0.0, 0.0, 0.0]);
701 assert_eq!(ann.point_b, [8.0, 0.0, 0.0]);
702 assert_eq!(ann.axis, [0.0; 3], "no base plane → no signed-drag axis");
703 }
704
705 #[test]
706 fn expression_distance_param_disables_the_drag() {
707 let rows = json!([{
708 "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
709 "anchors": [[0.0, 0.0, 0.0], [8.0, 0.0, 0.0]],
710 "directions": [null, null],
711 "value": 8.0, "unit": "mm",
712 }]);
713 let state = state_with(json!({ "id": "DIST1", "elements": [], "distance": "gap * 2" }));
714 let overlays = build_constraint_overlays(&rows, &state);
715 assert!(!overlays[0].draggable, "expression param must disable dragging");
716 assert!(overlays[0].annotation.is_some());
718 let state = state_with(json!({ "id": "DIST1", "elements": [], "distance": "8.0" }));
721 assert!(build_constraint_overlays(&rows, &state)[0].draggable);
722 let state = state_with(json!({ "id": "DIST1", "elements": [] }));
723 assert!(build_constraint_overlays(&rows, &state)[0].draggable);
724 }
725
726 #[test]
727 fn angle_row_maps_directions_onto_the_arc_annotation() {
728 let rows = json!([{
733 "id": "ANGL2", "type": "angle", "status": "adjusted", "message": "",
734 "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
735 "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
736 "value": 90.0, "unit": "deg",
737 }]);
738 let state = state_with(json!({ "id": "ANGL2", "elements": [], "angle": 90.0 }));
739 let overlays = build_constraint_overlays(&rows, &state);
740 let o = &overlays[0];
741 assert_eq!(o.kind, ConstraintOverlayKind::Angle);
742 assert!(o.draggable);
743 assert_eq!(o.field_key(), Some("angle"));
744 let ann = o.annotation.as_ref().expect("angular annotation");
745 assert_eq!(ann.kind, FeatureDimKind::Angular);
746 assert!((ann.value - 90.0).abs() < 1e-9);
747 assert!((ann.axis[2] - 1.0).abs() < 1e-9, "axis = d0×d1 = +Z: {:?}", ann.axis);
748 assert!((ann.ref_dir[0] - 1.0).abs() < 1e-9, "ref = d0 = +X: {:?}", ann.ref_dir);
749 assert!(co_norm(ann.center) < 1e-9, "vertex at the carrier intersection: {:?}", ann.center);
750 let end = crate::feature_dimensions::rotate_about_axis(
753 ann.ref_dir,
754 ann.axis,
755 ann.value.to_radians(),
756 );
757 assert!((end[1] - 1.0).abs() < 1e-9, "arc end ≈ d1: {end:?}");
758 assert_eq!(o.label_text(), "\u{2220} 90\u{00b0}");
759 }
760
761 #[test]
762 fn non_dimensional_rows_get_leaders_only_and_degenerate_rows_get_nothing() {
763 let rows = json!([
764 { "id": "PARA3", "type": "parallel", "status": "satisfied", "message": "",
766 "anchors": [[0.0, 0.0, 0.0], [0.0, 4.0, 0.0]],
767 "directions": [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]] },
768 { "id": "FIXD1", "type": "fixed", "status": "satisfied", "message": "",
770 "anchors": [[1.0, 2.0, 3.0]], "directions": [null] },
771 { "id": "COIN9", "type": "coincident", "status": "invalid-selection",
773 "message": "unknown element" },
774 ]);
775 let overlays = build_constraint_overlays(&rows, &Value::Null);
776 assert_eq!(overlays.len(), 3);
777 assert!(overlays.iter().all(|o| o.kind == ConstraintOverlayKind::Leader));
778 assert!(overlays.iter().all(|o| !o.draggable && o.annotation.is_none()));
779 assert_eq!(overlays[0].label_anchor(0.1), Some([0.0, 2.0, 0.0]));
780 assert_eq!(overlays[1].label_anchor(0.1), Some([1.0, 2.0, 3.0]));
781 assert_eq!(overlays[2].label_anchor(0.1), None, "no anchors → no label anchor");
782 assert_eq!(overlays[2].label_text(), "\u{2261}", "a leader row's chip is the bare icon");
783 assert_eq!(overlays[2].icon, "\u{2261}");
784
785 let (pos, col) = constraint_overlay_buffers(&overlays, 0.1);
788 assert!(!pos.is_empty(), "parallel leader emits tris");
789 assert_eq!(pos.len(), col.len());
790 assert_eq!(pos.len() % 9, 0, "whole triangles");
791 let only_first: Vec<ConstraintOverlay> = overlays[1..].to_vec();
792 let (pos2, _) = constraint_overlay_buffers(&only_first, 0.1);
793 assert!(pos2.is_empty(), "one-anchor + unresolved rows draw no leaders");
794 }
795
796 #[test]
802 fn center_rows_draw_the_width_span_and_the_tab_leader_by_role() {
803 let rows = json!([
805 { "id": "CNTR1", "type": "center", "status": "satisfied", "message": "",
806 "anchors": [[6.0, 1.0, 0.0], [4.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
807 "directions": [[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
808 "geoms": ["plane", "plane", "plane"],
809 "groups": [[2, 1], [0]] },
810 { "id": "CNTR2", "type": "center", "status": "satisfied", "message": "",
812 "anchors": [[0.0, 0.0, 0.0], [0.0, 3.0, 0.0], [5.0, 1.0, 0.0], [5.0, 2.0, 0.0]],
813 "directions": [null, null, null, null],
814 "geoms": ["plane", "plane", "plane", "plane"],
815 "groups": [[0, 1], [2, 3]] },
816 { "id": "CNTR3", "type": "center", "status": "adjusted", "message": "",
818 "anchors": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
819 "directions": [null, null],
820 "groups": [[0, 5], [1]] },
821 ]);
822 let overlays = build_constraint_overlays(&rows, &Value::Null);
823 assert_eq!(overlays.len(), 3);
824 assert!(overlays.iter().all(|o| o.kind == ConstraintOverlayKind::Leader && !o.draggable));
825 assert_eq!(overlays[0].groups, vec![vec![2, 1], vec![0]]);
826 assert_eq!(overlays[0].label_anchor(0.1), Some([2.0, 0.0, 0.0]));
828 let (span, tab) = overlays[0].role_leaders().expect("role leaders");
829 assert_eq!(span, ([0.0, 0.0, 0.0], [4.0, 0.0, 0.0]));
830 assert_eq!(tab, ([2.0, 0.0, 0.0], [6.0, 1.0, 0.0]));
831 assert_eq!(overlays[0].label_text(), "\u{25EB}", "a center chip is the bare icon");
832 let (_, tab2) = overlays[1].role_leaders().expect("role leaders");
833 assert_eq!(tab2, ([0.0, 1.5, 0.0], [5.0, 1.5, 0.0]));
834 assert!(overlays[2].role_leaders().is_none(), "stale groups draw nothing by role");
835 assert_eq!(overlays[2].label_anchor(0.1), Some([1.0, 0.0, 0.0]));
836
837 let (pos_center, col_center) = constraint_overlay_buffers(&overlays[..1], 0.1);
840 let (pos_plain, _) = constraint_overlay_buffers(&overlays[2..], 0.1);
841 assert_eq!(pos_center.len(), col_center.len());
842 assert_eq!(pos_center.len() % 9, 0);
843 assert!(!pos_plain.is_empty());
844 assert_eq!(pos_center.len(), 2 * pos_plain.len(), "two leaders vs one");
845 }
846
847 #[test]
848 fn dimensional_rows_bake_through_the_shared_leader_renderer() {
849 let rows = json!([
850 { "id": "DIST1", "type": "distance", "status": "satisfied", "message": "",
851 "anchors": [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
852 "directions": [null, null], "value": 10.0, "unit": "mm" },
853 { "id": "ANGL2", "type": "angle", "status": "blocked", "message": "",
854 "anchors": [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]],
855 "directions": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
856 "value": 90.0, "unit": "deg" },
857 ]);
858 let overlays = build_constraint_overlays(&rows, &Value::Null);
859 let (pos, col) = constraint_overlay_buffers(&overlays, 0.1);
860 assert!(!pos.is_empty());
861 assert_eq!(pos.len(), col.len());
862 assert_eq!(pos.len() % 9, 0);
863 let has = |rgb: [f32; 3]| {
866 col.chunks_exact(3).any(|c| {
867 (c[0] - rgb[0]).abs() < 1e-3
868 && (c[1] - rgb[1]).abs() < 1e-3
869 && (c[2] - rgb[2]).abs() < 1e-3
870 })
871 };
872 assert!(has([0.80, 0.81, 0.82]), "silver shaft/arc tris");
873 assert!(has([0.961, 0.651, 0.137]), "orange cone/handle tris");
874 assert!(has([0.902, 0.157, 0.157]), "red zero-reference tris");
875 assert!(has([0.204, 0.808, 0.267]), "green axis tris");
876 }
877
878 #[test]
879 fn status_colors_follow_the_requirements_vocabulary() {
880 let hex = |rgb: [f32; 3]| -> u32 {
881 (((rgb[0] * 255.0).round() as u32) << 16)
882 | (((rgb[1] * 255.0).round() as u32) << 8)
883 | ((rgb[2] * 255.0).round() as u32)
884 };
885 assert_eq!(hex(status_color("satisfied")), 0x30d158);
886 assert_eq!(hex(status_color("disabled")), 0x8e8e93);
887 assert_eq!(hex(status_color("adjusted")), 0xffd60a);
888 assert_eq!(hex(status_color("blocked")), 0xff3b30);
889 assert_eq!(hex(status_color("error")), 0xff3b30);
890 assert_eq!(hex(status_color("something-new")), 0xffd60a, "default is amber");
891 }
892}