mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user