From 6939a481a43b022263848a0cbe5aa88b1b5c2087 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:03:37 +0200 Subject: [PATCH 1/3] Dimension tool: Shift shows the constrained line while picking the second point (#2432) The Shift click already snapped the second point to 45-degree steps, but the dashed line from the first point followed the raw pointer, so the constraint looked absent until the click. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 18 ++++++++++++++++++ crates/tools/src/dimension.rs | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index f65a292c7..83e7be165 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -389,3 +389,21 @@ fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_open let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some()); } + +/// With Shift down, the dashed line from the first picked point follows the 45-degree step the +/// second click will pick (#2432), not the raw pointer. +#[test] +fn shift_shows_the_constrained_line_while_picking_the_second_point() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Down, 300.0, 300.0); + ev(&mut s, PointerKind::Up, 300.0, 300.0); + let end = |s: &mut Session, shift: bool| { + let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() }); + s.pointer(&e, ViewInfo::default()).unwrap(); + s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap() + }; + assert_eq!(end(&mut s, false).y, 310.0); + assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index eb482a036..9fd871c70 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -60,9 +60,17 @@ pub struct DimensionTool { dragging: bool, hover: Option, at: Point, + /// Whether Shift was down at the last pointer event: the picked line shows constrained. + shift: bool, began: bool, } +/// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the +/// constrain angle), the distance kept. +fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point { + last + vectorcraft_geom::constrain_angle_from(at - last, 45.0, cx.constrain_angle) +} + /// Nearest editable segment, with bounded work and no snapping to dimension artwork itself. pub fn pick(cx: &ToolContext, p: Point) -> Option { let mut best = None; @@ -217,7 +225,7 @@ impl DimensionTool { if mods.shift && let Some((last, _)) = self.points.last() { - at = *last + vectorcraft_geom::constrain_angle_from(at - *last, 45.0, cx.constrain_angle); + at = constrained(cx, *last, at); reference = reference.filter(|r| r.position(cx.doc) == Some(at)); } self.points.push((at, reference)); @@ -293,6 +301,7 @@ impl Tool for DimensionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.at = ev.pos; + self.shift = ev.mods.shift; match ev.kind { PointerKind::Move => { if self.pending.is_some() { @@ -393,7 +402,8 @@ impl Tool for DimensionTool { segment(edge, &mut out); } if let Some((p, _)) = self.points.last() { - out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true }); + let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out } From 848e7a296a55010c6b0f82a686987200e6a6c698 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:50 +0200 Subject: [PATCH 2/3] Dimension tool: a click near a line snaps the measured point onto the line (#2432) Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 14 ++++++++++++++ crates/tools/src/dimension.rs | 27 ++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 83e7be165..05915505c 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -407,3 +407,17 @@ fn shift_shows_the_constrained_line_while_picking_the_second_point() { assert_eq!(end(&mut s, false).y, 310.0); assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); } + +/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432). +#[test] +fn clicking_near_a_line_snaps_the_measured_point_onto_it() { + let mut s = session(); + edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] { + ev(&mut s, PointerKind::Down, x, y); + ev(&mut s, PointerKind::Up, x, y); + } + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "20.00 pt"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 9fd871c70..2cd39dfc6 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -119,6 +119,20 @@ fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p))) } +/// The point of `edge` closest to `p`. +fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let c = sp.segment(edge.segment); + let t = c.nearest(p, 1e-4).t; + use vectorcraft_geom::kurbo::ParamCurve; + let q = c.eval(t); + (q.x.is_finite() && q.y.is_finite()).then_some(q) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -216,11 +230,14 @@ impl DimensionTool { Kind::Linear => { let (mut at, _) = crate::guides::snap_pick(cx, p); let mut reference = None; - if let Some(edge) = pick(cx, p) - && let Some((anchor, point)) = anchor_near(cx, &edge, p) - { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } } if mods.shift && let Some((last, _)) = self.points.last() From 4dcd0b6caaabd5d5885ed53b7602646f1c6ed573 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:28:01 +0200 Subject: [PATCH 3/3] Dimension tool: show where a Linear click snaps before the click (#2516) The snap ring on an anchor (or a dot on a line) and the rubber band now follow the snapped point, so both measured points visibly land on anchors. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 31 +++++++++++++++++++++++ crates/tools/src/dimension.rs | 42 ++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 05915505c..99c415a75 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -421,3 +421,34 @@ fn clicking_near_a_line_snaps_the_measured_point_onto_it() { let id = s.doc().unwrap().selection.objects[0].0; assert_eq!(text(&s, id), "20.00 pt"); } + +/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the +/// snap before the click, and the dimension stays linked to the anchors (#2516). +#[test] +fn both_points_snap_onto_anchors_with_a_visible_indicator() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + let line = edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Move, 103.0, 102.0); + let ring = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .into_iter() + .any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0)) + }; + assert!(ring(&mut s), "ring on the anchor before the first click"); + ev(&mut s, PointerKind::Down, 103.0, 102.0); + ev(&mut s, PointerKind::Up, 103.0, 102.0); + ev(&mut s, PointerKind::Move, 169.0, 98.0); + let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap(); + assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor"); + ev(&mut s, PointerKind::Down, 169.0, 98.0); + ev(&mut s, PointerKind::Up, 169.0, 98.0); + ev(&mut s, PointerKind::Down, 136.0, 130.0); + ev(&mut s, PointerKind::Up, 136.0, 130.0); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "72.00 pt"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 2cd39dfc6..aed41c829 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -133,6 +133,23 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// Where a Linear pick at `p` lands: on an anchor within reach (kept as a link to its path), +/// else on the closest point of the picked line, else on the usual snap targets or `p` itself. +fn snap_linear(cx: &ToolContext, p: Point) -> (Point, Option) { + let (mut at, _) = crate::guides::snap_pick(cx, p); + let mut reference = None; + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } + } + (at, reference) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -228,17 +245,7 @@ impl DimensionTool { fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec { match self.kind { Kind::Linear => { - let (mut at, _) = crate::guides::snap_pick(cx, p); - let mut reference = None; - if let Some(edge) = pick(cx, p) { - if let Some((anchor, point)) = anchor_near(cx, &edge, p) { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); - } else if let Some(point) = nearest_on(cx, &edge, p) { - // Not on an anchor: snap to the closest point of the line (#2432). - at = point; - } - } + let (mut at, mut reference) = snap_linear(cx, p); if mods.shift && let Some((last, _)) = self.points.last() { @@ -418,8 +425,19 @@ impl Tool for DimensionTool { for edge in &self.lines { segment(edge, &mut out); } + // Linear: show where the next click lands (anchor ring, or a dot on a line), and draw the + // rubber band to that point, so the user sees the snap before clicking (#2516). + let linear_pick = self.kind == Kind::Linear && self.pending.is_none() && self.lines.is_empty(); + let snapped = linear_pick.then(|| snap_linear(cx, self.at)); + if let Some((q, reference)) = &snapped + && (reference.is_some() || q.distance(self.at) > 1e-9) + { + let on_anchor = reference.is_some(); + out.push(Overlay::Anchor { p: *q, color, filled: !on_anchor, size: if on_anchor { 10.0 } else { 6.0 } }); + } if let Some((p, _)) = self.points.last() { - let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + let target = snapped.map_or(self.at, |(q, _)| q); + let b = if self.shift { constrained(cx, *p, target) } else { target }; out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out