Skip to main content

brep_render/sketch/
dimensions.rs

1//! Dimension leaders + labels (S5) — a port of the previous sketcher's
2//! dimension rendering.
3//!
4//! For each DIMENSIONAL constraint (`⟺` distance, `↥` point-line distance, `∠`
5//! angle, and the radial `⟺` with `displayStyle:"radius"|"diameter"`) this builds:
6//!
7//! * the LEADER geometry — extension lines + a dimension line + arrowheads, all in
8//!   the sketch's plane `(u, v)` frame (the same per-type math `dimDistance3D` /
9//!   `dimRadius3D` / `dimAngle3D` author into the overlay), and
10//! * the LABEL anchor — the plane point the value text is drawn at, offset by the
11//!   constraint's persisted `{du, dv}` (`session.dim_offsets`, keyed by constraint
12//!   id) with a sensible per-type default when absent.
13//!
14//! CONVENTION (deliberately simpler than the previous dual `d`-scalar / `{du,dv}`
15//! bookkeeping): `dim_offsets[cid] = {du, dv}` is the plane-space vector from the
16//! type's ANCHOR to the LABEL, and BOTH the leader geometry and the label read that
17//! one offset. The auto-default (absent offset) reuses the previous `du/dv` seeds.
18//! The leader color is the default
19//! dimension blue (constraint hover/selection is not part of the engine's picking
20//! model, so the per-constraint color states are not ported).
21
22use std::collections::HashMap;
23use std::f64::consts::PI;
24
25use serde_json::Value;
26
27use super::doc::{id_key, SketchConstraint, SketchDoc, SketchPoint};
28use super::PlaneFrame;
29use crate::style::SketchColors;
30
31/// The dimension-leaders overlay group name (its own group so it upserts/clears
32/// independently of geometry/points/preview).
33pub const OVERLAY_DIM_LEADERS: &str = "sketch-dim-leaders";
34
35/// The label placement + metadata for one dimensional constraint.
36#[derive(Clone, Debug)]
37pub struct DimLabel {
38    /// The constraint id (raw `Value`).
39    pub id: Value,
40    /// The rendered text (`value.toFixed(3)`, `R…`, `⌀…`, or the angle value).
41    pub text: String,
42    /// The label anchor in world space (`plane.to_world(label_uv)`).
43    pub world: [f64; 3],
44    /// The stored numeric solver value (a radius for radial dims), if finite.
45    pub value: Option<f64>,
46    /// The stored `valueExpr` string (if any).
47    pub value_expr: Option<String>,
48    /// The display mode: `"distance" | "radius" | "diameter" | "angle"`.
49    pub mode: &'static str,
50}
51
52/// The plane-space geometry + label anchor for one dimensional constraint.
53struct DimGeometry {
54    /// Leader/arrow segments as `(a, b)` pairs in plane `(u, v)`.
55    segments: Vec<([f64; 2], [f64; 2])>,
56    /// The label anchor in plane `(u, v)`.
57    label_uv: [f64; 2],
58    text: String,
59    value: Option<f64>,
60    value_expr: Option<String>,
61    mode: &'static str,
62}
63
64/// A point-by-id lookup over a doc (mirrors the solver's identity keying). Shared
65/// with [`super::constraint_glyphs`] so both annotation overlays resolve point ids the
66/// same way.
67pub(super) fn point_index(doc: &SketchDoc) -> HashMap<String, &SketchPoint> {
68    let mut by_id: HashMap<String, &SketchPoint> = HashMap::with_capacity(doc.points.len());
69    for p in &doc.points {
70        by_id.insert(id_key(&p.id), p);
71    }
72    by_id
73}
74
75/// Whether `c` is a RADIAL dimension (`⟺` on `[center, rim]` with a
76/// radius/diameter display style) — port of `isRadialDimensionConstraint`.
77fn is_radial_dimension(c: &SketchConstraint) -> bool {
78    if c.ctype() != Some("⟺") || c.points().len() < 2 {
79        return false;
80    }
81    matches!(
82        c.raw.get("displayStyle").and_then(Value::as_str),
83        Some("radius") | Some("diameter")
84    )
85}
86
87/// Read the persisted `{du, dv}` plane offset for a constraint id, or `None` when
88/// absent / non-finite (so callers can apply the per-type default).
89fn saved_offset(dim_offsets: &serde_json::Map<String, Value>, id: &Value) -> Option<[f64; 2]> {
90    let entry = dim_offsets.get(&id_key(id))?;
91    let du = entry.get("du").and_then(Value::as_f64);
92    let dv = entry.get("dv").and_then(Value::as_f64);
93    match (du, dv) {
94        (Some(du), Some(dv)) if du.is_finite() && dv.is_finite() => Some([du, dv]),
95        // A partial offset still counts (either finite counts as set).
96        (Some(du), None) if du.is_finite() => Some([du, 0.0]),
97        (None, Some(dv)) if dv.is_finite() => Some([0.0, dv]),
98        _ => None,
99    }
100}
101
102/// The linear-distance endpoints for a `⟺` (the two points) or a `↥`
103/// point-line-distance (the point projected onto the infinite line + the point) —
104/// port of `resolveLinearDistanceEndpoints`. Returns `(a, b, anchor_a)` where
105/// `anchor_a` is where the extension line starts (the nearest point on the finite
106/// segment for `↥`, else `a`).
107fn linear_endpoints(
108    c: &SketchConstraint,
109    by_id: &HashMap<String, &SketchPoint>,
110) -> Option<([f64; 2], [f64; 2], [f64; 2])> {
111    let get = |id: &Value| by_id.get(&id_key(id)).copied();
112    let pts = c.points();
113    match c.ctype() {
114        Some("⟺") if pts.len() >= 2 && !is_radial_dimension(c) => {
115            let p0 = get(&pts[0])?;
116            let p1 = get(&pts[1])?;
117            let a = [p0.x, p0.y];
118            Some((a, [p1.x, p1.y], a))
119        }
120        Some("↥") if pts.len() >= 3 => {
121            let a = get(&pts[0])?;
122            let b = get(&pts[1])?;
123            let cpt = get(&pts[2])?;
124            let dx = b.x - a.x;
125            let dy = b.y - a.y;
126            let len_sq = dx * dx + dy * dy;
127            if !(len_sq > 1e-12) {
128                return Some(([a.x, a.y], [cpt.x, cpt.y], [a.x, a.y]));
129            }
130            let t_raw = ((cpt.x - a.x) * dx + (cpt.y - a.y) * dy) / len_sq;
131            let t_raw = if t_raw.is_finite() { t_raw } else { 0.0 };
132            let t_seg = t_raw.clamp(0.0, 1.0);
133            Some((
134                [a.x + t_raw * dx, a.y + t_raw * dy],
135                [cpt.x, cpt.y],
136                [a.x + t_seg * dx, a.y + t_seg * dy],
137            ))
138        }
139        _ => None,
140    }
141}
142
143/// The default radial label offset from the CENTER — port of `radialOffsetToPlane`
144/// (the no-saved branch).
145fn radial_default_offset(pc: [f64; 2], pr: [f64; 2]) -> [f64; 2] {
146    let vx = pr[0] - pc[0];
147    let vy = pr[1] - pc[1];
148    let l = vx.hypot(vy);
149    let (rx, ry) = if l > 1e-9 { (vx / l, vy / l) } else { (1.0, 0.0) };
150    let (nx, ny) = (-ry, rx);
151    let base = (l * 0.35).max(0.2);
152    [
153        vx + rx * base + nx * base * 0.35,
154        vy + ry * base + ny * base * 0.35,
155    ]
156}
157
158/// Push a two-line arrowhead at `tip` pointing along unit `(dx, dy)` with head
159/// length `ah` and half-width factor `s` (port of the previous arrowhead builder).
160fn push_arrow(
161    segments: &mut Vec<([f64; 2], [f64; 2])>,
162    tip: [f64; 2],
163    dx: f64,
164    dy: f64,
165    ah: f64,
166    s: f64,
167) {
168    let len = dx.hypot(dy);
169    if !(len > 1e-9) {
170        return;
171    }
172    let (tx, ty) = (dx / len, dy / len);
173    let (wx, wy) = (-ty, tx);
174    let a = [tip[0] + tx * ah + wx * ah * s, tip[1] + ty * ah + wy * ah * s];
175    let b = [tip[0] + tx * ah - wx * ah * s, tip[1] + ty * ah - wy * ah * s];
176    segments.push((tip, a));
177    segments.push((tip, b));
178}
179
180/// Assemble the leader geometry + label anchor for one dimensional constraint, or
181/// `None` for a non-dimensional / unresolvable constraint.
182fn dim_geometry(
183    c: &SketchConstraint,
184    by_id: &HashMap<String, &SketchPoint>,
185    dim_offsets: &serde_json::Map<String, Value>,
186    wpp: f64,
187) -> Option<DimGeometry> {
188    let ctype = c.ctype()?;
189    let id = c.raw.get("id")?;
190    let value = c
191        .raw
192        .get("value")
193        .and_then(Value::as_f64)
194        .filter(|v| v.is_finite());
195    let value_expr = c
196        .raw
197        .get("valueExpr")
198        .and_then(Value::as_str)
199        .filter(|s| !s.is_empty())
200        .map(str::to_string);
201
202    // Screen-constant sizes (port of the previous `worldPerPixel`-scaled bases).
203    let ah = (wpp * 6.0).max(0.06);
204    let arrow_s = 0.6;
205
206    if is_radial_dimension(c) {
207        // --- radius / diameter -------------------------------------------------
208        let pts = c.points();
209        let pc = by_id.get(&id_key(&pts[0])).copied()?;
210        let pr = by_id.get(&id_key(&pts[1])).copied()?;
211        let center = [pc.x, pc.y];
212        let radius = (pr.x - pc.x).hypot(pr.y - pc.y);
213        if !(radius > 1e-9) {
214            return None;
215        }
216        let diameter = c.raw.get("displayStyle").and_then(Value::as_str) == Some("diameter");
217        let off = saved_offset(dim_offsets, id).unwrap_or_else(|| radial_default_offset(center, [pr.x, pr.y]));
218        let label_uv = [center[0] + off[0], center[1] + off[1]];
219
220        // Direction center -> label (fall back to center -> rim).
221        let (mut dx, mut dy) = (label_uv[0] - center[0], label_uv[1] - center[1]);
222        let mut l = dx.hypot(dy);
223        if !(l > 1e-9) {
224            dx = pr.x - pc.x;
225            dy = pr.y - pc.y;
226            l = dx.hypot(dy).max(1e-9);
227        }
228        let (ux, uy) = (dx / l, dy / l);
229        let near = [center[0] + ux * radius, center[1] + uy * radius];
230        let far = [center[0] - ux * radius, center[1] - uy * radius];
231
232        let mut segments = Vec::new();
233        if diameter {
234            segments.push((far, near));
235            segments.push((near, label_uv));
236            // Arrowheads point inward toward the center from both ends.
237            push_arrow(&mut segments, near, -ux, -uy, ah, arrow_s);
238            push_arrow(&mut segments, far, ux, uy, ah, arrow_s);
239        } else {
240            segments.push((center, near));
241            segments.push((near, label_uv));
242            push_arrow(&mut segments, near, -ux, -uy, ah, arrow_s);
243        }
244
245        let safe = value.unwrap_or(0.0);
246        let (text, mode) = if diameter {
247            (format!("⌀{:.3}", 2.0 * safe), "diameter")
248        } else {
249            (format!("R{safe:.3}"), "radius")
250        };
251        return Some(DimGeometry {
252            segments,
253            label_uv,
254            text,
255            value,
256            value_expr,
257            mode,
258        });
259    }
260
261    if ctype == "⟺" || ctype == "↥" {
262        // --- linear distance ---------------------------------------------------
263        let (a, b, anchor_a) = linear_endpoints(c, by_id)?;
264        let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
265        let len = dx.hypot(dy).max(1e-9);
266        let (tx, ty) = (dx / len, dy / len);
267        let (nx, ny) = (-ty, tx);
268        let base = (wpp * 20.0).max(0.1);
269        let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
270        // Offset from the midpoint to the label; default a normal push of `base`.
271        let off = saved_offset(dim_offsets, id).unwrap_or([nx * base, ny * base]);
272        let label_uv = [mid[0] + off[0], mid[1] + off[1]];
273        // The dimension line runs parallel to the segment through the label's
274        // normal component (project the offset onto the normal so the line stays
275        // parallel and the extension lines meet it squarely).
276        let off_n = off[0] * nx + off[1] * ny;
277        let ou = nx * off_n;
278        let ov = ny * off_n;
279
280        let a_off = [a[0] + ou, a[1] + ov];
281        let b_off = [b[0] + ou, b[1] + ov];
282        let mut segments = vec![
283            (a_off, b_off),       // dimension line
284            (anchor_a, a_off),    // extension line at a
285            (b, b_off),           // extension line at b
286        ];
287        // Opposed arrowheads at the dimension-line ends, pointing toward the span.
288        push_arrow(&mut segments, a_off, tx, ty, ah, arrow_s);
289        push_arrow(&mut segments, b_off, -tx, -ty, ah, arrow_s);
290
291        // Leader: the label rides the (extended) dimension line, so when it is
292        // dragged PAST the span it detaches visually. Connect it back to the nearest
293        // end of the dimension line so the user can tell which dimension it belongs
294        // to. (Within the span the label sits on the line, so the gap is ~0 and no
295        // leader is drawn — matching the radial dimension's rim→label leader.)
296        let t_lbl = (label_uv[0] - a_off[0]) * tx + (label_uv[1] - a_off[1]) * ty;
297        let foot = {
298            let t = t_lbl.clamp(0.0, len);
299            [a_off[0] + tx * t, a_off[1] + ty * t]
300        };
301        if (label_uv[0] - foot[0]).hypot(label_uv[1] - foot[1]) > wpp * 2.0 {
302            segments.push((foot, label_uv));
303        }
304
305        let text = format!("{:.3}", value.unwrap_or(0.0));
306        return Some(DimGeometry {
307            segments,
308            label_uv,
309            text,
310            value,
311            value_expr,
312            mode: "distance",
313        });
314    }
315
316    if ctype == "∠" && c.points().len() >= 4 {
317        // --- angle -------------------------------------------------------------
318        let pts = c.points();
319        let get = |i: usize| by_id.get(&id_key(&pts[i])).copied();
320        let (p0, p1, p2, p3) = (get(0)?, get(1)?, get(2)?, get(3)?);
321        let inter = line_intersection([p0.x, p0.y], [p1.x, p1.y], [p2.x, p2.y], [p3.x, p3.y]);
322        let (d1x, d1y) = (p1.x - p0.x, p1.y - p0.y);
323        let (d2x, d2y) = (p3.x - p2.x, p3.y - p2.y);
324        if (d1x * d1x + d1y * d1y) < 1e-12 || (d2x * d2x + d2y * d2y) < 1e-12 {
325            return None;
326        }
327        let a0 = d1y.atan2(d1x);
328        let a1 = d2y.atan2(d2x);
329        let mut signed = a1 - a0;
330        while signed <= -PI {
331            signed += 2.0 * PI;
332        }
333        while signed > PI {
334            signed -= 2.0 * PI;
335        }
336        let default_deg = (a1 - a0).to_degrees().rem_euclid(360.0);
337        let mut target_deg = match value {
338            Some(v) => {
339                let abs = v.abs();
340                let mut t = abs % 360.0;
341                if t < 1e-6 && abs > 0.0 {
342                    t = 360.0;
343                }
344                t
345            }
346            None => default_deg,
347        };
348        if target_deg < 1e-6 {
349            target_deg = 1e-6;
350        }
351        let mut dir_sign = if signed == 0.0 { 1.0 } else { signed.signum() };
352        if target_deg > 180.0 && target_deg < 360.0 - 1e-6 {
353            dir_sign = -dir_sign;
354        }
355        let mut d = target_deg.to_radians() * dir_sign;
356        let two_pi = 2.0 * PI;
357        if d.abs() > two_pi {
358            d = d.signum() * (two_pi - 1e-6);
359        }
360
361        let base_r = (wpp * 24.0).max(0.3);
362        let off = saved_offset(dim_offsets, id).unwrap_or([0.0, 0.0]);
363        let r = base_r + off[0].hypot(off[1]);
364        let (cx, cy) = (inter[0], inter[1]);
365
366        // Choose the arc side to sit on the label's side (port of the bisector flip).
367        let mut a_start = a0;
368        if off[0] != 0.0 || off[1] != 0.0 {
369            let label_ang = off[1].atan2(off[0]);
370            let norm = |a: f64| {
371                let t = two_pi;
372                let m = a % t;
373                if m < 0.0 {
374                    m + t
375                } else {
376                    m
377                }
378            };
379            let ang_diff = |a: f64, b: f64| {
380                let mut x = norm(a - b);
381                if x > PI {
382                    x = two_pi - x;
383                }
384                x.abs()
385            };
386            let bisector = a_start + d * 0.5;
387            let bisector_opp = bisector + PI;
388            if ang_diff(label_ang, bisector_opp) + 1e-6 < ang_diff(label_ang, bisector) {
389                a_start += PI;
390            }
391        }
392
393        // Arc polyline.
394        let segs = 48usize;
395        let mut segments = Vec::with_capacity(segs + 4);
396        let mut prev = [cx + a_start.cos() * r, cy + a_start.sin() * r];
397        for i in 1..=segs {
398            let t = a_start + d * (i as f64 / segs as f64);
399            let cur = [cx + t.cos() * r, cy + t.sin() * r];
400            segments.push((prev, cur));
401            prev = cur;
402        }
403        // Arrowheads at both arc ends (tangential, facing each other).
404        let dir_start = if d >= 0.0 { 1.0 } else { -1.0 };
405        let add_arc_arrow = |segments: &mut Vec<([f64; 2], [f64; 2])>, t: f64, dir: f64| {
406            let tip = [cx + t.cos() * r, cy + t.sin() * r];
407            let (tx, ty) = (-t.sin() * dir, t.cos() * dir);
408            push_arrow(segments, tip, tx, ty, ah, arrow_s);
409        };
410        add_arc_arrow(&mut segments, a_start, dir_start);
411        add_arc_arrow(&mut segments, a_start + d, -dir_start);
412
413        // Label at the arc midpoint.
414        let mid_ang = a_start + d * 0.5;
415        let label_uv = [cx + mid_ang.cos() * r, cy + mid_ang.sin() * r];
416
417        // Angle label: degrees with a ° symbol (the stored value IS degrees). An
418        // unset angle shows the measured default so the label is never blank.
419        let text = format!("{:.1}°", value.unwrap_or(default_deg));
420        return Some(DimGeometry {
421            segments,
422            label_uv,
423            text,
424            value,
425            value_expr,
426            mode: "angle",
427        });
428    }
429
430    None
431}
432
433/// Robust 2D infinite-line intersection (port of `intersect`); falls back to `a`
434/// on (near-)parallel lines to avoid NaNs. Shared with [`super::constraint_glyphs`].
435pub(super) fn line_intersection(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> [f64; 2] {
436    let r = [b[0] - a[0], b[1] - a[1]];
437    let s = [d[0] - c[0], d[1] - c[1]];
438    let rxs = r[0] * s[1] - r[1] * s[0];
439    if rxs.abs() < 1e-12 {
440        return a;
441    }
442    let t = ((c[0] - a[0]) * s[1] - (c[1] - a[1]) * s[0]) / rxs;
443    [a[0] + t * r[0], a[1] + t * r[1]]
444}
445
446/// The plane-space LEADER segments for ONE dimensional constraint, resolved against
447/// `doc` (`None` for a non-dimensional / unresolvable constraint). These are the
448/// pick + selection-emphasis target for a dimension: the leader lines (extension +
449/// dimension line + arrows), NOT the value label — the label is the egui value-edit
450/// affordance, so "click the leader" selects and "click the label" edits.
451pub fn constraint_dim_segments(
452    c: &SketchConstraint,
453    doc: &SketchDoc,
454    dim_offsets: &serde_json::Map<String, Value>,
455    world_per_pixel: f64,
456) -> Option<Vec<([f64; 2], [f64; 2])>> {
457    let by_id = point_index(doc);
458    dim_geometry(c, &by_id, dim_offsets, world_per_pixel).map(|g| g.segments)
459}
460
461/// Build the world-space leader/arrow line segments for every dimensional
462/// constraint, as flat `(positions, colors)` buffers (6 position + 6 color floats
463/// per segment) ready to feed the `sketch-dim-leaders` overlay group.
464pub fn dimension_leaders_buffers(
465    doc: &SketchDoc,
466    plane: &PlaneFrame,
467    dim_offsets: &serde_json::Map<String, Value>,
468    world_per_pixel: f64,
469    colors: &SketchColors,
470) -> (Vec<f32>, Vec<f32>) {
471    dimension_leaders_buffers_with_state(doc, plane, dim_offsets, world_per_pixel, colors, None, &[])
472}
473
474/// Like [`dimension_leaders_buffers`], but emphasizes the hovered / selected
475/// dimensional constraint (matched by a `{"kind":"constraint","id":…}` ref) in the
476/// SAME amber / light-blue as a selected / hovered point or geometry; an empty hover
477/// + selection reproduce the plain-green output.
478pub fn dimension_leaders_buffers_with_state(
479    doc: &SketchDoc,
480    plane: &PlaneFrame,
481    dim_offsets: &serde_json::Map<String, Value>,
482    world_per_pixel: f64,
483    colors: &SketchColors,
484    hovered: Option<&Value>,
485    selection: &[Value],
486) -> (Vec<f32>, Vec<f32>) {
487    let by_id = point_index(doc);
488    let mut positions: Vec<f32> = Vec::new();
489    let mut color_buf: Vec<f32> = Vec::new();
490    for c in &doc.constraints {
491        let Some(geom) = dim_geometry(c, &by_id, dim_offsets, world_per_pixel) else {
492            continue;
493        };
494        let color = match c.raw.get("id") {
495            Some(id) => super::tessellate::interaction_color(
496                colors,
497                colors.constraint,
498                hovered,
499                selection,
500                "constraint",
501                id,
502            ),
503            None => colors.constraint,
504        };
505        let rgb = super::tessellate::rgb(color);
506        for (a, b) in geom.segments {
507            let wa = plane.to_world(a[0], a[1]);
508            let wb = plane.to_world(b[0], b[1]);
509            positions.extend_from_slice(&[
510                wa[0] as f32,
511                wa[1] as f32,
512                wa[2] as f32,
513                wb[0] as f32,
514                wb[1] as f32,
515                wb[2] as f32,
516            ]);
517            color_buf.extend_from_slice(&[rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]);
518        }
519    }
520    (positions, color_buf)
521}
522
523/// The `set_overlay` JSON for the `sketch-dim-leaders` group (always emitted, empty
524/// when there are no dimensions, so a stale group is cleared).
525pub fn dimension_leaders_overlay_json(
526    doc: &SketchDoc,
527    plane: &PlaneFrame,
528    dim_offsets: &serde_json::Map<String, Value>,
529    world_per_pixel: f64,
530    colors: &SketchColors,
531) -> String {
532    dimension_leaders_overlay_json_with_state(
533        doc,
534        plane,
535        dim_offsets,
536        world_per_pixel,
537        colors,
538        None,
539        &[],
540    )
541}
542
543/// Like [`dimension_leaders_overlay_json`], but colors the hovered / selected
544/// dimensional constraint's leader distinctly (S: constraint selection).
545pub fn dimension_leaders_overlay_json_with_state(
546    doc: &SketchDoc,
547    plane: &PlaneFrame,
548    dim_offsets: &serde_json::Map<String, Value>,
549    world_per_pixel: f64,
550    colors: &SketchColors,
551    hovered: Option<&Value>,
552    selection: &[Value],
553) -> String {
554    let (positions, color_buf) = dimension_leaders_buffers_with_state(
555        doc,
556        plane,
557        dim_offsets,
558        world_per_pixel,
559        colors,
560        hovered,
561        selection,
562    );
563    serde_json::json!({
564        "groups": [
565            {
566                "name": OVERLAY_DIM_LEADERS,
567                "renderOrder": 10003,
568                "lines": { "positions": positions, "colors": color_buf },
569            }
570        ]
571    })
572    .to_string()
573}
574
575/// The per-constraint label placements + metadata (one [`DimLabel`] per dimensional
576/// constraint), with each label anchored in world space.
577pub fn dimension_labels(
578    doc: &SketchDoc,
579    plane: &PlaneFrame,
580    dim_offsets: &serde_json::Map<String, Value>,
581    world_per_pixel: f64,
582) -> Vec<DimLabel> {
583    let by_id = point_index(doc);
584    let mut out = Vec::new();
585    for c in &doc.constraints {
586        let Some(geom) = dim_geometry(c, &by_id, dim_offsets, world_per_pixel) else {
587            continue;
588        };
589        let Some(id) = c.raw.get("id").cloned() else {
590            continue;
591        };
592        out.push(DimLabel {
593            id,
594            text: geom.text,
595            world: plane.to_world(geom.label_uv[0], geom.label_uv[1]),
596            value: geom.value,
597            value_expr: geom.value_expr,
598            mode: geom.mode,
599        });
600    }
601    out
602}
603
604/// The plane `(u, v)` ANCHOR a dimension's label offset is measured FROM — the
605/// midpoint for a linear distance, the center for a radial dim, the line
606/// intersection for an angle. Used by the drag-to-reposition mapping so the stored
607/// `{du, dv}` = (label uv) − (anchor uv). `None` for a non-dimensional constraint.
608pub fn dimension_anchor_uv(
609    c: &SketchConstraint,
610    doc: &SketchDoc,
611) -> Option<[f64; 2]> {
612    let by_id = point_index(doc);
613    let get = |id: &Value| by_id.get(&id_key(id)).copied();
614    if is_radial_dimension(c) {
615        let pc = get(&c.points()[0])?;
616        return Some([pc.x, pc.y]);
617    }
618    match c.ctype() {
619        Some("⟺") | Some("↥") => {
620            let (a, b, _) = linear_endpoints(c, &by_id)?;
621            Some([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0])
622        }
623        Some("∠") if c.points().len() >= 4 => {
624            let pts = c.points();
625            let g = |i: usize| get(&pts[i]);
626            let (p0, p1, p2, p3) = (g(0)?, g(1)?, g(2)?, g(3)?);
627            Some(line_intersection(
628                [p0.x, p0.y],
629                [p1.x, p1.y],
630                [p2.x, p2.y],
631                [p3.x, p3.y],
632            ))
633        }
634        _ => None,
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use serde_json::json;
642
643    fn doc_from(value: Value) -> SketchDoc {
644        serde_json::from_value(value).expect("sketch doc")
645    }
646
647    fn no_offsets() -> serde_json::Map<String, Value> {
648        serde_json::Map::new()
649    }
650
651    /// A distance dim between two points produces a non-empty leader group and a
652    /// label anchored near the midpoint plus the default normal offset.
653    #[test]
654    fn distance_dim_builds_leader_and_midpoint_label() {
655        let doc = doc_from(json!({
656            "points": [
657                { "id": 0, "x": 0.0, "y": 0.0 },
658                { "id": 1, "x": 10.0, "y": 0.0 }
659            ],
660            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
661            "constraints": [
662                { "id": 0, "type": "⟺", "points": [0, 1], "value": 10.0, "displayStyle": "" }
663            ]
664        }));
665        let plane = PlaneFrame::xy();
666        let (pos, col) = dimension_leaders_buffers(&doc, &plane, &no_offsets(), 0.05, &Default::default());
667        assert!(!pos.is_empty(), "distance dim should emit leader segments");
668        assert_eq!(pos.len(), col.len());
669        assert!(pos.iter().all(|f| f.is_finite()), "leader positions finite");
670
671        let labels = dimension_labels(&doc, &plane, &no_offsets(), 0.05);
672        assert_eq!(labels.len(), 1);
673        let l = &labels[0];
674        assert_eq!(l.mode, "distance");
675        assert_eq!(l.text, "10.000");
676        assert!(l.world.iter().all(|f| f.is_finite()));
677        // Anchored at the midpoint (5, 0) pushed off along +v by the default base.
678        assert!((l.world[0] - 5.0).abs() < 1e-6, "label x = {}", l.world[0]);
679        assert!(l.world[1] > 0.0, "label pushed off the segment: y = {}", l.world[1]);
680    }
681
682    /// A linear label dragged PAST the dimension-line span gets a leader back to
683    /// the nearest end (so a detached label stays associated with its dimension);
684    /// a centered label needs none.
685    #[test]
686    fn dragged_linear_label_gets_a_leader_back_to_the_dimension() {
687        let doc = doc_from(json!({
688            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 20.0, "y": 0.0 }],
689            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
690            "constraints": [{ "id": 0, "type": "⟺", "points": [0, 1], "value": 20.0, "displayStyle": "" }]
691        }));
692        let by_id: std::collections::HashMap<String, &SketchPoint> =
693            doc.points.iter().map(|p| (id_key(&p.id), p)).collect();
694        let wpp = 0.05;
695        let touches_label = |g: &DimGeometry| {
696            g.segments.iter().any(|(a, b)| {
697                let eq = |p: &[f64; 2]| {
698                    (p[0] - g.label_uv[0]).abs() < 1e-6 && (p[1] - g.label_uv[1]).abs() < 1e-6
699                };
700                eq(a) || eq(b)
701            })
702        };
703
704        // Centered (default normal push) → the label sits on the line, no leader.
705        let centered = dim_geometry(&doc.constraints[0], &by_id, &no_offsets(), wpp).unwrap();
706        assert!(!touches_label(&centered), "a centered label needs no leader");
707
708        // Dragged far past the left end tangentially → a leader connects it.
709        let mut offsets = serde_json::Map::new();
710        offsets.insert("0".to_string(), json!({ "du": -30.0, "dv": 5.0 }));
711        let dragged = dim_geometry(&doc.constraints[0], &by_id, &offsets, wpp).unwrap();
712        assert!(
713            touches_label(&dragged),
714            "a label dragged past the span should get a leader to the dimension"
715        );
716    }
717
718    /// An angle dim renders its value in DEGREES with a `°` symbol (not a raw
719    /// radian-free number).
720    #[test]
721    fn angle_dim_text_is_degrees_with_symbol() {
722        let doc = doc_from(json!({
723            "points": [
724                { "id": 0, "x": 0.0, "y": 0.0 },
725                { "id": 1, "x": 10.0, "y": 0.0 },
726                { "id": 2, "x": 0.0, "y": 0.0 },
727                { "id": 3, "x": 0.0, "y": 10.0 }
728            ],
729            "geometries": [
730                { "id": 10, "type": "line", "points": [0, 1] },
731                { "id": 11, "type": "line", "points": [2, 3] }
732            ],
733            "constraints": [
734                { "id": 0, "type": "∠", "points": [0, 1, 2, 3], "value": 90.0 }
735            ]
736        }));
737        let labels = dimension_labels(&doc, &PlaneFrame::xy(), &no_offsets(), 0.05);
738        assert_eq!(labels.len(), 1);
739        assert_eq!(labels[0].text, "90.0°", "angle label is degrees + ° symbol");
740    }
741
742    /// A diameter dim renders `⌀` = 2·value and reports radial mode.
743    #[test]
744    fn diameter_dim_doubles_the_stored_radius_in_the_text() {
745        let doc = doc_from(json!({
746            "points": [
747                { "id": 0, "x": 0.0, "y": 0.0 },
748                { "id": 1, "x": 3.0, "y": 0.0 }
749            ],
750            "geometries": [{ "id": 10, "type": "circle", "points": [0, 1] }],
751            "constraints": [
752                { "id": 0, "type": "⟺", "points": [0, 1], "value": 3.0, "displayStyle": "diameter" }
753            ]
754        }));
755        let labels = dimension_labels(&doc, &PlaneFrame::xy(), &no_offsets(), 0.05);
756        assert_eq!(labels.len(), 1);
757        assert_eq!(labels[0].mode, "diameter");
758        assert_eq!(labels[0].text, "⌀6.000");
759        let (pos, _) = dimension_leaders_buffers(&doc, &PlaneFrame::xy(), &no_offsets(), 0.05, &Default::default());
760        assert!(!pos.is_empty());
761    }
762
763    /// A saved `{du, dv}` overrides the default anchor: the label lands at
764    /// center + offset (radial) exactly.
765    #[test]
766    fn saved_offset_places_the_label() {
767        let doc = doc_from(json!({
768            "points": [
769                { "id": 0, "x": 0.0, "y": 0.0 },
770                { "id": 1, "x": 2.0, "y": 0.0 }
771            ],
772            "geometries": [{ "id": 10, "type": "circle", "points": [0, 1] }],
773            "constraints": [
774                { "id": 7, "type": "⟺", "points": [0, 1], "value": 2.0, "displayStyle": "radius" }
775            ]
776        }));
777        let mut offsets = serde_json::Map::new();
778        offsets.insert("7".to_string(), json!({ "du": 5.0, "dv": 4.0 }));
779        let labels = dimension_labels(&doc, &PlaneFrame::xy(), &offsets, 0.05);
780        assert_eq!(labels.len(), 1);
781        assert!((labels[0].world[0] - 5.0).abs() < 1e-6);
782        assert!((labels[0].world[1] - 4.0).abs() < 1e-6);
783    }
784
785    /// A non-dimensional constraint contributes no leaders and no labels.
786    #[test]
787    fn non_dimensional_constraints_are_skipped() {
788        let doc = doc_from(json!({
789            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 1.0, "y": 0.0 }],
790            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
791            "constraints": [
792                { "id": 0, "type": "━", "points": [0, 1] },
793                { "id": 1, "type": "⏚", "points": [0] }
794            ]
795        }));
796        let (pos, _) = dimension_leaders_buffers(&doc, &PlaneFrame::xy(), &no_offsets(), 0.05, &Default::default());
797        assert!(pos.is_empty());
798        assert!(dimension_labels(&doc, &PlaneFrame::xy(), &no_offsets(), 0.05).is_empty());
799    }
800
801    #[test]
802    fn anchor_uv_is_the_midpoint_for_a_distance_dim() {
803        let doc = doc_from(json!({
804            "points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 8.0, "y": 2.0 }],
805            "geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
806            "constraints": [{ "id": 0, "type": "⟺", "points": [0, 1], "value": 8.0 }]
807        }));
808        let c = &doc.constraints[0];
809        let anchor = dimension_anchor_uv(c, &doc).unwrap();
810        assert!((anchor[0] - 4.0).abs() < 1e-9 && (anchor[1] - 1.0).abs() < 1e-9);
811    }
812}