Keep Smooth's closed-path start corner within the tolerance

Found while testing crates/pathops/src/edit.rs:342-364 (smooth_level calls smooth_seam after the fit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-29 18:46:46 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent e962197260
commit 9c01829cbb
2 changed files with 99 additions and 6 deletions
+53 -6
View File
@@ -327,7 +327,7 @@ pub const SMOOTH_AUTO_LEVEL: f64 = 50.0;
/// within a tolerance from 0.1 % of the path's longer side at level 0 to 2.5 % at level 100. A refit
/// never has more anchor points than the subpath had: a shape whose corners the tolerance can't
/// round, such as a rectangle, stays as it is. Closed subpaths stay closed, with no kink left where
/// they start, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as
/// they start unless rounding it would pass the tolerance, and open ones keep their end points. A level outside 0..=100 counts as the nearer end, and one that is not a number as
/// [`SMOOTH_AUTO_LEVEL`].
pub fn smooth_level(path: &PathData, level: f64) -> PathData {
let level = if level.is_finite() { level.clamp(0.0, 100.0) } else { SMOOTH_AUTO_LEVEL };
@@ -338,7 +338,7 @@ pub fn smooth_level(path: &PathData, level: f64) -> PathData {
let opts = SimplifyOptions { tolerance: size * (0.001 + 0.024 * level / 100.0), corner_angle_deg: SMOOTH_CORNER_DEG, straight_lines: false };
let smoothed = |sp: &SubPath| {
let Some(mut fit) = simplify_subpath(sp, &opts).filter(|f| f != sp) else { return sp.clone() };
smooth_seam(&mut fit);
smooth_seam(&mut fit, sp, opts.tolerance);
fit
};
PathData::new(path.subpaths.iter().map(smoothed).collect())
@@ -349,18 +349,65 @@ const SMOOTH_CORNER_DEG: f64 = 179.0;
/// A closed subpath's refit leaves its start anchor with the tangents of the original segments on
/// either side of it, a kink when the path had one there: turn it into a smooth anchor with the same
/// handle lengths, unless the path turns back there.
fn smooth_seam(sp: &mut SubPath) {
/// handle lengths, unless the path turns back there or the rounded path would stray from `original`
/// by more than `tol` (or than the refit already does).
fn smooth_seam(sp: &mut SubPath, original: &SubPath, tol: f64) {
if !sp.closed {
return;
}
let Some(a) = sp.anchors.first_mut() else { return };
let Some(a) = sp.anchors.first() else { return };
let (Some(i), Some(o)) = (crate::fit::unit(a.p - a.h_in), crate::fit::unit(a.h_out - a.p)) else { return };
if i.dot(o) < SMOOTH_CORNER_DEG.to_radians().cos() {
return;
}
let Some(dir) = crate::fit::unit(i + o) else { return };
*a = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p));
let mut rounded = sp.clone();
if let Some(r) = rounded.anchors.first_mut() {
*r = Anchor::with_handles(a.p, a.p - dir * a.h_in.distance(a.p), a.p + dir * a.h_out.distance(a.p));
}
if seam_error(original, sp, &rounded, tol) <= tol.max(seam_error(original, sp, sp, tol)) {
*sp = rounded;
}
}
/// How far the segments on either side of the start anchor of `fit` (a refit of the closed
/// `original` whose other segments match `before`) are from `original`, both ways: the largest
/// distance from those segments to `original`, and from the parts of `original` that `before`'s
/// segments there cover (within `tol`) to `fit`.
fn seam_error(original: &SubPath, before: &SubPath, fit: &SubPath, tol: f64) -> f64 {
use kurbo::ParamCurveNearest;
let n = fit.segment_count();
if n == 0 || before.segment_count() != n || original.segment_count() == 0 {
return f64::INFINITY;
}
let is_seam = |j: usize| j == 0 || j + 1 == n;
let dist = |q: Point, sp: &SubPath, seam: bool| {
(0..sp.segment_count())
.filter(|&j| is_seam(j) == seam)
.map(|j| sp.segment(j).nearest(q, 1e-9).distance_sq)
.fold(f64::INFINITY, f64::min)
.sqrt()
};
let samples = |sp: &SubPath, seam_only: bool| {
let mut pts = Vec::new();
for j in (0..sp.segment_count()).filter(|&j| !seam_only || is_seam(j)) {
let c = sp.segment(j);
let m = ((c.arclen(1e-6) / (tol.max(1e-6) * 0.5)).ceil() as usize).clamp(4, 256);
sample(&c, m, &mut pts, true);
}
pts
};
let all = |q: Point| dist(q, original, true).min(dist(q, original, false));
let forward = samples(fit, true).into_iter().map(all).fold(0.0, f64::max);
let back = samples(original, false)
.into_iter()
.filter(|&q| dist(q, before, true) <= tol)
.map(|q| {
let d = dist(q, fit, true);
if d > tol { d.min(dist(q, fit, false)) } else { d }
})
.fold(0.0, f64::max);
forward.max(back)
}
/// Remove anchors that can be deleted without changing the shape by more than `tolerance`
+46
View File
@@ -445,6 +445,52 @@ fn smooth_level_keeps_corners_it_cannot_round_and_curves_polygons() {
assert_eq!(smooth_level(&polygon, 0.0), polygon, "0.1 pt is less than the sagitta");
}
/// The largest distance from a point of `a` to `b` (sampled on `a`, exact on `b`).
fn one_way_distance(a: &PathData, b: &PathData) -> f64 {
use kurbo::ParamCurveNearest;
let mut worst = 0.0_f64;
for sa in &a.subpaths {
for i in 0..sa.segment_count() {
let c = sa.segment(i);
for k in 0..=64 {
let q = c.eval(f64::from(k) / 64.0);
let d = b
.subpaths
.iter()
.flat_map(|sb| (0..sb.segment_count()).map(move |j| sb.segment(j).nearest(q, 1e-9).distance_sq))
.fold(f64::INFINITY, f64::min);
worst = worst.max(d.sqrt());
}
}
}
worst
}
/// Object › Path › Smooth at level 0 keeps a closed path within 0.1 % of its longer side, a sharp
/// corner where the path starts included (a drop with a 90° tip at its first anchor).
#[test]
fn smooth_level_0_keeps_a_closed_paths_start_corner_within_tolerance() {
let n = 24;
let step = 1.5 * PI / f64::from(n);
let at = |t: f64| {
let (r, dr) = (100.0 * (2.0 * t / 3.0).sin(), 200.0 / 3.0 * (2.0 * t / 3.0).cos());
(Point::new(r * t.cos(), r * t.sin()), kurbo::Vec2::new(dr * t.cos() - r * t.sin(), dr * t.sin() + r * t.cos()))
};
let anchors = (0..n)
.map(|i| {
let (p, d_out) = at(f64::from(i) * step);
let d_in = if i == 0 { at(1.5 * PI).1 } else { d_out };
vectorcraft_geom::Anchor::with_handles(p, p - d_in * (step / 3.0), p + d_out * (step / 3.0))
})
.collect();
let p = PathData::single(SubPath::new(anchors, true));
let b = p.bounds().unwrap();
let limit = 0.001 * b.width().max(b.height());
let s = smooth_level(&p, 0.0);
let moved = one_way_distance(&p, &s).max(one_way_distance(&s, &p));
assert!(moved <= limit, "level 0 moved the path by {moved} pt, more than {limit} pt: {:?}", s.subpaths[0].anchors[0]);
}
#[test]
fn remove_redundant_collinear_and_curve() {
let p = PathData::single(SubPath::polyline(