vectorcraft/crates/pathops/src/edit.rs

372 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
//! Join, Split Into Grid.
use kurbo::{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};
/// Options for [`simplify_with`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimplifyOptions {
/// Maximum distance (document points) between the original and simplified path.
pub tolerance: f64,
/// Anchors where the path turns by more than this many degrees stay corners.
pub corner_angle_deg: f64,
/// Produce only straight segments (Illustrator's "Convert to Straight Lines").
pub straight_lines: bool,
}
impl Default for SimplifyOptions {
fn default() -> Self {
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false }
}
}
/// Simplify with the default corner threshold (30°).
pub fn simplify(path: &PathData, tolerance: f64) -> PathData {
simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() })
}
/// Simplify never adds anchors: keep whichever subpath is smaller.
fn no_worse(original: &SubPath, fitted: SubPath) -> SubPath {
if fitted.anchors.len() > original.anchors.len() { original.clone() } else { fitted }
}
fn subpath_segs(sp: &SubPath) -> Vec<Seg> {
(0..sp.segment_count()).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect()
}
fn turn_deg(a: &Seg, b: &Seg) -> f64 {
let d = end_tangent(&a.c).dot(start_tangent(&b.c)).clamp(-1.0, 1.0);
d.acos().to_degrees()
}
/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold.
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
let tol = opts.tolerance.max(1e-6);
let mut out = Vec::new();
for sp in &path.subpaths {
let mut segs = subpath_segs(sp);
let n = segs.len();
if n == 0 {
out.push(sp.clone());
continue;
}
let corner_at = |segs: &[Seg], i: usize| -> bool {
if !sp.closed && (i == 0 || i == n) {
return true;
}
turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg
};
let mut corners: Vec<usize> = (0..n).filter(|&i| corner_at(&segs, i)).collect();
if sp.closed {
if let Some(&k) = corners.first() {
segs.rotate_left(k);
corners = corners.iter().map(|&c| c - k).collect();
} else {
corners = vec![0];
}
corners.push(n);
} else if corners.last() != Some(&n) {
corners.push(n);
}
let mut result: Vec<Seg> = Vec::new();
for w in corners.windows(2) {
let run = &segs[w[0]..w[1]];
if run.is_empty() {
continue;
}
let mut pts = Vec::new();
for (k, s) in run.iter().enumerate() {
let len = s.c.arclen(1e-6);
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
sample(&s.c, m, &mut pts, k == 0);
}
if opts.straight_lines {
let keep = rdp(&pts, tol);
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
continue;
}
let t0 = start_tangent(&run[0].c);
let t1 = end_tangent(&run[run.len() - 1].c);
for c in fit_cubics(&pts, t0, t1, tol) {
let line = is_straight(&c, tol * 0.02);
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
}
}
if let Some(s) = segs_to_subpath(&result, sp.closed) {
out.push(if opts.straight_lines { s } else { no_worse(sp, s) });
}
}
PathData::new(out)
}
/// Ramer–Douglas–Peucker polyline reduction.
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
if pts.len() < 3 {
return pts.to_vec();
}
let mut keep = vec![false; pts.len()];
keep[0] = true;
keep[pts.len() - 1] = true;
let mut stack = vec![(0, pts.len() - 1)];
while let Some((a, b)) = stack.pop() {
let (pa, pb) = (pts[a], pts[b]);
let chord = pb - pa;
let len = chord.hypot();
let mut best = (0.0, a);
for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) {
let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len };
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect()
}
/// Object → Path → Smooth: pull handles towards Catmull-Rom-like tangents. `amount` ∈ [0, 1]
/// (0 = unchanged, 1 = fully smooth). Endpoints of open paths keep their handles.
pub fn smooth(path: &PathData, amount: f64) -> PathData {
let t = amount.clamp(0.0, 1.0);
let mut out = path.clone();
for sp in &mut out.subpaths {
let n = sp.anchors.len();
if n < 3 {
continue;
}
let orig = sp.anchors.clone();
for i in 0..n {
if !sp.closed && (i == 0 || i == n - 1) {
continue;
}
let prev = orig[(i + n - 1) % n].p;
let next = orig[(i + 1) % n].p;
let p = orig[i].p;
let Some(dir) = crate::fit::unit(next - prev) else { continue };
let tin = p - dir * (p.distance(prev) / 3.0);
let tout = p + dir * (p.distance(next) / 3.0);
let a = &orig[i];
let h_in = a.h_in.lerp(tin, t);
let h_out = a.h_out.lerp(tout, t);
sp.anchors[i] = Anchor::with_handles(p, h_in, h_out);
}
}
out
}
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
/// (collinear corner points, duplicate points, and curve joints that one cubic can replace).
pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData {
let tol = tolerance.max(1e-9);
let mut out = Vec::new();
for sp in &path.subpaths {
let mut segs = subpath_segs(sp);
if segs.is_empty() {
out.push(sp.clone());
continue;
}
// Drop zero-length segments.
segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9));
let min_segs = if sp.closed { 2 } else { 1 };
let mut changed = true;
while changed && segs.len() > min_segs {
changed = false;
let n = segs.len();
let joints = if sp.closed { n } else { n - 1 };
for j in 0..joints {
let (i0, i1) = if sp.closed { ((j + n - 1) % n, j) } else { (j, j + 1) };
if let Some(m) = merge_pair(&segs[i0], &segs[i1], tol) {
segs[i0] = m;
segs.remove(i1);
if sp.closed && i1 == 0 {
// The merged segment now starts the loop.
let last = segs.pop().expect("non-empty");
segs.insert(0, last);
}
changed = true;
break;
}
}
}
if let Some(s) = segs_to_subpath(&segs, sp.closed) {
out.push(s);
}
}
PathData::new(out)
}
fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
if a.line && b.line {
let chord = b.c.p3 - a.c.p0;
let len = chord.hypot();
if len < 1e-12 {
return None;
}
let mid = a.c.p3 - a.c.p0;
let along = mid.dot(chord) / len;
return ((mid.cross(chord) / len).abs() <= tol && along >= 0.0 && along <= len).then(|| Seg::line(a.c.p0, b.c.p3));
}
// Only merge across (near-)smooth joints.
if end_tangent(&a.c).dot(start_tangent(&b.c)) < 0.99 {
return None;
}
let mut pts = Vec::new();
sample(&a.c, 16, &mut pts, true);
sample(&b.c, 16, &mut pts, false);
let (c, err, _) = fit_single(&pts, start_tangent(&a.c), end_tangent(&b.c));
(err <= tol).then_some(Seg { c, line: false })
}
/// 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();
for sp in &mut out.subpaths {
for seg in (0..sp.segment_count()).rev() {
sp.insert_anchor(seg, 0.5);
}
}
out
}
/// Axis for [`average`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AverageAxis {
/// Align on a common horizontal line (same y).
Horizontal,
/// Align on a common vertical line (same x).
Vertical,
/// Collapse to the common centroid.
Both,
}
/// Object → Path → Average: move the selected anchors `(subpath, anchor)` (with their handles)
/// to their average position along `axis`.
pub fn average(path: &PathData, selection: &[(usize, usize)], axis: AverageAxis) -> PathData {
let mut out = path.clone();
let pts: Vec<Point> = selection.iter().filter_map(|&(s, a)| path.subpaths.get(s).and_then(|sp| sp.anchors.get(a)).map(|a| a.p)).collect();
if pts.is_empty() {
return out;
}
let c = pts.iter().fold(Vec2::ZERO, |acc, p| acc + p.to_vec2()) / pts.len() as f64;
for &(s, a) in selection {
if let Some(an) = out.anchor_mut(s, a) {
let d = match axis {
AverageAxis::Horizontal => Vec2::new(0.0, c.y - an.p.y),
AverageAxis::Vertical => Vec2::new(c.x - an.p.x, 0.0),
AverageAxis::Both => c - an.p.to_vec2(),
};
an.translate(d);
}
}
out
}
/// Object → Path → Join. Open subpaths of all inputs are joined nearest-endpoint-first into one
/// open path (endpoints closer than `tolerance` are merged into a single anchor, otherwise a
/// straight segment connects them). A single open subpath is closed instead. Closed subpaths
/// pass through unchanged.
pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
let mut closed: Vec<SubPath> = Vec::new();
let mut open: Vec<SubPath> = Vec::new();
for p in paths {
for sp in &p.subpaths {
if sp.closed || sp.anchors.len() < 2 {
if !sp.anchors.is_empty() {
closed.push(sp.clone());
}
} else {
open.push(sp.clone());
}
}
}
if open.len() == 1 {
let mut sp = open.pop().expect("one");
let n = sp.anchors.len();
if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance {
let last = sp.anchors.pop().expect("n > 2");
let f = &mut sp.anchors[0];
*f = Anchor::with_handles(f.p, last.h_in, f.h_out);
} else {
let f = &mut sp.anchors[0];
f.h_in = f.p;
let l = sp.anchors.last_mut().expect("n >= 2");
l.h_out = l.p;
}
sp.closed = true;
closed.push(sp);
return PathData::new(closed);
}
while open.len() > 1 {
// Find the closest pair of endpoints on different subpaths.
let mut best = (f64::INFINITY, 0, 0, false, false);
for i in 0..open.len() {
for j in (i + 1)..open.len() {
for ei in [false, true] {
for ej in [false, true] {
let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p;
let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p;
let d = pi.distance(pj);
if d < best.0 {
best = (d, i, j, ei, ej);
}
}
}
}
}
let (d, i, j, ei, ej) = best;
let mut b = open.remove(j);
let a = &mut open[i];
if !ei {
a.reverse();
}
if ej {
b.reverse();
}
if d <= tolerance {
let first = b.anchors.remove(0);
let l = a.anchors.last_mut().expect("len>=2");
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
} else {
let l = a.anchors.last_mut().expect("len>=2");
l.h_out = l.p;
b.anchors[0].h_in = b.anchors[0].p;
}
a.anchors.extend(b.anchors);
}
closed.extend(open);
PathData::new(closed)
}
/// Object → Path → Split Into Grid: `rows × cols` rectangles filling `rect` with `gutter` spacing
/// (row-major, top-left first).
pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec<PathData> {
if rows == 0 || cols == 0 {
return Vec::new();
}
let w = (rect.width() - gutter * (cols as f64 - 1.0)) / cols as f64;
let h = (rect.height() - gutter * (rows as f64 - 1.0)) / rows as f64;
if w <= 0.0 || h <= 0.0 {
return Vec::new();
}
let mut out = Vec::with_capacity(rows * cols);
for r in 0..rows {
for c in 0..cols {
let x = rect.x0 + c as f64 * (w + gutter);
let y = rect.y0 + r as f64 * (h + gutter);
out.push(PathData::single(SubPath::polyline(
&[Point::new(x, y), Point::new(x + w, y), Point::new(x + w, y + h), Point::new(x, y + h)],
true,
)));
}
}
out
}