Fix #776: the Pen continues any open path and joins two paths

As in Illustrator, the Pen shows its continue cursor over an end of any
open path, selected or not, and a click there selects the path and draws
on from that end (reversing it from its first end). While drawing, it
shows a join cursor over an end of another open path, and a click joins
the two into one path, in one undo step, which finishes the path.

path.join takes {ids: [a, b], ends: ["first"|"last", "first"|"last"]}
to join the ends asked for (the Pen's join); without them it joins the
selected paths' nearest ends as before. Joining a path with itself, or
anything that isn't a path, is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 23:59:50 +07:00
co-authored by Claude Opus 5.5
parent 825d4fdb8b
commit bd8fa795ed
8 changed files with 146 additions and 12 deletions
+2
View File
@@ -432,6 +432,8 @@ pub enum Cursor {
PenDelete,
PenClose,
PenContinue,
/// While drawing, over an end of another open path (a click joins the two).
PenJoin,
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
PenConvert,
+47 -3
View File
@@ -11,8 +11,9 @@
//! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes
//! it. Cmd held lends the selection tool used last for a drag (the engine's
//! `Session::pointer`); the path being drawn goes on afterwards while it stays selected.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
//! Enter/Esc (or switching tools) ends the path. Clicking an end of an open path continues it
//! (selecting it); while drawing, clicking an end of another open path joins the two into one
//! (#776). The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
@@ -80,6 +81,29 @@ fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
Some((id, first, last.p, last.h_out))
}
/// An end of an open path (one subpath, not `except`) under `p`, within `tol`: the path the Pen
/// continues or joins to, and whether it's the path's first anchor. Only paths that can be edited,
/// not guides.
fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<NodeId>) -> Option<(NodeId, bool)> {
let id = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?.leaf;
if Some(id) == except || !cx.doc.is_editable(id) {
return None;
}
let NodeKind::Path { path, guide: false, .. } = &cx.doc.node(id)?.kind else { return None };
let [sp] = path.subpaths.as_slice() else { return None };
if sp.closed {
return None;
}
let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
if p.distance(last) <= tol {
Some((id, false))
} else if p.distance(first) <= tol {
Some((id, true))
} else {
None
}
}
/// The last anchor of `id`'s last subpath: (subpath, anchor).
fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
let pd = cx.doc.node(id)?.path_data()?;
@@ -160,6 +184,12 @@ impl Tool for PenTool {
self.handle = Some((id, si, ai, last));
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
}
// An end of another open path: the two become one, and the path is finished.
if let Some((other, at_first)) = open_end_at(cx, p, tol, Some(id)) {
self.stop();
let end = if at_first { "first" } else { "last" };
return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
@@ -184,6 +214,15 @@ impl Tool for PenTool {
}
return vec![];
}
// Continue any other open path from the end clicked, selecting it.
if let Some((id, at_first)) = open_end_at(cx, p, tol, None) {
(self.drawing, self.path) = (true, Some(id));
let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
if at_first {
out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]})));
}
return out;
}
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
return vec![act];
}
@@ -306,6 +345,9 @@ impl Tool for PenTool {
if p.distance(last) <= tol {
return Cursor::PenConvert;
}
if open_end_at(cx, p, tol, Some(id)).is_some() {
return Cursor::PenJoin;
}
}
if alt_converts(cx, p, m) {
return Cursor::PenConvert;
@@ -316,7 +358,9 @@ impl Tool for PenTool {
Some([_]) => return Cursor::PenAdd,
_ => {}
}
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol)
|| open_end_at(cx, p, tol, None).is_some()
{
return Cursor::PenContinue;
}
match auto_add_delete(cx, p, m, tol) {