Skip to main content

brep_render/
constraint_overlays.rs

1//! Assembly-constraint VIEWPORT overlays (build-spec §8.4) — the CONSTRAINT twin
2//! of [`crate::feature_dimensions`].
3//!
4//! Pure builders: the kernel's `assembly_overlay_json` rows (per-constraint world
5//! anchors / directions / status / measured value) + the `assembly_state_json`
6//! constraint list (for `inputParams` — expression detection + element refs) fold
7//! into [`ConstraintOverlay`] records, which bake into the SAME leader/arrow/arc
8//! triangle buffers the feature-dimension gizmo draws (via
9//! [`crate::feature_dimensions::leaders_buffers`] — nothing re-rolled, per the
10//! UI-consistency directive):
11//!
12//! * **distance** → a LINEAR dimension annotation — the silver rod + orange
13//!   cone arrow + orange origin sphere, GRABBABLE (drag edits
14//!   `inputParams.distance`, commit auto-solves). Plane-based pairings draw the
15//!   TRUE dimension perpendicular to the BASE face (the perpendicular-foot
16//!   construction on [`build_distance_annotation`], matching the kernel's
17//!   signed `d = (P_other − P_base)·n̂_base` convention); plane-less pairings
18//!   keep the plain anchor-to-anchor leader.
19//! * **angle** → an ANGULAR annotation (the screen-constant arc + orange sweep-end
20//!   handle + red dashed zero reference + green axis), GRABBABLE (drag edits
21//!   `inputParams.angle`).
22//! * **center** → two plain leaders drawn BY ROLE from the row's `groups`
23//!   (the kernel mapper's inferred `[width pair, tab]` element-index groups):
24//!   the width span between its two faces, and a leader from that span's
25//!   midpoint (which lies on the mid-plane) to the tab's anchor centroid; the
26//!   label sits at the width midpoint. Pick order never shapes the drawing.
27//! * everything else (coincident / parallel / perpendicular / concentric /
28//!   tangent / touch_align / fixed) → a plain anchor-to-anchor leader line +
29//!   label anchor only — never a handle.
30//!
31//! Dragging is DISABLED for a distance/angle whose param is a non-numeric
32//! EXPRESSION string (matches how feature dimensions treat expression-driven
33//! params); the engine consults [`ConstraintOverlay::draggable`].
34//!
35//! The interactive state machine (hit regions, drag preview, the
36//! `assembly_update_constraint_json` commit) lives in
37//! `engine_state/assembly_overlay.rs`; this module stays camera-free and pure so
38//! the geometry is unit-testable from canned JSON payloads.
39
40use serde_json::Value;
41
42use crate::feature_dimensions::{
43    append_plain_leader, leaders_buffers, FeatureDimAnnotation,
44};
45
46/// Which overlay family a constraint renders as.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ConstraintOverlayKind {
49    /// A grabbable linear dimension arrow pair (`distance`).
50    Distance,
51    /// A grabbable angle arc (`angle`).
52    Angle,
53    /// A non-interactive leader + label (every other type).
54    Leader,
55}
56
57/// One constraint's overlay record: identity + status for the label, the
58/// resolved world geometry, and (for the dimensional kinds) the annotation the
59/// shared leader renderer draws + the drag machinery grabs.
60#[derive(Clone, Debug)]
61pub struct ConstraintOverlay {
62    /// The constraint id (`DIST3`, `ANGL2`, …) — the mutation-ABI key.
63    pub id: String,
64    /// The constraint `type` string (`distance`, `coincident`, …).
65    pub constraint_type: String,
66    /// The type's icon glyph (`brep_kernel::ConstraintTypeDef::icon`) — what
67    /// the viewport chip shows in place of the id. Empty for an unknown type,
68    /// in which case the chip falls back to the id.
69    pub icon: String,
70    /// The solve status (`satisfied` / `adjusted` / `error` / …) — drives the
71    /// label color via [`status_color`].
72    pub status: String,
73    /// The human status/solve message (label tooltip).
74    pub message: String,
75    /// The overlay family.
76    pub kind: ConstraintOverlayKind,
77    /// The resolved WORLD anchor points (one per selection; empty when the
78    /// kernel could not resolve the selections — label-less, geometry-less row).
79    pub anchors: Vec<[f64; 3]>,
80    /// The element ROLE groups a multi-element type inferred (`groups` on the
81    /// overlay row): index groups into `anchors`, role order — center's
82    /// `[width pair, tab]`. Empty for the pairing types, whose two anchors ARE
83    /// the drawing.
84    pub groups: Vec<Vec<usize>>,
85    /// Distance/Angle: the dimension annotation (reusing the feature-dim shape so
86    /// `leaders_buffers` renders it verbatim). `field_key` is the `inputParams`
87    /// key the drag edits (`distance` / `angle`). `None` for `Leader` rows and
88    /// for dimensional rows whose anchors did not resolve.
89    pub annotation: Option<FeatureDimAnnotation>,
90    /// The measured value the kernel evaluated (`value` in the overlay row), for
91    /// the label suffix. `None` for non-dimensional rows.
92    pub value: Option<f64>,
93    /// The value's unit (`"mm"` / `"deg"`), empty when `value` is `None`.
94    pub unit: String,
95    /// Whether the dimensional handle may be DRAGGED: true only for
96    /// Distance/Angle whose current param is numeric (or absent — a first-solve
97    /// initialized target). A non-numeric expression string disables the drag.
98    pub draggable: bool,
99    /// The constraint's `inputParams` (from the state list) — the drag commit
100    /// mutates a clone of this (so `elements` / flags ride along unchanged).
101    pub input_params: Value,
102    /// The referenced element names (`inputParams.elements`) — label hover
103    /// highlights these through the existing emphasis machinery.
104    pub elements: Vec<String>,
105}
106
107impl ConstraintOverlay {
108    /// The `inputParams` key a drag on this overlay edits (`distance` / `angle`).
109    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    /// The world-space label anchor: a dimensional annotation's chip anchor (the
118    /// leader midpoint, or the angle arc's mid-sweep at the screen-constant
119    /// radius — camera-dependent via `world_per_pixel`); a role-grouped row's
120    /// first-group centroid (center: the width span's midpoint, on the
121    /// mid-plane); a leader row's anchor midpoint (or its single anchor).
122    /// `None` when nothing resolved.
123    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    /// The role-drawn leaders of a grouped row (center): the WIDTH span
147    /// between the first group's two anchors, and the leader from that span's
148    /// midpoint to the second group's anchor centroid. `None` unless the row
149    /// carries two groups whose indexes all resolve to anchors (a pairing row,
150    /// or a stale `groups` against a shorter anchor list, draws the plain
151    /// leader instead).
152    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    /// The label chip text: the type's icon, then `{value}{unit}` for
174    /// dimensional rows (`⟺ 5.25 mm`, `∠ 90°`); the bare icon otherwise. The
175    /// id no longer rides in the chip — the app's chip hover names it — so a
176    /// crowded assembly reads by picture, not by `DIST3`/`COIN9` codes. A row
177    /// whose type has no icon keeps the id in the icon's place.
178    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
197// ---------------------------------------------------------------------------
198// Status → color
199// ---------------------------------------------------------------------------
200
201/// The requirements-doc §5 status → color vocabulary as display-sRGB `[r,g,b]`
202/// (0..1, hex/255 like the leader palette — the overlay shader writes ~directly).
203///
204/// Overlay-shader color (0..1 floats) for a constraint status — a thin view
205/// over the ONE canonical map in [`crate::assembly_status`] (the panel's row
206/// labels and the tree rollup consume the same table; unified at Wave-3
207/// integration so the vocabulary can never drift).
208pub 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
213// ---------------------------------------------------------------------------
214// Builder
215// ---------------------------------------------------------------------------
216
217/// Build the overlay records from the kernel payloads: `overlay_rows` is the
218/// parsed `assembly_overlay_json` array; `state_constraints` is the parsed
219/// `assembly_state_json`'s `constraints` array (may be `Null` — the builder then
220/// has no `inputParams`, so dimensional rows fall back to draggable-with-empty
221/// params). Rows without resolved anchors yield status-only records (no
222/// geometry, no label anchor); a `fixed` constraint's single anchor yields a
223/// label anchor but no leader.
224pub 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    // The matching state entry's inputParams (expression check + elements).
268    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    // Dragging: only the dimensional kinds, and only while the param is NOT a
299    // non-numeric expression string (absent / number / plain numeric string are
300    // all draggable — matching the feature-dimension expression rule).
301    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
336/// Whether `params[key]` permits a value drag: absent (first-solve initialized),
337/// a JSON number, or a PLAIN numeric string — but NOT a non-numeric expression
338/// string (`"a + b"`), which stays authoritative and disables the handle.
339fn 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
348/// The distance constraint's LINEAR annotation.
349///
350/// PLANE-BASED pairings (at least one element is tagged `"plane"` in the row's
351/// `geoms` — the BASE face, element 0 preferred, matching the kernel mapper's
352/// base choice) draw the TRUE dimension: the other element's anchor `P` is
353/// projected onto the base plane along its outward normal `n̂` — the
354/// perpendicular foot `F = P − s·n̂` with `s = (P − Q)·n̂` the SIGNED offset —
355/// and the arrow runs `F → P`. That segment is normal to the base face by
356/// construction and its length `|s|` IS the constrained distance, so the arrow
357/// shows the kernel's signed `d = (P_other − P_base)·n̂_base` convention
358/// verbatim (a negative `s` points behind the face). The base normal rides in
359/// the annotation's (linear-unused) `axis` field so the drag can measure
360/// signed offsets along it — including through the face into negatives, and
361/// even when `s = 0` collapses the segment to a point.
362///
363/// Plane-less pairings (lines/points — no side to be on) keep the plain
364/// `anchors[0] → anchors[1]` leader with the unsigned measured value and a
365/// zero `axis`. `None` unless both anchors resolved.
366fn 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    // The base plane: the first element tagged "plane" with a usable normal.
376    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        // The kernel's measured `value` equals `s` for both plane arms (same
393        // formula over the same anchors); prefer it for label consistency.
394        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
405/// The angle constraint's ANGULAR annotation. Geometry: the arc sweeps from
406/// direction `d0` toward `d1` about `axis = normalize(d0 × d1)` — the axis that
407/// makes rotating `d0` by the measured interior angle land exactly on `d1` — and
408/// is centered at the closest-approach midpoint of the two carrier lines
409/// (`anchors[i] + t·dᵢ`), the natural angle vertex; parallel/degenerate carriers
410/// fall back to the anchor midpoint, and a parallel/antiparallel pair (the 0°/
411/// 180° satisfied states — `d0 × d1 ≈ 0`) falls back to an arbitrary
412/// perpendicular axis via the `angular` ctor, which still renders and keeps the
413/// drag well-defined once the value moves off zero. `None` unless both anchors
414/// AND both directions resolved.
415fn 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
433/// The midpoint of the closest-approach segment between carrier lines
434/// `a + t·da` and `b + s·db`; the anchor midpoint when (near) parallel.
435fn 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); // parallel carriers — no unique vertex
451    }
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
461// ---------------------------------------------------------------------------
462// Buffers
463// ---------------------------------------------------------------------------
464
465/// Bake the whole overlay set into flat world-space triangle `(positions,
466/// colors)` buffers (the `tris` shape the overlay group consumes): the
467/// dimensional annotations through [`leaders_buffers`] (identical arrow/arc
468/// styling), the non-dimensional rows as plain silver leaders
469/// ([`append_plain_leader`]). Rows without geometry contribute nothing.
470pub 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            // Role-drawn (center): the width span, then mid-plane → tab.
485            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
500// ---------------------------------------------------------------------------
501// JSON + vec helpers (self-contained; `co_` prefixed like the fd_ family)
502// ---------------------------------------------------------------------------
503
504fn 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
511/// Directions align index-wise with anchors; a JSON `null` (a point-like
512/// selection has no direction) stays `None`.
513fn 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
520/// The row's per-element `geoms` tags (aligned index-wise with anchors);
521/// empty when absent — a distance row then has no identifiable base plane and
522/// falls back to the plain anchor-to-anchor leader.
523fn 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
534/// The row's role `groups` (index groups into `anchors`); empty when absent.
535fn 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    /// A one-entry `assembly_state_json`-shaped constraint list (the builder
597    /// matches entries by `inputParams.id` only, so `type` is informational).
598    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        // Without state constraints the row still builds (params default empty).
615        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        // Label: id + value + unit; anchored at the leader midpoint.
629        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        // Base face: a plane TILTED off every axis (n̂ = [1,1,1]/√3) anchored
636        // at Q; the other anchor P sits obliquely off the plane — the naive
637        // Q→P chord is NOT normal to the face. The arrow must instead run
638        // foot → P along n̂ with length |s|, s = (P−Q)·n̂ (the constraint's
639        // signed value), and stash n̂ in `axis` for the signed drag.
640        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(); // (P−Q)·n̂ = (3 − 1 + 2)/√3
644        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        // Tip = the other element's anchor; base normal stashed for the drag.
654        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        // The segment is exactly s·n̂ — perpendicular to the base face, length
659        // == the constrained distance, pointing to P's (positive) side.
660        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        // The foot lies IN the base plane (through Q, normal n̂).
665        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        // The plane may be the SECOND element (a point/vertex picked first):
672        // it is still the base. P sits BEHIND the face (s = −4 along +Z), so
673        // the arrow points opposite the normal and the label goes negative.
674        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        // A plane-LESS row (line/point pairing) keeps the plain anchor-to-anchor
690        // leader and a zero axis (magnitude drag domain).
691        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        // The graphics still render — only the handle is inert.
717        assert!(overlays[0].annotation.is_some());
718        // A PLAIN-NUMERIC string param stays draggable (matches the feature-dim
719        // literal rule), as does an ABSENT param (first-solve initialized).
720        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        // Two carriers meeting at the origin: d0 = +X at (5,0,0), d1 = +Y at
729        // (0,5,0); measured 90°. The arc must sweep from +X about +Z (d0×d1) so
730        // rotating ref_dir by the value lands on d1, centered at the carrier
731        // intersection (the origin).
732        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        // Rotating the reference by the value lands on d1 — the arc end points at
751        // the second carrier.
752        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            // parallel: full anchors → a leader, never a handle.
765            { "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            // fixed: ONE anchor → label anchor only, no leader.
769            { "id": "FIXD1", "type": "fixed", "status": "satisfied", "message": "",
770              "anchors": [[1.0, 2.0, 3.0]], "directions": [null] },
771            // unresolved selections: status-only row (no anchors key at all).
772            { "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        // Buffers: only the two-anchor parallel row contributes leader tris; the
786        // single-anchor + unresolved rows add nothing (and nothing panics).
787        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    /// A center row draws BY ROLE: the width span between the two width
797    /// anchors and a leader from its midpoint to the tab centroid, with the
798    /// label on the width midpoint — whatever order the picks arrived in. A
799    /// grouped row whose indexes do not fit the anchors falls back to the
800    /// plain first-two leader rather than panicking.
801    #[test]
802    fn center_rows_draw_the_width_span_and_the_tab_leader_by_role() {
803        // Picked tab-first: anchors[0] is the tab, the width is [2, 1].
804        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            // Four picks: the tab centroid is the midpoint of its two anchors.
811            { "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            // Stale groups (an index past the anchors) → the plain leader.
817            { "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        // Label on the WIDTH midpoint (x=2), not the first-two-anchors midpoint (x=5).
827        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        // Buffers: the role-drawn row emits TWO tubes' worth of triangles — more
838        // than a plain one-leader row of the same anchor count.
839        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        // The shared feature-dim palette is present: silver shaft + orange handle
864        // (linear leader + arc), red dashed zero reference + green axis (arc).
865        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}