Pen: Auto Add/Delete reuses the Add/Delete Anchor Point tools' hit tests (review of #312)

The Pen's Auto Add/Delete had its own copy of the anchor and segment hit
tests and of the path.insertAnchor / path.removeAnchor actions the Add
and Delete Anchor Point tools build. draw2 now exposes anchor_in /
segment_in (the hit tests over a given set of paths) and insert_anchor /
remove_anchor, which both tools use. Adds the ROADMAP entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 03:22:31 +07:00
co-authored by Claude Opus 5.5
parent 133390ef7a
commit 7feeb73a81
4 changed files with 41 additions and 43 deletions
+24 -3
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Tool, ToolContext};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::CurvatureTool;
@@ -96,8 +96,13 @@ fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
/// Nearest anchor within `tol`: (path, subpath, anchor).
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
anchor_in(cx, candidate_paths(cx), p, tol)
}
/// Nearest anchor within `tol` on the first of `ids` that has one: (path, subpath, anchor).
pub(crate) fn anchor_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
let mut best: Option<((NodeId, usize, usize), f64)> = None;
for id in candidate_paths(cx) {
for id in ids {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
for (si, ai, a) in pd.anchors() {
let d = a.p.distance(p);
@@ -114,7 +119,13 @@ pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId
/// Nearest point on a path segment within `tol`: (path, subpath, segment, t).
pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
for id in candidate_paths(cx) {
segment_in(cx, candidate_paths(cx), p, tol)
}
/// Nearest point within `tol` on a segment of the first of `ids` that passes there: (path,
/// subpath, segment, t).
pub(crate) fn segment_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
for id in ids {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
if let Some((si, seg, t, _, d)) = pd.nearest(p)
&& d <= tol
@@ -125,6 +136,16 @@ pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeI
None
}
/// Add an anchor where [`hit_segment`] or [`segment_in`] found a segment.
pub(crate) fn insert_anchor((id, si, seg, t): (NodeId, usize, usize, f64)) -> Action {
Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))
}
/// Delete the anchor [`hit_anchor`] or [`anchor_in`] found.
pub(crate) fn remove_anchor((id, si, ai): (NodeId, usize, usize)) -> Action {
Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))
}
#[cfg(test)]
mod tests {
use super::*;
+3 -9
View File
@@ -10,7 +10,7 @@ use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{hit_anchor, hit_segment};
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug)]
@@ -38,17 +38,11 @@ impl AnchorTool {
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
match hit_segment(cx, p, cx.tol(4.0)) {
Some((id, si, seg, t)) => vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))],
None => vec![],
}
hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect()
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
match hit_anchor(cx, p, cx.tol(4.0)) {
Some((id, si, ai)) => vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))],
None => vec![],
}
hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect()
}
}
+11 -28
View File
@@ -13,7 +13,7 @@
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, PathData, Point};
use vectorcraft_geom::{BezPath, Point};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -253,32 +253,15 @@ fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option<Acti
if !cx.auto_add_delete || m.shift {
return None;
}
let paths: Vec<(NodeId, &PathData)> = cx
.selection
.objects
.iter()
.filter(|id| cx.doc.is_editable(**id))
.filter_map(|&id| match &cx.doc.node(id)?.kind {
NodeKind::Path { path, guide: false, .. } => Some((id, path)),
_ => None,
})
.collect();
let mut best: Option<(NodeId, usize, usize, f64)> = None;
for (id, path) in &paths {
for (si, ai, a) in path.anchors() {
let d = a.p.distance(p);
if d <= tol && best.is_none_or(|b| d < b.3) {
best = Some((*id, si, ai, d));
}
}
}
if let Some((id, si, ai, _)) = best {
return Some(Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai})));
}
paths.iter().find_map(|(id, path)| {
let (si, seg, t, _, d) = path.nearest(p)?;
(d <= tol).then(|| Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t})))
})
let paths = || {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
};
let anchor = crate::draw2::anchor_in(cx, paths(), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, paths(), p, tol).map(crate::draw2::insert_anchor))
}
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
@@ -486,7 +469,7 @@ mod tests {
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)];
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false));
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false));
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]);