mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
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:
co-authored by
Claude Opus 5.5
parent
f5c3ecb47c
commit
d5a060a009
+47
-128
@@ -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> {
|
||||
|
||||
@@ -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(()) }
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user