From 1b3a03316ebf59bede4904999a5e9b8870294b1f Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:22:09 -0700 Subject: [PATCH 1/2] Fix #314: Pen: clicking the last anchor retracts its handle While drawing, a click on the last anchor appended a second anchor at the same spot, keeping the old outgoing handle, so the path got a zero-length curve that showed as a spike. Now a click there retracts the anchor's outgoing handle (the next segment leaves it as a corner) and a drag pulls a new outgoing handle out on its own, with a convert cursor (^) over it, as in the reference app. A one-anchor path no longer closes on itself when its anchor is clicked again. Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/lib.rs | 2 + crates/tools/src/pen.rs | 108 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/cursors.rs | 5 ++ 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 5fa358fd7..7b948fc8d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -322,6 +322,8 @@ pub enum Cursor { PenDelete, PenClose, PenContinue, + /// Over the last anchor of the path being drawn: a click retracts its outgoing handle. + PenConvert, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index e0be018f0..93c53607f 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -3,7 +3,8 @@ //! 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 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. //! 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. On a selected blend's spine a click adds a //! point (on a point no key object sits on: deletes it). @@ -24,6 +25,9 @@ pub struct PenTool { in_h: Point, /// The pointer at the last button-down or drag: Space held moves the anchor as far as it moves. last: Point, + /// The last anchor of the path being drawn, while a click retracts its outgoing handle or a + /// drag pulls a new one out: (path, subpath, anchor, its position). + handle: Option<(NodeId, usize, usize, Point)>, } /// The open path the pen is extending: the single selected open path. @@ -43,12 +47,28 @@ fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { Some((id, first, last.p, last.h_out)) } +/// 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()?; + let si = pd.subpaths.len().checked_sub(1)?; + let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?; + Some((si, ai)) +} + +/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone. +fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action { + Action::Preview( + "path.setHandle".into(), + json!({"id": id.0, "subpath": si, "anchor": ai, "which": "out", "x": h.x, "y": h.y, "independent": true}), + ) +} + impl Tool for PenTool { fn id(&self) -> &'static str { "pen" } fn busy(&self) -> bool { - self.drag.is_some() + self.drag.is_some() || self.handle.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let exclude: Vec = if self.drawing { cx.selection.objects.clone() } else { vec![] }; @@ -69,10 +89,18 @@ impl Tool for PenTool { if ev.mods.shift { p = last + vectorcraft_geom::constrain_angle(p - last, 45.0); } - if p.distance(first) <= tol { + 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)); return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; } + if p.distance(last) <= tol + && let Some((si, ai)) = end + { + self.handle = Some((id, si, ai, last)); + return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; + } self.drag = Some((p, 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}))]; @@ -95,6 +123,13 @@ impl Tool for PenTool { vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { + if let Some((id, si, ai, a)) = self.handle { + let mut h = ev.pos; + if ev.mods.shift { + h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0); + } + return vec![set_out_handle(id, si, ai, h)]; + } let Some((mut a, closing)) = self.drag else { return vec![] }; // Space held moves the anchor being placed, handles and all. if ev.mods.space && !closing { @@ -133,6 +168,9 @@ impl Tool for PenTool { )] } PointerKind::Up => { + if self.handle.take().is_some() { + return vec![Action::Commit]; + } let Some((_, closing)) = self.drag.take() else { return vec![] }; if closing { self.drawing = false; @@ -147,6 +185,7 @@ impl Tool for PenTool { ToolKey::Enter | ToolKey::Escape => { self.drawing = false; self.drag = None; + self.handle = None; vec![] } _ => vec![], @@ -157,7 +196,7 @@ impl Tool for PenTool { vec![] } fn overlays(&self, cx: &ToolContext) -> Vec { - if !self.drawing || self.drag.is_some() { + if !self.drawing || self.drag.is_some() || self.handle.is_some() { return vec![]; } let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] }; @@ -179,10 +218,14 @@ impl Tool for PenTool { _ => {} } } - if let Some((_, first, last, _)) = active_path(cx) { - if self.drawing && p.distance(first) <= cx.tol(5.0) { + if let Some((id, first, last, _)) = active_path(cx) { + let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0); + if self.drawing && p.distance(first) <= cx.tol(5.0) && !one { return Cursor::PenClose; } + if self.drawing && p.distance(last) <= cx.tol(5.0) { + return Cursor::PenConvert; + } if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) { return Cursor::PenContinue; } @@ -304,6 +347,59 @@ mod tests { ); } + /// A document with an open path of `anchors`, selected, being drawn by a Pen. + fn drawing(anchors: vectorcraft_geom::SubPath) -> (vectorcraft_doc::Document, NodeId, Selection) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(anchors); + 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]); + (d, id, s) + } + + fn set_out(id: NodeId, ai: usize, x: f64, y: f64) -> Action { + Action::Preview("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": ai, "which": "out", "x": x, "y": y, "independent": true})) + } + + #[test] + fn clicking_the_last_anchor_retracts_its_handle_and_dragging_pulls_a_new_one() { + // The last anchor is smooth: handles at 40 and 80 either side of (60, 300). + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false); + sp.anchors[1].h_in = Point::new(40.0, 300.0); + sp.anchors[1].h_out = Point::new(80.0, 300.0); + let (d, id, s) = drawing(sp); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(61.0, 301.0), Mods::default()), Cursor::PenConvert); + // A click retracts the outgoing handle and adds no anchor. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 61.0, 301.0)); + assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 61.0, 301.0)), vec![Action::Commit]); + // A drag pulls a new outgoing handle out. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 60.0, 300.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 280.0)), vec![set_out(id, 1, 70.0, 280.0)]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 70.0, 280.0)), vec![Action::Commit]); + // Elsewhere a click still adds an anchor, and the first anchor still closes the path. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 400.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 400.0)); + assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose); + } + + #[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)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenConvert); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 300.0)); + assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 0, 10.0, 300.0)]); + } + #[test] fn clicking_a_selected_blend_spine_adds_a_point() { let (mut d, _) = doc_with_rect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 298d1f95b..9d8fa8e7f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -399,7 +399,7 @@ 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 => C::Crosshair, + Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenConvert => C::Crosshair, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 7ac1903b2..c80588aa5 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -61,6 +61,10 @@ fn pen(p: &Painter, 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)), + "^" => { + 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)); + } "*" => { line(p, b + vec2(0.0, 0.0), b + vec2(6.0, 6.0)); line(p, b + vec2(6.0, 0.0), b + vec2(0.0, 6.0)); @@ -204,6 +208,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { Cursor::PenDelete => pen(painter, p, "-"), Cursor::PenClose => pen(painter, p, "o"), Cursor::PenContinue => pen(painter, p, "/"), + Cursor::PenConvert => pen(painter, p, "^"), Cursor::Text => ibeam(painter, p), Cursor::AddStop => stop_badge(painter, p, true), Cursor::RemoveStop => stop_badge(painter, p, false), From 0830ad68c3935047be5c903cdee43a078d9bfc37 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:41:03 +0700 Subject: [PATCH 2/2] Pen: ROADMAP entry for clicking the last anchor (review of #330) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5968cfb37..c0ed7e9c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. - - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. + - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.