From 7feeb73a811bfa8d82e8b16364985697aeca691f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:41:35 +0700 Subject: [PATCH] Pen: Auto Add/Delete reuses the Add/Delete Anchor Point tools' hit tests (review of #312) The Pen's Auto Add/Delete had its own copy of the anchor and segment hit tests and of the path.insertAnchor / path.removeAnchor actions the Add and Delete Anchor Point tools build. draw2 now exposes anchor_in / segment_in (the hit tests over a given set of paths) and insert_anchor / remove_anchor, which both tools use. Adds the ROADMAP entry. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 ++--- crates/tools/src/draw2.rs | 27 +++++++++++++++++++--- crates/tools/src/draw2/anchor.rs | 12 +++------- crates/tools/src/pen.rs | 39 +++++++++----------------------- 4 files changed, 41 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c0ed7e9c..53725bf9 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/tools/src/draw2.rs b/crates/tools/src/draw2.rs index c1e410e6..152ca38a 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 7a623652..6f22bdab 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/pen.rs b/crates/tools/src/pen.rs index ea11aea6..7ab69e68 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -13,7 +13,7 @@ use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; -use vectorcraft_geom::{BezPath, PathData, Point}; +use vectorcraft_geom::{BezPath, Point}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -253,32 +253,15 @@ fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option = cx - .selection - .objects - .iter() - .filter(|id| cx.doc.is_editable(**id)) - .filter_map(|&id| match &cx.doc.node(id)?.kind { - NodeKind::Path { path, guide: false, .. } => Some((id, path)), - _ => None, - }) - .collect(); - let mut best: Option<(NodeId, usize, usize, f64)> = None; - for (id, path) in &paths { - for (si, ai, a) in path.anchors() { - let d = a.p.distance(p); - if d <= tol && best.is_none_or(|b| d < b.3) { - best = Some((*id, si, ai, d)); - } - } - } - if let Some((id, si, ai, _)) = best { - return Some(Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))); - } - paths.iter().find_map(|(id, path)| { - let (si, seg, t, _, d) = path.nearest(p)?; - (d <= tol).then(|| Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))) - }) + 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 @@ -486,7 +469,7 @@ mod tests { 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 = PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)); + 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]);