Merge pull request #517 from storytold/fix/issue-494

Fix #494: Pen + Cmd edits the path being drawn; Pen + Alt reshapes segments; handle drags fixed and fast
This commit is contained in:
BFlat 2026-10-08 22:31:38 +07:00 committed by GitHub
commit 3c91ca8a1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 578 additions and 180 deletions

File diff suppressed because one or more lines are too long

View File

@ -70,6 +70,49 @@ fn load_average() -> Option<f64> {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_geom::{Anchor, PathData, SubPath, Vec2};
let mut doc = synthetic(n)?;
let l = doc.layers.first().map(|l| l.id);
let id = doc.alloc_id();
// Anchors 2 pt apart, up and down by 20 pt, their handles 0.6 pt long.
let anchor = |i: usize| {
let p = Point::new(i as f64 * 2.0, if i.is_multiple_of(2) { 500.0 } else { 520.0 });
Anchor::smooth(p, p + Vec2::new(0.6, 0.0))
};
let path = PathData::single(SubPath::new((0..5000).map(anchor).collect(), false));
doc.insert(l, usize::MAX, Node::path(id, path, Appearance::default_art())).map_err(|e| e.to_string())?;
let mut s = vectorcraft_engine::Session::new();
s.add_document(doc, None);
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut drag = |down: Point| -> Result<f64, String> {
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
s.select_tool("directSelection", view).map_err(err)?;
let undo = s.doc().map_err(err)?.history.undo.len();
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut y = 0.0;
let ms = median_ms(9, || {
y += 3.0;
// A failed move shows as the missing undo step below.
let _ = s.pointer(&ev(PointerKind::Drag, y), view);
});
s.pointer(&ev(PointerKind::Up, y), view).map_err(err)?;
if s.doc().map_err(err)?.history.undo.len() != undo + 1 {
return Err(format!("the drag from {down:?} made no undo step"));
}
Ok(ms)
};
// Anchor 400 is at (800, 500): its outgoing handle's end, then its segment to anchor 401.
let handle = drag(Point::new(800.6, 500.0))?;
let segment = drag(Point::new(801.0, 510.0))?;
Ok((handle, segment))
}
pub fn run(args: &[String]) -> Result<(), String> {
let mut n = 50_000usize;
let mut it = args.iter();
@ -145,6 +188,15 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
});
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
// Path editing: one pointer move of a Direct Selection drag (the tool event and the preview it
// applies), on a 5,000-anchor path among the synthetic paths.
let (handle, segment) = edit_drags(n).unwrap_or_else(|e| {
failed = Some(e);
(0.0, 0.0)
});
rows.push(("handle drag, 5,000-anchor path (per move)", handle, 8.0));
rows.push(("segment drag, 5,000-anchor path (per move)", segment, 8.0));
if let Some(e) = failed {
return Err(e);
}

View File

@ -701,13 +701,16 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v.
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
let a = sp.anchors[i0];
sp.anchors[i0] = Anchor::with_handles(a.p, a.h_in, a.h_out + v);
let b = sp.anchors[i1];
sp.anchors[i1] = Anchor::with_handles(b.p, b.h_in + v, b.h_out);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
Ok(())
})?;
ok()

View File

