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 1/2] 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) { From a1f2f23b72213cfd3ecd46319de7227dd0382e4c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:24:18 +0700 Subject: [PATCH 2/2] Direct Selection: handle snapping ignores the path's moving bounds, Shift follows the Constrain Angle (review of #332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A dragged handle no longer snaps to its own path's bounds and centre, which move with the handle; the path's anchors are point and alignment targets instead. - Shift steps 45° from the Constrain Angle preference, like the cut and gradient tools. - snap_draw and snap_handle share one pixel/grid/smart-guides switch. - A test that a smooth anchor's opposite handle turns with a Shift-dragged handle and that Alt still breaks it, and the tools' module docs (both from #338). Co-authored-by: theironchef <6278036+theironchef@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_draw2.rs | 31 +++++++++++++++++++++++++++++++ crates/tools/src/direct.rs | 3 ++- crates/tools/src/draw2/anchor.rs | 3 ++- crates/tools/src/guides.rs | 31 ++++++++++++++++++++++--------- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 949ca693..86d6f17f 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -449,3 +449,34 @@ fn a_dragged_handle_snaps_to_smart_guides() { assert_eq!(out_handle(&s, id), Point::new(298.0, 170.0), "{tool}: smart guides off"); } } + +#[test] +fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() { + // #322: the opposite handle of a smooth anchor turns with the constrained one; Alt still + // moves the dragged handle alone. + let shift = Mods { shift: true, ..Mods::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Mods::default() }; + for (mods, opposite_follows) in [(shift, true), (shift_alt, false)] { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + s.select_tool("directSelection", view()).unwrap(); + for (kind, x, y, m) in [ + (PointerKind::Down, 200.0, 100.0, Mods::default()), + (PointerKind::Up, 200.0, 100.0, Mods::default()), + (PointerKind::Down, 250.0, 100.0, mods), + (PointerKind::Drag, 275.0, 165.0, mods), + (PointerKind::Up, 275.0, 165.0, mods), + ] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(m), view()).unwrap(); + } + let a = path(&s, id).subpaths[0].anchors[1]; + let (o, i) = (a.h_out - a.p, a.h_in - a.p); + assert!((o.x - o.y).abs() < 1e-6 && o.x > 0.0, "{mods:?}: 45°: {o:?}"); + if opposite_follows { + assert!(near(a.h_in, a.p - o.normalize() * 50.0), "{mods:?}: {i:?}"); + } else { + assert_eq!(a.h_in, Point::new(150.0, 100.0), "{mods:?}"); + } + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index b2802040..ce10957c 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -2,7 +2,8 @@ //! //! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the //! path's anchors on that segment, drag to move selected anchors, drag a direction handle to -//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners. +//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap +//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners. //! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. //! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its //! points (a key object on a point moves with it) and, once a point is clicked, its handles; and diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index db5a978f..9ea06d81 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -3,7 +3,8 @@ //! Add: click a segment to insert an anchor without changing the shape (Alt = delete). //! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add). //! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a -//! handle → move it independently; drag a segment → reshape the curve. +//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape +//! the curve. //! Scissors: click a path to split it there. use serde_json::json; diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 0f844a2b..e9350e80 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -158,6 +158,11 @@ impl Targets { /// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { + snap_with(cx, p, || Targets::collect(cx.doc, exclude, None)) +} + +/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`. +fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec) { if cx.snap_to_pixel { return (Point::new(p.x.round(), p.y.round()), vec![]); } @@ -168,20 +173,28 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec< if !cx.smart_guides { return (p, vec![]); } - Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0)) + targets().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). +/// pointer at `p`: with Shift at a multiple of 45° (from the Constrain Angle) round its anchor, +/// 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![]); - } + let path = cx.doc.node(id).and_then(|n| n.path_data()); + if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) { + return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]); } - snap_draw(cx, p, &[]) + snap_with(cx, p, || { + // The path's bounds move with the handle, so they would chase it: its anchors (which stay + // put) are the targets instead, the handle lining up with them too. + let mut t = Targets::collect(cx.doc, &[id], None); + for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) { + t.points.push((a.p, Kind::Anchor)); + t.xs.push((a.p.x, a.p, Kind::Anchor)); + t.ys.push((a.p.y, a.p, Kind::Anchor)); + } + t + }) } /// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the