mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Merge pull request #4571 from Silvertree2010/fix/h3903-object-path-smooth-at-level-0-mo
Keep Smooth's closed-path start corner within the tolerance
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user