diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 94788f8d1..520ff2bd3 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -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` diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 9feaa79a8..c3184a9fc 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -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(