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 <[email protected]>
This commit is contained in:
theironchefandClaude Sonnet 5.5 authored and bflatastic committed 2026-10-08 05:35:54 +07:00
1 parent ce12b4b0f5
commit 2b56a91ce2
2 files changed
+163 -3

No files matched your search

+37
View File
@@ -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();
+126 -3
View File
@@ -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<SegmentHit> {
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<Value> = 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:?}");
}
}