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!(