From 2b56a91ce2e46039dea19fb3dbbdb603c89ff6b0 Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:07:50 -0700 Subject: [PATCH 1/2] Fix #361: Direct Selection: dragging a segment bends or stretches it Any hit on a path's stroke took the "select the whole path" branch, so dragging a segment moved the whole path. Now a press on the stroke of a path that isn't selected as a whole selects the segment's two anchors (unless they already are), and the drag bends the segment with path.reshapeSegment when it's curved, else moves the selected anchors (a straight edge moves, the rest stays), as in the reference app. The fill, Shift-click and a path selected as a whole keep their behaviour. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests.rs | 37 +++++++++++ crates/tools/src/direct.rs | 129 ++++++++++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de61fe6e..9b4b8c7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -294,6 +294,43 @@ fn direct_selection_moves_one_anchor() { assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0)); } +/// Dragging a curved segment with Direct Selection bends it (its anchors stay), and dragging a +/// straight one moves its two anchors, not the whole path; each is one undo step. +#[test] +fn direct_selection_drags_segments() { + let mut s = session(); + let v = ViewInfo::default(); + let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64)| { + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); + }; + let arch = &json!({"anchors": [{"x": 100, "y": 200, "out": [100, 100]}, {"x": 300, "y": 200, "in": [300, 100]}]}); + let arch = NodeId(s.execute("path.create", arch).unwrap()["id"].as_u64().unwrap()); + let square = &json!({"anchors": [{"x": 150, "y": 400}, {"x": 150, "y": 300}, {"x": 250, "y": 300}, {"x": 250, "y": 400}], "closed": true}); + let square = NodeId(s.execute("path.create", square).unwrap()["id"].as_u64().unwrap()); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("directSelection", v).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + // The arch's middle, 40 up: the curve bends, its two anchors stay. + drag(&mut s, (200.0, 125.0), (200.0, 85.0)); + let sp = s.doc().unwrap().doc.node(arch).unwrap().path_data().unwrap().subpaths[0].clone(); + let (a, b) = (sp.anchors[0], sp.anchors[1]); + assert_eq!((a.p, b.p), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(300.0, 200.0)), "the anchors stay"); + assert!(a.h_out.y < 100.0 && b.h_in.y < 100.0, "the curve bent up: {a:?} {b:?}"); + assert_eq!(steps(&s), before + 1); + // The square's top edge, 40 up: its two anchors move, the bottom two stay. + drag(&mut s, (200.0, 300.0), (200.0, 260.0)); + let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap(); + assert_eq!((sq.y0, sq.y1, sq.x0, sq.x1), (260.0, 400.0, 150.0, 250.0), "the edge moved, the rest stayed"); + assert_eq!(steps(&s), before + 2); + // Its fill still moves the whole square. + drag(&mut s, (200.0, 350.0), (200.0, 330.0)); + let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap(); + assert_eq!((sq.y0, sq.y1), (240.0, 380.0), "the whole path moved"); +} + #[test] fn text_create_has_bounds() { let mut s = session(); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 9a02d3b7..72295373 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -10,11 +10,15 @@ //! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]). //! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`): //! the text reflows at its size. +//! +//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors +//! (the fill selects the whole path); dragging the segment bends it if it's curved, else moves its +//! anchors. use std::borrow::Cow; use serde_json::{Value, json}; -use vectorcraft_doc::hit::hit_test; +use vectorcraft_doc::hit::{HitKind, hit_test}; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point, Rect}; @@ -35,6 +39,15 @@ enum State { start: Point, began: bool, }, + /// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`). + Segment { + id: NodeId, + si: usize, + seg: usize, + t: f64, + start: Point, + began: bool, + }, Handle { id: NodeId, si: usize, @@ -191,6 +204,27 @@ fn anchors_json(v: &[AnchorRef]) -> Value { Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect()) } +/// A segment of a path under the pointer. +struct SegmentHit { + si: usize, + seg: usize, + t: f64, + /// The indexes of the segment's two anchors in its subpath. + anchors: [usize; 2], + curved: bool, +} + +/// The segment of path `id` nearest to `p`, where the hit test found its stroke. +fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { + let pd = cx.doc.node(id)?.path_data()?; + let (si, seg, t, ..) = pd.nearest(p)?; + let sp = pd.subpaths.get(si)?; + let n = sp.anchors.len(); + let (i0, i1) = (seg.checked_rem(n)?, (seg + 1).checked_rem(n)?); + let (a, b) = (sp.anchors.get(i0)?, sp.anchors.get(i1)?); + Some(SegmentHit { si, seg, t, anchors: [i0, i1], curved: a.has_out() || b.has_in() }) +} + impl Tool for DirectSelectionTool { fn id(&self) -> &'static str { if self.group { "groupSelection" } else { "directSelection" } @@ -267,7 +301,27 @@ impl Tool for DirectSelectionTool { return self.press_type_area(cx, id, anchors, p, ev.mods.shift); } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { - // Clicking a segment/fill selects the whole leaf path (all anchors). + // A segment of a path that isn't selected as a whole: its two anchors get + // selected, and dragging it reshapes it if it's curved, else moves it. + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + if !whole + && !ev.mods.shift + && matches!(h.kind, HitKind::Stroke | HitKind::Outline) + && let Some(s) = segment_at(cx, h.leaf, p) + { + let selected = cx.selection.partial(h.leaf).is_some_and(|sel| s.anchors.iter().all(|&ai| sel.contains(&(s.si, ai)))); + self.state = if s.curved { + State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } + } else { + State::MoveAnchors { start: p, began: false } + }; + if selected { + return vec![]; + } + let anchors: Vec = s.anchors.iter().map(|ai| json!([s.si, ai])).collect(); + return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))]; + } + // Clicking the fill selects the whole leaf path (all anchors). self.state = State::MoveObject { start: p, began: false }; if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; @@ -293,6 +347,22 @@ impl Tool for DirectSelectionTool { out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); out } + (PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Reshape".into())); + self.state = State::Segment { id, si, seg, t, start, began: true }; + } + let d = p - start; + out.push(Action::Preview( + "path.reshapeSegment".into(), + json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y}), + )); + out + } (PointerKind::Drag, State::MoveObject { start, began }) => { let mut out = vec![]; if !began { @@ -367,7 +437,11 @@ impl Tool for DirectSelectionTool { } ( PointerKind::Up, - State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. }, + State::MoveAnchors { began, .. } + | State::MoveObject { began, .. } + | State::Segment { began, .. } + | State::SpinePoint { began, .. } + | State::TypeArea { began, .. }, ) => { self.state = State::Idle; if began { vec![Action::Commit] } else { vec![] } @@ -570,4 +644,53 @@ mod tests { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]); } + /// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). + fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + sp.anchors[0].h_out = Point::new(100.0, 250.0); + sp.anchors[1].h_in = Point::new(200.0, 250.0); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, id) + } + + #[test] + fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { + let (d, arch) = arch_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The arch: its two anchors get selected, and the drag bends it. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": arch.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5)); + assert_eq!(a[0], Action::Begin("Reshape".into())); + let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") }; + assert_eq!( + (cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()), + ("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0)) + ); + assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]); + // A straight edge of the rectangle: its two anchors get selected and move. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.anchors" && v["anchors"] == json!([[0, 0], [0, 1]])), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); + // The fill still selects the whole path, and a path selected as a whole still moves whole. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let rect = d.layers[0].children().unwrap()[0].id; + let mut whole = Selection::default(); + whole.set([rect]); + let cx = crate::testutil::cx(&d, &whole, &p); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); + } } From acc708ec83fe1d24a09c89b686ddb924090ab83f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 04:54:14 +0700 Subject: [PATCH 2/2] Direct Selection: Alt on a segment still picks the whole path; ROADMAP (review of #363) With Alt held, a press on a path's stroke selects the whole path and an Alt-drag copies it, as before #363 and as Group Selection does in Illustrator; only a plain press on a segment selects its anchors and reshapes it. Records the segment drag in ROADMAP.md. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/tools/src/direct.rs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ca89a32a..8dbdbd6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). - 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. + - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **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. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 72295373..04febdf9 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -12,8 +12,8 @@ //! the text reflows at its size. //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors -//! (the fill selects the whole path); dragging the segment bends it if it's curved, else moves its -//! anchors. +//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else +//! moves its anchors. use std::borrow::Cow; @@ -302,10 +302,12 @@ impl Tool for DirectSelectionTool { } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { // A segment of a path that isn't selected as a whole: its two anchors get - // selected, and dragging it reshapes it if it's curved, else moves it. + // selected, and dragging it reshapes it if it's curved, else moves it. Alt picks + // the whole path (and Alt-drag copies it), as with Group Selection. let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); if !whole && !ev.mods.shift + && !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) { @@ -681,6 +683,13 @@ mod tests { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); + // Alt on the stroke picks the whole path, and Alt-drag copies it. + let alt = Mods { alt: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt)); // The fill still selects the whole path, and a path selected as a whole still moves whole. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");