Skip to main content

brep_render/sketch/
dimensions.rs

1//! Dimension leaders and labels for distance, point-line distance, angle,
2//! radius, and diameter constraints.
3//!
4//! `dim_offsets[cid] = {du, dv}` is the plane-space vector from a dimension's
5//! anchor to its label. Leaders and labels share this offset, with per-type
6//! defaults when absent. Interaction state overrides the configured constraint color.
7
8use std::collections::HashMap;
9use std::f64::consts::PI;
10
11use serde_json::Value;
12
13use super::doc::{id_key, point_index, SketchConstraint, SketchDiagnostics, SketchDoc, SketchPoint};
14use super::PlaneFrame;
15use crate::style::SketchColors;
16
17/// The dimension-leaders overlay group name (its own group so it upserts/clears
18/// independently of geometry/points/preview).
19pub const OVERLAY_DIM_LEADERS: &str = "sketch-dim-leaders";
20
21/// The label placement + metadata for one dimensional constraint.
22#[derive(Clone, Debug)]
23pub struct DimLabel {
24    /// The constraint id (raw `Value`).
25    pub id: Value,
26    /// The rendered text (`value.toFixed(3)`, `R…`, `⌀…`, or the angle value).
27    pub text: String,
28    /// The label anchor in world space (`plane.to_world(label_uv)`).
29    pub world: [f64; 3],
30    /// The stored numeric solver value (a radius for radial dims), if finite.
31    pub value: Option<f64>,
32    /// The stored `valueExpr` string (if any).
33    pub value_expr: Option<String>,
34    /// The display mode: `"distance" | "radius" | "diameter" | "angle"`.
35    pub mode: &'static str,
36    /// The solver names this dimension in a CONFLICT — brep-app paints the value
37    /// text red to match its leader. The label text is drawn by the app (not the
38    /// overlay), so the flag has to ride along with the placement.
39    pub conflicting: bool,
40}
41
42/// The plane-space geometry + label anchor for one dimensional constraint.
43struct DimGeometry {
44    /// Leader/arrow segments as `(a, b)` pairs in plane `(u, v)`.
45    segments: Vec<([f64; 2], [f64; 2])>,
46    /// The label anchor in plane `(u, v)`.
47    label_uv: [f64; 2],
48    text: String,
49    value: Option<f64>,
50    value_expr: Option<String>,
51    mode: &'static str,
52}
53
54/// Whether `c` is a RADIAL dimension (`⟺` on `[center, rim]` with a
55/// radius/diameter display style) — port of `isRadialDimensionConstraint`.
56fn is_radial_dimension(c: &SketchConstraint) -> bool {
57    if c.ctype() != Some("⟺") || c.points().len() < 2 {
58        return false;
59    }
60    matches!(
61        c.raw.get("displayStyle").and_then(Value::as_str),
62        Some("radius") | Some("diameter")
63    )
64}
65
66/// Read the persisted `{du, dv}` plane offset for a constraint id, or `None` when
67/// absent / non-finite (so callers can apply the per-type default).
68fn saved_offset(dim_offsets: &serde_json::Map<String, Value>, id: &Value) -> Option<[f64; 2]> {
69    let entry = dim_offsets.get(&id_key(id))?;
70    let du = entry.get("du").and_then(Value::as_f64);
71    let dv = entry.get("dv").and_then(Value::as_f64);
72    match (du, dv) {
73        (Some(du), Some(dv)) if du.is_finite() && dv.is_finite() => Some([du, dv]),
74        // A partial offset still counts (either finite counts as set).
75        (Some(du), None) if du.is_finite() => Some([du, 0.0]),
76        (None, Some(dv)) if dv.is_finite() => Some([0.0, dv]),
77        _ => None,
78    }
79}
80
81/// The linear-distance endpoints for a `⟺` (the two points) or a `↥`
82/// point-line-distance (the point projected onto the infinite line + the point) —
83/// port of `resolveLinearDistanceEndpoints`. Returns `(a, b, anchor_a)` where
84/// `anchor_a` is where the extension line starts (the nearest point on the finite
85/// segment for `↥`, else `a`).
86fn linear_endpoints(
87    c: &SketchConstraint,
88    by_id: &HashMap<String, &SketchPoint>,
89) -> Option<([f64; 2], [f64; 2], [f64; 2])> {
90    let get = |id: &Value| by_id.get(&id_key(id)).copied();
91    let pts = c.points();
92    match c.ctype() {
93        Some("⟺") if pts.len() >= 2 && !is_radial_dimension(c) => {
94            let p0 = get(&pts[0])?;
95            let p1 = get(&pts[1])?;
96            let a = [p0.x, p0.y];
97            Some((a, [p1.x, p1.y], a))
98        }
99        Some("↥") if pts.len() >= 3 => {
100            let a = get(&pts[0])?;
101            let b = get(&pts[1])?;
102            let cpt = get(&pts[2])?;
103            let dx = b.x - a.x;
104            let dy = b.y - a.y;
105            let len_sq = dx * dx + dy * dy;
106            if !(len_sq > 1e-12) {
107                return Some(([a.x, a.y], [cpt.x, cpt.y], [a.x, a.y]));
108            }
109            let t_raw = ((cpt.x - a.x) * dx + (cpt.y - a.y) * dy) / len_sq;
110            let t_raw = if t_raw.is_finite() { t_raw } else { 0.0 };
111            let t_seg = t_raw.clamp(0.0, 1.0);
112            Some((
113                [a.x + t_raw * dx, a.y + t_raw * dy],
114                [cpt.x, cpt.y],
115                [a.x + t_seg * dx, a.y + t_seg * dy],
116            ))
117        }
118        _ => None,
119    }
120}
121
122/// The default radial label offset from the CENTER — port of `radialOffsetToPlane`
123/// (the no-saved branch).
124fn radial_default_offset(pc: [f64; 2], pr: [f64; 2]) -> [f64; 2] {
125    let vx = pr[0] - pc[0];
126    let vy = pr[1] - pc[1];
127    let l = vx.hypot(vy);
128    let (rx, ry) = if l > 1e-9 { (vx / l, vy / l) } else { (1.0, 0.0) };
129    let (nx, ny) = (-ry, rx);
130    let base = (l * 0.35).max(0.2);
131    [
132        vx + rx * base + nx * base * 0.35,
133        vy + ry * base + ny * base * 0.35,
134    ]
135}
136
137/// Push a two-line arrowhead at `tip` pointing along unit `(dx, dy)` with head
138/// length `ah` and half-width factor `s` (port of the previous arrowhead builder).
139fn push_arrow(
140    segments: &mut Vec<([f64; 2], [f64; 2])>,
141    tip: [f64; 2],
142    dx: f64,
143    dy: f64,
144    ah: f64,
145    s: f64,
146) {
147    let len = dx.hypot(dy);
148    if !(len > 1e-9) {
149        return;
150    }
151    let (tx, ty) = (dx / len, dy / len);
152    let (wx, wy) = (-ty, tx);
153    let a = [tip[0] + tx * ah + wx * ah * s, tip[1] + ty * ah + wy * ah * s];
154    let b = [tip[0] + tx * ah - wx * ah * s, tip[1] + ty * ah - wy * ah * s];
155    segments.push((tip, a));
156    segments.push((tip, b));
157}
158
159/// Assemble the leader geometry + label anchor for one dimensional constraint, or
160/// `None` for a non-dimensional / unresolvable constraint.
161fn dim_geometry(
162    c: &SketchConstraint,
163    by_id: &HashMap<String, &SketchPoint>,
164    dim_offsets: &serde_json::Map<String, Value>,
165    wpp: f64,
166) -> Option<DimGeometry> {
167    let ctype = c.ctype()?;
168    let id = c.raw.get("id")?;
169    let value = c
170        .raw
171        .get("value")
172        .and_then(Value::as_f64)
173        .filter(|v| v.is_finite());
174    let value_expr = c
175        .raw
176        .get("valueExpr")
177        .and_then(Value::as_str)
178        .filter(|s| !s.is_empty())
179        .map(str::to_string);
180
181    // Screen-constant sizes (port of the previous `worldPerPixel`-scaled bases).
182    let ah = (wpp * 6.0).max(0.06);
183    let arrow_s = 0.6;
184
185    if is_radial_dimension(c) {
186        // --- radius / diameter -------------------------------------------------
187        let pts = c.points();
188        let pc = by_id.get(&id_key(&pts[0])).copied()?;
189        let pr = by_id.get(&id_key(&pts[1])).copied()?;
190        let center = [pc.x, pc.y];
191        let radius = (pr.x - pc.x).hypot(pr.y - pc.y);
192        if !(radius > 1e-9) {
193            return None;
194        }
195        let diameter = c.raw.get("displayStyle").and_then(Value::as_str) == Some("diameter");
196        let off = saved_offset(dim_offsets, id).unwrap_or_else(|| radial_default_offset(center, [pr.x, pr.y]));
197        let label_uv = [center[0] + off[0], center[1] + off[1]];
198
199        // Direction center -> label (fall back to center -> rim).
200        let (mut dx, mut dy) = (label_uv[0] - center[0], label_uv[1] - center[1]);
201        let mut l = dx.hypot(dy);
202        if !(l > 1e-9) {
203            dx = pr.x - pc.x;
204            dy = pr.y - pc.y;
205            l = dx.hypot(dy).max(1e-9);
206        }
207        let (ux, uy) = (dx / l, dy / l);
208        let near = [center[0] + ux * radius, center[1] + uy * radius];
209        let far = [center[0] - ux * radius, center[1] - uy * radius];
210
211        let mut segments = Vec::new();
212        if diameter {
213            segments.push((far, near));
214            segments.push((near, label_uv));
215            // Arrowheads point inward toward the center from both ends.
216            push_arrow(&mut segments, near, -ux, -uy, ah, arrow_s);
217            push_arrow(&mut segments, far, ux, uy, ah, arrow_s);
218        } else {
219            segments.push((center, near));
220            segments.push((near, label_uv));
221            push_arrow(&mut segments, near, -ux, -uy, ah, arrow_s);
222        }
223
224        let safe = value.unwrap_or(0.0);
225        let (text, mode) = if diameter {
226            (format!("⌀{:.3}", 2.0 * safe), "diameter")
227        } else {
228            (format!("R{safe:.3}"), "radius")
229        };
230        return Some(DimGeometry {
231            segments,
232            label_uv,
233            text,
234            value,
235            value_expr,
236            mode,
237        });
238    }
239
240    if ctype == "⟺" || ctype == "↥" {
241        // --- linear distance ---------------------------------------------------
242        let (a, b, anchor_a) = linear_endpoints(c, by_id)?;
243        let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
244        let len = dx.hypot(dy).max(1e-9);
245        let (tx, ty) = (dx / len, dy / len);
246        let (nx, ny) = (-ty, tx);
247        let base = (wpp * 20.0).max(0.1);
248        let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
249        // Offset from the midpoint to the label; default a normal push of `base`.
250        let off = saved_offset(dim_offsets, id).unwrap_or([nx * base, ny * base]);
251        let label_uv = [mid[0] + off[0], mid[1] + off[1]];
252        // The dimension line runs parallel to the segment through the label's
253        // normal component (project the offset onto the normal so the line stays
254        // parallel and the extension lines meet it squarely).
255        let off_n = off[0] * nx + off[1] * ny;
256        let ou = nx * off_n;
257        let ov = ny * off_n;
258
259        let a_off = [a[0] + ou, a[1] + ov];
260        let b_off = [b[0] + ou, b[1] + ov];
261        let mut segments = vec![
262            (a_off, b_off),       // dimension line
263            (anchor_a, a_off),    // extension line at a
264            (b, b_off),           // extension line at b
265        ];
266        // Opposed arrowheads at the dimension-line ends, pointing toward the span.
267        push_arrow(&mut segments, a_off, tx, ty, ah, arrow_s);
268        push_arrow(&mut segments, b_off, -tx, -ty, ah, arrow_s);
269
270        // Leader: the label rides the (extended) dimension line, so when it is
271        // dragged PAST the span it detaches visually. Connect it back to the nearest
272        // end of the dimension line so the user can tell which dimension it belongs
273        // to. (Within the span the label sits on the line, so the gap is ~0 and no
274        // leader is drawn — matching the radial dimension's rim→label leader.)
275        let t_lbl = (label_uv[0] - a_off[0]) * tx + (label_uv[1] - a_off[1]) * ty;
276        let foot = {
277            let t = t_lbl.clamp(0.0, len);
278            [a_off[0] + tx * t, a_off[1] + ty * t]
279        };
280        if (label_uv[0] - foot[0]).hypot(label_uv[1] - foot[1]) > wpp * 2.0 {
281            segments.push((foot, label_uv));
282        }
283
284        let text = format!("{:.3}", value.unwrap_or(0.0));
285        return Some(DimGeometry {
286            segments,
287            label_uv,
288            text,
289            value,
290            value_expr,
291            mode: "distance",
292        });
293    }
294
295    if ctype == "∠" && c.points().len() >= 4 {
296        // --- angle -------------------------------------------------------------
297        let pts = c.points();
298        let get = |i: usize| by_id.get(&id_key(&pts[i])).copied();
299        let (p0, p1, p2, p3) = (get(0)?, get(1)?, get(2)?, get(3)?);
300        let inter = line_intersection([p0.x, p0.y], [p1.x, p1.y], [p2.x, p2.y], [p3.x, p3.y]);
301        let (d1x, d1y) = (p1.x - p0.x, p1.y - p0.y);
302        let (d2x, d2y) = (p3.x - p2.x, p3.y - p2.y);
303        if (d1x * d1x + d1y * d1y) < 1e-12 || (d2x * d2x + d2y * d2y) < 1e-12 {
304            return None;
305        }
306        let a0 = d1y.atan2(d1x);
307        let a1 = d2y.atan2(d2x);
308        let mut signed = a1 - a0;
309        while signed <= -PI {
310            signed += 2.0 * PI;
311        }
312        while signed > PI {
313            signed -= 2.0 * PI;
314        }
315        let default_deg = (a1 - a0).to_degrees().rem_euclid(360.0);
316        let mut target_deg = match value {
317            Some(v) => {
318                let abs = v.abs();
319                let mut t = abs % 360.0;
320                if t < 1e-6 && abs > 0.0 {
321                    t = 360.0;
322                }
323                t
324            }
325            None => default_deg,
326        };
327        if target_deg < 1e-6 {
328            target_deg = 1e-6;
329        }
330        let mut dir_sign = if signed == 0.0 { 1.0 } else { signed.signum() };
331        if target_deg > 180.0 && target_deg < 360.0 - 1e-6 {
332            dir_sign = -dir_sign;
333        }
334        let mut d = target_deg.to_radians() * dir_sign;
335        let two_pi = 2.0 * PI;
336        if d.abs() > two_pi {
337            d = d.signum() * (two_pi - 1e-6);
338        }
339
340        let base_r = (wpp * 24.0).max(0.3);
341        let off = saved_offset(dim_offsets, id).unwrap_or([0.0, 0.0]);
342        let r = base_r + off[0].hypot(off[1]);
343        let (cx, cy) = (inter[0], inter[1]);
344
345        // Choose the arc side to sit on the label's side (port of the bisector flip).
346        let mut a_start = a0;
347        if off[0] != 0.0 || off[1] != 0.0 {
348            let label_ang = off[1].atan2(off[0]);
349            let norm = |a: f64| {
350                let t = two_pi;
351                let m = a % t;
352                if m < 0.0 {
353                    m + t
354                } else {
355                    m
356                }
357            };
358            let ang_diff = |a: f64, b: f64| {
359                let mut x = norm(a - b);
360                if x > PI {
361                    x = two_pi - x;
362                }
363                x.abs()
364            };
365            let bisector = a_start + d * 0.5;
366            let bisector_opp = bisector + PI;
367            if ang_diff(label_ang, bisector_opp) + 1e-6 < ang_diff(label_ang, bisector) {
368                a_start += PI;
369            }
370        }
371
372        // Arc polyline.
373        let segs = 48usize;
374        let mut segments = Vec::with_capacity(segs + 4);
375        let mut prev = [cx + a_start.cos() * r, cy + a_start.sin() * r];
376        for i in 1..=segs {
377            let t = a_start + d * (i as f64 / segs as f64);
378            let cur = [cx + t.cos() * r, cy + t.sin() * r];
379            segments.push((prev, cur));
380            prev = cur;
381        }
382        // Arrowheads at both arc ends (tangential, facing each other).
383        let dir_start = if d >= 0.0 { 1.0 } else { -1.0 };
384        let add_arc_arrow = |segments: &mut Vec<([f64; 2], [f64; 2])>, t: f64, dir: f64| {
385            let tip = [cx + t.cos() * r, cy + t.sin() * r];
386            let (tx, ty) = (-t.sin() * dir, t.cos() * dir);
387            push_arrow(segments, tip, tx, ty, ah, arrow_s);
388        };
389        add_arc_arrow(&mut segments, a_start, dir_start);
390        add_arc_arrow(&mut segments, a_start + d, -dir_start);
391
392        // Label at the arc midpoint.
393        let mid_ang = a_start + d * 0.5;
394        let label_uv = [cx + mid_ang.cos() * r, cy + mid_ang.sin() * r];
395
396        // Angle label: degrees with a ° symbol (the stored value IS degrees). An
397        // unset angle shows the measured default so the label is never blank.
398        let text = format!("{:.1}°", value.unwrap_or(default_deg));
399        return Some(DimGeometry {
400            segments,
401            label_uv,
402            text,
403            value,
404            value_expr,
405            mode: "angle",
406        });
407    }
408
409    None
410}
411
412/// Robust 2D infinite-line intersection (port of `intersect`); falls back to `a`
413/// on (near-)parallel lines to avoid NaNs. Shared with [`super::constraint_glyphs`].
414pub(super) fn line_intersection(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> [f64; 2] {
415    let r = [b[0] - a[0], b[1] - a[1]];
416    let s = [d[0] - c[0], d[1] - c[1]];
417    let rxs = r[0] * s[1] - r[1] * s[0];
418    if rxs.abs() < 1e-12 {
419        return a;
420    }
421    let t = ((c[0] - a[0]) * s[1] - (c[1] - a[1]) * s[0]) / rxs;
422    [a[0] + t * r[0], a[1] + t * r[1]]
423}
424
425/// The plane-space LEADER segments for ONE dimensional constraint, resolved against
426/// `doc` (`None` for a non-dimensional / unresolvable constraint). These are the
427/// pick + selection-emphasis target for a dimension: the leader lines (extension +
428/// dimension line + arrows), NOT the value label — the label is the egui value-edit
429/// affordance, so "click the leader" selects and "click the label" edits.
430pub fn constraint_dim_segments(
431    c: &SketchConstraint,
432    doc: &SketchDoc,
433    dim_offsets: &serde_json::Map<String, Value>,
434    world_per_pixel: f64,
435) -> Option<Vec<([f64; 2], [f64; 2])>> {
436    let by_id = point_index(doc);
437    dim_geometry(c, &by_id, dim_offsets, world_per_pixel).map(|g| g.segments)
438}
439
440/// Build the world-space leader/arrow line segments for every dimensional
441/// constraint, as flat `(positions, colors)` buffers (6 position + 6 color floats
442/// per segment) ready to feed the `sketch-dim-leaders` overlay group.
443pub fn dimension_leaders_buffers(
444    doc: &SketchDoc,
445    diag: &SketchDiagnostics,
446    plane: &PlaneFrame,
447    dim_offsets: &serde_json::Map<String, Value>,
448    world_per_pixel: f64,
449    colors: &SketchColors,
450) -> (Vec<f32>, Vec<f32>) {
451    dimension_leaders_buffers_with_state(
452        doc,
453        diag,
454        plane,
455        dim_offsets,
456        world_per_pixel,
457        colors,
458        None,
459        &[],
460    )
461}
462
463/// Like [`dimension_leaders_buffers`], but emphasizes the hovered / selected
464/// dimensional constraint (matched by a `{"kind":"constraint","id":…}` ref) in the
465/// SAME amber / light-blue as a selected / hovered point or geometry; an empty hover
466/// + selection reproduce the plain-green output.
467pub fn dimension_leaders_buffers_with_state(
468    doc: &SketchDoc,
469    diag: &SketchDiagnostics,
470    plane: &PlaneFrame,
471    dim_offsets: &serde_json::Map<String, Value>,
472    world_per_pixel: f64,
473    colors: &SketchColors,
474    hovered: Option<&Value>,
475    selection: &[Value],
476) -> (Vec<f32>, Vec<f32>) {
477    let by_id = point_index(doc);
478    let mut positions: Vec<f32> = Vec::new();
479    let mut color_buf: Vec<f32> = Vec::new();
480    for c in &doc.constraints {
481        let Some(geom) = dim_geometry(c, &by_id, dim_offsets, world_per_pixel) else {
482            continue;
483        };
484        // Green normally, RED while the solver names this dimension in a conflict;
485        // selection (amber) and hover (light blue) still win over both.
486        let color = match c.raw.get("id") {
487            Some(id) => super::tessellate::interaction_color(
488                colors,
489                super::constraint_base_color(colors, diag, id),
490                hovered,
491                selection,
492                "constraint",
493                id,
494            ),
495            None => colors.constraint,
496        };
497        let rgb = crate::color::hex_to_srgb_f32(color);
498        for (a, b) in geom.segments {
499            let wa = plane.to_world(a[0], a[1]);
500            let wb = plane.to_world(b[0], b[1]);
501            positions.extend_from_slice(&[
502                wa[0] as f32,
503                wa[1] as f32,
504                wa[2] as f32,
505                wb[0] as f32,
506                wb[1] as f32,
507                wb[2] as f32,
508            ]);
509            color_buf.extend_from_slice(&[rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]);
510        }
511    }
512    (positions, color_buf)
513}
514
515/// The `set_overlay` JSON for the `sketch-dim-leaders` group (always emitted, empty
516/// when there are no dimensions, so a stale group is cleared).
517pub fn dimension_leaders_overlay_json(
518    doc: &SketchDoc,
519    diag: &SketchDiagnostics,
520    plane: &PlaneFrame,
521    dim_offsets: &serde_json::Map<String, Value>,
522    world_per_pixel: f64,
523    colors: &SketchColors,
524) -> String {
525    dimension_leaders_overlay_json_with_state(
526        doc,
527        diag,
528        plane,
529        dim_offsets,
530        world_per_pixel,
531        colors,
532        None,
533        &[],
534    )
535}
536
537/// Like [`dimension_leaders_overlay_json`], but colors the hovered / selected
538/// dimensional constraint's leader distinctly (S: constraint selection).
539pub fn dimension_leaders_overlay_json_with_state(
540    doc: &SketchDoc,
541    diag: &SketchDiagnostics,
542    plane: &PlaneFrame,
543    dim_offsets: &serde_json::Map<String, Value>,
544    world_per_pixel: f64,
545    colors: &SketchColors,
546    hovered: Option<&Value>,
547    selection: &[Value],
548) -> String {
549    let (positions, color_buf) = dimension_leaders_buffers_with_state(
550        doc,
551        diag,
552        plane,
553        dim_offsets,
554        world_per_pixel,
555        colors,
556        hovered,
557        selection,
558    );
559    serde_json::json!({
560        "groups": [
561            {
562                "name": OVERLAY_DIM_LEADERS,
563                "renderOrder": 10003,
564                "lines": { "positions": positions, "colors": color_buf },
565            }
566        ]
567    })
568    .to_string()
569}
570
571/// The per-constraint label placements + metadata (one [`DimLabel`] per dimensional
572/// constraint), with each label anchored in world space.
573pub fn dimension_labels(
574    doc: &SketchDoc,
575    diag: &SketchDiagnostics,
576    plane: &PlaneFrame,
577    dim_offsets: &serde_json::Map<String, Value>,
578    world_per_pixel: f64,
579) -> Vec<DimLabel> {
580    let by_id = point_index(doc);
581    let mut out = Vec::new();
582    for c in &doc.constraints {
583        let Some(geom) = dim_geometry(c, &by_id, dim_offsets, world_per_pixel) else {
584            continue;
585        };
586        let Some(id) = c.raw.get("id").cloned() else {
587            continue;
588        };
589        out.push(DimLabel {
590            conflicting: diag.constraint_conflicting(&id),
591            id,
592            text: geom.text,
593            world: plane.to_world(geom.label_uv[0], geom.label_uv[1]),
594            value: geom.value,
595            value_expr: geom.value_expr,
596            mode: geom.mode,
597        });
598    }
599    out
600}
601
602/// The plane `(u, v)` ANCHOR a dimension's label offset is measured FROM — the
603/// midpoint for a linear distance, the center for a radial dim, the line
604/// intersection for an angle. Used by the drag-to-reposition mapping so the stored
605/// `{du, dv}` = (label uv) − (anchor uv). `None` for a non-dimensional constraint.
606pub fn dimension_anchor_uv(
607    c: &SketchConstraint,
608    doc: &SketchDoc,
609) -> Option<[f64; 2]> {
610    let by_id = point_index(doc);
611    let get = |id: &Value| by_id.get(&id_key(id)).copied();
612    if is_radial_dimension(c) {
613        let pc = get(&c.points()[0])?;
614        return Some([pc.x, pc.y]);
615    }
616    match c.ctype() {
617        Some("⟺") | Some("↥") => {
618            let (a, b, _) = linear_endpoints(c, &by_id)?;
619            Some([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0])
620        }
621        Some("∠") if c.points().len() >= 4 => {
622            let pts = c.points();
623            let g = |i: usize| get(&pts[i]);
624            let (p0, p1, p2, p3) = (g(0)?, g(1)?, g(2)?, g(3)?);
625            Some(line_intersection(
626                [p0.x, p0.y],
627                [p1.x, p1.y],
628                [p2.x, p2.y],
629                [p3.x, p3.y],
630            ))
631        }
632        _ => None,
633    }
634}
635
636// BREP private tests: 330b9c1a11ef118c