From bbfad166b08db944c7a1a2b745487d070d51691c Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 08:38:38 -0400 Subject: [PATCH] Sketch: a dimension's extension lines reach the nearest part of the lines it measures (#1361) (#1388) A dimension to a line, or between two parallel lines, drew its extension lines from the measured end point and its foot on the other line, so they ran the whole length of the lines to wherever that end happened to be. They now start from the nearest point of each line to the dimension line, and disappear where the dimension line already crosses it. Fixes #1361 Co-authored-by: Claude Opus 5.5 --- crates/sketch/src/dims.rs | 75 +++++++++++++++++++++++++++++++++- crates/sketch/src/lib.rs | 2 +- crates/ui-egui/src/browser.rs | 4 +- crates/ui-egui/src/dim_view.rs | 4 +- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/crates/sketch/src/dims.rs b/crates/sketch/src/dims.rs index 91b73bcd..9d9d9eca 100644 --- a/crates/sketch/src/dims.rs +++ b/crates/sketch/src/dims.rs @@ -178,10 +178,51 @@ fn arc_pts(c: Vec2, r: f64, a0: f64, sweep: f64) -> Vec { (0..=n).map(|i| c + Vec2::from_angle(a0 + sweep * i as f64 / n as f64) * r).collect() } +/// The lines a linear dimension's two extension lines may start from (#1361), for its `p0` and +/// `p1` sides: a dimension to a line reaches the nearest part of that line, not the foot of the +/// measured point, and a dimension between two parallel lines reaches the nearest part of the +/// line whose end it measures from. `None` for a side that measures a point. +pub fn dim_reach(sk: &Sketch, k: &ConstraintKind) -> [Option<(Vec2, Vec2)>; 2] { + use ConstraintKind::*; + let (p, l, to_line) = match *k { + PointLineDistance { p, l, .. } => (p, l, true), + LinearDiameter { p, l, .. } => (p, l, false), + _ => return [None, None], + }; + let Some((a, b)) = line_ends(sk, l) else { return [None, None] }; + let d = b - a; + // A line ending at `p` that runs parallel to `l`. + let parallel = sk.curves.iter().enumerate().find_map(|(i, c)| match c.kind { + CurveKind::Line { a: pa, b: pb } if i != l && (pa == p || pb == p) => { + let (u, v) = line_ends(sk, i)?; + let e = v - u; + (e.len() > 1e-12 && d.cross(e).abs() <= 1e-9 * d.len() * e.len()).then_some((u, v)) + } + _ => None, + }); + [to_line.then_some((a, b)), parallel] +} + +/// The point of segment `s` nearest `q`. +fn nearest_on(s: (Vec2, Vec2), q: Vec2) -> Vec2 { + let d = s.1 - s.0; + let l2 = d.dot(d); + if !(l2 > 1e-24) { + return s.0; + } + s.0 + d * ((q - s.0).dot(d) / l2).clamp(0.0, 1.0) +} + /// Lay a dimension out. `text` is the stored text position (None: the default place), `px` /// the sketch length of one screen pixel and `half` the half size of the text box (sketch /// units), which the dimension line leaves a gap for. pub fn dim_layout(f: &DimFrame, text: Option, px: f64, half: Vec2) -> DimLayout { + dim_layout_reaching(f, text, px, half, [None, None]) +} + +/// [`dim_layout`] with the extension lines of a linear dimension starting from the nearest +/// point of the geometry in `reach` ([`dim_reach`]) instead of the measured points. +pub fn dim_layout_reaching(f: &DimFrame, text: Option, px: f64, half: Vec2, reach: [Option<(Vec2, Vec2)>; 2]) -> DimLayout { let px = if px.is_finite() && px > 0.0 { px } else { 1.0 }; let arrow = 9.0 * px; let gap = 3.0 * px; @@ -196,7 +237,8 @@ pub fn dim_layout(f: &DimFrame, text: Option, px: f64, half: Vec2) -> DimL // The dimension line sits `h` across from the middle of the measured points. let q0 = p0 + n * (h - (p0 - m).dot(n)); let q1 = p1 + n * (h - (p1 - m).dot(n)); - for (p, q) in [(p0, q0), (p1, q1)] { + for ((p, q), r) in [(p0, q0), (p1, q1)].into_iter().zip(reach) { + let p = r.map_or(p, |r| nearest_on(r, q)); let v = q - p; if let Some(u) = v.normalized() { out.lines.push(vec![p + u * gap.min(v.len()), q + u * over]); @@ -375,4 +417,35 @@ mod tests { assert_eq!(l.arrows.len(), 2); assert!(l.text.x > 0.0 && l.text.y > 0.0); } + + #[test] + fn extension_lines_reach_the_nearest_part_of_the_lines_measured() { + // #1361: two parallel lines dimensioned from the low end of one; the dimension placed + // across their middles needs no extension lines, one placed above them reaches their + // top ends, not the low end. + let mut sk = Sketch::new(); + let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0); + let m = sk.add_line(Vec2::new(30.0, -80.0), Vec2::new(30.0, 90.0), None, None, None).unwrap_or(0); + let p = sk.curves[m].kind.point_ids()[0]; + let k = ConstraintKind::PointLineDistance { p, l, value: 30.0 }; + let f = dim_frame(&sk, &k).unwrap_or(DimFrame::Radial { center: Vec2::ZERO, radius: 1.0, diameter: false }); + let reach = dim_reach(&sk, &k); + assert!(reach[0].is_some() && reach[1].is_some()); + let extension_ys = |at: Vec2| -> Vec<(f64, f64)> { + let lay = dim_layout_reaching(&f, Some(encode_text(&f, at)), 0.1, Vec2::new(2.0, 1.0), reach); + lay.lines.iter().filter(|line| line.len() == 2 && (line[0].x - line[1].x).abs() < 1e-9).map(|line| (line[0].y, line[1].y)).collect() + }; + assert!(extension_ys(Vec2::new(15.0, 50.0)).is_empty(), "{:?}", extension_ys(Vec2::new(15.0, 50.0))); + let above = extension_ys(Vec2::new(15.0, 120.0)); + assert_eq!(above.len(), 2); + assert!((above[0].0 - 100.3).abs() < 1e-9 && (above[1].0 - 90.3).abs() < 1e-9, "{above:?}"); + // Without the lines' reach the extension lines start at the measured point's height. + let lay = dim_layout(&f, Some(encode_text(&f, Vec2::new(15.0, 120.0))), 0.1, Vec2::new(2.0, 1.0)); + assert!(lay.lines.iter().any(|line| (line[0].y + 79.7).abs() < 1e-9)); + // A point that is not the end of a parallel line keeps its own extension line. + let mut sk = Sketch::new(); + let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0); + let q = sk.add_point(Vec2::new(30.0, -80.0), None).unwrap_or(0); + assert!(dim_reach(&sk, &ConstraintKind::PointLineDistance { p: q, l, value: 30.0 })[1].is_none()); + } } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 3922472e..60690953 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -43,7 +43,7 @@ pub use curves::{ spline_end_tangent, spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, }; pub use decompose::Budget; -pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; +pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text}; pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom}; diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index bc76b565..6c396c19 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1818,7 +1818,7 @@ fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) { /// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable. pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { use solidcraft_engine::geom::Vec2; - use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout}; + use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach}; let Some(rect) = app.viewport.rect else { return }; if app.ui.shown_dims.is_empty() { return; @@ -1856,7 +1856,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { ); let size = galley.size() + vec2(6.0, 2.0); let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); - let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half); + let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half, dim_reach(sk, &c.kind)); for line in &lay.lines { painter.add(egui::Shape::line(line.iter().filter_map(|q| to(*q)).collect(), Stroke::new(1.0, col))); } diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index a596b081..11bcf39b 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -16,7 +16,7 @@ use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2}; use serde_json::json; use solidcraft_engine::Sel; use solidcraft_engine::geom::Vec2; -use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout}; +use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach}; use crate::SolveApp; use crate::theme::Tokens; @@ -516,7 +516,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px); // Unplaced linear dimensions sit outside the shape they measure. let away = first_point(sk, &c.kind).and_then(|p| centres.get(p).copied()).unwrap_or(anchor); - let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half); + let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half, dim_reach(sk, &c.kind)); let stroke = Stroke::new(1.0, col); let mut segs = Vec::new(); for line in &lay.lines {