Add-ons: Artwork Cleanup — simplify keeping shape, health check, deep ungroup, rejoin

A built-in add-on under Object › Plug-ins › Artwork Cleanup:
- Simplify Anchors (Keep Shape) removes anchors while the outline stays
  within a tolerance, optionally keeping sharp corners.
- Document Health Check finds and fixes 16 kinds of problems in the
  selection or the document (stray points, duplicate and collinear
  anchors, tiny handles, nearly closed paths, needless groups, compound
  paths and clipping groups, unpainted and invisible objects, duplicate
  objects, art off every artboard, empty artboards…), with Show, Fix
  Selected and Fix All, each fix one undo step.
- Deep Ungroup and Rejoin Path Pieces.

The add-on lives in its own modules (pathops, engine and ui-egui
addons folders) and leaves the existing commands, tools and menus as
they were.
This commit is contained in:
bflatastic
2026-10-30 13:11:20 +07:00
parent a24d811c7b
commit ac85b529fe
45 changed files with 3461 additions and 3 deletions
+14
View File
@@ -0,0 +1,14 @@
//! Geometry for VectorCraft's built-in add-ons, one module per add-on, with the kernels they share.
//!
//! - [`keep_shape`]: remove anchors while refitting their neighbours' handles, so the outline keeps
//! its shape within a tolerance (shared by every add-on that takes anchors away).
//! - [`cleanup`]: Artwork Cleanup's path repairs (collinear, duplicate and near anchors, tiny
//! handles, nearly closed paths) and Rejoin Path Pieces.
pub mod cleanup;
pub mod keep_shape;
pub use cleanup::{
Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles,
};
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_keep_shape};
+323
View File
@@ -0,0 +1,323 @@
//! Artwork Cleanup's path repairs, each on one subpath and returning how much it changed (so a
//! check is the repair run on a copy): collinear anchors, duplicate anchors, tiny handles and
//! nearly closed paths; [`near_point_groups`] finds anchors to snap together, and [`rejoin`] welds
//! open pieces whose ends meet back into paths.
use std::collections::HashMap;
use kurbo::{Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, EPS, SubPath};
use crate::fit::{end_tangent, start_tangent};
/// The fewest anchors a subpath keeps: two when open, three when closed.
fn min_anchors(sp: &SubPath) -> usize {
if sp.closed { 3 } else { 2 }
}
/// `a`'s handle offset `h - a.p` moved onto `p`.
fn moved(p: Point, a_p: Point, h: Point) -> Point {
p + (h - a_p)
}
/// Remove the corner anchors (no handles, straight segments both sides) where a straight run goes
/// on in the same direction: the sine of the angle it turns there is at most `tolerance`. Open ends
/// stay, and a subpath keeps two anchors (three when closed) → how many went.
pub fn remove_collinear_anchors(sp: &mut SubPath, tolerance: f64) -> usize {
let mut removed = 0;
let mut i = 0;
while i < sp.anchors.len() && sp.anchors.len() > min_anchors(sp) {
let n = sp.anchors.len();
let inner = sp.closed || (i > 0 && i + 1 < n);
let (Some(prev), Some(cur), Some(next)) = (sp.anchors.get((i + n - 1) % n), sp.anchors.get(i), sp.anchors.get((i + 1) % n)) else { break };
let straight = !prev.has_out() && !cur.has_in() && !cur.has_out() && !next.has_in();
let (a, b) = (cur.p - prev.p, next.p - cur.p);
let len = a.hypot() * b.hypot();
if inner && straight && len > EPS && a.dot(b) > 0.0 && a.cross(b).abs() / len <= tolerance {
sp.anchors.remove(i);
removed += 1;
} else {
i += 1;
}
}
removed
}
/// Merge each anchor lying within `distance` of the one before it (and a closed subpath's last
/// anchor within `distance` of its first) into that one, which keeps its in handle and takes the
/// merged anchor's out handle → how many went.
pub fn merge_duplicate_anchors(sp: &mut SubPath, distance: f64) -> usize {
let before = sp.anchors.len();
let mut out: Vec<Anchor> = Vec::with_capacity(before);
for a in sp.anchors.drain(..) {
match out.last_mut() {
Some(l) if l.p.distance(a.p) <= distance => *l = Anchor::with_handles(l.p, l.h_in, moved(l.p, a.p, a.h_out)),
_ => out.push(a),
}
}
if sp.closed
&& out.len() > 1
&& let (Some(first), Some(last)) = (out.first(), out.last())
&& first.p.distance(last.p) <= distance
&& let Some(last) = out.pop()
&& let Some(f) = out.first_mut()
{
*f = Anchor::with_handles(f.p, moved(f.p, last.p, last.h_in), f.h_out);
}
sp.anchors = out;
before - sp.anchors.len()
}
/// Retract the handles shorter than `length` (but not already on their anchor); an anchor that
/// loses a handle becomes a corner → how many handles were retracted.
pub fn retract_tiny_handles(sp: &mut SubPath, length: f64) -> usize {
let mut count = 0;
for a in &mut sp.anchors {
for out in [false, true] {
let h = if out { &mut a.h_out } else { &mut a.h_in };
let l = h.distance(a.p);
if l > EPS && l < length {
*h = a.p;
a.kind = AnchorKind::Corner;
count += 1;
}
}
}
count
}
/// Close an open subpath of three or more anchors whose ends lie within `distance` of each other:
/// the last anchor merges into the first, which takes its in handle → whether it closed.
pub fn close_nearly_closed(sp: &mut SubPath, distance: f64) -> bool {
let ends_meet = sp.anchors.first().zip(sp.anchors.last()).is_some_and(|(f, l)| f.p.distance(l.p) <= distance);
if sp.closed || sp.anchors.len() < 3 || !ends_meet {
return false;
}
let Some(last) = sp.anchors.pop() else { return false };
if let Some(first) = sp.anchors.first_mut() {
*first = Anchor::with_handles(first.p, moved(first.p, last.p, last.h_in), first.h_out);
}
sp.closed = true;
true
}
/// Groups of `points` to snap together: points within `distance` of one another (and so on, one
/// to the next) that aren't all at the same place, skipping the pairs `linked` names (such as
/// neighbouring anchors of one path). Each group lists its points in order; snapping them all to
/// the first one makes them coincide. Non-finite points and a distance that isn't a positive number
/// find nothing.
pub fn near_point_groups(points: &[Point], distance: f64, linked: impl Fn(usize, usize) -> bool) -> Vec<Vec<usize>> {
if !(distance > 0.0 && distance.is_finite()) {
return vec![];
}
let cell = |p: Point| ((p.x / distance).floor() as i64, (p.y / distance).floor() as i64);
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) {
grid.entry(cell(*p)).or_default().push(i);
}
let mut parent: Vec<usize> = (0..points.len()).collect();
fn root(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i).filter(|&&p| p != i) {
let up = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = up;
}
i = p;
}
i
}
for (&(cx, cy), members) in &grid {
for (dx, dy) in [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue };
for &i in members {
for &j in others.iter().filter(|&&j| (dx, dy) != (0, 0) || j > i) {
let (Some(a), Some(b)) = (points.get(i), points.get(j)) else { continue };
let d = a.distance(*b);
if d <= distance && d > 0.0 && !linked(i.min(j), i.max(j)) {
let (ri, rj) = (root(&mut parent, i), root(&mut parent, j));
if let Some(slot) = parent.get_mut(ri.max(rj)) {
*slot = ri.min(rj);
}
}
}
}
}
}
// Only pairs that aren't at the same place were joined, so no group is all at one place.
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..points.len() {
groups.entry(root(&mut parent, i)).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = groups.into_values().filter(|g| g.len() > 1).collect();
out.sort_unstable();
out
}
/// Open pieces welded into one path by [`rejoin`]: `members` index the pieces in the order they
/// run along `path`.
#[derive(Clone, Debug, PartialEq)]
pub struct Rejoined {
pub members: Vec<usize>,
pub path: SubPath,
}
/// One end of a piece: (piece, true for its last anchor).
type End = (usize, bool);
/// The direction pointing out of the piece at `end`.
fn outward(sp: &SubPath, last: bool) -> Option<Vec2> {
let n = sp.segment_count();
Some(match last {
true => end_tangent(&sp.segment(n.checked_sub(1)?)),
false => -start_tangent(&sp.segment(0)),
})
}
/// Weld the open `pieces` whose ends lie within `gap` of each other back into paths: an end meets
/// at most one other, and where an end could meet several, it meets the one the path turns least
/// to go on into (then the nearest). A piece may run either way; a chain whose ends meet closes.
/// Each junction keeps the earlier piece's anchor and the later piece's out handle. Pieces that are
/// closed, have fewer than two anchors or a non-finite coordinate stay out. → the chains of two or
/// more pieces, and the single pieces that closed.
pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec<Rejoined> {
if !(gap >= 0.0 && gap.is_finite()) {
return vec![];
}
let usable = |sp: &&SubPath| {
!sp.closed && sp.anchors.len() >= 2 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
};
// Each usable end: where it is and the way out of its piece there.
let ends: Vec<(End, Point, Vec2)> = pieces
.iter()
.enumerate()
.filter(|(_, sp)| usable(sp))
.flat_map(|(i, sp)| [(i, sp, false), (i, sp, true)])
.filter_map(|(i, sp, last)| {
let a = if last { sp.anchors.last() } else { sp.anchors.first() }?;
Some(((i, last), a.p, outward(sp, last)?))
})
.collect();
let points: Vec<Point> = ends.iter().map(|e| e.1).collect();
// Every pair of ends within the gap, by the turn across the junction.
let mut pairs: Vec<(f64, f64, End, End)> = vec![];
for (a, b) in candidate_pairs(&points, gap.max(1e-9)) {
let (Some(&(ea, pa, oa)), Some(&(eb, pb, ob))) = (ends.get(a), ends.get(b)) else { continue };
// A piece's own two ends meet only when closing it leaves a shape (three anchors).
if ea.0 == eb.0 && pieces.get(ea.0).is_none_or(|sp| sp.anchors.len() < 3) {
continue;
}
let d = pa.distance(pb);
if d <= gap {
pairs.push((oa.dot(-ob).clamp(-1.0, 1.0).acos(), d, ea, eb));
}
}
pairs.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
let mut link: HashMap<End, End> = HashMap::new();
for (_, _, a, b) in pairs {
if !link.contains_key(&a) && !link.contains_key(&b) {
link.insert(a, b);
link.insert(b, a);
}
}
chains(pieces, &link)
}
/// Pairs (a < b) of `points` that may lie within `gap`: those in neighbouring grid cells.
fn candidate_pairs(points: &[Point], gap: f64) -> Vec<(usize, usize)> {
let cell = |p: Point| ((p.x / gap).floor() as i64, (p.y / gap).floor() as i64);
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, p) in points.iter().enumerate() {
grid.entry(cell(*p)).or_default().push(i);
}
let mut out = vec![];
for (i, p) in points.iter().enumerate() {
let (cx, cy) = cell(*p);
for dx in -1..=1 {
for dy in -1..=1 {
let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue };
out.extend(others.iter().filter(|&&j| j > i).map(|&j| (i, j)));
}
}
}
out
}
/// Walk the links between piece ends into chains.
fn chains(pieces: &[SubPath], link: &HashMap<End, End>) -> Vec<Rejoined> {
let mut seen = vec![false; pieces.len()];
let mut out = vec![];
for start in 0..pieces.len() {
if seen.get(start) != Some(&false) || !(link.contains_key(&(start, false)) || link.contains_key(&(start, true))) {
continue;
}
// A member is (piece, reversed): it comes in by end (piece, reversed) and leaves by
// (piece, !reversed). Back up to the chain's first piece, or round to `start` in a loop.
let mut first = (start, false);
let mut closed = false;
for _ in 0..pieces.len() {
let Some(&(p, at_last)) = link.get(&first) else { break };
// The piece before leaves by its end `at_last`.
let prev = (p, !at_last);
if prev.0 == start {
closed = true;
break;
}
first = prev;
}
let members = walk(link, first, closed, &mut seen);
if let Some(path) = assemble(pieces, &members, closed) {
out.push(Rejoined { members: members.iter().map(|m| m.0).collect(), path });
}
}
out
}
/// The pieces of the chain that begins with `first` (piece, reversed), in order.
fn walk(link: &HashMap<End, End>, first: (usize, bool), closed: bool, seen: &mut [bool]) -> Vec<(usize, bool)> {
let mut members = vec![];
let mut cur = first;
loop {
match seen.get_mut(cur.0) {
Some(s) if !*s => *s = true,
_ => break,
}
members.push(cur);
// A piece run forwards leaves by its last anchor.
let Some(&(p, at_last)) = link.get(&(cur.0, !cur.1)) else { break };
let next = (p, at_last);
if closed && next.0 == first.0 {
break;
}
cur = next;
}
members
}
/// The anchors of `pieces` along the chain `members`, each piece's start merged into the end before it.
fn assemble(pieces: &[SubPath], members: &[(usize, bool)], closed: bool) -> Option<SubPath> {
if members.len() < 2 && !closed {
return None;
}
let mut anchors: Vec<Anchor> = vec![];
for &(i, reversed) in members {
let mut sp = pieces.get(i)?.clone();
if reversed {
sp.reverse();
}
let mut rest = sp.anchors.into_iter();
match (anchors.last_mut(), rest.next()) {
(Some(l), Some(f)) => *l = Anchor::with_handles(l.p, l.h_in, moved(l.p, f.p, f.h_out)),
(None, Some(f)) => anchors.push(f),
_ => {}
}
anchors.extend(rest);
}
let mut sp = SubPath::new(anchors, false);
if closed && !close_nearly_closed(&mut sp, f64::INFINITY) {
sp.closed = sp.anchors.len() >= 2;
}
Some(sp)
}
#[cfg(test)]
mod tests;
+134
View File
@@ -0,0 +1,134 @@
use kurbo::{Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
use super::{close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles};
fn line(pts: &[(f64, f64)], closed: bool) -> SubPath {
SubPath::polyline(&pts.iter().map(|&p| p.into()).collect::<Vec<Point>>(), closed)
}
fn points(sp: &SubPath) -> Vec<(f64, f64)> {
sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
}
#[test]
fn collinear_anchors_go_and_corners_stay() {
let mut sp = line(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 5.0)], true);
assert_eq!(remove_collinear_anchors(&mut sp, 0.005), 2);
assert_eq!(points(&sp), [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
assert_eq!(remove_collinear_anchors(&mut sp, 0.005), 0, "clean art is left alone");
// A turn back on itself is not a straight run; an open end stays.
let mut back = line(&[(0.0, 0.0), (10.0, 0.0), (5.0, 0.0)], false);
assert_eq!(remove_collinear_anchors(&mut back, 0.005), 0);
// An anchor with a handle is not a corner on a straight run.
let mut curved = line(&[(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)], false);
curved.anchors[1] = Anchor::smooth((5.0, 0.0).into(), (7.0, 0.0).into());
assert_eq!(remove_collinear_anchors(&mut curved, 0.005), 0);
// A slight bend within the tolerance goes; a larger one stays.
let mut bend = line(&[(0.0, 0.0), (100.0, 0.1), (200.0, 0.0)], false);
assert_eq!(remove_collinear_anchors(&mut bend.clone(), 0.0001), 0);
assert_eq!(remove_collinear_anchors(&mut bend, 0.005), 1);
}
#[test]
fn duplicate_anchors_merge_keeping_the_outer_handles() {
let mut sp = SubPath::new(
vec![
Anchor::with_handles((0.0, 0.0).into(), (0.0, 0.0).into(), (0.0, 0.0).into()),
Anchor::with_handles((10.0, 0.0).into(), (8.0, 0.0).into(), (10.0, 0.0).into()),
Anchor::with_handles((10.005, 0.0).into(), (10.005, 0.0).into(), (10.005, 3.0).into()),
Anchor::corner((10.0, 10.0).into()),
],
false,
);
assert_eq!(merge_duplicate_anchors(&mut sp, 0.01), 1);
assert_eq!(sp.anchors.len(), 3);
let a = sp.anchors[1];
assert_eq!((a.p, a.h_in), (Point::new(10.0, 0.0), Point::new(8.0, 0.0)));
assert!(a.h_out.distance(Point::new(10.0, 3.0)) < 1e-9, "{:?}", a.h_out);
assert_eq!(merge_duplicate_anchors(&mut sp, 0.01), 0);
// A closed path's last anchor on its first merges into it.
let mut ring = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 0.0)], true);
assert_eq!(merge_duplicate_anchors(&mut ring, 0.01), 1);
assert_eq!(points(&ring), [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
}
#[test]
fn tiny_handles_retract() {
let p = Point::new(5.0, 5.0);
let mut sp = SubPath::new(vec![Anchor { p, h_in: p - Vec2::new(0.004, 0.0), h_out: p + Vec2::new(3.0, 0.0), kind: AnchorKind::Smooth }], false);
assert_eq!(retract_tiny_handles(&mut sp, 0.01), 1);
let a = sp.anchors[0];
assert_eq!((a.h_in, a.kind), (p, AnchorKind::Corner));
assert_eq!(a.h_out, p + Vec2::new(3.0, 0.0), "a long handle stays");
assert_eq!(retract_tiny_handles(&mut sp, 0.01), 0);
}
#[test]
fn nearly_closed_paths_close() {
let mut sp = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.005, 0.0)], false);
assert!(close_nearly_closed(&mut sp, 0.01));
assert!(sp.closed);
assert_eq!(points(&sp), [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
let mut apart = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (1.0, 0.0)], false);
assert!(!close_nearly_closed(&mut apart, 0.01));
assert!(!apart.closed);
let mut two = line(&[(0.0, 0.0), (0.0, 0.0)], false);
assert!(!close_nearly_closed(&mut two, 0.01), "two anchors make no shape");
}
#[test]
fn near_points_group_but_linked_and_equal_ones_do_not() {
let pts: Vec<Point> = [(0.0, 0.0), (0.005, 0.0), (5.0, 5.0), (5.0, 5.0), (9.0, 9.0), (9.004, 9.0), (f64::NAN, 0.0)].map(Into::into).to_vec();
let groups = near_point_groups(&pts, 0.01, |a, b| (a, b) == (4, 5));
assert_eq!(groups, [vec![0, 1]], "equal points and linked ones are not snapped");
assert!(near_point_groups(&pts, 0.0, |_, _| false).is_empty());
assert!(near_point_groups(&pts, f64::NAN, |_, _| false).is_empty());
// Far coordinates don't overflow the grid.
let far: Vec<Point> = [(1e300, 1e300), (-1e300, 1e300)].map(Into::into).to_vec();
assert!(near_point_groups(&far, 1e-300, |_, _| false).is_empty());
}
#[test]
fn rejoin_welds_pieces_within_the_gap() {
// Three pieces of one zig-zag, the middle one drawn backwards, plus one too far away.
let pieces = [
line(&[(0.0, 0.0), (10.0, 0.0)], false),
line(&[(20.0, 10.0), (10.05, 0.0)], false),
line(&[(20.0, 10.0), (30.0, 10.0)], false),
line(&[(31.0, 10.0), (40.0, 10.0)], false),
];
let out = rejoin(&pieces, 0.1);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(out[0].members, [0, 1, 2]);
assert_eq!(points(&out[0].path), [(0.0, 0.0), (10.0, 0.0), (20.0, 10.0), (30.0, 10.0)]);
assert!(!out[0].path.closed);
// With a wider gap the last piece joins too.
assert_eq!(rejoin(&pieces, 1.0)[0].members, [0, 1, 2, 3]);
// Nothing within the gap: nothing joins.
assert!(rejoin(&pieces[2..], 0.5).is_empty());
}
#[test]
fn rejoin_takes_the_straightest_continuation_and_closes_loops() {
// Piece 0 ends at the origin; piece 1 goes straight on, piece 2 turns back.
let pieces = [line(&[(-10.0, 0.0), (0.0, 0.0)], false), line(&[(0.0, 0.0), (0.0, 10.0)], false), line(&[(0.0, 0.0), (10.0, 0.0)], false)];
let out = rejoin(&pieces, 0.1);
assert_eq!(out.len(), 1);
assert_eq!(out[0].members, [0, 2]);
// Four sides of a square close into one path.
let sides = [
line(&[(0.0, 0.0), (10.0, 0.0)], false),
line(&[(10.0, 0.0), (10.0, 10.0)], false),
line(&[(0.0, 10.0), (10.0, 10.0)], false),
line(&[(0.0, 10.0), (0.0, 0.0)], false),
];
let out = rejoin(&sides, 0.1);
assert_eq!(out.len(), 1);
assert!(out[0].path.closed);
assert_eq!(out[0].path.anchors.len(), 4);
// Closed, single-anchor and non-finite pieces stay out; bad gaps join nothing.
let odd = [line(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)], true), line(&[(1.0, 0.0)], false), line(&[(f64::NAN, 0.0), (0.0, 1.0)], false)];
assert!(rejoin(&odd, 1.0).is_empty());
assert!(rejoin(&sides, f64::NAN).is_empty() && rejoin(&sides, -1.0).is_empty());
}
+305
View File
@@ -0,0 +1,305 @@
//! Shape-keeping anchor removal: an anchor goes, and the handles of its two neighbours that face it
//! are refitted (their directions kept, their lengths fitted) so one segment follows the two that
//! met there. [`remove_anchor_keep_shape`] removes one anchor and says how far the outline moved;
//! [`simplify_keep_shape`] removes as many as it can while every new segment stays within a
//! tolerance of the original outline.
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use vectorcraft_geom::{Anchor, PathData, SubPath};
use crate::edit::remove_anchor;
use crate::fit::{end_tangent, start_tangent};
/// The turn (degrees) at an anchor above which `protect_corners` keeps it.
pub const SHARP_CORNER_DEG: f64 = 30.0;
/// Points sampled along each curve to measure how far two outlines are apart.
const SAMPLES: usize = 8;
/// Nearest-point accuracy (pt) of those measurements.
const ACCURACY: f64 = 1e-6;
/// The segment that takes the place of the two meeting at `cur` once it is removed: from `prev`
/// to `next`, their facing handles refitted ([`remove_anchor`]).
fn merged(prev: &Anchor, cur: &Anchor, next: &Anchor) -> CubicBez {
let mut run = SubPath::new(vec![*prev, *cur, *next], false);
remove_anchor(&mut run, 1);
match run.anchors.as_slice() {
[a, b] => CubicBez::new(prev.p, a.h_out, b.h_in, next.p),
_ => CubicBez::new(prev.p, prev.h_out, next.h_in, next.p),
}
}
/// How far `fitted` strays from the run of segments `original` it replaces, measured both ways
/// (pt): the farthest a sampled point of either lies from the other.
fn deviation(original: &[&CubicBez], fitted: &CubicBez) -> f64 {
let t = |k: usize| k as f64 / SAMPLES as f64;
let mut worst: f64 = 0.0;
for c in original {
for k in 0..=SAMPLES {
worst = worst.max(fitted.nearest(c.eval(t(k)), ACCURACY).distance_sq);
}
}
for k in 1..SAMPLES {
let p = fitted.eval(t(k));
worst = worst.max(original.iter().map(|c| c.nearest(p, ACCURACY).distance_sq).fold(f64::INFINITY, f64::min));
}
worst.sqrt()
}
/// The fewest anchors a subpath keeps: two when open, three when closed.
fn min_anchors(sp: &SubPath) -> usize {
if sp.closed { 3 } else { 2 }
}
/// Indices of anchor `i`'s neighbours in a subpath of `n` anchors (`None` for an open end).
fn neighbours(n: usize, closed: bool, i: usize) -> Option<(usize, usize)> {
match closed {
true => (n > 0).then(|| ((i + n - 1) % n, (i + 1) % n)),
false => (i > 0 && i + 1 < n).then(|| (i - 1, i + 1)),
}
}
fn finite(sp: &SubPath) -> bool {
sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
}
/// Does the path turn by more than [`SHARP_CORNER_DEG`] at anchor `i` (an inner anchor)?
fn is_sharp(sp: &SubPath, i: usize) -> bool {
let Some((prev, _)) = neighbours(sp.anchors.len(), sp.closed, i) else { return false };
let (into, out) = (end_tangent(&sp.segment(prev)), start_tangent(&sp.segment(i)));
into.dot(out).clamp(-1.0, 1.0).acos().to_degrees() > SHARP_CORNER_DEG
}
/// Give anchors `prev` and `next` the facing handles of segment `c`.
fn apply(sp: &mut SubPath, prev: usize, next: usize, c: &CubicBez) {
if let Some(a) = sp.anchors.get_mut(prev) {
a.h_out = c.p1;
}
if let Some(a) = sp.anchors.get_mut(next) {
a.h_in = c.p2;
}
}
/// Remove anchor `index` of `sp`, refitting the handles of its neighbours that face it so one
/// segment follows the two that met there → (the new subpath, how far its outline strays from the
/// old one, in points). `None` when the anchor can't go: out of range, an end of an open subpath,
/// a non-finite coordinate, or the subpath would keep fewer than two (open) or three (closed)
/// anchors.
pub fn remove_anchor_keep_shape(sp: &SubPath, index: usize) -> Option<(SubPath, f64)> {
let n = sp.anchors.len();
if n <= min_anchors(sp) || index >= n || !finite(sp) {
return None;
}
let (prev, next) = neighbours(n, sp.closed, index)?;
let c = merged(sp.anchors.get(prev)?, sp.anchors.get(index)?, sp.anchors.get(next)?);
let error = deviation(&[&sp.segment(prev), &sp.segment(index)], &c);
let mut out = sp.clone();
apply(&mut out, prev, next, &c);
out.anchors.remove(index);
Some((out, error))
}
/// Remove as many anchors of `path` as can go while its outline stays within `tolerance` points of
/// where it was, refitting the handles beside each ([`remove_anchor_keep_shape`]). Every new segment
/// is measured against the original outline it replaces, so removals don't add up past the
/// tolerance. With `protect_corners`, anchors where the path turns by more than
/// [`SHARP_CORNER_DEG`] stay. Open ends always stay. A subpath with a non-finite coordinate, and a
/// tolerance that isn't a positive number, are left as they are.
pub fn simplify_keep_shape(path: &PathData, tolerance: f64, protect_corners: bool) -> PathData {
if !(tolerance > 0.0 && tolerance.is_finite()) {
return path.clone();
}
PathData::new(path.subpaths.iter().map(|sp| Reducer::new(sp, protect_corners).run(tolerance)).collect())
}
/// One subpath being reduced: what is left of it, and where its anchors were in the original.
struct Reducer<'a> {
original: &'a SubPath,
segments: Vec<CubicBez>,
/// Anchors of the original that stay whatever the tolerance (sharp corners).
kept: Vec<bool>,
cur: SubPath,
/// The original index of each anchor left.
origin: Vec<usize>,
}
impl<'a> Reducer<'a> {
fn new(sp: &'a SubPath, protect_corners: bool) -> Self {
let n = sp.anchors.len();
Reducer {
original: sp,
segments: (0..sp.segment_count()).map(|i| sp.segment(i)).collect(),
kept: (0..n).map(|i| protect_corners && is_sharp(sp, i)).collect(),
cur: sp.clone(),
origin: (0..n).collect(),
}
}
/// Each pass removes the anchors whose removal strays least, no two side by side (so each
/// segment measured is the one that results), until none can go.
fn run(mut self, tolerance: f64) -> SubPath {
if self.cur.anchors.len() <= min_anchors(self.original) || !finite(self.original) {
return self.cur;
}
while self.pass(tolerance) {}
self.cur
}
/// The original segments between original anchors `from` and `to`, round the end of a closed
/// path.
fn span(&self, from: usize, to: usize) -> Option<Vec<&CubicBez>> {
Some(match from < to {
true => self.segments.get(from..to)?.iter().collect(),
false => self.segments.get(from..)?.iter().chain(self.segments.get(..to)?).collect(),
})
}
/// Anchor `i` of what is left, if it may go: (how far its removal strays, the segment that
/// replaces its two).
fn candidate(&self, i: usize, tolerance: f64) -> Option<(f64, CubicBez)> {
if self.origin.get(i).and_then(|&o| self.kept.get(o)) != Some(&false) {
return None;
}
let (prev, next) = neighbours(self.cur.anchors.len(), self.cur.closed, i)?;
let a = &self.cur.anchors;
let c = merged(a.get(prev)?, a.get(i)?, a.get(next)?);
let e = deviation(&self.span(*self.origin.get(prev)?, *self.origin.get(next)?)?, &c);
(e <= tolerance).then_some((e, c))
}
/// One pass: whether any anchor went.
fn pass(&mut self, tolerance: f64) -> bool {
let m = self.cur.anchors.len();
let min = min_anchors(self.original);
if m <= min {
return false;
}
let mut found: Vec<(f64, usize, CubicBez)> = (0..m).filter_map(|i| self.candidate(i, tolerance).map(|(e, c)| (e, i, c))).collect();
found.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut gone = vec![false; m];
let mut left = m - min;
for (_, i, c) in found {
let Some((prev, next)) = neighbours(m, self.cur.closed, i) else { continue };
if left == 0 || gone.get(prev) != Some(&false) || gone.get(next) != Some(&false) {
continue;
}
apply(&mut self.cur, prev, next, &c);
if let Some(g) = gone.get_mut(i) {
*g = true;
}
left -= 1;
}
let mut keep = gone.iter().map(|g| !g);
self.cur.anchors.retain(|_| keep.next().unwrap_or(true));
let mut keep = gone.iter().map(|g| !g);
self.origin.retain(|_| keep.next().unwrap_or(true));
left < m - min
}
}
#[cfg(test)]
mod tests {
use kurbo::{ParamCurveNearest, Point, Vec2};
use vectorcraft_geom::{Anchor, PathData, SubPath};
use super::{remove_anchor_keep_shape, simplify_keep_shape};
/// A circle of radius `r` about the origin drawn with `n` smooth anchors.
fn circle(r: f64, n: usize) -> SubPath {
let step = std::f64::consts::TAU / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan() * r;
let anchors = (0..n)
.map(|i| {
let a = step * i as f64;
let p = Point::new(r * a.cos(), r * a.sin());
Anchor::smooth(p, p + Vec2::new(-a.sin(), a.cos()) * k)
})
.collect();
SubPath::new(anchors, true)
}
/// The farthest a point of `sp`'s outline lies from the circle of radius `r`.
fn off_circle(sp: &SubPath, r: f64) -> f64 {
(0..sp.segment_count())
.flat_map(|i| (0..=20).map(move |k| (i, k as f64 / 20.0)))
.map(|(i, t)| (kurbo::ParamCurve::eval(&sp.segment(i), t).to_vec2().hypot() - r).abs())
.fold(0.0, f64::max)
}
#[test]
fn a_circle_keeps_its_shape_with_fewer_anchors() {
let sp = circle(50.0, 16);
let out = simplify_keep_shape(&PathData::single(sp), 0.2, false);
let s = &out.subpaths[0];
assert!(s.anchors.len() <= 8 && s.anchors.len() >= 3, "{} anchors left", s.anchors.len());
assert!(s.closed);
assert!(off_circle(s, 50.0) <= 0.2 + 1e-6, "strays {}", off_circle(s, 50.0));
}
#[test]
fn one_removal_reports_how_far_the_outline_moved() {
let sp = circle(50.0, 16);
let (out, err) = remove_anchor_keep_shape(&sp, 3).unwrap();
assert_eq!(out.anchors.len(), 15);
assert!(err < 0.05, "{err}");
assert!((off_circle(&out, 50.0) - err).abs() < 0.05);
// A corner of a square moves its outline a long way.
let sq = SubPath::polyline(&[(0.0, 0.0).into(), (10.0, 0.0).into(), (10.0, 10.0).into(), (0.0, 10.0).into()], true);
let (_, err) = remove_anchor_keep_shape(&sq, 1).unwrap();
assert!((err - 50f64.sqrt()).abs() < 1e-3, "{err}");
}
#[test]
fn sharp_corners_stay_when_protected() {
// A 90° corner at (1, 0) that a 1 pt tolerance would otherwise take away.
let sp = SubPath::polyline(&[(0.0, 0.0).into(), (1.0, 0.0).into(), (1.0, 1.0).into()], false);
let path = PathData::single(sp);
assert_eq!(simplify_keep_shape(&path, 1.0, false).anchor_count(), 2);
assert_eq!(simplify_keep_shape(&path, 1.0, true).anchor_count(), 3);
// Collinear anchors on a straight edge go either way; the square's corners stay.
let sq = SubPath::polyline(
&[(0.0, 0.0).into(), (5.0, 0.0).into(), (10.0, 0.0).into(), (10.0, 10.0).into(), (0.0, 10.0).into(), (0.0, 5.0).into()],
true,
);
let out = simplify_keep_shape(&PathData::single(sq), 0.01, true);
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts, [(0.0, 0.0).into(), (10.0, 0.0).into(), (10.0, 10.0).into(), (0.0, 10.0).into()] as [Point; 4]);
}
#[test]
fn removals_do_not_add_up_past_the_tolerance() {
// A gentle arc of many anchors: each removal alone is small, together they would flatten it.
let pts: Vec<Point> = (0..=40).map(|i| Point::new(i as f64, (i as f64 / 40.0 * std::f64::consts::PI).sin() * 3.0)).collect();
let sp = SubPath::polyline(&pts, false);
let out = simplify_keep_shape(&PathData::single(sp.clone()), 0.1, false);
let s = &out.subpaths[0];
assert!(s.anchors.len() < 41);
for p in &pts {
let d = (0..s.segment_count()).map(|i| s.segment(i).nearest(*p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
assert!(d <= 0.1 + 1e-6, "{p:?} is {d} away");
}
}
#[test]
fn degenerate_input_is_left_alone() {
let line = SubPath::polyline(&[(0.0, 0.0).into(), (1.0, 0.0).into()], false);
assert!(remove_anchor_keep_shape(&line, 0).is_none(), "two anchors stay");
let three = SubPath::polyline(&[(0.0, 0.0).into(), (1.0, 0.0).into(), (2.0, 0.0).into()], false);
assert!(remove_anchor_keep_shape(&three, 0).is_none(), "an open end stays");
assert!(remove_anchor_keep_shape(&three, 9).is_none(), "out of range");
assert_eq!(remove_anchor_keep_shape(&three, 1).unwrap().0.anchors.len(), 2);
let tri = SubPath::polyline(&[(0.0, 0.0).into(), (1.0, 0.0).into(), (0.0, 1.0).into()], true);
assert!(remove_anchor_keep_shape(&tri, 1).is_none(), "a closed path keeps three");
let nan = SubPath::polyline(&[(0.0, 0.0).into(), (f64::NAN, 0.0).into(), (2.0, 0.0).into()], false);
assert!(remove_anchor_keep_shape(&nan, 1).is_none());
for path in [PathData::default(), PathData::single(SubPath::default()), PathData::single(nan.clone()), PathData::single(three.clone())] {
// Compared as text: NaN never equals itself.
for tol in [f64::NAN, -1.0, 0.0, f64::INFINITY] {
assert_eq!(format!("{:?}", simplify_keep_shape(&path, tol, false)), format!("{path:?}"));
}
let _ = simplify_keep_shape(&path, 1.0, true);
}
assert_eq!(format!("{:?}", simplify_keep_shape(&PathData::single(nan.clone()), 1.0, false)), format!("{:?}", PathData::single(nan)));
}
}
+1
View File
@@ -6,6 +6,7 @@
//! anchors. See the crate README for the API overview.
#![forbid(unsafe_code)]
pub mod addons;
mod boolean;
mod budget;
mod edit;