@ -721,6 +721,12 @@ pub struct Session {
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
pub(crate) tool: Box<dyn Tool>,
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
/// lends the other tools. None until one is chosen.
pub(crate) last_selection_tool: Option<&'static str>,
pub(crate) last_view: ViewInfo,
depth: u32,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
@ -800,6 +806,8 @@ impl Session {
clipboard: Clipboard::default(),
journal: vec![],
tool: vectorcraft_tools::create("selection"),
lender: None,
last_selection_tool: None,
last_view: ViewInfo::default(),
depth: 0,
tool_panic: None,
@ -858,6 +866,8 @@ impl Session {
return;
}
let view = self.last_view;
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
// A batch leaves its interaction open in the document it leaves, to keep or roll back with

View File

@ -57,6 +57,20 @@ fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec<UiRequest> {
drag(s, tool, &[(x, y)], Mods::default())
}
/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the
/// others, release at the last. The undo steps it made.
fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) -> usize {
let v = view();
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
}
/// A wavy stroke sampled every 2 pt.
fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> {
let n = ((x1 - x0) / 2.0) as usize;
@ -622,16 +636,6 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
};
// Alt-drag the out handle: it moves alone, the in handle stays.
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
assert!(!s.in_interaction());
@ -659,3 +663,74 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
assert_eq!(paths(&s).len(), 2);
}
/// The anchor's handles point opposite ways.
fn smooth(a: vectorcraft_geom::Anchor) -> bool {
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
(i.x * o.y - i.y * o.x).abs() < 1e-6 && i.x * o.x + i.y * o.y < 0.0
}
#[test]
fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
// #494: Cmd (Ctrl) held with the Pen drags with the selection tool used last (Direct Selection
// until one is chosen): handles, segments and anchors of the path being drawn; released, the
// Pen goes on drawing that path. Through pointer events, so agents reach it too.
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(100.0, 300.0)], none);
gesture(&mut s, &[(200.0, 300.0), (250.0, 300.0)], none);
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(s.cursor(Point::new(250.0, 300.0), none, v), vectorcraft_tools::Cursor::Pen);
assert_eq!(s.cursor(Point::new(250.0, 300.0), cmd, v), vectorcraft_tools::Cursor::ArrowHollow, "Direct Selection's");
// The new anchor's outgoing handle: it moves, its incoming one turning with it.
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 300.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "directSelection");
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "pen");
let a = path(&s, id).subpaths[0].anchors[1];
assert!(a.h_out == Point::new(250.0, 260.0) && smooth(a), "{a:?}");
// The segment between the two anchors bends, the smooth anchor staying smooth.
let before = path(&s, id).subpaths[0].anchors[1].h_in;
gesture(&mut s, &[(120.0, 300.0), (120.0, 270.0)], cmd);
let a = path(&s, id).subpaths[0].anchors[1];
assert!(a.h_in != before && smooth(a), "{a:?}");
assert_eq!(s.tool_id(), "pen");
// A plain click goes on drawing the same path.
gesture(&mut s, &[(300.0, 350.0)], none);
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 3));
// A Cmd-click away from the art deselects: the path is done, the next click starts another.
gesture(&mut s, &[(600.0, 100.0)], cmd);
gesture(&mut s, &[(400.0, 500.0)], none);
assert_eq!(paths(&s).len(), 2);
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
// With the Selection tool chosen last, Cmd lends it.
s.select_tool("selection", v).unwrap();
s.select_tool("pen", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "selection");
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "pen");
// Choosing a tool mid-gesture keeps it: the Pen doesn't come back over it.
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0), v).unwrap();
assert_eq!(s.tool_id(), "rectangle");
}
#[test]
fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
// #494: the anchors at the ends of a dragged segment keep their kind: a smooth one's other
// handle turns with the moved one (at its length), a corner's stays.
let mut s = session();
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300, "in": [160, 300], "out": [240, 300]}, {"x": 300, "y": 300}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("path.reshapeSegment", &json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -30})).unwrap();
let sp = &path(&s, id).subpaths[0];
let a = sp.anchors[1];
assert!(near(a.h_in, Point::new(160.0, 260.0)) && smooth(a), "{a:?}");
assert!((a.h_out.distance(a.p) - 40.0).abs() < 1e-9, "its length kept");
assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
}

View File

