diff --git a/ROADMAP.md b/ROADMAP.md index c0ed7e9c9..53725bf9f 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. 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. + - **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. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. - **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. @@ -153,7 +153,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, the remaining Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | @@ -185,7 +185,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | +| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7dbe71c1c..132ec54c0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -252,6 +252,33 @@ fn pen_tool_draws_closed_path() { assert!(p.is_closed()); } +/// Auto Add/Delete: a Pen click on a selected path's segment adds an anchor, unless General → +/// Disable Auto Add/Delete is on. +#[test] +fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() { + let mut s = session(); + let v = ViewInfo::default(); + let r = s.execute("path.create", &json!({"anchors": [{"x": 10, "y": 100}, {"x": 110, "y": 100}, {"x": 210, "y": 100}]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.all", &json!({})).unwrap(); + s.select_tool("pen", v).unwrap(); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 101.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 101.0), v).unwrap(); + }; + click(&mut s); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers[0].children().unwrap().len(), 1); + assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 4); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("prefs.set", &json!({"key": "disableAutoAddDelete", "value": true})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + click(&mut s); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers[0].children().unwrap().len(), 2, "a new path starts"); + assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3); +} + #[test] fn direct_selection_moves_one_anchor() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 27402ec14..1f095a9d2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -138,6 +138,7 @@ impl Session { paste_plain_text: self.prefs.paste_text_formatting == "plain", slices_hidden: self.menu.slices_hidden, slices_locked: self.menu.slices_locked, + auto_add_delete: !self.prefs.disable_auto_add_delete, screen: view.screen, plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position), }; diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index c1e410e66..152ca38ac 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath}; -use crate::{Tool, ToolContext}; +use crate::{Action, Tool, ToolContext}; pub use anchor::AnchorTool; pub use curvature::CurvatureTool; @@ -96,8 +96,13 @@ fn candidate_paths(cx: &ToolContext) -> Vec { /// Nearest anchor within `tol`: (path, subpath, anchor). pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> { + anchor_in(cx, candidate_paths(cx), p, tol) +} + +/// Nearest anchor within `tol` on the first of `ids` that has one: (path, subpath, anchor). +pub(crate) fn anchor_in(cx: &ToolContext, ids: impl IntoIterator, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> { let mut best: Option<((NodeId, usize, usize), f64)> = None; - for id in candidate_paths(cx) { + for id in ids { let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue }; for (si, ai, a) in pd.anchors() { let d = a.p.distance(p); @@ -114,7 +119,13 @@ pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId /// Nearest point on a path segment within `tol`: (path, subpath, segment, t). pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> { - for id in candidate_paths(cx) { + segment_in(cx, candidate_paths(cx), p, tol) +} + +/// Nearest point within `tol` on a segment of the first of `ids` that passes there: (path, +/// subpath, segment, t). +pub(crate) fn segment_in(cx: &ToolContext, ids: impl IntoIterator, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> { + for id in ids { let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue }; if let Some((si, seg, t, _, d)) = pd.nearest(p) && d <= tol @@ -125,6 +136,16 @@ pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeI None } +/// Add an anchor where [`hit_segment`] or [`segment_in`] found a segment. +pub(crate) fn insert_anchor((id, si, seg, t): (NodeId, usize, usize, f64)) -> Action { + Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t})) +} + +/// Delete the anchor [`hit_anchor`] or [`anchor_in`] found. +pub(crate) fn remove_anchor((id, si, ai): (NodeId, usize, usize)) -> Action { + Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai})) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 7a6236521..6f22bdab9 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -10,7 +10,7 @@ use serde_json::json; use vectorcraft_doc::NodeId; use vectorcraft_geom::Point; -use super::{hit_anchor, hit_segment}; +use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor}; use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Clone, Copy, Debug)] @@ -38,17 +38,11 @@ impl AnchorTool { } fn add(cx: &ToolContext, p: Point) -> Vec { - match hit_segment(cx, p, cx.tol(4.0)) { - Some((id, si, seg, t)) => vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))], - None => vec![], - } + hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect() } fn delete(cx: &ToolContext, p: Point) -> Vec { - match hit_anchor(cx, p, cx.tol(4.0)) { - Some((id, si, ai)) => vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))], - None => vec![], - } + hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect() } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 7b948fc8d..df3b90a02 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -250,6 +250,9 @@ pub struct ToolContext<'a> { pub slices_hidden: bool, /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. pub slices_locked: bool, + /// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's + /// segment and deletes one of its anchors. + pub auto_add_delete: bool, /// The document window (none headless): screen-fixed widgets sit in it. pub screen: Option, /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. @@ -520,6 +523,7 @@ pub(crate) mod testutil { paste_plain_text: false, slices_hidden: false, slices_locked: false, + auto_add_delete: true, screen: None, plane_widget: Some(Default::default()), } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 93c53607f..7ab69e681 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -6,8 +6,10 @@ //! 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). +//! it. The rubber-band preview shows the next segment. 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). use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; @@ -118,6 +120,9 @@ impl Tool for PenTool { } return vec![]; } + if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { + return vec![act]; + } self.drawing = true; self.drag = Some((p, false)); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] @@ -210,7 +215,7 @@ impl Tool for PenTool { let c = cx.doc.layer_color(id); vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }] } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { if !self.drawing { match spine_click(cx, p, cx.tol(5.0)).as_deref() { Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete, @@ -230,10 +235,35 @@ impl Tool for PenTool { return Cursor::PenContinue; } } + if !self.drawing { + match auto_add_delete(cx, p, m, cx.tol(5.0)) { + Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete, + Some(_) => return Cursor::PenAdd, + None => {} + } + } Cursor::Pen } } +/// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes +/// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the +/// preference turns it off, Shift is held, or no selected path is there. +fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option { + if !cx.auto_add_delete || m.shift { + return None; + } + let paths = || { + cx.selection + .objects + .iter() + .copied() + .filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. }))) + }; + let anchor = crate::draw2::anchor_in(cx, paths(), p, tol).map(crate::draw2::remove_anchor); + anchor.or_else(|| crate::draw2::segment_in(cx, paths(), p, tol).map(crate::draw2::insert_anchor)) +} + /// A click on a selected blend's spine: delete the point under `p` when no key object sits on it /// (a key's point does nothing), else add one where the spine passes within `tol`. fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option> { @@ -426,4 +456,49 @@ mod tests { // A key's own point is left alone (the click snaps to the spine line). assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); } + + fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec { + let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m)); + acts + } + + #[test] + fn clicking_a_selected_path_adds_or_deletes_an_anchor() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)]; + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)); + 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 off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) }; + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = PenTool::default(); + // On a segment: add an anchor there. + assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd); + let a = click(&mut t, &cx, 325.0, 351.0, none); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}"); + // On an anchor: delete it. + assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete); + let a = click(&mut t, &cx, 350.0, 302.0, none); + assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]); + // An end still continues the path. + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue); + // Shift, or the preference turned off, starts a new path instead. + let shift = Mods { shift: true, ..Mods::default() }; + assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen); + assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen); + let a = click(&mut t, &off, 350.0, 302.0, none); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); + // While a path is being drawn, a click goes on drawing it. + let a = click(&mut t, &cx, 325.0, 351.0, none); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}"); + let mut t = PenTool::default(); + let a = click(&mut t, &cx, 325.0, 351.0, shift); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}"); + } }