diff --git a/ROADMAP.md b/ROADMAP.md index a8bc032f..e2dc3b8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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 import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), 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 into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), 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 import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), 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 into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index bd624689..b4d90f1d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -205,6 +205,7 @@ impl Session { slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, selection_tolerance: self.prefs.selection_tolerance, + anchor_size: self.prefs.anchor_size, path_only: self.prefs.object_selection_by_path_only, type_path_only: self.prefs.type_selection_by_path_only, double_click_isolate: self.prefs.double_click_to_isolate, diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index b5c8426a..cf17647c 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -201,15 +201,21 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec { v } -/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is). +/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame +/// that has one: (id, si, ai, where it is). fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> { let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n)); owners.into_iter().find_map(|id| { let pd = cx.doc.node(id).and_then(editable_path)?; - pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p)) + nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a)) }) } +/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). +fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> { + pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) +} + /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec)> { anchor_owners(cx, is_area_type).into_iter().find_map(|id| { @@ -290,7 +296,8 @@ impl Tool for DirectSelectionTool { return out; } let p = ev.pos; - let tol = cx.pick_tol(); + // Anchors and handle ends are picked from a little further than segments and objects. + let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (ev.kind, self.state.clone()) { (PointerKind::Down, _) if self.group => { if let Some(out) = self.guide.press(cx, ev) { @@ -332,7 +339,7 @@ impl Tool for DirectSelectionTool { return vec![]; } if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) - && let Some(out) = hit_spine_handle(cx, sel, p, tol) + && let Some(out) = hit_spine_handle(cx, sel, p, point) { self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out }; return vec![Action::Begin("Reshape Spine".into())]; @@ -343,17 +350,17 @@ impl Tool for DirectSelectionTool { return self.mesh.press(g); } self.mesh.unfocus(); - if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { 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) { + if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) { self.spine = Some((id, anchor)); self.state = State::SpinePoint { id, anchor, from, start: p, began: false }; return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } - if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) { + if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) { if cx.doc.node(id).is_some_and(is_area_type) { return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); } @@ -608,14 +615,14 @@ impl Tool for DirectSelectionTool { } } -/// The anchor within the selection tolerance of `p`, of a selected path or the path under `p`: -/// what Highlight anchors on mouse over marks (a press there picks it). +/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the +/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it). fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> { let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf); - let tol = cx.pick_tol(); + let tol = cx.point_tol(); cx.selection.objects.iter().copied().chain(under).find_map(|id| { let path = cx.doc.node(id).and_then(editable_path)?; - path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p)) + nearest_anchor(&path, p, tol).map(|(.., a)| (id, a)) }) } @@ -685,6 +692,29 @@ mod tests { assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); } + /// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and + /// further with a larger anchor Size or Tolerance (#593); away from it the segment is. + #[test] + fn anchors_are_picked_before_the_segments_through_them() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); + let c = cx(&d, &s, &p); + // 4.3 px from the corner, 0.5 px from the top edge. + assert_eq!(press(&c, 104.2, 100.5), corner); + assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); + assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); + let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; + assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); + let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&loose, 107.5, 100.5), corner); + // On screen: at 200% the reach is half as far in the document. + let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&zoomed, 102.1, 100.2), corner); + assert_ne!(press(&zoomed, 102.6, 100.2), corner); + } + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index c2c2d5ba..53bf528f 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -48,7 +48,7 @@ impl AnchorTool { } fn delete(cx: &ToolContext, p: Point) -> Vec { - hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect() + hit_anchor(cx, p, cx.point_tol()).map(remove_anchor).into_iter().collect() } } @@ -61,7 +61,8 @@ impl Tool for AnchorTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; - let tol = cx.pick_tol(); + // Anchors and handle ends are picked from a little further than segments. + let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (self.id, ev.kind) { ("addAnchor", PointerKind::Down) => { if ev.mods.alt { @@ -78,7 +79,7 @@ impl Tool for AnchorTool { } } ("scissors", PointerKind::Down) => { - if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + if let Some((id, si, ai)) = hit_anchor(cx, p, point) { return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))]; } match hit_segment(cx, p, tol) { @@ -87,10 +88,10 @@ impl Tool for AnchorTool { } } ("anchorPoint", PointerKind::Down) => { - if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { 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) { + } else if let Some((id, si, ai)) = hit_anchor(cx, p, point) { self.state = State::Convert { id, si, ai, start: p, began: false }; } else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) { self.state = State::Reshape { id, si, seg, t, start: p, began: false }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c3cb04b9..11ed1106 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -274,6 +274,8 @@ pub struct ToolContext<'a> { /// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path /// or an anchor to pick it. pub selection_tolerance: f64, + /// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn. + pub anchor_size: u32, /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path /// doesn't pick it, only one on its path does. pub path_only: bool, @@ -362,6 +364,14 @@ impl ToolContext<'_> { pub fn pick_tol(&self) -> f64 { self.tol(self.selection_tolerance) } + /// How near (document units) a press picks an anchor or a direction handle's end: the + /// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch + /// and wins over the segments through it, which are tested after it (#593). + pub fn point_tol(&self) -> f64 { + // Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step. + let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0; + self.tol(self.selection_tolerance.max(half + 2.0)) + } pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { vectorcraft_doc::hit::HitOptions { tol: self.pick_tol(), @@ -666,6 +676,7 @@ pub(crate) mod testutil { slices_locked: false, auto_add_delete: true, selection_tolerance: 3.0, + anchor_size: 3, path_only: false, type_path_only: false, double_click_isolate: true, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index b69537dc..71b05f2f 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -91,11 +91,11 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { /// 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.pick_tol(); + let point = cx.point_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()) + && (hit_handle(cx, p, point).is_some() + || crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some() + || crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some()) } /// The selected paths the pen edits (not guides, nor locked or hidden ones). @@ -123,7 +123,7 @@ impl Tool for PenTool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let tol = cx.tol(5.0); + let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. if self.drawing && self.path.is_none() { self.path = active_path(cx).map(|a| a.0); @@ -297,7 +297,7 @@ impl Tool for PenTool { o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - let tol = cx.tol(5.0); + let tol = cx.tol(5.0).max(cx.point_tol()); if let Some((id, first, last, _)) = self.active(cx) { let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0); if p.distance(first) <= tol && !one {