@ -82,6 +82,7 @@ impl Session {
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
self.give_back_tool(view)?;
if self.tool.id() == id {
return Ok(());
}
@ -89,9 +90,47 @@ impl Session {
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
if vectorcraft_tools::catalog::is_selection_tool(id) {
self.last_selection_tool = Some(self.tool.id());
}
Ok(())
}
/// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is,
/// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others.
/// None for the selection tools themselves and the view tools.
fn lent_tool(&self, id: &str) -> Option<&'static str> {
use vectorcraft_tools::catalog::{edits_anchors, is_selection_tool};
if is_selection_tool(id) || matches!(id, "hand" | "zoom" | "rotateView") {
return None;
}
Some(self.last_selection_tool.unwrap_or(if edits_anchors(id) { "directSelection" } else { "selection" }))
}
/// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the
/// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click,
/// not Select Behind). The tool lent to stays as it was, unaware, until the release.
fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent {
if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() {
return *ev;
}
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
let lent = self.make_tool(id);
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
}
/// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the
/// Pen goes on drawing its path).
pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> {
let Some(lender) = self.lender.take() else { return Ok(()) };
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let r = self.apply_actions(acts);
self.keep_tool_settings();
self.tool = lender;
r.map(drop)
}
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
/// session or a saved one.
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
@ -212,13 +251,19 @@ impl Session {
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
let ev = &self.lend_selection_tool(ev);
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.take_tool_panic()?;
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
let r = self.take_tool_panic().and_then(|()| {
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
});
if ev.kind == PointerKind::Up {
self.keep_tool_settings();
self.give_back_tool(view)?;
}
self.apply_actions(acts)
r
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
@ -325,9 +370,20 @@ impl Session {
}
/// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing
/// tools' a crosshair, [`Cursor::precise`]).
/// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
/// press would borrow ([`Self::lend_selection_tool`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods));
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id()) } else { None };
let c = match lent {
Some(id) => {
let lent = self.make_tool(id);
let own = std::mem::replace(&mut self.tool, lent);
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, Mods { cmd: false, ..mods }));
self.tool = own;
c
}
None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)),
};
if self.prefs.use_precise_cursors { c.precise() } else { c }
}

View File

@ -49,6 +49,8 @@ mod tests_links;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_pen;
#[cfg(test)]
mod tests_persp;
#[cfg(test)]
mod tests_place;

View File

@ -0,0 +1,47 @@
//! The Pen over MCP, headless: `pointer_gesture` with the modifiers held as on the desktop.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
/// A press at the first point, drags through the others, the release at the last.
fn gesture(pts: &[(f64, f64)]) -> Value {
let (first, last) = (pts[0], pts[pts.len() - 1]);
let mut events = vec![json!({"kind": "down", "x": first.0, "y": first.1})];
events.extend(pts[1..].iter().map(|(x, y)| json!({"kind": "drag", "x": x, "y": y})));
events.push(json!({"kind": "up", "x": last.0, "y": last.1}));
json!({ "events": events })
}
/// The anchors of each path, in paint order.
fn paths(h: &Headless) -> Vec<Vec<vectorcraft_geom::Anchor>> {
let mut v = vec![];
h.session.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
v.push(p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect());
}
});
v
}
/// #494: Cmd held at the press borrows Direct Selection from the Pen; the path goes on afterwards.
#[test]
fn cmd_with_the_pen_edits_the_path_being_drawn() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(100.0, 300.0)])));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(200.0, 300.0), (250.0, 300.0)])));
let mut drag = gesture(&[(250.0, 300.0), (250.0, 280.0), (250.0, 263.0)]);
drag["mods"] = json!({"cmd": true});
let r = text(&call_tool(&mut h, "pointer_gesture", &drag));
assert_eq!(r["tool"], "pen", "{r}");
assert_eq!(paths(&h)[0][1].h_out, vectorcraft_geom::Point::new(250.0, 263.0));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(300.0, 350.0)])));
assert_eq!(paths(&h).iter().map(Vec::len).collect::<Vec<_>>(), [3], "the same path, three anchors");
}

View File

