Fix #501: a Pen drag out of the first anchor always closes the path

Reported: closing a shape by pressing on the first anchor and dragging out a curve flickered, and
releasing at the wrong moment left the shape open.

The press decided to close (path.close preview), but every move asked again which path was being
drawn: the closing preview leaves the path closed, so no longer one the Pen extends, and the move
fell through to "first anchor of a new path" (a path.create preview), the next move back to a
close of a path that no longer existed in the preview... The release committed whichever was last.

- The press now records what it placed (Place: the anchor, the path it goes on, none for a new
  path, and whether it closes it), and the drag only shapes that anchor's handles: a close stays a
  close, symmetric by default and Alt breaking the handles, and the release closes the path wherever
  it ends. The first anchor of a new path no longer depends on the selection either.
- Tests: tools (the drag sees the closed path), engine (closed throughout a 12-move drag, symmetric
  handles at the release), MCP headless pointer_gesture. ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 22:00:24 +07:00
co-authored by Claude Opus 5.5
parent aefd961bbd
commit f6cd4588ee
4 changed files with 103 additions and 13 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -734,3 +734,35 @@ fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
assert!((a.h_out.distance(a.p) - 40.0).abs() < 1e-9, "its length kept");
assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
}
#[test]
fn pen_drag_from_the_first_anchor_closes_the_path() {
// #501: closing a path by dragging out of its first anchor flickered between a close and a new
// path, and released at the wrong moment left the shape open. The press decides: the drag
// shapes the closing curve, symmetric, and the release closes the path wherever it ends.
let none = Mods::default();
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
for (x, y) in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
gesture(&mut s, &[(x, y)], none);
}
let id = s.doc().unwrap().selection.objects[0];
let pts: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
let p = path(&s, id);
assert!(p.subpaths[0].closed && paths(&s).len() == 1, "closed throughout the drag, at ({x}, {y})");
}
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 252.0), v).unwrap();
assert!(!s.in_interaction());
let sp = &path(&s, id).subpaths[0];
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
let a = sp.anchors[0];
assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(160.0, 252.0)), "symmetric");
assert_eq!(paths(&s).len(), 1);
// The next click starts a new path.
gesture(&mut s, &[(500.0, 500.0)], none);
assert_eq!(paths(&s).len(), 2);
}
+19
View File
@@ -45,3 +45,22 @@ fn cmd_with_the_pen_edits_the_path_being_drawn() {
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(300.0, 350.0)])));
assert_eq!(paths(&h).iter().map(Vec::len).collect::<Vec<_>>(), [3], "the same path, three anchors");
}
/// #501: a drag out of the first anchor closes the path, shaping the closing curve.
#[test]
fn a_drag_from_the_first_anchor_closes_the_path() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
for p in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[p])));
}
let close: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
text(&call_tool(&mut h, "pointer_gesture", &gesture(&close)));
let d = &h.session.doc().unwrap().doc;
let mut closed = vec![];
d.walk(|n| closed.extend(n.path_data().map(|p| p.is_closed())));
assert_eq!(closed, [true], "one path, closed");
let first = paths(&h)[0][0];
assert_eq!((first.h_in, first.h_out), (vectorcraft_geom::Point::new(40.0, 348.0), vectorcraft_geom::Point::new(160.0, 252.0)));
}
+50 -11
View File
@@ -3,8 +3,9 @@
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt breaks
//! them: the incoming handle stays where it was when Alt went down and only the outgoing one
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
//! A press on the first anchor closes the path, the drag shaping the closing curve. Clicking the
//! last one retracts its outgoing handle, so the next segment leaves it as a corner; dragging from
//! it pulls a new one out on its own.
//! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too)
//! works as the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes
//! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes
@@ -30,7 +31,8 @@ pub struct PenTool {
drawing: bool,
/// ...this one, once the engine made it: another path selected meanwhile isn't drawn on.
path: Option<NodeId>,
drag: Option<(Point, bool)>,
/// The anchor a press placed, while the drag shapes its handles.
drag: Option<Place>,
hover: Option<Point>,
/// The incoming handle of the anchor being dragged out, as last previewed.
in_h: Point,
@@ -43,6 +45,17 @@ pub struct PenTool {
convert: Option<AnchorTool>,
}
/// An anchor being placed, as the press decided: where it is, the path it goes on (none: the
/// first anchor of a new path) and whether it closes that path (the press was on its first
/// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing
/// preview leaves the path closed, so no longer one the pen draws on.
#[derive(Clone, Copy)]
struct Place {
at: Point,
path: Option<NodeId>,
closing: bool,
}
/// The open path the pen is extending: the single selected open path.
fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
if cx.selection.objects.len() != 1 {
@@ -128,7 +141,7 @@ impl Tool for PenTool {
let end = last_anchor(cx, id);
// A one-anchor path doesn't close on itself: its anchor is the last one too.
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
self.drag = Some((first, true));
self.drag = Some(Place { at: first, path: Some(id), closing: true });
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
}
if p.distance(last) <= tol
@@ -140,7 +153,7 @@ impl Tool for PenTool {
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
self.drag = Some((p, false));
self.drag = Some(Place { at: p, path: Some(id), closing: false });
self.in_h = p;
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
@@ -165,7 +178,7 @@ impl Tool for PenTool {
return vec![act];
}
(self.drawing, self.path) = (true, None);
self.drag = Some((p, false));
self.drag = Some(Place { at: p, path: None, closing: false });
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
@@ -179,13 +192,13 @@ impl Tool for PenTool {
}
return vec![set_out_handle(id, si, ai, h)];
}
let Some((mut a, closing)) = self.drag else { return vec![] };
let Some(Place { at: mut a, path, closing }) = self.drag else { return vec![] };
// Space held moves the anchor being placed, handles and all.
if ev.mods.space && !closing {
let d = ev.pos - self.last;
a += d;
self.in_h += d;
self.drag = Some((a, closing));
self.drag = Some(Place { at: a, path, closing });
}
self.last = ev.pos;
let mut out_h = ev.pos;
@@ -193,7 +206,7 @@ impl Tool for PenTool {
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
}
let alt = ev.mods.alt;
let Some((id, ..)) = active else {
let Some(id) = path else {
// First anchor of a new path: re-issue create with handles.
let in_h = a - (out_h - a);
return vec![Action::Preview(
@@ -223,8 +236,8 @@ impl Tool for PenTool {
if self.handle.take().is_some() {
return vec![Action::Commit];
}
let Some((_, closing)) = self.drag.take() else { return vec![] };
if closing {
let Some(place) = self.drag.take() else { return vec![] };
if place.closing {
self.stop();
}
vec![Action::Commit]
@@ -492,6 +505,32 @@ mod tests {
assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose);
}
/// #501: a press on the first anchor closes the path, however far the drag then goes: the
/// closing preview leaves the path closed (so not one the pen extends), and each move still
/// previews the close with symmetric handles (Alt breaks them), released as a close.
#[test]
fn a_drag_from_the_first_anchor_always_closes() {
let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)];
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false));
let p = paint();
let mut t = PenTool { drawing: true, ..PenTool::default() };
let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0));
assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]);
// What the tool sees once the preview applied: the path closed.
d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true;
let c = cx(&d, &s, &p);
let close = |x: f64, y: f64, alt: bool| {
vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))]
};
for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})");
}
let alt = Mods { alt: true, ..Mods::default() };
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]);
assert!(!t.busy() && t.active(&c).is_none(), "the path is done");
}
#[test]
fn a_one_anchor_path_does_not_close_on_itself() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false));