Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cd4588ee | ||
|
|
aefd961bbd |
No files matched your search
+3
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -122,7 +122,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,106 @@ 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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_drag_from_the_first_anchor_closes_the_path() {
|
||||
// #501: closing a path by dragging out of its first anchor flickered between a close and a new
|
||||
// path, and released at the wrong moment left the shape open. The press decides: the drag
|
||||
// shapes the closing curve, symmetric, and the release closes the path wherever it ends.
|
||||
let none = Mods::default();
|
||||
let v = view();
|
||||
let mut s = session();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
for (x, y) in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
|
||||
gesture(&mut s, &[(x, y)], none);
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let pts: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
|
||||
for &(x, y) in &pts[1..] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
|
||||
let p = path(&s, id);
|
||||
assert!(p.subpaths[0].closed && paths(&s).len() == 1, "closed throughout the drag, at ({x}, {y})");
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 252.0), v).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
let sp = &path(&s, id).subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
|
||||
let a = sp.anchors[0];
|
||||
assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(160.0, 252.0)), "symmetric");
|
||||
assert_eq!(paths(&s).len(), 1);
|
||||
// The next click starts a new path.
|
||||
gesture(&mut s, &[(500.0, 500.0)], none);
|
||||
assert_eq!(paths(&s).len(), 2);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 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");
|
||||
}
|
||||
|
||||
/// #501: a drag out of the first anchor closes the path, shaping the closing curve.
|
||||
#[test]
|
||||
fn a_drag_from_the_first_anchor_closes_the_path() {
|
||||
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"})));
|
||||
for p in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
|
||||
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[p])));
|
||||
}
|
||||
let close: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
|
||||
text(&call_tool(&mut h, "pointer_gesture", &gesture(&close)));
|
||||
let d = &h.session.doc().unwrap().doc;
|
||||
let mut closed = vec![];
|
||||
d.walk(|n| closed.extend(n.path_data().map(|p| p.is_closed())));
|
||||
assert_eq!(closed, [true], "one path, closed");
|
||||
let first = paths(&h)[0][0];
|
||||
assert_eq!((first.h_in, first.h_out), (vectorcraft_geom::Point::new(40.0, 348.0), vectorcraft_geom::Point::new(160.0, 252.0)));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+88
-22
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
-18
@@ -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
|
||||
|
||||
+146
-33
@@ -3,11 +3,14 @@
|
||||
//! 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°.
|
||||
//! 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.
|
||||
//! 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 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,14 +21,18 @@ 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,
|
||||
drag: Option<(Point, 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,
|
||||
@@ -38,6 +45,17 @@ pub struct PenTool {
|
||||
convert: Option<AnchorTool>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -63,10 +81,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 +119,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 => {
|
||||
@@ -115,7 +141,7 @@ impl Tool for PenTool {
|
||||
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((first, true));
|
||||
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
|
||||
@@ -127,7 +153,7 @@ impl Tool for PenTool {
|
||||
if let Some(acts) = self.alt_convert(cx, ev) {
|
||||
return acts;
|
||||
}
|
||||
self.drag = Some((p, false));
|
||||
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}))];
|
||||
}
|
||||
@@ -142,6 +168,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,8 +177,8 @@ impl Tool for PenTool {
|
||||
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
|
||||
return vec![act];
|
||||
}
|
||||
self.drawing = true;
|
||||
self.drag = Some((p, false));
|
||||
(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 => {
|
||||
@@ -165,13 +192,13 @@ impl Tool for PenTool {
|
||||
}
|
||||
return vec![set_out_handle(id, si, ai, h)];
|
||||
}
|
||||
let Some((mut a, closing)) = self.drag else { return vec![] };
|
||||
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((a, closing));
|
||||
self.drag = Some(Place { at: a, path, closing });
|
||||
}
|
||||
self.last = ev.pos;
|
||||
let mut out_h = ev.pos;
|
||||
@@ -179,7 +206,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) = path else {
|
||||
// First anchor of a new path: re-issue create with handles.
|
||||
let in_h = a - (out_h - a);
|
||||
return vec![Action::Preview(
|
||||
@@ -209,9 +236,9 @@ impl Tool for PenTool {
|
||||
if self.handle.take().is_some() {
|
||||
return vec![Action::Commit];
|
||||
}
|
||||
let Some((_, closing)) = self.drag.take() else { return vec![] };
|
||||
if closing {
|
||||
self.drawing = false;
|
||||
let Some(place) = self.drag.take() else { return vec![] };
|
||||
if place.closing {
|
||||
self.stop();
|
||||
}
|
||||
vec![Action::Commit]
|
||||
}
|
||||
@@ -221,7 +248,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 +258,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 +281,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 +299,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 +313,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)
|
||||
}
|
||||
@@ -467,6 +505,32 @@ mod tests {
|
||||
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));
|
||||
@@ -587,4 +651,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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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?}` | |
|
||||
|
||||
+20
-5
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user