@ -154,6 +154,17 @@ pub fn group_of(id: &str) -> Option<usize> {
TOOL_GROUPS.iter().position(|g| g.iter().any(|t| t.id == id))
}
/// Selection, Direct Selection and Group Selection: the tools Cmd held lends the others for a drag.
pub fn is_selection_tool(id: &str) -> bool {
matches!(id, "selection" | "directSelection" | "groupSelection")
}
/// The tools that show a selected path's anchors and handles as Direct Selection does, and drag
/// them: a Cmd drag with one of them is a Direct Selection drag until a selection tool is chosen.
pub fn edits_anchors(id: &str) -> bool {
matches!(id, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature")
}
#[cfg(test)]
mod tests {
use super::*;
@ -178,5 +189,7 @@ mod tests {
assert_eq!(tool_for_shortcut("V").unwrap().id, "selection");
assert_eq!(tool_for_shortcut("Shift+M").unwrap().id, "shapeBuilder");
assert_eq!(group_of("star"), group_of("rectangle"));
assert!(is_selection_tool("groupSelection") && !is_selection_tool("pen"));
assert!(edits_anchors("pen") && !edits_anchors("rectangle"));
}
}

View File

@ -28,11 +28,11 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, Rect};
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{PointSnap, Targets};
use crate::guides::{HandleSnap, PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
@ -112,6 +112,8 @@ pub struct DirectSelectionTool {
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// What the dragged handle snaps to.
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
@ -128,6 +130,7 @@ impl DirectSelectionTool {
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
hover: None,
guide: GuideEdit::default(),
@ -217,30 +220,37 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
})
}
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, handles show
/// (and drag) only while a single anchor is selected.
pub fn handles_shown(selection: &vectorcraft_doc::Selection, multiple: bool) -> bool {
multiple || selection.anchors.values().map(|set| set.len()).sum::<usize>() <= 1
/// The anchors whose direction handles show, and drag, with the tools that edit anchors
/// ([`crate::catalog::edits_anchors`]): the direct-selected anchors of a path, every anchor of a
/// path selected as a whole. Selection & Anchor Display → Show handles when multiple anchors are
/// selected off, none while more than one anchor is selected.
fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
let mut v = vec![];
for &id in &cx.selection.objects {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
let shown = cx.selection.partial(id);
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| (id, si, ai, *a)));
}
if !cx.handles_multiple && v.len() > 1 {
v.clear();
}
v
}
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
if !handles_shown(cx.selection, cx.handles_multiple) {
return None;
}
for (id, set) in &cx.selection.anchors {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for &(si, ai) in set {
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
/// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and
/// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path,
/// subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
let mut best: Option<((NodeId, usize, usize, bool), f64)> = None;
for (id, si, ai, a) in handle_anchors(cx) {
for (out, has, h) in [(true, a.has_out(), a.h_out), (false, a.has_in(), a.h_in)] {
let d = h.distance(p);
if has && d <= tol && d < a.p.distance(p) && best.is_none_or(|b| d < b.1) {
best = Some(((id, si, ai, out), d));
}
}
}
None
best.map(|b| b.0)
}
fn anchors_json(v: &[AnchorRef]) -> Value {
@ -335,6 +345,7 @@ impl Tool for DirectSelectionTool {
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
self.handle_snap = HandleSnap::default();
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
@ -471,7 +482,7 @@ impl Tool for DirectSelectionTool {
out
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
vec![Action::Preview(
"path.setHandle".into(),
@ -871,6 +882,61 @@ mod tests {
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
}
/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the
/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles
/// only otherwise; none with Show handles when multiple anchors are selected off and several
/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and
/// the tolerance is the preference's, in screen pixels at any zoom.
#[test]
fn the_handles_shown_are_the_handles_dragged() {
let (d, arch) = arch_doc();
let p = paint();
// Does a press at (x, y) take a handle?
let handle = |c: &ToolContext, x: f64, y: f64| {
DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())]
};
let mut whole = Selection::default();
whole.set([arch]);
let c = cx(&d, &whole, &p);
assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole");
let mut t = DirectSelectionTool::new(false);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0));
assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}");
// Only the second anchor direct-selected: the first one's handle is hidden.
let second = anchors_of(arch, &[(0, 1)]);
assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0));
assert!(handle(&cx(&d, &second, &p), 201.0, 251.0));
// Show handles when multiple anchors are selected off: the whole path's are hidden.
assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0));
assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0));
// The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%.
let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) };
assert!(!handle(&zoomed, 101.0, 251.0));
assert!(handle(&zoomed, 100.5, 250.5));
assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0));
}
/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor.
#[test]
fn a_short_handle_leaves_its_anchor_to_be_picked() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
sp.anchors[0].h_out = Point::new(102.0, 300.0);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
let s = anchors_of(id, &[(0, 0)]);
let p = paint();
let c = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected");
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0));
assert_eq!(a[0], Action::Begin("Move".into()));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
}
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, a) = doc_with_rect();

