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
+71 -2
View File
@@ -1,11 +1,11 @@
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
//! Join, Split Into Grid.
use kurbo::{ParamCurveArclen, Point, Rect, Vec2};
use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2};
use vectorcraft_geom::{Anchor, PathData, SubPath};
use crate::boolean::{Seg, segs_to_subpath};
use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent};
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent};
/// Options for [`simplify_with`].
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -226,6 +226,75 @@ fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
(err <= tol).then_some(Seg { c, line: false })
}
/// Remove anchor `index` without opening the path (the Delete Anchor Point tool and Object → Path
/// → Remove Anchor Points). Its neighbours keep their handle directions and their facing handles
/// are refitted so one cubic follows the two segments that met there; two straight segments
/// become one. Removing an end of an open path drops its segment. Returns false, leaving `sp`
/// alone, when `index` is out of range.
pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool {
let n = sp.anchors.len();
if index >= n {
return false;
}
if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 {
let (pi, ni) = ((index + n - 1) % n, (index + 1) % n);
if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) {
let (left, right) = (sp.segment(pi), sp.segment(index));
const N: usize = 16;
let mut pts = Vec::with_capacity(2 * N + 1);
sample(&left, N, &mut pts, true);
sample(&right, N, &mut pts, false);
let (t0, t1) = (start_tangent(&left), end_tangent(&right));
// The new curve passes the removed point at some parameter k: the left samples sit
// at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit.
let fit = |k: f64| {
let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect();
fit_single_from(&pts, u, t0, t1)
};
let (c, _, _) = golden_min(fit, 0.0, 1.0);
if c.p1.is_finite() && c.p2.is_finite() {
sp.anchors[pi].h_out = c.p1;
sp.anchors[ni].h_in = c.p2;
}
}
}
sp.anchors.remove(index);
if !sp.closed {
// A new end has nothing beyond it.
if let Some(a) = sp.anchors.first_mut() {
a.h_in = a.p;
}
if let Some(a) = sp.anchors.last_mut() {
a.h_out = a.p;
}
}
if sp.anchors.len() < 3 {
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
}
true
}
/// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a
/// golden-section search (a bounded number of steps).
fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) {
const R: f64 = 0.618_033_988_749_895;
let (mut x1, mut x2) = (b - R * (b - a), a + R * (b - a));
let (mut f1, mut f2) = (f(x1), f(x2));
for _ in 0..40 {
// A NaN error never wins.
if f1.1 < f2.1 || f2.1.is_nan() {
(b, x2, f2) = (x2, x1, f1);
x1 = b - R * (b - a);
f1 = f(x1);
} else {
(a, x1, f1) = (x1, x2, f2);
x2 = a + R * (b - a);
f2 = f(x2);
}
}
if f1.1 <= f2.1 { f1 } else { f2 }
}
/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment.
pub fn add_anchor_points(path: &PathData) -> PathData {
let mut out = path.clone();
+6 -1
View File
@@ -69,7 +69,12 @@ fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>,
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
/// the worst point).
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
let mut u = chord_params(pts);
fit_single_from(pts, chord_params(pts), t0, t1)
}
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
/// chord-length ones.
pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
let mut best = generate(pts, &u, t0, t1);
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
for _ in 0..MAX_REPARAM {
+2 -1
View File
@@ -15,7 +15,8 @@ mod planar;
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
pub use edit::{
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_anchor, remove_redundant_points, simplify, simplify_with, smooth,
split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};