Measure: a Shift-drag snaps its end point on its 45° step

The Measure tool and the Measure & Inspect tool snapped the end point of
a drag without Shift, but with Shift held they only turned the pointer
onto its 45° step and kept its distance: with Snap to Grid on the
measurement ended off the grid, with Smart Guides on it did not land in
line with an anchor, and with no snapping a drag of (37.3, 12.1) measured
39.2 pt at 0°.

Both now project the pointer onto the 45° step nearest its direction and
snap it there as the Line Segment tool snaps a Shift-drawn end, so it
stays on the step: (37.3, 12.1) measures 36 pt at 0° on a 9 pt grid, and
37.3 pt with no snapping. With Snap to Point alone, the anchor the pointer
snaps to is projected onto the step instead, so the end lands on an
anchor on the step and in line with one beside it.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:38 +07:00
committed by bflatastic
parent 58e6ce9f02
commit 76f0a8c615
3 changed files with 127 additions and 12 deletions
+20 -6
View File
@@ -95,12 +95,7 @@ impl Tool for MeasureInspectTool {
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
let b = if ev.mods.shift {
a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle)
} else {
crate::guides::snap_draw(cx, ev.pos, &[]).0
};
self.line = Some((a, b));
self.line = Some((a, crate::xform::measure_end_point(cx, a, ev.pos, ev.mods.shift)));
}
self.dragging = ev.kind == PointerKind::Drag;
}
@@ -213,4 +208,23 @@ mod tests {
assert_eq!(t.options(), Value::Null);
assert_eq!(area_label(Unit::Points, 2.0), "2.00 pt²");
}
/// A Shift-drag snaps its end point to the grid, then projects it onto its 45° step.
#[test]
fn shift_drag_snaps_its_end_point() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
cx.snap_to_grid = true;
let shift = Mods { shift: true, ..Mods::default() };
let mut t = MeasureInspectTool::default();
// (37.3, 12.1) snaps to (36, 9) on the 9 pt grid, and Shift keeps it at 0°: (36, 0).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 37.3, 12.1).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 37.3, 12.1).with_mods(shift));
let o = t.options();
assert!((o["distance"].as_f64().unwrap() - 36.0).abs() < 1e-9, "on the 9 pt grid: {o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "kept horizontal: {o}");
}
}
+1
View File
@@ -22,6 +22,7 @@ pub use artboard::ArtboardTool;
pub use eyedropper::EyedropperTool;
pub use free::{DistortMode, FreeTransformTool};
pub use gradient::GradientTool;
pub(crate) use measure::end_point as measure_end_point;
pub use measure::{MeasureTool, measure};
pub use transform::{TransformKind, TransformTool};
pub use wand::{LassoTool, MagicWandTool};
+106 -6
View File
@@ -23,6 +23,28 @@ pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) {
(d.hypot(), (-d.y).atan2(d.x).to_degrees() + 0.0, d.x, d.y)
}
/// The end of a measurement from `a` with the pointer at `p`: `p` snapped as a drawing tool snaps
/// it. With Shift held, the end stays on the 45° step from the Constrain Angle nearest the
/// pointer's direction: `p` is projected onto the step, then snapped along it as the Line Segment
/// tool snaps a Shift-drawn end. With Snap to Point alone, the point `p` snaps to (an anchor, or
/// in line with a ruler guide) is projected instead.
pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Point {
if !shift {
return crate::guides::snap_draw(cx, p, &[]).0;
}
let d = vectorcraft_geom::constrain_angle_from(p - a, 45.0, cx.constrain_angle);
let len = d.hypot();
if len < 1e-12 {
return a;
}
let u = d / len;
let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snap_to_pixel);
let s = if to_point { crate::guides::snap_draw(cx, p, &[]).0 } else { p };
let q = a + u * (s - a).dot(u);
let leave = crate::guides::Leave::segment(cx, a, true);
crate::guides::PointSnap::new(cx, || crate::guides::Targets::collect(cx.doc, &[], None)).snap_from(cx, q, Some(&leave)).0
}
impl Tool for MeasureTool {
fn id(&self) -> &'static str {
"measure"
@@ -42,12 +64,7 @@ impl Tool for MeasureTool {
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
let b = if ev.mods.shift {
a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle)
} else {
crate::guides::snap_draw(cx, ev.pos, &[]).0
};
self.line = Some((a, b));
self.line = Some((a, end_point(cx, a, ev.pos, ev.mods.shift)));
}
self.dragging = ev.kind == PointerKind::Drag;
}
@@ -141,6 +158,89 @@ mod tests {
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "the measured line stays");
}
/// A Shift-drag snaps its end point as a drag without Shift does, then projects it onto its
/// 45° step: onto the grid with Snap to Grid, in line with an anchor with Smart Guides. With
/// no snapping, the end is the pointer projected onto the step.
#[test]
fn shift_drag_snaps_its_end_point() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
cx.snap_to_grid = true;
let shift = Mods { shift: true, ..Mods::default() };
let mut t = MeasureTool::default();
// (37.3, 12.1) snaps to (36, 9) on the 9 pt grid, and Shift keeps it at 0°: (36, 0).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 37.3, 12.1).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 37.3, 12.1).with_mods(shift));
let o = t.options();
assert!((o["distance"].as_f64().unwrap() - 36.0).abs() < 1e-9, "on the 9 pt grid: {o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "kept horizontal: {o}");
// Near 45°: (31.4, 27.7) snaps to (27, 27).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 31.4, 27.7).with_mods(shift));
let o = t.options();
assert!((o["dx"].as_f64().unwrap() - 27.0).abs() < 1e-9, "{o}");
assert!((o["dy"].as_f64().unwrap() - 27.0).abs() < 1e-9, "{o}");
// No snapping: the pointer is projected onto 0°, so the measurement is 37.3 pt.
cx.snap_to_grid = false;
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 37.3, 12.1).with_mods(shift));
let o = t.options();
assert!((o["distance"].as_f64().unwrap() - 37.3).abs() < 1e-9, "{o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "{o}");
// Snap to Point alone: the end snaps to the anchor at (200, 200) and is projected onto
// 0°, so it ends in line with it: (200, 203).
assert!(cx.snap_to_point);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 203.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 201.0, 201.0).with_mods(shift));
let o = t.options();
assert!((o["dx"].as_f64().unwrap() + 100.0).abs() < 1e-9, "{o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "{o}");
// An anchor on the 45° step: the end lands on it.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 100.0).with_mods(shift));
let o = t.options();
assert!((o["dx"].as_f64().unwrap() - 100.0).abs() < 1e-9, "{o}");
assert!((o["dy"].as_f64().unwrap() - 100.0).abs() < 1e-9, "{o}");
// Smart Guides: from (300, 300), Shift keeps the end at 0° and it lands in line with x = 200.
cx.snap_to_grid = false;
cx.smart_guides = true;
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 303.0).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 203.0, 303.0).with_mods(shift));
let o = t.options();
assert!((o["distance"].as_f64().unwrap() - 100.0).abs() < 1e-9, "{o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "{o}");
// At 45°, the end slides along the step to where it meets x = 200: (200, 220).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 320.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 203.0, 222.5).with_mods(shift));
let o = t.options();
assert!((o["dx"].as_f64().unwrap() + 100.0).abs() < 1e-9, "{o}");
assert!((o["dy"].as_f64().unwrap() + 100.0).abs() < 1e-9, "{o}");
}
/// With Snap to Point alone, a Shift-drag's end slides along its 45° step to a ruler guide.
#[test]
fn shift_drag_meets_a_ruler_guide() {
let (mut d, _) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
assert!(cx.snap_to_point);
let shift = Mods { shift: true, ..Mods::default() };
let mut t = MeasureTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 51.0, 49.0).with_mods(shift));
let o = t.options();
assert!((o["dx"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{o}");
assert!((o["dy"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{o}");
}
/// Escape during a drag ends it and brings back the result of the drag before (none at
/// first); the release after it measures nothing.
#[test]