View File

@ -15,7 +15,6 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub(crate) use anchor::hit_handle;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;

View File

@ -12,6 +12,8 @@ use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
use crate::direct::hit_handle;
use crate::guides::HandleSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug)]
@ -25,8 +27,9 @@ enum State {
pub struct AnchorTool {
id: &'static str,
state: State,
/// Smart guides of the handle being dragged.
/// Smart guides of the handle being dragged, and what it snaps to.
guides: Vec<Overlay>,
snap: HandleSnap,
}
impl AnchorTool {
@ -37,36 +40,18 @@ impl AnchorTool {
"scissors" => "scissors",
_ => "addAnchor",
};
Self { id, state: State::Idle, guides: vec![] }
Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default() }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect()
hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect()
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect()
hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect()
}
}
/// A direction handle of a selected path under `p`, among those shown (a path with anchors
/// direct-selected shows theirs only): (id, subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let shown = cx.selection.partial(*id);
for (si, ai, a) in pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))) {
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
None
}
impl Tool for AnchorTool {
fn id(&self) -> &'static str {
self.id
@ -76,7 +61,7 @@ impl Tool for AnchorTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.tol(4.0);
let tol = cx.pick_tol();
match (self.id, ev.kind) {
("addAnchor", PointerKind::Down) => {
if ev.mods.alt {
@ -104,6 +89,7 @@ impl Tool for AnchorTool {
("anchorPoint", PointerKind::Down) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out, began: false };
self.snap = HandleSnap::default();
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
@ -134,7 +120,7 @@ impl Tool for AnchorTool {
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
),
State::Handle { id, si, ai, out, .. } => {
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
let (q, guides) = self.snap.snap(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
Action::Preview(
"path.setHandle".into(),
@ -192,7 +178,7 @@ impl Tool for AnchorTool {
"deleteAnchor" if !m.alt => Cursor::PenDelete,
"deleteAnchor" => Cursor::PenAdd,
"scissors" => {
if hit_segment(cx, p, cx.tol(4.0)).is_some() {
if hit_segment(cx, p, cx.pick_tol()).is_some() {
Cursor::Crosshair
} else {
Cursor::NotAllowed
@ -257,4 +243,38 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0));
}
/// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags
/// only the handles shown.
#[test]
fn anchor_point_picks_within_the_tolerance_and_drags_shown_handles() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 300.0));
let path = vectorcraft_geom::PathData::single(sp);
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 press = |c: &ToolContext, x: f64, y: f64| {
let mut t = AnchorTool::new("anchorPoint");
t.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y + 30.0))
};
let dragged = |a: Vec<Action>| match a.as_slice() {
[Action::Begin(_), Action::Preview(c, _)] => c.clone(),
_ => String::new(),
};
// The segment 6 px off: picked with an 8 px tolerance, not with the default 3 px.
assert_eq!(dragged(press(&cx(&d, &s, &p), 150.0, 306.0)), "");
let wide = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
assert_eq!(dragged(press(&wide, 150.0, 306.0)), "path.reshapeSegment");
// The middle anchor's handle drags; with Show handles when multiple anchors are selected
// off it's hidden (three anchors selected) and the press finds the segment under it.
assert_eq!(dragged(press(&cx(&d, &s, &p), 240.0, 301.0)), "path.setHandle");
let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) };
assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment");
}
}

View File

