Files
vectorcraft/crates/tools/src/pen.rs
T
bflatasticandClaude Opus 5.5 1db82cbc02 Fix #798: the Curvature tool edits any selected path
The Curvature tool only edited the path it was drawing. It now edits any
selected path, whatever drew it, keeping its shape except where edited:
a click on a point makes it the current (direct-selected) point and a
drag moves it, re-curving only its two segments (the neighbours keep
their handle directions); Alt-click or double-click toggles it smooth or
corner and Delete removes it; a click on a segment adds a smooth point
without changing the shape, and a drag moves it in the same undo step.
With an end of an open path current, clicks go on from it (the old
segments keep their shape) and a click on the other end closes it.

The edits go through a new path.curvatureEdit command (move, insert,
extend, close), path.convertAnchor and path.removeAnchor, one undo step
each. The tool now claims Delete/Backspace while it has a point to
remove, so the key no longer deletes the whole path.

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

781 lines
40 KiB
Rust

//! The Pen tool (P).
//!
//! 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°.
//! A press on the first anchor closes the path, the drag shaping the closing curve. 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.
//! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too)
//! works as the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes
//! 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 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).
//! Each anchor placed snaps to Smart Guides ([`DrawSnap`]): onto the anchors, centres and paths of
//! the other art and of the path being drawn, into line with them, onto the construction guides
//! through the last anchor; with Shift held it slides along its 45° step into line. Hovering shows
//! where the next one would go.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::direct::hit_handle;
use crate::draw2::{AnchorTool, editable_paths};
use crate::guides::{DrawSnap, Leave};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
pub struct PenTool {
/// A path is being drawn...
drawing: bool,
/// ...this one, once the engine made it: another path selected meanwhile isn't drawn on.
path: Option<NodeId>,
/// The anchor a press placed, while the drag shapes its handles.
drag: Option<Place>,
hover: Option<Point>,
/// The incoming handle of the anchor being dragged out, as last previewed.
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 Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
convert: Option<AnchorTool>,
/// Smart Guides for the anchors placed (and the pointer hovering).
snap: DrawSnap,
}
/// An anchor being placed, as the press decided: where it is, the path it goes on (none: the
/// first anchor of a new path) and whether it closes that path (the press was on its first
/// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing
/// preview leaves the path closed, so no longer one the pen draws on.
#[derive(Clone, Copy)]
struct Place {
at: Point,
path: Option<NodeId>,
closing: bool,
}
/// The open path the pen is extending: the single selected open path.
fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
if cx.selection.objects.len() != 1 {
return None;
}
let id = cx.selection.objects[0];
let n = cx.doc.node(id)?;
let NodeKind::Path { path, .. } = &n.kind else { return None };
let sp = path.subpaths.last()?;
if sp.closed {
return None;
}
let first = sp.anchors.first()?.p;
let last = sp.anchors.last()?;
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()?;
let si = pd.subpaths.len().checked_sub(1)?;
let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?;
Some((si, ai))
}
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
/// gesture, within its tolerance.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
let point = cx.point_tol();
m.alt
&& (hit_handle(cx, p, point).is_some()
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some()
|| crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some())
}
/// 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.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let tol = cx.tol(5.0).max(cx.point_tol());
// The path a press began is the selected one by the next event.
if self.drawing && self.path.is_none() {
self.path = active_path(cx).map(|a| a.0);
}
let active = self.active(cx);
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
self.stop();
}
// The path being drawn moves its bounds as it grows: only its anchors and segments pull.
let drawn = active.map(|a| a.0);
let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift));
let p = match ev.kind {
PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()),
PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()),
_ => ev.pos,
};
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
vec![]
}
PointerKind::Down => {
self.last = ev.pos;
if let Some((id, first, last, _)) = active {
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(Place { at: first, path: Some(id), closing: 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)];
}
// 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;
}
self.drag = Some(Place { at: p, path: Some(id), closing: 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}))];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
if let Some(acts) = spine_click(cx, p, tol) {
return acts;
}
// Continue a selected open path when clicking on one of its ends.
if let Some((_, first, last, _)) = active_path(cx)
&& (p.distance(last) <= tol || p.distance(first) <= tol)
{
self.drawing = true;
self.path = active_path(cx).map(|a| a.0);
if p.distance(first) <= tol && p.distance(last) > tol {
return vec![Action::Exec("path.reverse".into(), json!({}))];
}
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];
}
(self.drawing, self.path) = (true, None);
self.drag = Some(Place { at: p, path: None, closing: false });
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
if let Some(t) = &mut self.convert {
return t.pointer(cx, ev);
}
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(Place { at: mut a, path, closing }) = self.drag else { return vec![] };
// Space held moves the anchor being placed, handles and all.
if ev.mods.space && !closing {
let d = ev.pos - self.last;
a += d;
self.in_h += d;
self.drag = Some(Place { at: a, path, closing });
}
self.last = ev.pos;
let mut out_h = ev.pos;
if ev.mods.shift {
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
}
let alt = ev.mods.alt;
let Some(id) = path else {
// First anchor of a new path: re-issue create with handles.
let in_h = a - (out_h - a);
return vec![Action::Preview(
"path.create".into(),
json!({"anchors": [{"x": a.x, "y": a.y, "out": [out_h.x, out_h.y], "in": [in_h.x, in_h.y]}]}),
)];
};
if closing {
return vec![Action::Preview(
"path.close".into(),
json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}),
)];
}
// Alt pressed mid-drag keeps the curve already shaped into the anchor: only the
// outgoing handle follows. Held from the start, the incoming handle stays retracted.
let in_h = if alt { self.in_h } else { a - (out_h - a) };
self.in_h = in_h;
vec![Action::Preview(
"path.appendAnchor".into(),
json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}),
)]
}
PointerKind::Up => {
self.snap.clear();
if let Some(mut t) = self.convert.take() {
return t.pointer(cx, ev);
}
if self.handle.take().is_some() {
return vec![Action::Commit];
}
let Some(place) = self.drag.take() else { return vec![] };
if place.closing {
self.stop();
}
vec![Action::Commit]
}
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.stop();
self.drag = None;
self.handle = None;
self.snap.clear();
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
_ => vec![],
}
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.stop();
self.snap.clear();
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
return t.overlays(cx);
}
let mut o = vec![];
if cx.pen_rubber_band
&& self.drag.is_none()
&& self.handle.is_none()
&& let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover)
{
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
}
o.extend_from_slice(self.snap.guides());
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0).max(cx.point_tol());
if let Some((id, first, last, _)) = self.active(cx) {
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {
return Cursor::PenClose;
}
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;
}
if !self.drawing {
match spine_click(cx, p, tol).as_deref() {
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
Some([_]) => return Cursor::PenAdd,
_ => {}
}
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) {
Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete,
Some(_) => return Cursor::PenAdd,
None => {}
}
}
Cursor::Pen
}
}
impl PenTool {
/// The open path being drawn: the one the pen made or went on with, while it is the single
/// selected open path.
fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id))
}
/// The path is finished.
fn stop(&mut self) {
(self.drawing, self.path) = (false, None);
}
/// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool.
fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
if !alt_converts(cx, ev.pos, ev.mods) {
return None;
}
let mut t = AnchorTool::new("anchorPoint");
let acts = t.pointer(cx, ev);
if !t.busy() {
return None;
}
self.convert = Some(t);
Some(acts)
}
}
/// 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<Action> {
if !cx.auto_add_delete || m.shift {
return None;
}
let anchor = crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, editable_paths(cx), 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<Vec<Action>> {
for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) {
let Some(NodeKind::Blend { children, spec }) = cx.doc.node(id).map(|n| &n.kind) else { continue };
let keys = vectorcraft_doc::live::blend_spine(children, spec).and_then(|(_, a)| a).unwrap_or_default();
if let Some((_, ai, _)) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol) {
if keys.contains(&ai) {
return Some(vec![]);
}
return Some(vec![Action::Exec("object.blend.spine.removeAnchor".into(), json!({"id": id.0, "anchor": ai}))]);
}
if path.nearest(p).is_some_and(|n| n.4 <= tol) {
return Some(vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
#[test]
fn first_click_creates_path() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(a[0], Action::Begin("Pen".into()));
assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create"));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]);
}
#[test]
fn alt_pressed_mid_drag_keeps_the_incoming_handle() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool { drawing: true, ..PenTool::default() };
let alt = Mods { alt: true, ..Mods::default() };
let handles = |acts: Vec<Action>| match acts.as_slice() {
[Action::Preview(c, v)] if c == "path.appendAnchor" => (v["in"].clone(), v["out"].clone()),
other => panic!("not an anchor preview: {other:?}"),
};
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
let drag = PointerEvent::new(PointerKind::Drag, 450.0, 420.0);
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 420.0])));
// Alt goes down: the curve shaped so far stays, and only the outgoing handle moves on.
let drag = PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt);
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 470.0])));
let drag = PointerEvent::new(PointerKind::Drag, 400.0, 480.0).with_mods(alt);
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([400.0, 480.0])));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 400.0, 480.0).with_mods(alt)), vec![Action::Commit]);
// Alt held from the start: the new anchor gets an outgoing handle only.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 440.0).with_mods(alt));
let drag = PointerEvent::new(PointerKind::Drag, 340.0, 440.0).with_mods(alt);
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([300.0, 440.0]), json!([340.0, 440.0])));
}
#[test]
fn space_moves_the_anchor_being_placed() {
let (mut d, _) = doc_with_rect();
let space = Mods { space: true, ..Mods::default() };
// The first anchor of a new path.
let (s, p) = (Selection::default(), paint());
let cx0 = cx(&d, &s, &p);
let mut t = PenTool::default();
t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0));
let created = |acts: Vec<Action>| match acts.as_slice() {
[Action::Preview(c, v)] if c == "path.create" => v["anchors"][0].clone(),
other => panic!("not a create preview: {other:?}"),
};
let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 380.0).with_mods(space)));
assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [450.0, 380.0], "in": [350.0, 380.0]}), "moved, handles and all");
// Space released: the drag shapes the handles round the anchor's new place.
let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 460.0, 380.0)));
assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [460.0, 380.0], "in": [340.0, 380.0]}));
// An anchor added to an open path, its handles broken by Alt: the kept incoming handle
// moves with it.
let l = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let cx = cx(&d, &s, &p);
let mut t = PenTool { drawing: true, ..PenTool::default() };
let alt = Mods { alt: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt));
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt }));
assert_eq!(
acts,
vec![Action::Preview(
"path.appendAnchor".into(),
json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]})
)]
);
}
/// 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);
}
/// #501: a press on the first anchor closes the path, however far the drag then goes: the
/// closing preview leaves the path closed (so not one the pen extends), and each move still
/// previews the close with symmetric handles (Alt breaks them), released as a close.
#[test]
fn a_drag_from_the_first_anchor_always_closes() {
let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)];
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false));
let p = paint();
let mut t = PenTool { drawing: true, ..PenTool::default() };
let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0));
assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]);
// What the tool sees once the preview applied: the path closed.
d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true;
let c = cx(&d, &s, &p);
let close = |x: f64, y: f64, alt: bool| {
vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))]
};
for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})");
}
let alt = Mods { alt: true, ..Mods::default() };
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]);
assert!(!t.busy() && t.active(&c).is_none(), "the path is done");
}
#[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();
let l = d.layers[0].id;
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(
Some(l),
1,
vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }),
)
.unwrap();
let mut s = Selection::default();
s.set([g]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool::default();
assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]);
// 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<Action> {
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:?}");
}
/// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends
/// there), in line with another object's centre; Shift slides it along its 45° step into line.
#[test]
fn anchors_snap_to_smart_guides() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool { drawing: true, ..PenTool::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
let o = t.overlays(&cx);
assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}");
assert!(o.iter().any(
|o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0)))
));
let a = click(&mut t, &cx, 152.0, 318.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0})));
let shift = Mods { shift: true, ..Mods::default() };
let a = click(&mut t, &cx, 152.0, 310.0, shift);
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0})));
// The first anchor of a new path lands on the rect's corner.
let mut t = PenTool::default();
let a = click(&mut t, &cx, 102.0, 99.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0));
let (d, id, mut s) = drawing(sp);
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let t = PenTool::default();
let cx0 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
// A drag of the handle moves it alone, as one gesture.
let mut t = PenTool::default();
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]);
assert!(t.busy());
let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt));
assert_eq!(
a,
vec![
Action::Begin("Reshape".into()),
Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true})
)
]
);
// Alt let go mid-drag: the gesture goes on to its end.
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]);
assert!(!t.busy());
// With other anchors direct-selected the handles of this one are hidden, and not grabbed.
s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)]));
let cx1 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen);
}
/// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does,
/// while drawing too; Alt away from paths still places an anchor.
#[test]
fn alt_over_a_segment_reshapes_it() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::Pen);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
assert_eq!(a[0], Action::Begin("Reshape".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
"{a:?}"
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
}
/// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd
/// drag with Direct Selection), but never another open path selected meanwhile.
#[test]
fn the_pen_draws_on_the_path_it_began_only() {
let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let l = d.layers[0].id;
let b = d.alloc_id();
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0));
d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let p = paint();
let mut t = PenTool { drawing: true, ..PenTool::default() };
let cx_a = cx(&d, &s, &p);
let appended = |acts: &[Action]| match acts {
[Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(),
_ => None,
};
assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0));
// Path B selected instead: the next click starts a new path.
let mut sb = Selection::default();
sb.set([b]);
let cx_b = cx(&d, &sb, &p);
assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen);
let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default());
assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}");
}
}