Path: Remove Anchor Points is Object › Path's one item and shares the Delete Anchor Point refit (review of #241)

- Parity: Object › Path › Remove Anchor Points now runs the new command (`path.removeAnchors`,
  the menu's feature name) instead of `path.deleteAnchors`, which deleted the anchors with their
  segments and opened closed paths; that stays the Delete key's command ("Delete Anchor Points",
  off the menu). No second "Smart" item.
- Geometry: the refit moves to `vectorcraft_pathops::remove_anchor` on the existing least-squares
  fitter (fixed end tangents, a golden-section search for where the curve passes the removed
  point) instead of a ~1,000-candidate grid search in geom (about 4 ms per anchor, now well under
  0.1 ms). Two straight sides stay one straight side (a rectangle loses a corner and becomes a
  triangle), as Illustrator does; non-finite input leaves the handles alone.
- The Delete Anchor Point tool (`path.removeAnchor`) uses the same function, replacing its
  heuristic handle stretch; the explicit id/anchor and `fit` params of the new command are dropped
  as duplicates of it. The command is enabled by direct-selected anchors (`has_anchors`), not a
  UI override.
- The Control bar shows an icon button (the Delete Anchor Point icon, tooltip "Remove Anchor
  Points") instead of a 204 pt text button; the labels reuse the translated "Remove Anchor
  Points"; zh-hant gains "Delete Anchor Points". The Pen test that pinned Alt-click as plain Pen
  (Illustrator's Alt over an anchor is Convert Anchor Point) is dropped.
- Tests for the refit, closed paths, straight sides and bad input; docs/mcp.md and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 03:27:07 +07:00
co-authored by Claude Opus 5.5
parent f5c3ecb47c
commit d5a060a009
21 changed files with 257 additions and 485 deletions
+47 -128
View File
@@ -46,13 +46,13 @@ pub fn specs() -> Vec<CommandSpec> {
remove_anchor
),
cmd!(
"path.smartRemoveAnchor",
"Smart Remove Anchor Points",
"path.removeAnchors",
"Remove Anchor Points",
["Object", "Path"],
None,
"{id?, subpath?, anchor?, fit?: bool (true)} remove an anchor and lengthen or shorten the facing handles so the curve stays close. Without id: every direct-selected anchor. fit false deletes and leaves the handles",
has_selection,
smart_remove_anchor
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
has_anchors,
remove_anchors
),
cmd!(
"path.convertAnchor",
@@ -601,141 +601,60 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
let n = sp.anchors.len();
if ai >= n {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if ai >= sp.anchors.len() {
return Err(EngineError::Other("no such anchor".into()));
}
let interior = sp.closed || (ai > 0 && ai + 1 < n);
if interior && n >= 3 {
let (pi, ni) = ((ai + n - 1) % n, (ai + 1) % n);
let (prev, a, next) = (sp.anchors[pi], sp.anchors[ai], sp.anchors[ni]);
// Keep the outer tangents; stretch them to span both removed segments.
let l1 = prev.p.distance(a.p);
let l2 = a.p.distance(next.p);
let chord = prev.p.distance(next.p).max(1e-9);
let first_line = !prev.has_out() && !a.has_in();
let second_line = !a.has_out() && !next.has_in();
let (mut hout, mut hin) = (prev.h_out, next.h_in);
if !(first_line && second_line) {
// Curved: stretch the outer handles to span both segments; a straight side aims a
// third of the chord at the removed anchor.
hout = if prev.has_out() {
prev.p + (prev.h_out - prev.p) * ((l1 + l2) / l1.max(1e-9)).min(3.0)
} else {
prev.p + (a.p - prev.p) * (chord / 3.0 / l1.max(1e-9))
};
hin = if next.has_in() {
next.p + (next.h_in - next.p) * ((l1 + l2) / l2.max(1e-9)).min(3.0)
} else {
next.p + (a.p - next.p) * (chord / 3.0 / l2.max(1e-9))
};
}
sp.anchors[pi].h_out = hout;
sp.anchors[ni].h_in = hin;
}
sp.anchors.remove(ai);
if !sp.closed && !sp.anchors.is_empty() {
let (f, l) = (0, sp.anchors.len() - 1);
let a0 = sp.anchors[f].p;
sp.anchors[f].h_in = a0;
let al = sp.anchors[l].p;
sp.anchors[l].h_out = al;
}
if sp.anchors.len() < 3 {
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
}
let empty = sp.anchors.len() < 2;
if empty {
path.subpaths.remove(si);
}
if path.is_empty() {
d.remove(id)?;
sel.clear();
return Ok(true);
}
sel.anchors.remove(&id);
Ok(false)
remove_anchors_of(d, sel, id, [(si, ai)])
})?;
Ok(json!({ "removedObject": removed }))
}
fn smart_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smartRemoveAnchor";
let fit = bool_or(p, "fit", true);
let explicit = p.get("anchor").is_some();
let jobs: Vec<(NodeId, usize, usize)> = if explicit {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
vec![(id, si, usize_req(p, "anchor", C)?)]
} else {
let anchors = s.doc()?.selection.anchors.clone();
let v: Vec<(NodeId, usize, usize)> = anchors.iter().flat_map(|(id, set)| set.iter().map(|&(si, ai)| (*id, si, ai))).collect();
if v.is_empty() {
return Err(bad(C, "direct-select an anchor point"));
fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st
.selection
.anchors
.iter()
.filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. })))
.map(|(id, set)| (*id, set.iter().copied().collect()))
.collect();
if jobs.is_empty() {
return Err(bad("path.removeAnchors", "direct-select anchor points of a path"));
}
let removed = s.edit("Remove Anchor Points", |d, sel| {
let mut removed = 0;
for (id, anchors) in jobs {
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
}
v
};
let label = if fit { "Smart Remove Anchor Point" } else { "Delete Anchor Point" };
s.edit(label, |d, sel| {
let mut ids: Vec<NodeId> = jobs.iter().map(|(id, ..)| *id).collect();
ids.sort();
ids.dedup();
for id in ids {
let gone = {
let path = path_mut(d, id)?;
let mut subs: Vec<usize> = jobs.iter().filter(|(i, ..)| *i == id).map(|(_, si, _)| *si).collect();
subs.sort_unstable();
subs.dedup();
for si in subs {
let mut ais: Vec<usize> = jobs.iter().filter(|(i, s, _)| *i == id && *s == si).map(|(_, _, ai)| *ai).collect();
// Higher indexes go first, so each later index in this subpath stays valid.
ais.sort_unstable_by(|a, b| b.cmp(a));
ais.dedup();
for ai in ais {
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if ai >= sp.anchors.len() {
if explicit {
return Err(EngineError::Other("no such anchor".into()));
}
continue;
}
finish_smart_remove(sp, ai, fit);
}
}
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
path.is_empty()
};
if gone {
d.remove(id)?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
}
}
Ok(())
Ok(removed)
})?;
ok()
Ok(json!({ "removedObjects": removed }))
}
/// Remove one anchor. `fit` lengthens or shortens the facing handles, then retracts a new open
/// end's free handle. `fit` false deletes the point and leaves every other handle where it is.
fn finish_smart_remove(sp: &mut SubPath, ai: usize, fit: bool) {
if !sp.remove_anchor(ai, fit) {
return;
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
/// the path went.
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
// The last first, so the indexes still to come stay valid.
anchors.sort_unstable_by(|a, b| b.cmp(a));
anchors.dedup();
let path = path_mut(d, id)?;
for (si, ai) in anchors {
if let Some(sp) = path.subpaths.get_mut(si) {
po::remove_anchor(sp, ai);
}
}
if fit && !sp.closed && !sp.anchors.is_empty() {
let n = sp.anchors.len();
let p0 = sp.anchors[0].p;
sp.anchors[0].h_in = p0;
let last = n - 1;
let pl = sp.anchors[last].p;
sp.anchors[last].h_out = pl;
}
if sp.anchors.len() < 3 {
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
let gone = path.is_empty();
if gone {
d.remove(id)?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
}
Ok(gone)
}
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
+4
View File
@@ -144,6 +144,10 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
pub fn has_anchors(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) }
}
pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
+4 -4
View File
@@ -87,11 +87,11 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"path.deleteAnchors",
"Remove Anchor Points",
["Object", "Path"],
"Delete Anchor Points",
[],
None,
"{} delete direct-selected anchors",
has_selection,
"{} delete the direct-selected anchors and their segments, opening closed paths there (the Delete key)",
has_anchors,
delete_anchors
),
cmd!(
+24 -47
View File
@@ -1,6 +1,5 @@
//! Drawing / path-editing tools driven through the session (tools → actions → `path.*` commands).
use kurbo::ParamCurveNearest;
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Point};
@@ -376,8 +375,7 @@ fn draw2_commands_reject_bad_params() {
("path.freehand", json!({"points": [[1, 2]]})),
("path.curvature", json!({"points": []})),
("path.removeAnchor", json!({"id": id.0, "anchor": 99})),
("path.smartRemoveAnchor", json!({})),
("path.smartRemoveAnchor", json!({"id": id.0, "anchor": 99})),
("path.removeAnchors", json!({})),
("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})),
("path.reshapeSegment", json!({"id": id.0, "segment": 42})),
("path.split", json!({"id": id.0})),
@@ -485,7 +483,7 @@ fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() {
}
#[test]
fn smart_remove_refits_a_split_curve_and_undo_restores_it() {
fn removing_an_anchor_refits_a_split_curve_and_undo_restores_it() {
let mut s = session();
let made = s
.execute(
@@ -500,7 +498,7 @@ fn smart_remove_refits_a_split_curve_and_undo_restores_it() {
let before = path(&s, id);
let inserted = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.4})).unwrap();
let ai = inserted["anchor"].as_u64().unwrap();
s.execute("path.smartRemoveAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
s.execute("path.removeAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
let after = path(&s, id);
assert_eq!(after.subpaths[0].anchors.len(), 2);
let (got, orig) = (&after.subpaths[0].anchors, &before.subpaths[0].anchors);
@@ -511,49 +509,28 @@ fn smart_remove_refits_a_split_curve_and_undo_restores_it() {
}
#[test]
fn smart_remove_without_fit_leaves_handles() {
fn remove_anchor_points_keeps_the_paths_closed() {
// Object › Path › Remove Anchor Points, unlike the Delete key: a rectangle loses a corner and
// stays closed, a straight-sided triangle; a curve keeps its shape round a removed point.
let mut s = session();
let made = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 0, "y": 0, "out": [10, 30]},
{"x": 50, "y": 10},
{"x": 100, "y": 0, "in": [80, 25]}
]}),
)
.unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("path.smartRemoveAnchor", &json!({"id": id.0, "anchor": 1, "fit": false})).unwrap();
let pd = path(&s, id);
let sp = &pd.subpaths[0];
assert_eq!(sp.anchors.len(), 2);
assert_eq!(sp.anchors[0].h_out, Point::new(10.0, 30.0));
assert_eq!(sp.anchors[1].h_in, Point::new(80.0, 25.0));
}
#[test]
fn smart_remove_uses_the_direct_selected_anchors() {
let mut s = session();
let made = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 0, "y": 0},
{"x": 50, "y": 40},
{"x": 100, "y": 0}
]}),
)
.unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
s.execute("path.smartRemoveAnchor", &json!({})).unwrap();
let sp = &path(&s, id).subpaths[0];
assert_eq!(sp.anchors.len(), 2);
let d = sp.segment(0).nearest(Point::new(50.0, 40.0), 1e-4).distance_sq.sqrt();
assert!(d < 40.0, "the refit stays closer to the removed corner than the chord ({d})");
let r = rect(&mut s, 0.0, 0.0, 100.0, 80.0);
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 0, "out": [330, 80]}, {"x": 400, "y": 0, "in": [370, 80]}]})).unwrap();
let c = NodeId(made["id"].as_u64().unwrap());
let before = path(&s, c).subpaths[0].segment(0);
s.execute("path.insertAnchor", &json!({"id": c.0, "segment": 0, "t": 0.5})).unwrap();
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 1]], "mode": "add"})).unwrap();
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
let sp = &path(&s, r).subpaths[0];
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "straight sides stay straight: {sp:?}");
let after = path(&s, c).subpaths[0].segment(0);
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 2);
assert!(after.p1.distance(before.p1) < 0.5 && after.p2.distance(before.p2) < 0.5, "{after:?}");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove Anchor Points");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
let spec = find_command("path.smartRemoveAnchor").unwrap();
assert_eq!(path(&s, r).subpaths[0].anchors.len(), 4);
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 3);
let spec = find_command("path.removeAnchors").unwrap();
assert_eq!(spec.menu, &["Object", "Path"][..]);
}