@ -553,25 +553,35 @@ impl PointSnap {
}
}
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
/// pointer at `p`: with Shift at a multiple of 45° (from the Constrain Angle) round its anchor,
/// else snapped as a drawn point is (smart guides, the grid, pixels).
pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
snap_with(cx, p, || {
// The path's bounds move with the handle, so they would chase it: its anchors (which stay
// put) are the targets instead, the handle lining up with them too.
let mut t = Targets::collect(cx.doc, &[id], None);
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
t.points.push((a.p, Kind::Anchor));
t.xs.push((a.p.x, a.p, Kind::Anchor));
t.ys.push((a.p.y, a.p, Kind::Anchor));
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
/// snaps as a drawn point does. The targets are gathered on the first move that needs them and
/// kept for the rest of the drag (gathering them takes as long as the document is big).
#[derive(Default)]
pub struct HandleSnap(Option<PointSnap>);
impl HandleSnap {
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
/// and the guides showing why.
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
t
})
let snap = self.0.get_or_insert_with(|| {
PointSnap::new(cx, || {
// The path's bounds move with the handle, so they would chase it: its anchors
// (which stay put) are the targets instead, the handle lining up with them too.
let mut t = Targets::collect(cx.doc, &[id], None);
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
t.points.push((a.p, Kind::Anchor));
t.xs.push((a.p.x, a.p, Kind::Anchor));
t.ys.push((a.p.y, a.p, Kind::Anchor));
}
t
})
});
snap.snap(cx, p)
}
}
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the

View File

@ -5,9 +5,11 @@
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
//! 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.
//! Alt held over a handle end or an anchor 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 and dragging an anchor pulls out new symmetric handles.
//! 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 the end of a selected open path continues
//! it. 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
@ -18,13 +20,16 @@ use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::draw2::{AnchorTool, hit_handle};
use crate::direct::hit_handle;
use crate::draw2::AnchorTool;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
pub struct PenTool {
/// The path being drawn (set once the first anchor exists and the engine selected it).
/// 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>,
drag: Option<(Point, bool)>,
hover: Option<Point>,
/// The incoming handle of the anchor being dragged out, as last previewed.
@ -63,10 +68,14 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
Some((si, ai))
}
/// Alt held over a handle end or an anchor of a selected path: the Anchor Point tool's gesture.
/// 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 tol = cx.tol(4.0);
m.alt && (hit_handle(cx, p, tol).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some())
let tol = cx.pick_tol();
m.alt
&& (hit_handle(cx, p, tol).is_some()
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some()
|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
@ -97,9 +106,13 @@ impl Tool for PenTool {
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) };
let tol = cx.tol(5.0);
let active = if self.drawing { active_path(cx) } else { None };
// 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.drawing = false;
self.stop();
}
match ev.kind {
PointerKind::Move => {
@ -142,6 +155,7 @@ impl Tool for PenTool {
&& (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!({}))];
}
@ -150,7 +164,7 @@ impl Tool for PenTool {
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
return vec![act];
}
self.drawing = true;
(self.drawing, self.path) = (true, None);
self.drag = Some((p, false));
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
@ -179,7 +193,7 @@ impl Tool for PenTool {
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
}
let alt = ev.mods.alt;
let Some((id, ..)) = active_path(cx).or(active) else {
let Some((id, ..)) = active else {
// First anchor of a new path: re-issue create with handles.
let in_h = a - (out_h - a);
return vec![Action::Preview(
@ -211,7 +225,7 @@ impl Tool for PenTool {
}
let Some((_, closing)) = self.drag.take() else { return vec![] };
if closing {
self.drawing = false;
self.stop();
}
vec![Action::Commit]
}
@ -221,7 +235,7 @@ impl Tool for PenTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.drawing = false;
self.stop();
self.drag = None;
self.handle = None;
// An Alt gesture under way ends as it stands.
@ -231,17 +245,17 @@ impl Tool for PenTool {
}
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.drawing = false;
self.stop();
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);
}
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
if !cx.pen_rubber_band || 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![] };
let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) else { return vec![] };
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
@ -254,10 +268,7 @@ impl Tool for PenTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
let active = active_path(cx);
if self.drawing
&& let Some((id, first, last, _)) = active
{
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;
@ -275,7 +286,7 @@ impl Tool for PenTool {
Some([_]) => return Cursor::PenAdd,
_ => {}
}
if active.is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
return Cursor::PenContinue;
}
match auto_add_delete(cx, p, m, tol) {
@ -289,13 +300,27 @@ impl Tool for PenTool {
}
impl PenTool {
/// Hand an Alt press on a selected path's handle or anchor to the Anchor Point tool.
/// 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)
}
@ -587,4 +612,53 @@ mod tests {
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:?}");
}
}

