Anchor Point tool: a click on a handle end retracts that handle only (fixes #2267)

This commit is contained in:
Marco Arnulfo
2026-10-16 14:58:08 +07:00
committed by bflatastic
parent f88242f576
commit bb66070841
3 changed files with 75 additions and 8 deletions
+19
View File
@@ -430,6 +430,25 @@ fn anchor_point_tool_converts() {
assert!(d < 0.5, "{q:?}");
}
/// #2267: clicking one handle end with the Anchor Point tool retracts that handle alone, as one
/// undo step.
#[test]
fn anchor_point_tool_click_on_a_handle_retracts_it() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
drag(&mut s, "anchorPoint", &[(100.0, 100.0), (130.0, 80.0)], Mods::default());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let smooth = path(&s, id).subpaths[0].anchors[0];
let undo = undo_steps(&s);
click(&mut s, "anchorPoint", 130.0, 80.0);
let a = path(&s, id).subpaths[0].anchors[0];
assert!(!a.has_out());
assert_eq!((a.p, a.h_in, a.kind), (smooth.p, smooth.h_in, vectorcraft_geom::AnchorKind::Corner));
assert_eq!(undo_steps(&s), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[0], smooth);
}
#[test]
fn scissors_opens_closed_path() {
let mut s = session();
+53 -6
View File
@@ -2,9 +2,10 @@
//!
//! 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 (Shift: at 45° steps round its anchor); drag a segment → reshape
//! the curve, a selected blend's spine's too, straight or curved (#1111).
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; click a
//! handle end → retract that handle only (#2267); drag a handle → move it independently (Shift: at
//! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too,
//! straight or curved (#1111).
//! Scissors: click a path to split it there.
use serde_json::json;
@@ -31,6 +32,7 @@ enum State {
si: usize,
ai: usize,
out: bool,
start: Point,
began: bool,
},
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
@@ -110,7 +112,7 @@ impl Tool for AnchorTool {
}
("anchorPoint", PointerKind::Down) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
self.state = State::Handle { id, si, ai, out, began: false };
self.state = State::Handle { id, si, ai, out, start: p, began: false };
self.snap = HandleSnap::default();
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
self.state = State::Convert { id, si, ai, start: p, began: false };
@@ -125,8 +127,9 @@ impl Tool for AnchorTool {
let mut out = vec![];
let (began, far) = match self.state {
State::Idle => return out,
State::Convert { began, start, .. } | State::Reshape { began, start, .. } => (began, p.distance(start) >= cx.tol(2.0)),
State::Handle { began, .. } => (began, true),
State::Convert { began, start, .. } | State::Handle { began, start, .. } | State::Reshape { began, start, .. } => {
(began, p.distance(start) >= cx.tol(2.0))
}
};
if !began {
if !far {
@@ -183,6 +186,15 @@ impl Tool for AnchorTool {
vec![]
}
}
State::Handle { id, si, ai, out, began: false, .. } => {
let Some(a) = cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) else {
return vec![];
};
vec![Action::Exec(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": a.p.x, "y": a.p.y, "independent": true}),
)]
}
_ => vec![],
}
}
@@ -305,4 +317,39 @@ mod tests {
let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) };
assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment");
}
/// #2267: clicking a handle end retracts that handle only; the anchor becomes a corner and
/// keeps the other handle, as Illustrator's Anchor Point tool does.
#[test]
fn anchor_point_click_on_a_handle_retracts_only_that_handle() {
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), Point::new(300.0, 300.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
let path = vectorcraft_geom::PathData::single(sp);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let retract = |which: &str| {
vec![Action::Exec(
"path.setHandle".into(),
json!({"id": id.0, "subpath": 0, "anchor": 1, "which": which, "x": 200.0, "y": 300.0, "independent": true}),
)]
};
let mut t = AnchorTool::new("anchorPoint");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 240.0, 320.0));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 240.0, 320.0)), retract("out"));
// A hand that shakes by a pixel still clicks; the incoming handle sits opposite, at (160, 280).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 280.0));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 160.5, 280.5)).is_empty());
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 160.5, 280.5)), retract("in"));
// The command leaves the other handle where it was.
let mut a = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
a.set_handle(true, a.p, true);
assert!(!a.has_out());
assert_eq!(a.h_in, Point::new(160.0, 280.0));
}
}