diff --git a/crates/tools/src/addons/measure_inspect.rs b/crates/tools/src/addons/measure_inspect.rs index d46edb6c6..1ea8a9324 100644 --- a/crates/tools/src/addons/measure_inspect.rs +++ b/crates/tools/src/addons/measure_inspect.rs @@ -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}"); + } } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index de9c9c0cb..915eb35e8 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -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}; diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 3f2b350e0..140697dd6 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -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]