mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
825d4fdb8b
commit
bd8fa795ed
8 files changed
+146
-12
No files matched your search
+1
-1
@@ -126,7 +126,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. 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. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **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. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
- **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).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{} join two open paths' nearest endpoints, or close a single open path",
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -283,8 +283,27 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_paths(s)?;
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
let st = s.doc()?;
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
|
||||
return Err(bad(C, format!("{} isn't a path", id.0)));
|
||||
}
|
||||
ids
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
};
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
None => None,
|
||||
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
|
||||
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
|
||||
},
|
||||
};
|
||||
match ids.as_slice() {
|
||||
[one] => {
|
||||
let id = *one;
|
||||
@@ -305,6 +324,9 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
[a, b, ..] => {
|
||||
let (a, b) = (*a, *b);
|
||||
if a == b {
|
||||
return Err(bad(C, "join two different paths"));
|
||||
}
|
||||
s.edit("Join", |d, sel| {
|
||||
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
|
||||
let pa = path_mut(d, a)?;
|
||||
@@ -316,10 +338,11 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
|
||||
return Err(EngineError::Other("join needs open paths".into()));
|
||||
};
|
||||
// Pick the closest pair of ends.
|
||||
// The ends asked for, else the closest pair.
|
||||
let cands =
|
||||
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
|
||||
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false));
|
||||
let (rx, ry) =
|
||||
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
|
||||
if rx {
|
||||
x.reverse();
|
||||
}
|
||||
|
||||
@@ -790,3 +790,57 @@ fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() {
|
||||
gesture(&mut s, &[(500.0, 400.0)], none);
|
||||
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5));
|
||||
}
|
||||
|
||||
/// #776: the Pen continues any open path from the end clicked, selected or not, and while drawing a
|
||||
/// click on an end of another open path joins the two into one path, in one undo step, which
|
||||
/// finishes it.
|
||||
#[test]
|
||||
fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
use vectorcraft_tools::Cursor;
|
||||
let (v, none) = (view(), Mods::default());
|
||||
let mut s = session();
|
||||
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let b = line(&mut s, 300.0, 200.0, 400.0, 200.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue, "an end of a path that isn't selected");
|
||||
gesture(&mut s, &[(200.0, 100.0)], none);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
assert!(s.doc().unwrap().doc.node(b).is_none() && paths(&s).len() == 1, "one path");
|
||||
// The join finished it: the next click starts a new path.
|
||||
gesture(&mut s, &[(500.0, 500.0)], none);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
// From a path's first end, drawing goes on from there: the path is reversed.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
gesture(&mut s, &[(100.0, 100.0)], none);
|
||||
gesture(&mut s, &[(50.0, 50.0)], none);
|
||||
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
|
||||
}
|
||||
|
||||
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
|
||||
#[test]
|
||||
fn join_takes_the_paths_and_the_ends_to_join() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
|
||||
let b = line(&mut s, 110.0, 0.0, 200.0, 0.0);
|
||||
// The far ends: a's first to b's last (the nearest pair would be a's last and b's first).
|
||||
s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap();
|
||||
let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();
|
||||
assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]);
|
||||
let r = rect(&mut s, 0.0, 50.0, 10.0, 10.0);
|
||||
for bad in [
|
||||
json!({"ids": [a.0, a.0]}),
|
||||
json!({"ids": [a.0, 999]}),
|
||||
json!({"ids": [a.0, r.0], "ends": ["first"]}),
|
||||
json!({"ids": [a.0, b.0], "ends": ["first", "middle"]}),
|
||||
] {
|
||||
assert!(s.execute("path.join", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -483,7 +483,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenConvert => C::Crosshair,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => {
|
||||
C::Crosshair
|
||||
}
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
|
||||
@@ -84,6 +84,12 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
}
|
||||
"-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)),
|
||||
"/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)),
|
||||
// Join: a segment running into a square end point.
|
||||
"j" => {
|
||||
line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5));
|
||||
let sq = [(3.0, -1.0), (7.0, -1.0), (7.0, 3.0), (3.0, 3.0)].map(|(x, y)| b + vec2(x, y));
|
||||
poly(p, sq.to_vec(), INK, INK);
|
||||
}
|
||||
"^" => {
|
||||
line(p, b + vec2(0.0, 6.0), b + vec2(3.0, 0.0));
|
||||
line(p, b + vec2(3.0, 0.0), b + vec2(6.0, 6.0));
|
||||
@@ -270,6 +276,7 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::PenDelete => pen(ink, p, "-"),
|
||||
Cursor::PenClose => pen(ink, p, "o"),
|
||||
Cursor::PenContinue => pen(ink, p, "/"),
|
||||
Cursor::PenJoin => pen(ink, p, "j"),
|
||||
Cursor::PenConvert => pen(ink, p, "^"),
|
||||
Cursor::Text => ibeam(ink, p),
|
||||
Cursor::AddStop => stop_badge(ink, p, true),
|
||||
@@ -425,7 +432,7 @@ mod tests {
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 32] = [
|
||||
const GLYPHS: [Cursor; 33] = [
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
@@ -441,6 +448,7 @@ mod tests {
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenJoin,
|
||||
Cursor::PenConvert,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
|
||||
+2
-1
@@ -1150,7 +1150,8 @@ or pulls handles out in line with the neighbouring anchors (a smooth anchor keep
|
||||
(Cut Path at Selected Anchor Points) cuts there and answers `{ids}`: a closed path opens at the cut, its two ends
|
||||
on top of each other, and an open path becomes one path per piece. Each cut leaves one of its two anchors selected,
|
||||
so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect
|
||||
Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
|
||||
Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last", "first"]}` joins the ends asked for
|
||||
(the Pen's join, #776: drawing on from one open path, a click on another's end makes them one path). `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
|
||||
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
|
||||
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id,
|
||||
subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
|
||||
|
||||
Reference in new issue
Block a user