mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Fix #494: Pen + Cmd edits the path being drawn; Pen + Alt reshapes segments; handle drags fixed and fast
Reported: with the Pen there was no way to edit curves in place as Ctrl does in Illustrator on Windows (Cmd on the Mac), segments couldn't be grabbed, and the curve-editing tools felt slow and sometimes didn't take a handle. - Cmd/Ctrl held at a press with any tool but a selection tool now borrows the selection tool in the engine (Session::pointer), not in the canvas: the selection tool chosen last, or Direct Selection for the Pen, Curvature and anchor tools until one is chosen. The tool lent to isn't deactivated, so the Pen goes on drawing its path after the release while that path stays selected; it no longer draws on another open path selected meanwhile. Agents reach it with pointer_gesture mods.cmd. With Cmd held the pointer is the borrowed tool's. Choosing a tool or switching documents mid-gesture gives the tool back first. - Pen + Alt over a segment of a selected path reshapes it, as the Anchor Point tool does. - The handles shown are the handles that drag (one helper for Direct Selection, the Anchor Point tool and Pen + Alt): Direct Selection now drags the handles of a path selected as a whole (as the Pen leaves it, so Cmd + drag of a handle while drawing works), the Anchor Point tool no longer grabs handles that Show handles when multiple anchors are selected hides, the nearest handle wins, and a handle shorter than the tolerance leaves its anchor to be picked. The anchor tools pick within Selection & Anchor Display > Tolerance instead of a fixed 4 px. - path.reshapeSegment keeps a smooth anchor at either end smooth (its other handle turns with the moved one) instead of kinking it. - A dragged handle gathered its smart guide targets from the whole document on every pointer move (21 ms per move on 20,000 paths): HandleSnap now gathers them once per drag (1 ms). vectorcraft-cli perf measures handle and segment drags on a 5,000-anchor path. - catalog::is_selection_tool and catalog::edits_anchors replace the UI's own lists. - Tests in tools, engine and MCP (headless pointer_gesture); docs/mcp.md, control-protocol.md, ROADMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d12d5195da
commit
aefd961bbd
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user