From 86de124cc84dddc7a3189faeea4cc784e52fff47 Mon Sep 17 00:00:00 2001 From: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:17:01 -0400 Subject: [PATCH] Fix #322: dragged direction handles take Shift and smart guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a direction handle with Direct Selection or the Anchor Point tool sent the pointer position straight to path.setHandle: Shift did nothing and the handle never snapped, while the Pen already constrains the handles it pulls out. guides::snap_handle places a dragged handle: with Shift at a multiple of 45° from its anchor (the Pen's constraint, keeping the dragged length), otherwise snapped as a drawn point is (smart guides, the grid, pixels). Both tools use it and show the smart guides while the handle moves. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/tests_draw2.rs | 60 ++++++++++++++++++++++++++++++++ crates/tools/src/direct.rs | 10 ++++-- crates/tools/src/draw2/anchor.rs | 22 ++++++++---- crates/tools/src/guides.rs | 13 +++++++ 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 292be64a..f9867c99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. + - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 4e711626..949ca693 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -389,3 +389,63 @@ fn draw2_commands_reject_bad_params() { assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step"); } } + +/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100). +fn curve(s: &mut Session) -> NodeId { + let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]); + NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()) +} + +/// Drag the out handle of path `id`'s first anchor with `tool` to `to`, holding `mods`. Direct +/// Selection clicks the anchor first, so its handles show. +fn drag_handle(s: &mut Session, tool: &str, id: NodeId, to: (f64, f64), mods: Mods, v: ViewInfo) { + let from = path(s, id).subpaths[0].anchors[0].h_out; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool(tool, v).unwrap(); + if tool == "directSelection" { + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods), v).unwrap(); + assert!(!s.in_interaction()); +} + +/// The out handle of path `id`'s first anchor. +fn out_handle(s: &Session, id: NodeId) -> Point { + path(s, id).subpaths[0].anchors[0].h_out +} + +#[test] +fn shift_keeps_a_dragged_handle_at_45_degree_steps() { + // #322. + let shift = Mods { shift: true, ..Mods::default() }; + for tool in ["directSelection", "anchorPoint"] { + let mut s = session(); + let id = curve(&mut s); + drag_handle(&mut s, tool, id, (190.0, 130.0), shift, view()); + let h = out_handle(&s, id); + assert!(near(h, Point::new(100.0 + 90.0f64.hypot(30.0), 100.0)), "{tool}: {h:?}"); + drag_handle(&mut s, tool, id, (160.0, 155.0), shift, view()); + let h = out_handle(&s, id); + assert!((h.x - h.y).abs() < 1e-6 && h.x > 100.0, "{tool}: 45°: {h:?}"); + // Without Shift the handle goes where it is dragged. + drag_handle(&mut s, tool, id, (190.0, 130.0), Mods::default(), view()); + assert_eq!(out_handle(&s, id), Point::new(190.0, 130.0), "{tool}"); + } +} + +#[test] +fn a_dragged_handle_snaps_to_smart_guides() { + // #322: in line with the other anchor, with smart guides on. + for tool in ["directSelection", "anchorPoint"] { + let mut s = session(); + let id = curve(&mut s); + drag_handle(&mut s, tool, id, (298.0, 160.0), Mods::default(), ViewInfo::default()); + assert_eq!(out_handle(&s, id), Point::new(300.0, 160.0), "{tool}"); + drag_handle(&mut s, tool, id, (298.0, 170.0), Mods::default(), view()); + assert_eq!(out_handle(&s, id), Point::new(298.0, 170.0), "{tool}: smart guides off"); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d97c3ab1..b2802040 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -77,11 +77,13 @@ pub struct DirectSelectionTool { /// The spine point last clicked (blend, anchor): its handles show and can be dragged. spine: Option<(NodeId, usize)>, mesh: MeshEdit, + /// Smart guides of the handle being dragged. + guides: Vec, } impl DirectSelectionTool { pub fn new(group: bool) -> Self { - Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() } + Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![] } } } @@ -320,9 +322,11 @@ impl Tool for DirectSelectionTool { out } (PointerKind::Drag, State::Handle { id, si, ai, out }) => { + let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift); + self.guides = guides; vec![Action::Preview( "path.setHandle".into(), - json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}), + json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}), )] } (PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => { @@ -369,6 +373,7 @@ impl Tool for DirectSelectionTool { } (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { self.state = State::Idle; + self.guides.clear(); vec![Action::Commit] } (PointerKind::Up, State::Marquee { start, add, .. }) => { @@ -398,6 +403,7 @@ impl Tool for DirectSelectionTool { match &self.state { State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], State::Corner(c) => c.overlays(cx), + State::Handle { .. } => self.guides.clone(), _ => { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx)); diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 6f22bdab..db5a978f 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_geom::Point; use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor}; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Clone, Copy, Debug)] enum State { @@ -24,6 +24,8 @@ enum State { pub struct AnchorTool { id: &'static str, state: State, + /// Smart guides of the handle being dragged. + guides: Vec, } impl AnchorTool { @@ -34,7 +36,7 @@ impl AnchorTool { "scissors" => "scissors", _ => "addAnchor", }; - Self { id, state: State::Idle } + Self { id, state: State::Idle, guides: vec![] } } fn add(cx: &ToolContext, p: Point) -> Vec { @@ -128,10 +130,14 @@ impl Tool for AnchorTool { "path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}), ), - State::Handle { id, si, ai, out, .. } => Action::Preview( - "path.setHandle".into(), - json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}), - ), + State::Handle { id, si, ai, out, .. } => { + let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift); + self.guides = guides; + Action::Preview( + "path.setHandle".into(), + json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}), + ) + } State::Reshape { id, si, seg, t, start, .. } => Action::Preview( "path.reshapeSegment".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}), @@ -141,6 +147,7 @@ impl Tool for AnchorTool { out } ("anchorPoint", PointerKind::Up) => { + self.guides.clear(); let st = std::mem::replace(&mut self.state, State::Idle); match st { State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => { @@ -172,6 +179,9 @@ impl Tool for AnchorTool { _ => vec![], } } + fn overlays(&self, _cx: &ToolContext) -> Vec { + if matches!(self.state, State::Handle { .. }) { self.guides.clone() } else { vec![] } + } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.id { "addAnchor" if !m.alt => Cursor::PenAdd, diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 716b3b22..0f844a2b 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -171,6 +171,19 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec< Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0)) } +/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the +/// pointer at `p`: with Shift at a multiple of 45° from its anchor (as the Pen draws them), else +/// snapped as a drawn point is (smart guides, the grid, pixels). +pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec) { + if shift { + let anchor = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).map(|a| a.p)); + if let Some(a) = anchor { + return (a + vectorcraft_geom::constrain_angle(p - a, 45.0), vec![]); + } + } + snap_draw(cx, p, &[]) +} + /// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the /// selection or of the object under the pointer, when Snap to Point or Smart Guides is on. pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec) {