Files
vectorcraft/crates/tools/src/draw2.rs
T
bflatasticandClaude Opus 5.5 aefd961bbd Fix #494: Pen + Cmd edits the path being drawn; Pen + Alt reshapes segments; handle drags fixed and fast
Reported: with the Pen there was no way to edit curves in place as Ctrl does in Illustrator on
Windows (Cmd on the Mac), segments couldn't be grabbed, and the curve-editing tools felt slow and
sometimes didn't take a handle.

- Cmd/Ctrl held at a press with any tool but a selection tool now borrows the selection tool in the
  engine (Session::pointer), not in the canvas: the selection tool chosen last, or Direct Selection
  for the Pen, Curvature and anchor tools until one is chosen. The tool lent to isn't deactivated,
  so the Pen goes on drawing its path after the release while that path stays selected; it no
  longer draws on another open path selected meanwhile. Agents reach it with pointer_gesture
  mods.cmd. With Cmd held the pointer is the borrowed tool's. Choosing a tool or switching
  documents mid-gesture gives the tool back first.
- Pen + Alt over a segment of a selected path reshapes it, as the Anchor Point tool does.
- The handles shown are the handles that drag (one helper for Direct Selection, the Anchor Point
  tool and Pen + Alt): Direct Selection now drags the handles of a path selected as a whole (as
  the Pen leaves it, so Cmd + drag of a handle while drawing works), the Anchor Point tool no
  longer grabs handles that Show handles when multiple anchors are selected hides, the nearest
  handle wins, and a handle shorter than the tolerance leaves its anchor to be picked. The anchor
  tools pick within Selection & Anchor Display > Tolerance instead of a fixed 4 px.
- path.reshapeSegment keeps a smooth anchor at either end smooth (its other handle turns with the
  moved one) instead of kinking it.
- A dragged handle gathered its smart guide targets from the whole document on every pointer move
  (21 ms per move on 20,000 paths): HandleSnap now gathers them once per drag (1 ms).
  vectorcraft-cli perf measures handle and segment drags on a 5,000-anchor path.
- catalog::is_selection_tool and catalog::edits_anchors replace the UI's own lists.
- Tests in tools, engine and MCP (headless pointer_gesture); docs/mcp.md, control-protocol.md,
  ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:00:13 +07:00

191 lines
6.5 KiB
Rust

//! Drawing and path-editing tools (pencil, curvature, anchor tools, scissors, knife, line family…).
//!
//! Like every tool these only emit [`crate::Action`]s; the geometry work happens in the engine's
//! `path.*` commands (`crates/engine/src/cmd/draw2.rs`), so gestures replay through the journal.
mod anchor;
mod curvature;
mod family;
mod gesture;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;
/// Colour of tool feedback (Illustrator's selection blue).
pub const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// Create a drawing tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser" | "join" => Box::new(GestureTool::new(id)),
"curvature" => Box::new(CurvatureTool::default()),
"addAnchor" | "deleteAnchor" | "anchorPoint" | "scissors" => Box::new(AnchorTool::new(id)),
"arc" | "spiral" | "rectangularGrid" | "polarGrid" => Box::new(FamilyTool::new(id)),
_ => return None,
})
}
/// A spline through `points` (Catmull-Rom converted to cubic Béziers). Points flagged `true` are
/// corners (no handles); open ends have no handles either. Used by the Curvature tool and by the
/// engine's freehand fitting.
pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath {
let n = points.len();
let mut anchors = Vec::with_capacity(n);
for i in 0..n {
let (p, corner) = points[i];
let ends = !closed && (i == 0 || i + 1 == n);
if corner || ends || n < 3 {
anchors.push(Anchor::corner(p));
continue;
}
let prev = points[(i + n - 1) % n].0;
let next = points[(i + 1) % n].0;
let t = (next - prev) / 6.0;
if t.hypot() < 1e-9 {
anchors.push(Anchor::corner(p));
continue;
}
anchors.push(Anchor { p, h_in: p - t, h_out: p + t, kind: AnchorKind::Smooth });
}
SubPath::new(anchors, closed && n > 2)
}
/// `[[x, y], …]` for command params.
pub fn points_json(pts: &[Point]) -> Value {
Value::Array(pts.iter().map(|p| json!([p.x, p.y])).collect())
}
/// A polyline preview path.
pub fn polyline(pts: &[Point]) -> BezPath {
let mut bp = BezPath::new();
if let Some(f) = pts.first() {
bp.move_to(*f);
for p in &pts[1..] {
bp.line_to(*p);
}
}
bp
}
/// Editable path nodes, selected ones first, then the rest top-most first.
fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| n.path_data().is_some())).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if let NodeKind::Path { guide: false, .. } = &n.kind {
rest.push(n.id);
}
});
rest.reverse();
for id in rest {
if !v.contains(&id) {
v.push(id);
}
}
v.retain(|id| cx.doc.is_editable(*id));
v
}
/// 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<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
let mut best: Option<((NodeId, usize, usize), f64)> = None;
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);
if d <= tol && best.is_none_or(|b| d < b.1 - 1e-9) {
best = Some(((id, si, ai), d));
}
}
if best.is_some() {
break;
}
}
best.map(|b| b.0)
}
/// 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)> {
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<Item = NodeId>, 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
{
return Some((id, si, seg, t));
}
}
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::*;
#[test]
fn catmull_rom_passes_through_points() {
let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)];
let sp = catmull_rom(&pts, false);
assert_eq!(sp.anchors.len(), 3);
assert_eq!(sp.anchors[1].p, Point::new(50.0, 50.0));
assert_eq!(sp.anchors[1].kind, AnchorKind::Smooth);
// The middle tangent is horizontal (parallel to first → last).
assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9);
assert!(!sp.anchors[0].has_out());
}
#[test]
fn all_tools_exist() {
for id in [
"pencil",
"paintbrush",
"blobBrush",
"eraser",
"knife",
"smooth",
"pathEraser",
"join",
"curvature",
"addAnchor",
"deleteAnchor",
"anchorPoint",
"scissors",
"arc",
"spiral",
"rectangularGrid",
"polarGrid",
] {
assert_eq!(create(id).unwrap().id(), id);
}
assert!(create("selection").is_none());
}
}