mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 11:16:25 +00:00
Merge pull request #7396 from dotCooCoo/feat/smooth-keeps-corners
Smooth tool: keep corners where the path turns by more than 57.5°
This commit is contained in:
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Smooth",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}",
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag, keeping a corner where the path turns by more than 57.5° at the anchor and over radius and twice radius on either side → {ids, before, after}",
|
||||
has_selection,
|
||||
smooth_region
|
||||
),
|
||||
|
||||
@@ -13,9 +13,237 @@ use super::{
|
||||
/// of path within the reach on either side of it. VectorCraft's own value.
|
||||
const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The degrees by which a path must turn at an anchor, and over the tool's reach and twice its reach
|
||||
/// on either side of it, for the Smooth tool to keep the anchor as a corner ([`smooth_corners`]): a
|
||||
/// turn of more than 57.5°, an angle inside the corner of less than 122.5°. VectorCraft's own value,
|
||||
/// the same on every drag and at any Fidelity.
|
||||
const SMOOTH_CORNER_DEG: f64 = 57.5;
|
||||
|
||||
/// Most pieces a curved segment is cut into evenly, and most more pieces near each of its ends, to
|
||||
/// measure lengths along it in [`smooth_corners`].
|
||||
const CORNER_PIECES: usize = 64;
|
||||
|
||||
/// The control polygon's length of `c`, which is at least its length.
|
||||
fn polygon_len(c: CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that
|
||||
/// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over
|
||||
/// a stretch at least three times `reach` long where the segment is that long, up to as many more
|
||||
/// that are no more than `reach` / 8 apart along the control polygon, and as many again in each
|
||||
/// stretch twice as long after it until the pieces reach three times `reach`. The turns over `reach` and
|
||||
/// twice `reach` from an anchor are then measured along the curve however long the segment is.
|
||||
fn corner_places(c: CubicBez, reach: f64) -> Vec<f64> {
|
||||
let piece = reach / 8.0;
|
||||
let pieces = |len: f64| if piece > 0.0 { (len / piece).ceil().clamp(1.0, CORNER_PIECES as f64) as usize } else { 1 };
|
||||
let k = pieces(polygon_len(c)).max(4);
|
||||
let mut places: Vec<f64> = (1..=k).map(|j| j as f64 / k as f64).collect();
|
||||
if piece > 0.0 {
|
||||
for from_start in [true, false] {
|
||||
// The stretch from the end: halved until its control polygon is at most six reaches long.
|
||||
let first = 1.0 / k as f64;
|
||||
let mut t = first;
|
||||
let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) };
|
||||
for _ in 0..60 {
|
||||
if polygon_len(part(t)) <= 6.0 * reach {
|
||||
break;
|
||||
}
|
||||
t /= 2.0;
|
||||
}
|
||||
let side = |u: f64| if from_start { u } else { 1.0 - u };
|
||||
let m = pieces(polygon_len(part(t)));
|
||||
let mut fine: Vec<f64> = (1..=m).map(|j| t * j as f64 / m as f64).collect();
|
||||
// Then the stretches twice as long in turn, cut as finely, up to the first even place, until
|
||||
// the pieces reach three reaches from the end, so twice the reach is measured along the curve
|
||||
// where a handle that does not reach out makes the curve start more slowly than its polygon.
|
||||
let mut covered = 0.0;
|
||||
let mut at = c.eval(side(0.0));
|
||||
for u in &fine {
|
||||
let q = c.eval(side(*u));
|
||||
covered += at.distance(q);
|
||||
at = q;
|
||||
}
|
||||
let (mut from, mut levels) = (t, 0);
|
||||
while covered < 3.0 * reach && from < first && levels < 60 {
|
||||
let to = (2.0 * from).min(first);
|
||||
let piece_len = polygon_len(if from_start { c.subsegment(from..to) } else { c.subsegment(1.0 - to..1.0 - from) });
|
||||
let m = pieces(piece_len);
|
||||
for j in 1..=m {
|
||||
let u = from + (to - from) * j as f64 / m as f64;
|
||||
let q = c.eval(side(u));
|
||||
covered += at.distance(q);
|
||||
at = q;
|
||||
fine.push(u);
|
||||
}
|
||||
(from, levels) = (to, levels + 1);
|
||||
}
|
||||
places.extend(fine.into_iter().map(side));
|
||||
}
|
||||
}
|
||||
places.retain(|u| *u > 0.0 && *u <= 1.0);
|
||||
places.sort_by(f64::total_cmp);
|
||||
places.dedup();
|
||||
places
|
||||
}
|
||||
|
||||
/// The degrees between the directions `u` and `v`, `None` when either has no length or is not a
|
||||
/// number.
|
||||
fn turn_between(u: kurbo::Vec2, v: kurbo::Vec2) -> Option<f64> {
|
||||
let (lu, lv) = (u.hypot(), v.hypot());
|
||||
if !(lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let deg = u.cross(v).atan2(u.dot(v)).abs().to_degrees();
|
||||
deg.is_finite().then_some(deg)
|
||||
}
|
||||
|
||||
/// Which anchors of `anchors` (a closed path when `closed`) the Smooth tool keeps as corners: those
|
||||
/// where the path turns by more than [`SMOOTH_CORNER_DEG`] at the anchor itself, between the
|
||||
/// direction it arrives along and the one it leaves along, and also over `reach` and over twice
|
||||
/// `reach` on either side of it, between the direction from the point that far back along the path
|
||||
/// to the anchor and the direction from the anchor to the point that far ahead. The points are taken
|
||||
/// no farther than the ends of an open path, and no more than an eighth of the way round a closed
|
||||
/// one. A jag or a bump smaller than the reach turns sharply at its anchor but not over twice the
|
||||
/// reach, and an anchor in the middle of a tight curve turns over the reach but not at the anchor, so
|
||||
/// neither is kept. An open path's first and last anchors are never corners.
|
||||
fn smooth_corners(anchors: &[Anchor], closed: bool, reach: f64) -> Vec<bool> {
|
||||
let n = anchors.len();
|
||||
let mut corners = vec![false; n];
|
||||
if n < 2 || (n < 3 && !closed) {
|
||||
return corners;
|
||||
}
|
||||
// The distances on either side the turn is measured over: one reach and two, no more than an
|
||||
// eighth of the way round a closed path. Over that much of a circle the path turns by 45°, so a
|
||||
// loop shorter than the reach is not all corners, while a square's or a triangle's corners still are.
|
||||
let mut spans = [reach, 2.0 * reach];
|
||||
if closed {
|
||||
let mut total = 0.0;
|
||||
for i in 0..n {
|
||||
total += walk(anchors, closed, (i, true), [f64::INFINITY; 2], reach, 1).1;
|
||||
}
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
return corners;
|
||||
}
|
||||
spans = spans.map(|r| r.min(total / 8.0));
|
||||
}
|
||||
for (i, flag) in corners.iter_mut().enumerate() {
|
||||
if !closed && (i == 0 || i == n - 1) {
|
||||
continue;
|
||||
}
|
||||
let (prev, a, next) = (anchors[(i + n - 1) % n], anchors[i], anchors[(i + 1) % n]);
|
||||
let arrive = [a.p - a.h_in, a.p - prev.h_out, a.p - prev.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
|
||||
let leave = [a.h_out - a.p, next.h_in - a.p, next.p - a.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
|
||||
if !turn_between(arrive, leave).is_some_and(|t| t > SMOOTH_CORNER_DEG) {
|
||||
continue;
|
||||
}
|
||||
let [Some(back_near), Some(back_far)] = walk(anchors, closed, (i, false), spans, reach, MAX_CORNER_WALK).0 else { continue };
|
||||
let [Some(ahead_near), Some(ahead_far)] = walk(anchors, closed, (i, true), spans, reach, MAX_CORNER_WALK).0 else { continue };
|
||||
*flag = [(back_near, ahead_near, spans[0]), (back_far, ahead_far, spans[1])]
|
||||
.into_iter()
|
||||
.all(|(back, ahead, r)| r <= 0.0 || turn_between(a.p - back, ahead - a.p).is_some_and(|t| t > SMOOTH_CORNER_DEG));
|
||||
}
|
||||
corners
|
||||
}
|
||||
|
||||
/// Most segments [`walk`] passes from an anchor before it stops, which bounds the work for each
|
||||
/// anchor on a path of very many very short segments.
|
||||
const MAX_CORNER_WALK: usize = 256;
|
||||
|
||||
/// A walk along the path `anchors` from anchor `from.0`, ahead when `from.1` and back otherwise,
|
||||
/// past no more than `segments` segments, measured along the lines between each curved segment's
|
||||
/// points at [`corner_places`]. It gives the points at the distances `to` (in order, the nearer
|
||||
/// first), an open path's end for one beyond the end, and `None` for one it did not reach within
|
||||
/// `segments` segments, and the length it walked. Both points are `None` when it meets a length that
|
||||
/// is not a number or is too large to add, and the length is then infinite.
|
||||
fn walk(anchors: &[Anchor], closed: bool, from: (usize, bool), to: [f64; 2], reach: f64, segments: usize) -> ([Option<Point>; 2], f64) {
|
||||
let (n, forward) = (anchors.len(), from.1);
|
||||
let Some(mut here) = anchors.get(from.0).map(|a| a.p) else { return ([None; 2], 0.0) };
|
||||
let (mut at, mut gone) = (from.0, 0.0);
|
||||
let mut found: [Option<Point>; 2] = [None; 2];
|
||||
let mut at_end = false;
|
||||
'segments: for _ in 0..segments {
|
||||
let next = match (forward, closed) {
|
||||
(true, _) if at + 1 < n => at + 1,
|
||||
(true, true) => 0,
|
||||
(false, _) if at > 0 => at - 1,
|
||||
(false, true) => n - 1,
|
||||
_ => {
|
||||
at_end = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (a, b) = (anchors[at], anchors[next]);
|
||||
let c = if forward { CubicBez::new(a.p, a.h_out, b.h_in, b.p) } else { CubicBez::new(a.p, a.h_in, b.h_out, b.p) };
|
||||
let places = if c.p1 == c.p0 && c.p2 == c.p3 { vec![1.0] } else { corner_places(c, reach) };
|
||||
for u in places {
|
||||
let q = if u >= 1.0 { c.p3 } else { c.eval(u) };
|
||||
let d = here.distance(q);
|
||||
if !(d.is_finite() && (gone + d).is_finite()) {
|
||||
return ([None; 2], f64::INFINITY);
|
||||
}
|
||||
for (slot, &dist) in found.iter_mut().zip(&to) {
|
||||
if slot.is_none() && gone + d >= dist {
|
||||
let t = if d > 0.0 { ((dist - gone) / d).clamp(0.0, 1.0) } else { 1.0 };
|
||||
*slot = Some(here.lerp(q, t));
|
||||
}
|
||||
}
|
||||
gone += d;
|
||||
here = q;
|
||||
if found.iter().all(Option::is_some) {
|
||||
break 'segments;
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
// An open path that ends after the last segment walked ends there too.
|
||||
if !closed && at == if forward { n - 1 } else { 0 } {
|
||||
at_end = true;
|
||||
}
|
||||
(found.map(|p| p.or(at_end.then_some(here))), gone)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are the same anchors, to within a rounding error.
|
||||
fn same_anchors(a: &[Anchor], b: &[Anchor]) -> bool {
|
||||
let near = |p: Point, q: Point| p.distance(q) <= 1e-9;
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out))
|
||||
}
|
||||
|
||||
/// [`refit_run`] of the open run `anchors` cut at its anchors marked in `corners`: each part between
|
||||
/// two corners is smoothed and refitted on its own, so each corner keeps its place and the direction
|
||||
/// the path arrives and leaves along. `free` applies to the run's first and last anchors only.
|
||||
fn refit_split(anchors: &[Anchor], corners: &[bool], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
|
||||
let last = anchors.len().saturating_sub(1);
|
||||
let mut cuts: Vec<usize> = (1..last).filter(|&i| corners.get(i).copied().unwrap_or(false)).collect();
|
||||
if cuts.is_empty() {
|
||||
return refit_run(anchors, tol, sigma, free);
|
||||
}
|
||||
cuts.insert(0, 0);
|
||||
cuts.push(last);
|
||||
let mut out: Vec<Anchor> = Vec::with_capacity(anchors.len());
|
||||
for w in cuts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let part = refit_run(&anchors[a..=b], tol, sigma, [free[0] && a == 0, free[1] && b == last]);
|
||||
match out.pop() {
|
||||
// The corner ends one part and starts the next: it keeps its place, and takes the handle
|
||||
// in from the part before and the handle out from the part after.
|
||||
Some(end) => {
|
||||
let mut rest = part.into_iter();
|
||||
if let Some(start) = rest.next() {
|
||||
out.push(Anchor::with_handles(anchors[a].p, end.h_in, start.h_out));
|
||||
}
|
||||
out.extend(rest);
|
||||
}
|
||||
None => out.extend(part),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of
|
||||
/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the
|
||||
/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether
|
||||
/// smoothing moved it. The tool cuts the path at its corners first ([`smooth_corners`]), so within a
|
||||
/// part the refit keeps a corner only where the path turns back on itself. `free` says whether
|
||||
/// the start and the end of the stretch refitted are ends of an open path, whose direction then
|
||||
/// follows the smoothed path within the reach rather than the first or last segment.
|
||||
fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions {
|
||||
@@ -321,9 +549,31 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
if sp.closed {
|
||||
let start = sp.anchors[0].p;
|
||||
let Some(k) = marked.iter().position(|m| !m) else {
|
||||
// Everything is in range: smooth the whole closed path, across its start too.
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
|
||||
let corners = smooth_corners(&sp.anchors, true, radius);
|
||||
let Some(c) = corners.iter().position(|c| *c) else {
|
||||
// Everything is in range and nothing is a corner: smooth the whole closed path,
|
||||
// across its start too.
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
|
||||
};
|
||||
// Cut the path open at a corner, which then ends the last part as well as starting the first.
|
||||
let mut open = sp.anchors.clone();
|
||||
open.rotate_left(c);
|
||||
let mut cut = corners;
|
||||
cut.rotate_left(c);
|
||||
open.push(open[0]);
|
||||
cut.push(true);
|
||||
let mut res = refit_split(&open, &cut, tol, sigma, [false; 2]);
|
||||
if same_anchors(&res, &open) {
|
||||
return None;
|
||||
}
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
*r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out);
|
||||
}
|
||||
// The path starts where it did when the refit keeps an anchor there, and otherwise at its
|
||||
// point nearest there.
|
||||
return Some(start_nearest(SubPath::new(res, true), start, sp));
|
||||
};
|
||||
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
|
||||
let mut open = sp.clone();
|
||||
@@ -333,7 +583,10 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false);
|
||||
if same_anchors(&res, &open.anchors) {
|
||||
return None;
|
||||
}
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
@@ -342,7 +595,8 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
let res = SubPath::new(res, true);
|
||||
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
|
||||
let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true);
|
||||
(!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false))
|
||||
}
|
||||
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
@@ -376,8 +630,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
|
||||
/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next
|
||||
/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first
|
||||
/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends,
|
||||
/// and those of the open run a closed path is cut into, keep their direction.
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec<Anchor> {
|
||||
/// and those of the open run a closed path is cut into, keep their direction. Each run keeps its
|
||||
/// corners ([`smooth_corners`] within `radius`, the tool's reach, measured within the run), and a run
|
||||
/// whose refit changes nothing stays as it was.
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, radius: f64, open: bool) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -418,7 +674,11 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
|
||||
let new = refit_run(&piece, tol, sigma, free);
|
||||
let corners = smooth_corners(&piece, false, radius);
|
||||
let new = refit_split(&piece, &corners, tol, sigma, free);
|
||||
if same_anchors(&new, &piece) {
|
||||
continue;
|
||||
}
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
@@ -816,4 +1076,123 @@ mod tests {
|
||||
assert_eq!(drag_stretches(&one), vec![0]);
|
||||
assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
fn polyline(pts: &[(f64, f64)]) -> Vec<Anchor> {
|
||||
pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect()
|
||||
}
|
||||
|
||||
/// Anchors on one point, anchors that are not numbers or are too far out to measure, and a
|
||||
/// zigzag of 100,001 anchors 1 pt high give no corner and do not fail; a square's four anchors
|
||||
/// are its corners, and an open path's ends never are.
|
||||
#[test]
|
||||
fn corners_are_found_only_where_the_path_turns_at_and_around_an_anchor() {
|
||||
assert_eq!(smooth_corners(&polyline(&[(5.0, 5.0); 4]), false, 12.0), vec![false; 4]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (f64::NAN, 1.0), (10.0, 0.0)]), false, 12.0), vec![false; 3]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (1e308, 0.0), (1e308, 1e308), (-1e308, 0.0)]), true, 12.0), vec![false; 4]);
|
||||
let zigzag: Vec<(f64, f64)> = (0..=100_000).map(|i| (f64::from(i), f64::from(i % 2))).collect();
|
||||
assert!(!smooth_corners(&polyline(&zigzag), false, 12.0).contains(&true));
|
||||
let square = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
assert_eq!(smooth_corners(&square, true, 12.0), vec![true; 4]);
|
||||
assert_eq!(smooth_corners(&square, false, 12.0), vec![false, true, true, false]);
|
||||
assert_eq!(smooth_corners(&square, false, 0.0), vec![false, true, true, false], "with no reach the turn at the anchor decides");
|
||||
assert_eq!(smooth_corners(&square[..2], false, 12.0), vec![false; 2]);
|
||||
}
|
||||
|
||||
/// A 90° bump 400 pt high cut into 1 pt segments, between two long straight segments: with a reach
|
||||
/// of 10 pt its top is a corner, and with a reach of 1,000 pt, where the walk passes more segments
|
||||
/// than it measures before it gets that far, its top is not one, as the path turns by about 51°
|
||||
/// over that reach.
|
||||
#[test]
|
||||
fn a_corner_is_not_kept_where_the_reach_is_not_measured() {
|
||||
let side = 400.0 * std::f64::consts::SQRT_2;
|
||||
let steps = side.ceil() as usize;
|
||||
let mut pts = vec![(-3000.0, 0.0)];
|
||||
pts.extend((0..steps).map(|j| {
|
||||
let f = j as f64 / steps as f64;
|
||||
(-400.0 + 400.0 * f, 400.0 * f)
|
||||
}));
|
||||
pts.extend((0..=steps).map(|j| {
|
||||
let f = j as f64 / steps as f64;
|
||||
(400.0 * f, 400.0 - 400.0 * f)
|
||||
}));
|
||||
pts.push((3000.0, 0.0));
|
||||
let anchors = polyline(&pts);
|
||||
let top = steps + 1;
|
||||
assert_eq!(anchors[top].p, Point::new(0.0, 400.0));
|
||||
assert!(smooth_corners(&anchors, false, 10.0)[top]);
|
||||
assert!(!smooth_corners(&anchors, false, 1000.0)[top]);
|
||||
}
|
||||
|
||||
/// A 60° corner between two curved segments 1,000 pt long is found at a reach of 2 pt as it is
|
||||
/// when each segment is cut into a thousand pieces: the turns over the reach are measured along the
|
||||
/// curve near the anchor, not along a chord much longer than the reach.
|
||||
#[test]
|
||||
fn a_corner_between_long_curves_is_measured_along_them() {
|
||||
// The curves arrive along x and leave at 60° to it, each bending away from the other.
|
||||
let d = kurbo::Vec2::from_angle(-60f64.to_radians());
|
||||
let side = kurbo::Vec2::new(-d.y, d.x) * 1000.0;
|
||||
let v = Point::new(0.0, 0.0);
|
||||
let a = Anchor::with_handles(Point::new(-1000.0, 1000.0), Point::new(-1000.0, 1000.0), Point::new(-300.0, 1000.0));
|
||||
let b = Anchor::with_handles(v, Point::new(-300.0, 0.0), v + d * 300.0);
|
||||
let end = v + d * 1000.0 + side;
|
||||
let c = Anchor::with_handles(end, v + d * 300.0 + side, end);
|
||||
let long = [a, b, c];
|
||||
let fine: Vec<Anchor> = {
|
||||
let sp = SubPath::new(long.to_vec(), false);
|
||||
let mut pts = vec![];
|
||||
for i in 0..2 {
|
||||
let s = sp.segment(i);
|
||||
for j in 0..1000 {
|
||||
let p = s.subsegment(f64::from(j) / 1000.0..f64::from(j + 1) / 1000.0);
|
||||
if j == 0 && i == 0 {
|
||||
pts.push(Anchor::with_handles(p.p0, p.p0, p.p1));
|
||||
} else if let Some(last) = pts.last_mut() {
|
||||
*last = Anchor::with_handles(last.p, last.h_in, p.p1);
|
||||
}
|
||||
pts.push(Anchor::with_handles(p.p3, p.p2, p.p3));
|
||||
}
|
||||
}
|
||||
pts
|
||||
};
|
||||
let at_v = |anchors: &[Anchor]| {
|
||||
let corners = smooth_corners(anchors, false, 2.0);
|
||||
anchors.iter().zip(corners).find(|(x, _)| x.p == v).map(|(_, c)| c)
|
||||
};
|
||||
assert_eq!(at_v(&fine), Some(true), "cut finely, the anchor is a corner");
|
||||
assert_eq!(at_v(&long), Some(true));
|
||||
}
|
||||
|
||||
/// A line that meets a curve leaving with no handle, turning by 58° at the anchor and by about
|
||||
/// 57.53° over twice a reach of 0.02 pt: the anchor is a corner, measured either way along the path,
|
||||
/// as the stretch measured finely near the anchor covers twice the reach along the curve.
|
||||
#[test]
|
||||
fn a_corner_next_to_a_curve_without_a_handle_is_kept() {
|
||||
let at = 58f64.to_radians();
|
||||
let prev = Anchor::corner(Point::new(-100.0 * at.cos(), -100.0 * at.sin()));
|
||||
let v = Point::new(0.0, 0.0);
|
||||
let a = Anchor::with_handles(v, v, v);
|
||||
let end = Point::new(600.0, 600.0);
|
||||
let next = Anchor::with_handles(end, Point::new(200.0, 0.0), end);
|
||||
assert_eq!(smooth_corners(&[prev, a, next], false, 0.02), vec![false, true, false]);
|
||||
let back = |x: Anchor| Anchor::with_handles(x.p, x.h_out, x.h_in);
|
||||
assert_eq!(smooth_corners(&[back(next), back(a), back(prev)], false, 0.02), vec![false, true, false]);
|
||||
}
|
||||
|
||||
/// A jagged closed path whose length is less than twice the reach: its jags are not corners,
|
||||
/// while a triangle that small keeps its three.
|
||||
#[test]
|
||||
fn a_small_jagged_loop_has_no_corners() {
|
||||
let ring: Vec<(f64, f64)> = (0..16)
|
||||
.map(|i| {
|
||||
let (a, r) = (f64::from(i) / 16.0 * std::f64::consts::TAU, if i % 2 == 0 { 3.0 } else { 2.0 });
|
||||
(r * a.cos(), r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let jags = polyline(&ring);
|
||||
let sp = SubPath::new(jags.clone(), true);
|
||||
assert!(subpath_len(&sp) < 24.0);
|
||||
assert!(smooth_corners(&jags, true, 1e-6).iter().all(|c| *c), "each jag turns sharply at its anchor");
|
||||
assert_eq!(smooth_corners(&jags, true, 12.0), vec![false; 16]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (6.0, 0.0), (3.0, 5.196)]), true, 12.0), vec![true; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2023,6 +2023,260 @@ fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() {
|
||||
assert_eq!(path(&s, line), before);
|
||||
}
|
||||
|
||||
/// The turn, in degrees, of `sp` at each of its anchors: round a closed path, and 0 at an open
|
||||
/// path's first and last anchors.
|
||||
fn smooth_anchor_turns(sp: &vectorcraft_geom::SubPath) -> Vec<f64> {
|
||||
let (n, segs) = (sp.anchors.len(), sp.segment_count());
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
if segs == 0 || !sp.closed && (i == 0 || i == n - 1) {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (sp.segment((i + segs - 1) % segs), sp.segment(i % segs));
|
||||
let tin = [a.p3 - a.p2, a.p3 - a.p1, a.p3 - a.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
let tout = [b.p1 - b.p0, b.p2 - b.p0, b.p3 - b.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The turn of `sp` at its anchor on `at`, `None` when no anchor is there.
|
||||
fn smooth_turn_at(sp: &vectorcraft_geom::SubPath, at: Point) -> Option<f64> {
|
||||
let i = sp.anchors.iter().position(|a| a.p.distance(at) < 1e-9)?;
|
||||
smooth_anchor_turns(sp).get(i).copied()
|
||||
}
|
||||
|
||||
/// An open polyline with a corner at (300, 300) whose angle inside is `inside` degrees: a 100 pt
|
||||
/// leg along y = 300 from the left, then a 100 pt leg turning by 180 - `inside` degrees, each leg cut
|
||||
/// into `per_leg` segments. Returns the path, the corner, and a drag along the path from 40 pt
|
||||
/// before the corner to 40 pt after it.
|
||||
fn smooth_corner_path(s: &mut Session, inside: f64, per_leg: usize) -> (NodeId, Point, Vec<Point>) {
|
||||
let v = Point::new(300.0, 300.0);
|
||||
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
|
||||
let back = kurbo::Vec2::new(-100.0, 0.0);
|
||||
let legs = per_leg as f64;
|
||||
let mut pts: Vec<Point> = (0..per_leg).map(|k| v + back * (1.0 - k as f64 / legs)).collect();
|
||||
pts.extend((0..=per_leg).map(|k| v + d * (100.0 * k as f64 / legs)));
|
||||
let anchors: Vec<_> = pts.iter().map(|p| json!({"x": p.x, "y": p.y})).collect();
|
||||
let id = smooth_test_path(s, &anchors, false);
|
||||
let mut drag: Vec<Point> = (0..8).map(|k| v - kurbo::Vec2::new(40.0 - 5.0 * f64::from(k), 0.0)).collect();
|
||||
drag.extend((0..=8).map(|k| v + d * (5.0 * f64::from(k))));
|
||||
(id, v, drag)
|
||||
}
|
||||
|
||||
/// A Smooth drag over a corner keeps it where the path turns there by more than 57.5° (an angle
|
||||
/// inside the corner under 122.5°), whether each leg is one segment or ten and at any Fidelity. A
|
||||
/// wider corner is not kept: when the drag changes the path, no anchor is left turning as sharply
|
||||
/// there, and a leg of ten segments at the default Fidelity always rounds it.
|
||||
#[test]
|
||||
fn the_smooth_tool_keeps_corners_sharper_than_its_threshold() {
|
||||
let mut wrong = vec![];
|
||||
for inside in [30.0f64, 60.0, 90.0, 120.0, 122.4, 122.6, 125.0, 135.0, 150.0, 170.0] {
|
||||
let kept = inside < 122.5;
|
||||
for per_leg in [1, 10] {
|
||||
for fidelity in [0.5, 2.5, 10.0] {
|
||||
let mut s = session();
|
||||
let (id, v, drag) = smooth_corner_path(&mut s, inside, per_leg);
|
||||
let before = path(&s, id);
|
||||
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
|
||||
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": fidelity})).unwrap();
|
||||
let p = path(&s, id);
|
||||
let turn = 180.0 - inside;
|
||||
let at = smooth_turn_at(&p.subpaths[0], v);
|
||||
let changed = p != before;
|
||||
let rounded = changed && at.is_none_or(|t| t < turn - 1.0);
|
||||
let right = if kept { at.is_some_and(|t| (t - turn).abs() < 0.01) } else { rounded || !changed && per_leg == 1 };
|
||||
let most = smooth_anchor_turns(&p.subpaths[0]).into_iter().fold(0.0, f64::max);
|
||||
if !right || !kept && most > 57.5 || !kept && per_leg == 10 && fidelity == 2.5 && !rounded {
|
||||
wrong.push(format!(
|
||||
"{inside}° inside, {per_leg} segments a leg, Fidelity {fidelity}: turn {at:?}, changed {changed}, sharpest {most}°"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Three Smooth drags over a 90° and a 120° corner leave the corner where it was and as sharp, while
|
||||
/// the first one smooths a wobble on the same leg.
|
||||
#[test]
|
||||
fn smooth_drags_again_and_again_keep_a_corner_in_place() {
|
||||
for inside in [90.0f64, 120.0] {
|
||||
let mut s = session();
|
||||
let v = Point::new(300.0, 300.0);
|
||||
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
|
||||
let mut anchors: Vec<_> = (0..=18).map(|k| json!({"x": 180.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 301.5 } else { 300.0 }})).collect();
|
||||
anchors.push(json!({"x": v.x, "y": v.y}));
|
||||
let c = v + d * 100.0;
|
||||
anchors.push(json!({"x": c.x, "y": c.y}));
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let mut drag = smooth_drag_along(300.0, 175.0, 300.0);
|
||||
drag.extend((1..=8).map(|k| v + d * (5.0 * f64::from(k))));
|
||||
let mut before = path(&s, id);
|
||||
for pass in 1..=3 {
|
||||
smooth_pass(&mut s, &drag);
|
||||
let p = path(&s, id);
|
||||
let at = smooth_turn_at(&p.subpaths[0], v);
|
||||
assert!(at.is_some_and(|t| (t - (180.0 - inside)).abs() < 0.01), "{inside}°, pass {pass}: {at:?}");
|
||||
if pass == 1 {
|
||||
assert!(p.anchor_count() < before.anchor_count(), "{inside}°: the wobble is smoothed: {} anchors", p.anchor_count());
|
||||
assert!(total_turning(&p) < total_turning(&before), "{inside}°: the path turns less");
|
||||
}
|
||||
before = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Smooth drag along a line with 1 pt jags, each turning by 90°, smooths them all: none is kept
|
||||
/// as a corner, since the line hardly turns over the tool's reach.
|
||||
#[test]
|
||||
fn smoothing_keeps_no_small_jag_as_a_corner() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..=200).map(|i| json!({"x": 100.0 + f64::from(i), "y": if i % 2 == 1 { 201.0 } else { 200.0 }})).collect();
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
assert!(smooth_anchor_turns(&path(&s, id).subpaths[0]).iter().skip(1).take(199).all(|t| (t - 90.0).abs() < 1e-6));
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.5, 90.0, 310.0));
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.anchors.len() < 50, "the jags are smoothed: {} anchors", sp.anchors.len());
|
||||
let most = smooth_anchor_turns(sp).into_iter().fold(0.0, f64::max);
|
||||
assert!(most <= 57.5, "a jag is kept, turning {most}°");
|
||||
}
|
||||
|
||||
/// A closed path with 90° corners at (200, 200), where it starts, and at (400, 200), 75° ones at
|
||||
/// (400, 400) and (200, 400), a 30° bend at (300, 426.79), and a top side jagged by 1 pt. With
|
||||
/// `from_bend` the path starts at the bend instead.
|
||||
fn smooth_house(s: &mut Session, from_bend: bool) -> (NodeId, [Point; 4], Point) {
|
||||
let corners = [Point::new(200.0, 200.0), Point::new(400.0, 200.0), Point::new(400.0, 400.0), Point::new(200.0, 400.0)];
|
||||
let bend = Point::new(300.0, 400.0 + 100.0 / 75f64.to_radians().tan());
|
||||
let mut anchors = vec![json!({"x": 200.0, "y": 200.0})];
|
||||
anchors.extend((1..40).map(|k| json!({"x": 200.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 201.0 } else { 200.0 }})));
|
||||
for p in [corners[1], corners[2], bend, corners[3]] {
|
||||
anchors.push(json!({"x": p.x, "y": p.y}));
|
||||
}
|
||||
if from_bend {
|
||||
let at_bend = anchors.len() - 2;
|
||||
anchors.rotate_left(at_bend);
|
||||
}
|
||||
(smooth_test_path(s, &anchors, true), corners, bend)
|
||||
}
|
||||
|
||||
/// A Smooth drag round the whole of a closed path, and one along its top side only, keep its corners,
|
||||
/// the one where the path starts included, and smooth the jags; the path still starts where it did,
|
||||
/// at a corner or at the bend, which the refit keeps, and the bend out of the second drag's reach
|
||||
/// stays as it was.
|
||||
#[test]
|
||||
fn smoothing_a_closed_path_keeps_its_corners() {
|
||||
let turns: Vec<f64> = {
|
||||
let mut s = session();
|
||||
let (id, corners, _) = smooth_house(&mut s, false);
|
||||
corners.iter().map(|c| smooth_turn_at(&path(&s, id).subpaths[0], *c).unwrap()).collect()
|
||||
};
|
||||
for from_bend in [false, true] {
|
||||
let mut s = session();
|
||||
let (id, corners, bend) = smooth_house(&mut s, from_bend);
|
||||
let before = path(&s, id);
|
||||
let mut ring: Vec<Point> = vec![];
|
||||
for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], bend), (bend, corners[3]), (corners[3], corners[0])] {
|
||||
let n = (a.distance(b) / 5.0).ceil();
|
||||
ring.extend((0..n as usize).map(|k| a.lerp(b, k as f64 / n)));
|
||||
}
|
||||
ring.push(corners[0]);
|
||||
smooth_pass(&mut s, &ring);
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "{} anchors", sp.anchors.len());
|
||||
assert_eq!(sp.anchors[0].p, before.subpaths[0].anchors[0].p, "the path still starts where it did");
|
||||
for (c, t) in corners.iter().zip(&turns) {
|
||||
let at = smooth_turn_at(sp, *c);
|
||||
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "the corner at {c:?} turning {t}° is kept: {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = session();
|
||||
let (id, corners, bend) = smooth_house(&mut s, false);
|
||||
let before = path(&s, id);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 190.0, 410.0));
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "the top is smoothed: {} anchors", sp.anchors.len());
|
||||
for (c, t) in corners.iter().zip(&turns) {
|
||||
let at = smooth_turn_at(sp, *c);
|
||||
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "part of the path: the corner at {c:?} turning {t}° is kept: {at:?}");
|
||||
}
|
||||
assert!(smooth_turn_at(sp, bend).is_some_and(|t| (t - 30.0).abs() < 0.01), "the bend out of reach stays");
|
||||
}
|
||||
|
||||
/// A Smooth drag over an anchor whose curved legs meet at a corner keeps the corner, taking the turn
|
||||
/// from the directions the curves arrive and leave along, a handle of no length included.
|
||||
#[test]
|
||||
fn smoothing_keeps_a_corner_between_curves() {
|
||||
use kurbo::ParamCurve;
|
||||
let v = Point::new(200.0, 300.0);
|
||||
let with_in = json!({"x": 200.0, "y": 300.0, "in": [170.0, 270.0], "out": [230.0, 270.0]});
|
||||
let no_in = json!({"x": 200.0, "y": 300.0, "out": [230.0, 270.0]});
|
||||
for (corner, first_out) in [(with_in, [130.0, 260.0]), (no_in, [170.0, 230.0])] {
|
||||
let mut s = session();
|
||||
let anchors = [json!({"x": 100.0, "y": 300.0, "out": first_out}), corner, json!({"x": 300.0, "y": 300.0, "in": [270.0, 260.0]})];
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let before = path(&s, id);
|
||||
let turn = smooth_turn_at(&before.subpaths[0], v).unwrap();
|
||||
assert!(turn > 80.0, "{turn}");
|
||||
let (a, b) = (before.subpaths[0].segment(0), before.subpaths[0].segment(1));
|
||||
let mut drag: Vec<Point> = (0..=10).map(|k| a.eval(0.6 + 0.04 * f64::from(k))).collect();
|
||||
drag.extend((1..=10).map(|k| b.eval(0.04 * f64::from(k))));
|
||||
smooth_pass(&mut s, &drag);
|
||||
let at = smooth_turn_at(&path(&s, id).subpaths[0], v);
|
||||
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° is kept: {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner at the first anchor of the stretch a drag smooths, just out of its reach, keeps its
|
||||
/// place and its turn.
|
||||
#[test]
|
||||
fn a_corner_at_the_end_of_the_smoothed_stretch_stays() {
|
||||
let mut s = session();
|
||||
let mut anchors = vec![json!({"x": 100.0, "y": 300.0}), json!({"x": 200.0, "y": 300.0})];
|
||||
anchors.extend((1..=20).map(|k| json!({"x": if k % 2 == 1 { 201.0 } else { 200.0 }, "y": 300.0 - 5.0 * f64::from(k)})));
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let before = path(&s, id);
|
||||
let turn = smooth_turn_at(&before.subpaths[0], Point::new(200.0, 300.0)).unwrap();
|
||||
let drag: Vec<Point> = (0..=19).map(|k| Point::new(200.0, 287.0 - 5.0 * f64::from(k))).collect();
|
||||
smooth_pass(&mut s, &drag);
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert_ne!(p, before, "the jagged leg is smoothed");
|
||||
assert_eq!(sp.anchors[0], before.subpaths[0].anchors[0]);
|
||||
let at = smooth_turn_at(sp, Point::new(200.0, 300.0));
|
||||
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° stays: {at:?}");
|
||||
}
|
||||
|
||||
/// A Smooth drag over corners it keeps, with nothing else to smooth, changes nothing and adds no
|
||||
/// undo step: an open path's corner and a square's four corners.
|
||||
#[test]
|
||||
fn a_smooth_drag_that_only_meets_corners_adds_no_undo_step() {
|
||||
let mut s = session();
|
||||
let (id, v, drag) = smooth_corner_path(&mut s, 90.0, 1);
|
||||
let before = path(&s, id);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([]), "a corner at {v:?}");
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
let square = [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let id = smooth_test_path(&mut s, &square, true);
|
||||
let before = path(&s, id);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let mut ring = smooth_drag_along(100.0, 100.0, 200.0);
|
||||
ring.extend((1..=20).map(|k| Point::new(200.0, 100.0 + 5.0 * f64::from(k))));
|
||||
ring.extend(smooth_drag_along(200.0, 100.0, 200.0).into_iter().rev().skip(1));
|
||||
ring.extend((1..=20).map(|k| Point::new(100.0, 200.0 - 5.0 * f64::from(k))));
|
||||
assert_eq!(smooth_pass_ids(&mut s, &ring), json!([]), "a square");
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -5473,6 +5473,13 @@ keeps the segment's direction when the smoothed path there turns back by 90° or
|
||||
path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's
|
||||
direction.
|
||||
|
||||
`path.smoothRegion` keeps an anchor as a corner where the path turns by more than 57.5° (an angle inside the corner
|
||||
under 122.5°) at the anchor and also over `radius` and over twice `radius` on either side of it (on a closed path that the drag reaches all the
|
||||
way round, no more than an eighth of its length), so a jag or a bump smaller than `radius` is still smoothed. The stretches on either side of a kept corner are smoothed and refitted on their
|
||||
own, and the corner keeps its place and the directions the path arrives and leaves along, on every call and at any
|
||||
`fidelity`. A call that changes no anchor returns `{ids: [], before: 0, after: 0}` and adds no undo step. The 57.5°
|
||||
threshold is VectorCraft's own value.
|
||||
|
||||
In the desktop app, Object › Path › Smooth, the canvas context menu's Smooth… (listed while the selection holds a
|
||||
path) and the Contextual Task Bar's Smooth (with a path selected whole with the Direct Selection tool) open the
|
||||
slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the
|
||||
|
||||
@@ -1196,6 +1196,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`).
|
||||
The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`).
|
||||
When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`).
|
||||
A Smooth tool drag keeps a corner where the path turns by more than 57.5° (an angle inside the corner under 122.5°) and rounds a wider one. An anchor is kept as a corner when the path turns by that much at the anchor and also over the drag's reach and over twice the reach on either side of it, so a jag or a bump smaller than the reach is still smoothed; [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes the tool as reducing irregularities in a path. The stretches on either side of a kept corner are smoothed and refitted on their own, and the corner keeps its place and the directions the path arrives and leaves along, on every drag, on open and closed paths, the anchor a closed path starts at included. A drag that changes no anchor adds no undo step. The threshold and the two distances are VectorCraft's own values, the same on every drag and at any Fidelity (`tests/draw2.rs`, `the_smooth_tool_keeps_corners_sharper_than_its_threshold`, `smoothing_a_closed_path_keeps_its_corners` and `a_smooth_drag_that_only_meets_corners_adds_no_undo_step`).
|
||||
A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is.
|
||||
Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)).
|
||||
Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`).
|
||||
|
||||
Reference in New Issue
Block a user