From a1f2f23b72213cfd3ecd46319de7227dd0382e4c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:24:18 +0700 Subject: [PATCH] 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