View File

@ -76,31 +76,19 @@ enum Drag {
start_angle: f64,
start_rot: f64,
},
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
/// A Selection tool move dragged off the canvas: the panels get the art
/// ([`widgets::PanelDrag::Art`]); `temp` restores the tool Cmd switched from on release.
Art {
temp: bool,
},
/// ([`widgets::PanelDrag::Art`]).
Art,
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
/// Where the pointer was last seen during a press on the canvas.
fn drag_pos_id() -> egui::Id {
egui::Id::new("canvas-drag-pos")
}
/// Selection, Direct Selection and Group Selection: the tools Cmd switches to for a drag.
pub(crate) fn is_selection_tool(id: &str) -> bool {
matches!(id, "selection" | "directSelection" | "groupSelection")
}
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
/// until a pen reports it). A mouse presses fully (1).
@ -531,19 +519,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let c = rect.center();
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
} else {
let mut kind = Drag::Tool;
// Cmd with another tool: drag with the selection tool used last.
if m.command && !is_selection_tool(tool) {
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
let last = app.ui.last_selection_tool.clone();
app.select_tool(if is_selection_tool(&last) { &last } else { "selection" });
kind = Drag::TempSelect;
}
// Cmd only brought the selection tool: its click is a plain one, not Select Behind.
let mods = Mods { cmd: m.command && kind != Drag::TempSelect, ..mods(m, space) };
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods, pressure: pen_pressure(ui, true) };
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
dispatch(app, &ev, view);
kind
Drag::Tool
};
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), d);
@ -580,11 +559,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.rotation = vectorcraft_geom::normalize_deg(deg);
}
}
Drag::ZoomBox { .. } | Drag::Art { .. } => {}
Drag::Tool | Drag::TempSelect if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art { temp: d == Drag::TempSelect }));
Drag::ZoomBox { .. } | Drag::Art => {}
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
}
Drag::Tool | Drag::TempSelect => {
Drag::Tool => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
@ -620,17 +599,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
}
Drag::Tool | Drag::TempSelect | Drag::Art { .. } => {
if !matches!(d, Drag::Art { .. }) {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
{
app.restore_tool(&prev);
}
Drag::Tool => {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
Drag::Art => {}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
}
@ -1371,7 +1344,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let app = &*app;
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
// box's handles a point bigger or smaller per step.
let look = HandleLook::of(&app.session.prefs);

View File

@ -540,19 +540,8 @@ impl VectorcraftApp {
/// Select a tool (also used by the toolbar and shortcuts).
pub fn select_tool(&mut self, id: &str) {
self.change_tool(id, true);
}
/// Go back to tool `id` after a temporary one, without choosing it afresh
/// ([`vectorcraft_engine::Session::switch_tool`]).
pub fn restore_tool(&mut self, id: &str) {
self.change_tool(id, false);
}
fn change_tool(&mut self, id: &str, choose: bool) {
let v = self.view_info();
let r = if choose { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) };
if let Err(e) = r {
if let Err(e) = self.session.select_tool(id, v) {
self.ui.status = e.to_string();
}
if let Some(g) = vectorcraft_tools::catalog::group_of(id)
@ -560,9 +549,6 @@ impl VectorcraftApp {
{
*slot = id.to_string();
}
if canvas::is_selection_tool(id) {
self.ui.last_selection_tool = id.to_string();
}
toolbar::remember(self, id);
self.ui.flyout = None;
}

View File

@ -246,7 +246,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Enter with a selection tool opens the Move dialog, as a double-click on its button
// does.
let tool = app.session.tool_id();
if app.ui.dialog.is_none() && crate::canvas::is_selection_tool(tool) {
if app.ui.dialog.is_none() && vectorcraft_tools::catalog::is_selection_tool(tool) {
// Nothing selected: it fails and nothing opens (the menu item is disabled then too).
let _ = crate::toolbar::open_options(app, tool);
}

View File

@ -283,9 +283,6 @@ pub struct UiState {
pub flyout: Option<usize>,
/// Last tool shown for each toolbar group (flyout selection sticks).
pub group_tool: Vec<String>,
/// The selection tool used last (Selection, Direct Selection or Group Selection): a Cmd press
/// with any other tool drags with it.
pub last_selection_tool: String,
pub status: String,
pub palette_open: bool,
pub palette_query: String,
@ -496,7 +493,6 @@ impl Default for UiState {
dialog: None,
flyout: None,
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
last_selection_tool: "selection".into(),
status: String::new(),
palette_open: false,
palette_query: String::new(),

View File

@ -300,7 +300,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
"hand" => app.run("view.fitArtboard", json!({})),
"zoom" => app.run("view.actualSize", json!({})),
"rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => {
let dialog = if crate::canvas::is_selection_tool(tool) { "move" } else { tool };
let dialog = if vectorcraft_tools::catalog::is_selection_tool(tool) { "move" } else { tool };
let id = format!("object.{dialog}");
if let Some(c) = vectorcraft_engine::find_command(&id) {
(c.enabled)(&app.session)?;

View File

@ -22,7 +22,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |

View File

@ -1049,7 +1049,20 @@ on top of each other, and an open path becomes one path per piece. Each cut leav
so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect
Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
held over a selected path's handle or anchor works as the Anchor Point tool.
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id,
subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
point at `t` by `dx`, `dy`: a smooth anchor at either end stays smooth, its other handle turning with the moved one.
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
Curvature and anchor tools until one is chosen; the release gives the tool back as it was, so the Pen goes on drawing
the path it had begun while that stays selected. Its reply's `tool` is the tool given back.
```json
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":100,"y":300},{"kind":"up","x":100,"y":300},{"kind":"down","x":200,"y":300},{"kind":"drag","x":250,"y":300},{"kind":"up","x":250,"y":300}]}}
{"name":"pointer_gesture","arguments":{"mods":{"cmd":true},"events":[{"kind":"down","x":250,"y":300},{"kind":"drag","x":250,"y":260},{"kind":"up","x":250,"y":260}]}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":350},{"kind":"up","x":300,"y":350}]}}
```
```json
{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,2]]}}}
@ -1153,8 +1166,9 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets
The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse:
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor or
handle for Direct Selection.
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor,
handle or segment for Direct Selection, the Anchor Point, Add and Delete Anchor Point and Scissors tools, and the
Pen with Alt. A handle shorter than that leaves its anchor to be picked when pressed nearer the anchor.
- `objectSelectionByPathOnly`: a click inside a filled path or compound path doesn't select it, one on its path does.
- `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl
elsewhere) selects the object under the selected one there, the next such click the one under that, then the
@ -1168,8 +1182,9 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
hidden art with the artboard too; off (the default) it stays where it is.
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
the pointer.
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection shows and drags direction handles only while a
single anchor is selected. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt)
shows and drags direction handles only while a single anchor is selected. On, the handles of the direct-selected
anchors, or of every anchor of a path selected as a whole, show and drag. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
- `hideCornerWidgetAbove` (177° by default): corners wider than this show no Live Corners widget (a rectangle's right
angles hide below 90°).
- `transformPatternTiles` (off by default): the default of the transforms' `patterns` param (`object.transform`,