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//! * everything else (coincident / parallel / perpendicular / concentric /
23//!   tangent / touch_align / fixed) → a plain anchor-to-anchor leader line +
24//!   label anchor only — never a handle.
25//!
26//! Dragging is DISABLED for a distance/angle whose param is a non-numeric
27//! EXPRESSION string (matches how feature dimensions treat expression-driven
28//! params); the engine consults [`ConstraintOverlay::draggable`].
29//!
30//! The interactive state machine (hit regions, drag preview, the
31//! `assembly_update_constraint_json` commit) lives in
32//! `engine_state/assembly_overlay.rs`; this module stays camera-free and pure so
33//! the geometry is unit-testable from canned JSON payloads.
34
35use serde_json::Value;
36
37use crate::feature_dimensions::{
38    append_plain_leader, leaders_buffers, FeatureDimAnnotation,
39};
40
41/// Which overlay family a constraint renders as.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum ConstraintOverlayKind {
44    /// A grabbable linear dimension arrow pair (`distance`).
45    Distance,
46    /// A grabbable angle arc (`angle`).
47    Angle,
48    /// A non-interactive leader + label (every other type).
49    Leader,
50}
51
52/// One constraint's overlay record: identity + status for the label, the
53/// resolved world geometry, and (for the dimensional kinds) the annotation the
54/// shared leader renderer draws + the drag machinery grabs.
55#[derive(Clone, Debug)]
56pub struct ConstraintOverlay {
57    /// The constraint id (`DIST3`, `ANGL2`, …) — the mutation-ABI key.
58    pub id: String,
59    /// The constraint `type` string (`distance`, `coincident`, …).
60    pub constraint_type: String,
61    /// The solve status (`satisfied` / `adjusted` / `error` / …) — drives the
62    /// label color via [`status_color`].
63    pub status: String,
64    /// The human status/solve message (label tooltip).
65    pub message: String,
66    /// The overlay family.
67    pub kind: ConstraintOverlayKind,
68    /// The resolved WORLD anchor points (one per selection; empty when the
69    /// kernel could not resolve the selections — label-less, geometry-less row).
70    pub anchors: Vec<[f64; 3]>,
71    /// Distance/Angle: the dimension annotation (reusing the feature-dim shape so
72    /// `leaders_buffers` renders it verbatim). `field_key` is the `inputParams`
73    /// key the drag edits (`distance` / `angle`). `None` for `Leader` rows and
74    /// for dimensional rows whose anchors did not resolve.
75    pub annotation: Option<FeatureDimAnnotation>,
76    /// The measured value the kernel evaluated (`value` in the overlay row), for
77    /// the label suffix. `None` for non-dimensional rows.
78    pub value: Option<f64>,
79    /// The value's unit (`"mm"` / `"deg"`), empty when `value` is `None`.
80    pub unit: String,
81    /// Whether the dimensional handle may be DRAGGED: true only for
82    /// Distance/Angle whose current param is numeric (or absent — a first-solve
83    /// initialized target). A non-numeric expression string disables the drag.
84    pub draggable: bool,
85    /// The constraint's `inputParams` (from the state list) — the drag commit
86    /// mutates a clone of this (so `elements` / flags ride along unchanged).
87    pub input_params: Value,
88    /// The referenced element names (`inputParams.elements`) — label hover
89    /// highlights these through the existing emphasis machinery.
90    pub elements: Vec<String>,
91}
92
93impl ConstraintOverlay {
94    /// The `inputParams` key a drag on this overlay edits (`distance` / `angle`).
95    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    /// The world-space label anchor: a dimensional annotation's chip anchor (the
104    /// leader midpoint, or the angle arc's mid-sweep at the screen-constant
105    /// radius — camera-dependent via `world_per_pixel`); a leader row's anchor
106    /// midpoint (or its single anchor). `None` when nothing resolved.
107    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    /// The label chip text: `{id} {value}{unit}` for dimensional rows
128    /// (`DIST3 5.25 mm`, `ANGL2 90°`), bare `{id}` otherwise.
129    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
147// ---------------------------------------------------------------------------
148// Status → color
149// ---------------------------------------------------------------------------
150
151/// The requirements-doc §5 status → color vocabulary as display-sRGB `[r,g,b]`
152/// (0..1, hex/255 like the leader palette — the overlay shader writes ~directly).
153///
154/// Overlay-shader color (0..1 floats) for a constraint status — a thin view
155/// over the ONE canonical map in [`crate::assembly_status`] (the panel's row
156/// labels and the tree rollup consume the same table; unified at Wave-3
157/// integration so the vocabulary can never drift).
158pub 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
163// ---------------------------------------------------------------------------
164// Builder
165// ---------------------------------------------------------------------------
166
167/// Build the overlay records from the kernel payloads: `overlay_rows` is the
168/// parsed `assembly_overlay_json` array; `state_constraints` is the parsed
169/// `assembly_state_json`'s `constraints` array (may be `Null` — the builder then
170/// has no `inputParams`, so dimensional rows fall back to draggable-with-empty
171/// params). Rows without resolved anchors yield status-only records (no
172/// geometry, no label anchor); a `fixed` constraint's single anchor yields a
173/// label anchor but no leader.
174pub 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    // The matching state entry's inputParams (expression check + elements).
214    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    // Dragging: only the dimensional kinds, and only while the param is NOT a
245    // non-numeric expression string (absent / number / plain numeric string are
246    // all draggable — matching the feature-dimension expression rule).
247    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
280/// Whether `params[key]` permits a value drag: absent (first-solve initialized),
281/// a JSON number, or a PLAIN numeric string — but NOT a non-numeric expression
282/// string (`"a + b"`), which stays authoritative and disables the handle.
283fn 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
292/// The distance constraint's LINEAR annotation.
293///
294/// PLANE-BASED pairings (at least one element is tagged `"plane"` in the row's
295/// `geoms` — the BASE face, element 0 preferred, matching the kernel mapper's
296/// base choice) draw the TRUE dimension: the other element's anchor `P` is
297/// projected onto the base plane along its outward normal `n̂` — the
298/// perpendicular foot `F = P − s·n̂` with `s = (P − Q)·n̂` the SIGNED offset —
299/// and the arrow runs `F → P`. That segment is normal to the base face by
300/// construction and its length `|s|` IS the constrained distance, so the arrow
301/// shows the kernel's signed `d = (P_other − P_base)·n̂_base` convention
302/// verbatim (a negative `s` points behind the face). The base normal rides in
303/// the annotation's (linear-unused) `axis` field so the drag can measure
304/// signed offsets along it — including through the face into negatives, and
305/// even when `s = 0` collapses the segment to a point.
306///
307/// Plane-less pairings (lines/points — no side to be on) keep the plain
308/// `anchors[0] → anchors[1]` leader with the unsigned measured value and a
309/// zero `axis`. `None` unless both anchors resolved.
310fn 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    // The base plane: the first element tagged "plane" with a usable normal.
320    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        // The kernel's measured `value` equals `s` for both plane arms (same
337        // formula over the same anchors); prefer it for label consistency.
338        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
349/// The angle constraint's ANGULAR annotation. Geometry: the arc sweeps from
350/// direction `d0` toward `d1` about `axis = normalize(d0 × d1)` — the axis that
351/// makes rotating `d0` by the measured interior angle land exactly on `d1` — and
352/// is centered at the closest-approach midpoint of the two carrier lines
353/// (`anchors[i] + t·dᵢ`), the natural angle vertex; parallel/degenerate carriers
354/// fall back to the anchor midpoint, and a parallel/antiparallel pair (the 0°/
355/// 180° satisfied states — `d0 × d1 ≈ 0`) falls back to an arbitrary
356/// perpendicular axis via the `angular` ctor, which still renders and keeps the
357/// drag well-defined once the value moves off zero. `None` unless both anchors
358/// AND both directions resolved.
359fn 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
377/// The midpoint of the closest-approach segment between carrier lines
378/// `a + t·da` and `b + s·db`; the anchor midpoint when (near) parallel.
379fn 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); // parallel carriers — no unique vertex
395    }
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
405// ---------------------------------------------------------------------------
406// Buffers
407// ---------------------------------------------------------------------------
408
409/// Bake the whole overlay set into flat world-space triangle `(positions,
410/// colors)` buffers (the `tris` shape the overlay group consumes): the
411/// dimensional annotations through [`leaders_buffers`] (identical arrow/arc
412/// styling), the non-dimensional rows as plain silver leaders
413/// ([`append_plain_leader`]). Rows without geometry contribute nothing.
414pub 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
437// ---------------------------------------------------------------------------
438// JSON + vec helpers (self-contained; `co_` prefixed like the fd_ family)
439// ---------------------------------------------------------------------------
440
441fn 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
448/// Directions align index-wise with anchors; a JSON `null` (a point-like
449/// selection has no direction) stays `None`.
450fn 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
457/// The row's per-element `geoms` tags (aligned index-wise with anchors);
458/// empty when absent — a distance row then has no identifiable base plane and
459/// falls back to the plain anchor-to-anchor leader.
460fn 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    /// A one-entry `assembly_state_json`-shaped constraint list (the builder
507    /// matches entries by `inputParams.id` only, so `type` is informational).
508    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        // Without state constraints the row still builds (params default empty).
525        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        // Label: id + value + unit; anchored at the leader midpoint.
539        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        // Base face: a plane TILTED off every axis (n̂ = [1,1,1]/√3) anchored
546        // at Q; the other anchor P sits obliquely off the plane — the naive
547        // Q→P chord is NOT normal to the face. The arrow must instead run
548        // foot → P along n̂ with length |s|, s = (P−Q)·n̂ (the constraint's
549        // signed value), and stash n̂ in `axis` for the signed drag.
550        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(); // (P−Q)·n̂ = (3 − 1 + 2)/√3
554        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        // Tip = the other element's anchor; base normal stashed for the drag.
564        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        // The segment is exactly s·n̂ — perpendicular to the base face, length
569        // == the constrained distance, pointing to P's (positive) side.
570        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        // The foot lies IN the base plane (through Q, normal n̂).
575        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        // The plane may be the SECOND element (a point/vertex picked first):
582        // it is still the base. P sits BEHIND the face (s = −4 along +Z), so
583        // the arrow points opposite the normal and the label goes negative.
584        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        // A plane-LESS row (line/point pairing) keeps the plain anchor-to-anchor
600        // leader and a zero axis (magnitude drag domain).
601        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        // The graphics still render — only the handle is inert.
627        assert!(overlays[0].annotation.is_some());
628        // A PLAIN-NUMERIC string param stays draggable (matches the feature-dim
629        // literal rule), as does an ABSENT param (first-solve initialized).
630        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        // Two carriers meeting at the origin: d0 = +X at (5,0,0), d1 = +Y at
639        // (0,5,0); measured 90°. The arc must sweep from +X about +Z (d0×d1) so
640        // rotating ref_dir by the value lands on d1, centered at the carrier
641        // intersection (the origin).
642        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        // Rotating the reference by the value lands on d1 — the arc end points at
661        // the second carrier.
662        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            // parallel: full anchors → a leader, never a handle.
675            { "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            // fixed: ONE anchor → label anchor only, no leader.
679            { "id": "FIXD1", "type": "fixed", "status": "satisfied", "message": "",
680              "anchors": [[1.0, 2.0, 3.0]], "directions": [null] },
681            // unresolved selections: status-only row (no anchors key at all).
682            { "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        // Buffers: only the two-anchor parallel row contributes leader tris; the
695        // single-anchor + unresolved rows add nothing (and nothing panics).
696        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        // The shared feature-dim palette is present: silver shaft + orange handle
722        // (linear leader + arc), red dashed zero reference + green axis (arc).
723        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}