Fix #593: anchors and handle ends are easier to grab than the segments through them

An anchor was picked within the selection tolerance (3 px), the same reach
as its segments: a press a little off the anchor's 5 px square took the
curve instead.

- `ToolContext::point_tol`: anchors and direction handle ends are picked
  within the selection tolerance, but at least 2 px past the drawn point
  (4.5 px at the default Size 3, more with a larger Size). Anchors are
  tested before segments, so where both are within reach the anchor wins.
- Direct Selection (presses, Highlight anchors on mouse over, blend spine
  points and handles), the Anchor Point, Delete Anchor Point and Scissors
  tools, and the Pen (its Alt conversions; its close/continue reach stays
  5 px unless the points are drawn larger) use it. Segments, objects and
  frame edges keep the selection tolerance.
- Of the topmost path with anchors in reach, the nearest anchor is picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 05:09:31 +07:00
parent c3b73f0146
commit a0d5d72d90
6 changed files with 66 additions and 23 deletions

File diff suppressed because one or more lines are too long

View File

@ -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,

View File

@ -201,15 +201,21 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
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<AnchorRef>)> {
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();

View File

@ -48,7 +48,7 @@ impl AnchorTool {
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
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<Action> {
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 };

View File

@ -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,

View File

@ -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<Action> {
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 {