Path: Object › Path › Smooth smooths the selected paths with a slider and Auto-Smooth (#4115)

This commit is contained in:
bflatastic
2026-10-29 13:51:51 +07:00
parent 5769caaa95
commit efc1501ac9
44 changed files with 583 additions and 93 deletions
+49
View File
@@ -314,6 +314,55 @@ pub fn smooth(path: &PathData, amount: f64) -> PathData {
out
}
/// The Smooth slider's level Auto-Smooth picks, halfway between minimum and maximum smoothing.
/// VectorCraft's own rule: the help page says only that Auto-Smooth smooths the path automatically
/// (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html).
/// [`smooth_level`] already scales with each path's size, so one level suits every path.
pub const SMOOTH_AUTO_LEVEL: f64 = 50.0;
/// Object › Path › Smooth at slider level `level`, from 0 (minimum smoothing: the path stays within
/// 0.1 % of its longer side of where it was) to 100 (maximum). The help page names only the two
/// ends; the rest is VectorCraft's own rule. Each subpath is refitted with curves as Simplify does,
/// but keeping a corner only where the path turns back on itself (so jags and kinks become curves),
/// 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
/// [`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 };
let size = path.bounds().map_or(0.0, |b| b.width().max(b.height()));
if !size.is_finite() {
return path.clone();
}
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);
fit
};
PathData::new(path.subpaths.iter().map(smoothed).collect())
}
/// [`smooth_level`] keeps a corner only where the path turns by more than this (turns back).
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) {
if !sp.closed {
return;
}
let Some(a) = sp.anchors.first_mut() 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));
}
/// 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 {
+2 -2
View File
@@ -20,8 +20,8 @@ pub use boolean::{
try_unite_all_with, unite_all, unite_all_with,
};
pub use edit::{
AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor,
remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid,
AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join,
remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions};
+16 -1
View File
@@ -1,7 +1,7 @@
use std::f64::consts::PI;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath};
use vectorcraft_pathops::*;
const NZ: FillRule = FillRule::NonZero;
@@ -430,6 +430,21 @@ fn smooth_rounds_corners() {
assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0));
}
/// Object › Path › Smooth: a rectangle, whose corners no curve rounds within the tolerance, stays as
/// it is even at maximum smoothing; a polygon of 16 sides becomes a closed curve of fewer anchors once the tolerance
/// passes its 0.96 pt sagitta, smooth all round.
#[test]
fn smooth_level_keeps_corners_it_cannot_round_and_curves_polygons() {
let r = rect(0.0, 0.0, 100.0, 50.0);
assert_eq!(smooth_level(&r, 100.0), r);
let pts: Vec<Point> = (0..16).map(|i| Point::new(50.0 * (f64::from(i) * PI / 8.0).cos(), 50.0 * (f64::from(i) * PI / 8.0).sin())).collect();
let polygon = PathData::single(SubPath::polyline(&pts, true));
let s = smooth_level(&polygon, SMOOTH_AUTO_LEVEL);
assert!(s.subpaths.len() == 1 && s.subpaths[0].closed && s.anchor_count() < 8, "{}", s.anchor_count());
assert!(s.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "no kink, its start included: {s:?}");
assert_eq!(smooth_level(&polygon, 0.0), polygon, "0.1 pt is less than the sagitta");
}
#[test]
fn remove_redundant_collinear_and_curve() {
let p = PathData::single(SubPath::polyline(