mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 06:31:41 +00:00
Merge pull request #7263 from dotCooCoo/feat/smooth-free-end-tangent
Smooth tool: an open path's end turns with the smoothing when a drag reaches it
This commit is contained in:
@@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// 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.
|
||||
fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions {
|
||||
SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true }
|
||||
/// smoothing moved it, keeping 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 {
|
||||
SimplifyOptions {
|
||||
tolerance: tol,
|
||||
corner_angle_deg: 179.0,
|
||||
straight_lines: false,
|
||||
smooth: sigma,
|
||||
smooth_moved_only: true,
|
||||
free_start: free[0],
|
||||
free_end: free[1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
|
||||
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
|
||||
/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or
|
||||
/// arriving at such an end can turn.
|
||||
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
|
||||
let piece = PathData::single(SubPath::new(anchors.to_vec(), false));
|
||||
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma));
|
||||
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free));
|
||||
let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() };
|
||||
let (f, l) = (anchors[0], anchors[anchors.len() - 1]);
|
||||
let last = out.len() - 1;
|
||||
@@ -228,7 +240,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
if sp.closed {
|
||||
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));
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next();
|
||||
};
|
||||
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
|
||||
@@ -239,14 +251,14 @@ 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);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
}
|
||||
return Some(SubPath::new(res, true));
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false))
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
|
||||
}
|
||||
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
@@ -278,8 +290,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
|
||||
/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted
|
||||
/// within `tol`, together with the stretch of the segments leading into the run that is in the
|
||||
/// 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).
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec<Anchor> {
|
||||
/// 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> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -319,7 +333,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
head = Some(Anchor { h_out: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let new = refit_run(&piece, tol, sigma);
|
||||
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
|
||||
let new = refit_run(&piece, tol, sigma, free);
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
|
||||
@@ -1632,6 +1632,83 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() {
|
||||
assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt");
|
||||
}
|
||||
|
||||
/// A straight line along y = 200 from x = 100 to 400, one anchor every 5 pt, whose second and
|
||||
/// second-to-last anchors are 1.5 pt off the line: its first and last segments leave and arrive at
|
||||
/// 16.7° to it.
|
||||
fn smooth_jagged_ends(s: &mut Session) -> NodeId {
|
||||
let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": if i == 1 || i == 59 { 201.5 } else { 200.0 }})).collect();
|
||||
smooth_test_path(s, &anchors, false)
|
||||
}
|
||||
|
||||
/// The degrees between the direction `sp` leaves its first anchor along (`at_end` false), or arrives
|
||||
/// at its last anchor along, and the x axis.
|
||||
fn smooth_end_angle(sp: &vectorcraft_geom::SubPath, at_end: bool) -> f64 {
|
||||
let c = sp.segment(if at_end { sp.segment_count() - 1 } else { 0 });
|
||||
let d = if at_end {
|
||||
[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9)
|
||||
} else {
|
||||
[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9)
|
||||
};
|
||||
d.map_or(f64::NAN, |d| d.y.atan2(d.x).to_degrees().abs())
|
||||
}
|
||||
|
||||
/// The largest turn, in degrees, at any anchor of `sp` (other than its ends) whose x is in `xs`.
|
||||
fn smooth_largest_turn(sp: &vectorcraft_geom::SubPath, xs: std::ops::Range<f64>) -> f64 {
|
||||
let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord);
|
||||
(1..sp.segment_count())
|
||||
.filter(|&i| xs.contains(&sp.anchors[i].p.x))
|
||||
.map(|i| {
|
||||
let (prev, next) = (sp.segment(i - 1), sp.segment(i));
|
||||
let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0);
|
||||
let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0);
|
||||
arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// A Smooth drag over the whole of an open path turns the curve at each end to follow the smoothed
|
||||
/// path rather than the jag of its first or last segment, and leaves both end points where they were.
|
||||
#[test]
|
||||
fn smoothing_an_open_path_turns_its_jagged_ends_with_the_path() {
|
||||
let mut s = session();
|
||||
let id = smooth_jagged_ends(&mut s);
|
||||
let before = path(&s, id);
|
||||
assert!(smooth_end_angle(&before.subpaths[0], false) > 16.0 && smooth_end_angle(&before.subpaths[0], true) > 16.0);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 410.0));
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the path is smoothed");
|
||||
let sp = &p.subpaths[0];
|
||||
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||||
assert!(first == Point::new(100.0, 200.0) && last == Point::new(400.0, 200.0), "the ends stay: {first:?} {last:?}");
|
||||
let (start, end) = (smooth_end_angle(sp, false), smooth_end_angle(sp, true));
|
||||
assert!(start <= 5.0, "the path leaves its start at {start}° to the line");
|
||||
assert!(end <= 5.0, "and arrives at its end at {end}°");
|
||||
let off = smooth_test_samples(&p).iter().map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off <= 1.5, "the path stays within {off} pt of the line");
|
||||
}
|
||||
|
||||
/// A Smooth drag that reaches only the start of an open path turns the curve there, and the rest of
|
||||
/// the path, its far end with its jag included, stays as it was, joining the refitted part without a
|
||||
/// corner.
|
||||
#[test]
|
||||
fn a_smooth_drag_turns_only_the_end_it_reaches() {
|
||||
let mut s = session();
|
||||
let id = smooth_jagged_ends(&mut s);
|
||||
let before = path(&s, id);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 200.0));
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the start is smoothed");
|
||||
let sp = &p.subpaths[0];
|
||||
assert_eq!(sp.anchors[0].p, Point::new(100.0, 200.0), "the start stays");
|
||||
let start = smooth_end_angle(sp, false);
|
||||
assert!(start <= 5.0, "the path leaves its start at {start}° to the line");
|
||||
let kept = |sp: &vectorcraft_geom::SubPath| sp.anchors.iter().filter(|a| a.p.x >= 215.0).copied().collect::<Vec<_>>();
|
||||
assert_eq!(kept(sp), kept(&before.subpaths[0]), "the anchors out of reach stay as they were");
|
||||
assert!(smooth_end_angle(sp, true) > 16.0, "the far end keeps its jag");
|
||||
let turn = smooth_largest_turn(sp, 101.0..300.0);
|
||||
assert!(turn <= 1.0, "the refitted part joins the rest with a {turn}° turn");
|
||||
}
|
||||
|
||||
/// `path.smoothRegion` along `drag` with the Smooth tool's reach and default Fidelity → the ids changed.
|
||||
fn smooth_pass_ids(s: &mut Session, drag: &[Point]) -> serde_json::Value {
|
||||
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
|
||||
|
||||
+109
-7
@@ -28,11 +28,26 @@ pub struct SimplifyOptions {
|
||||
/// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of
|
||||
/// its points far, all of it is refitted within up to `tolerance`, as without this option.
|
||||
pub smooth_moved_only: bool,
|
||||
/// With [`smooth`](Self::smooth), the curve at an open subpath's first anchor leaves it along the
|
||||
/// direction of the smoothed path over the 3 spreads next to it rather than along its first
|
||||
/// segment. The anchor stays where it is. A closed subpath has no such end, and [`simplify_part`]
|
||||
/// ignores this.
|
||||
pub free_start: bool,
|
||||
/// [`free_start`](Self::free_start) for an open subpath's last anchor.
|
||||
pub free_end: bool,
|
||||
}
|
||||
|
||||
impl Default for SimplifyOptions {
|
||||
fn default() -> Self {
|
||||
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false }
|
||||
Self {
|
||||
tolerance: 0.5,
|
||||
corner_angle_deg: 30.0,
|
||||
straight_lines: false,
|
||||
smooth: 0.0,
|
||||
smooth_moved_only: false,
|
||||
free_start: false,
|
||||
free_end: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +122,8 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
|
||||
if n == 0 || sp.closed && n < 2 {
|
||||
return Some(sp.clone());
|
||||
}
|
||||
let closed_opts = SimplifyOptions { free_start: false, free_end: false, ..*opts };
|
||||
let opts = if sp.closed { &closed_opts } else { opts };
|
||||
if sp.closed {
|
||||
// Start the loop at its first corner, so no fit runs across one.
|
||||
let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0);
|
||||
@@ -282,6 +299,12 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
cuts.insert(0, 0);
|
||||
cuts.push(run.len());
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
// Whether `part` starts where `run` does and whether it ends where `run` does, for the free ends.
|
||||
let free = |part: &[(&Seg, usize)]| {
|
||||
let starts = part.first().zip(run.first()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r));
|
||||
let ends = part.last().zip(run.last()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r));
|
||||
[opts.free_start && starts, opts.free_end && ends]
|
||||
};
|
||||
for w in cuts.windows(2) {
|
||||
let piece = &run[w[0]..w[1]];
|
||||
let mut part: Vec<(&Seg, usize)> = Vec::new();
|
||||
@@ -290,7 +313,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
let steps = sample_steps(&s.c, tol);
|
||||
if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT {
|
||||
if part_samples + steps > MAX_FIT_SAMPLES {
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
refit_part(&part, opts, tol, None, free(&part), &mut result);
|
||||
part.clear();
|
||||
part_samples = 0;
|
||||
}
|
||||
@@ -299,7 +322,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
part.push((s, steps));
|
||||
} else {
|
||||
part_samples = 0;
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
refit_part(&part, opts, tol, None, free(&part), &mut result);
|
||||
part.clear();
|
||||
if opts.straight_lines {
|
||||
let room = MAX_RUN_RESULT.saturating_sub(result.len());
|
||||
@@ -313,7 +336,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
}
|
||||
}
|
||||
}
|
||||
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result);
|
||||
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), free(&part), &mut result);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -321,14 +344,18 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
/// Adds the refit of `part`, consecutive segments with no corner between them and the steps to
|
||||
/// sample each one with, to `result`. `ends`, given only when `part` is a whole closed loop, is the
|
||||
/// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its
|
||||
/// start and the direction comes from the smoothed samples there.
|
||||
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option<Vec2>, result: &mut Vec<Seg>) {
|
||||
/// start and the direction comes from the smoothed samples there. `free` says whether the start and
|
||||
/// the end of `part` are free ends of an open subpath: when it is smoothed, the refit leaves such a
|
||||
/// start and arrives at such an end along [`free_end_direction`], or along its first or last segment
|
||||
/// when that gives none or turns from the segment by 90° or more.
|
||||
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option<Vec2>, free: [bool; 2], result: &mut Vec<Seg>) {
|
||||
let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return };
|
||||
let mut pts = Vec::new();
|
||||
for (k, (s, steps)) in part.iter().enumerate() {
|
||||
sample(&s.c, *steps, &mut pts, k == 0);
|
||||
}
|
||||
let mut tol = tol;
|
||||
let (mut t0, mut t1) = (start_tangent(&first.c), end_tangent(&last.c));
|
||||
if opts.smooth > 0.0 {
|
||||
let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) };
|
||||
if ends.is_some()
|
||||
@@ -336,6 +363,18 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends
|
||||
{
|
||||
ends = unit(next - prev).or(ends);
|
||||
}
|
||||
if ends.is_none() {
|
||||
if free[0]
|
||||
&& let Some(d) = free_end_direction(&smoothed, opts.smooth, false).filter(|d| d.dot(t0) > 0.0)
|
||||
{
|
||||
t0 = d;
|
||||
}
|
||||
if free[1]
|
||||
&& let Some(d) = free_end_direction(&smoothed, opts.smooth, true).filter(|d| d.dot(t1) > 0.0)
|
||||
{
|
||||
t1 = d;
|
||||
}
|
||||
}
|
||||
if opts.smooth_moved_only {
|
||||
tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR));
|
||||
}
|
||||
@@ -351,7 +390,7 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends
|
||||
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join
|
||||
// smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last
|
||||
// curve of a loop fitted across its start leaves along the loop's first segment.
|
||||
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
|
||||
let (t0, t1) = ends.map_or((t0, t1), |d| (d, d));
|
||||
let fitted = fit_cubics(&pts, t0, t1, tol * 0.98);
|
||||
let from = result.len();
|
||||
let mut arrive = t0;
|
||||
@@ -451,6 +490,66 @@ fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 {
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// How far along the path, in spreads of the smoothing, [`free_end_direction`] looks from the end:
|
||||
/// as far as the Gaussian reaches. VectorCraft's own value.
|
||||
const FREE_END_SPREADS: f64 = 3.0;
|
||||
|
||||
/// [`free_end_direction`] gives no direction when the path near the end spreads out from it more
|
||||
/// evenly than this: the ratio of the least to the most squared distance from a line through the end,
|
||||
/// over all such lines. VectorCraft's own value.
|
||||
const FREE_END_MAX_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The direction the samples `pts` of an open run leave their first point along, or with `at_end`
|
||||
/// arrive at their last point along: the direction of the line through that point that the path
|
||||
/// within [`FREE_END_SPREADS`] spreads `sigma` of it along its length, and at most half its length,
|
||||
/// lies closest to by the least sum of squared distances, pointing along the path. `None` when the
|
||||
/// path there has no length or is not finite, when its mean point is within a tenth of its length
|
||||
/// there of that point (it doubles back), or when it spreads from that point more evenly than
|
||||
/// [`FREE_END_MAX_SPREAD`].
|
||||
fn free_end_direction(pts: &[Point], sigma: f64, at_end: bool) -> Option<Vec2> {
|
||||
let n = pts.len();
|
||||
let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum();
|
||||
let reach = (FREE_END_SPREADS * sigma).min(total / 2.0);
|
||||
if !(reach.is_finite() && reach > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let at = |i: usize| pts.get(if at_end { n - 1 - i } else { i }).copied();
|
||||
let origin = at(0)?;
|
||||
// The integrals along the path of v vᵀ and of v, for v from the end to each point.
|
||||
let (mut sxx, mut sxy, mut syy, mut sum, mut along) = (0.0, 0.0, 0.0, Vec2::ZERO, 0.0);
|
||||
let mut a = Vec2::ZERO;
|
||||
for i in 1..n {
|
||||
let Some(p) = at(i) else { break };
|
||||
let mut b = p - origin;
|
||||
let mut len = (b - a).hypot();
|
||||
if along + len > reach {
|
||||
b = a.lerp(b, (reach - along) / len);
|
||||
len = reach - along;
|
||||
}
|
||||
sxx += len * (a.x * a.x + a.x * b.x + b.x * b.x) / 3.0;
|
||||
syy += len * (a.y * a.y + a.y * b.y + b.y * b.y) / 3.0;
|
||||
sxy += len * (2.0 * a.x * a.y + a.x * b.y + b.x * a.y + 2.0 * b.x * b.y) / 6.0;
|
||||
sum += (a + b) * (len / 2.0);
|
||||
along += len;
|
||||
a = b;
|
||||
if along >= reach {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (trace, apart) = (sxx + syy, (sxx - syy).hypot(2.0 * sxy));
|
||||
// The two principal values are (trace ± apart) / 2.
|
||||
if !(trace.is_finite() && apart.is_finite() && trace > 0.0) || trace - apart > FREE_END_MAX_SPREAD * (trace + apart) {
|
||||
return None;
|
||||
}
|
||||
let mean = sum / along;
|
||||
if !mean.hypot().is_finite() || mean.hypot() < 0.1 * along {
|
||||
return None;
|
||||
}
|
||||
let d = Vec2::from_angle(0.5 * (2.0 * sxy).atan2(sxx - syy));
|
||||
let d = if d.dot(mean) >= 0.0 { d } else { -d };
|
||||
unit(if at_end { -d } else { d })
|
||||
}
|
||||
|
||||
/// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups
|
||||
/// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one
|
||||
/// by one.
|
||||
@@ -597,6 +696,7 @@ pub struct SimplifiedPart {
|
||||
/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more
|
||||
/// segments than it had is left as it is (except with Convert to Straight Lines).
|
||||
pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart {
|
||||
let opts = &SimplifyOptions { free_start: false, free_end: false, ..*opts };
|
||||
let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new());
|
||||
let mut subpaths = Vec::with_capacity(path.subpaths.len());
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
@@ -784,6 +884,8 @@ pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions {
|
||||
straight_lines: false,
|
||||
smooth: size * 0.05 * level / 100.0,
|
||||
smooth_moved_only: false,
|
||||
free_start: false,
|
||||
free_end: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -488,6 +488,61 @@ fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The degrees between the x axis and the direction `sp` leaves its first anchor along.
|
||||
fn start_angle(sp: &SubPath) -> f64 {
|
||||
let c = sp.segment(0);
|
||||
let d = [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
d.y.atan2(d.x).to_degrees()
|
||||
}
|
||||
|
||||
/// With `free_start` and `free_end`, a smoothed open subpath leaves its first anchor and arrives at
|
||||
/// its last one along the smoothed path, not along a jagged first or last segment, and its end points
|
||||
/// stay. Only a flagged end turns, and a closed subpath, Object › Path › Smooth's selected segments
|
||||
/// (`simplify_part`) and a refit without smoothing give the same result with the flags as without.
|
||||
#[test]
|
||||
fn free_ends_turn_only_open_smoothed_ends() {
|
||||
let pts: Vec<Point> = (0..=40).map(|i| Point::new(5.0 * f64::from(i), if i == 1 || i == 39 { 1.5 } else { 0.0 })).collect();
|
||||
let path = PathData::single(SubPath::polyline(&pts, false));
|
||||
let base = SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, ..Default::default() };
|
||||
let both = SimplifyOptions { free_start: true, free_end: true, ..base };
|
||||
let s = simplify_with(&path, &both);
|
||||
let sp = &s.subpaths[0];
|
||||
assert_eq!((sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p), (pts[0], pts[40]), "{s:?}");
|
||||
let mut back = sp.clone();
|
||||
back.reverse();
|
||||
assert!(start_angle(sp).abs() <= 5.0 && (start_angle(&back).abs() - 180.0).abs() <= 5.0, "{s:?}");
|
||||
let start_only = simplify_with(&path, &SimplifyOptions { free_start: true, ..base });
|
||||
let plain = simplify_with(&path, &base);
|
||||
assert!(start_angle(&start_only.subpaths[0]).abs() <= 5.0, "{start_only:?}");
|
||||
assert!(start_angle(&plain.subpaths[0]) > 16.0, "{plain:?}");
|
||||
let (mut a, mut b) = (start_only.subpaths[0].clone(), plain.subpaths[0].clone());
|
||||
a.reverse();
|
||||
b.reverse();
|
||||
assert!((start_angle(&a) - start_angle(&b)).abs() <= 1e-9, "the last anchor keeps its direction: {start_only:?}");
|
||||
// Flags that must have no effect.
|
||||
let ring = PathData::single(SubPath::polyline(&pts[..39], true));
|
||||
assert_eq!(simplify_with(&ring, &both), simplify_with(&ring, &base));
|
||||
let unsmoothed = SimplifyOptions { smooth: 0.0, ..base };
|
||||
assert_eq!(simplify_with(&path, &SimplifyOptions { free_start: true, free_end: true, ..unsmoothed }), simplify_with(&path, &unsmoothed));
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = (0..20).map(|i| (0, i)).collect();
|
||||
assert_eq!(simplify_part(&path, &picked, &both).path, simplify_part(&path, &picked, &base).path);
|
||||
}
|
||||
|
||||
/// A free end whose smoothed path turns back by more than 90° from its first segment keeps that
|
||||
/// segment's direction: a path that leaves its start along x for 2 pt, then turns by 170°.
|
||||
#[test]
|
||||
fn a_free_end_that_turns_back_keeps_its_first_direction() {
|
||||
let back = kurbo::Vec2::from_angle(170f64.to_radians());
|
||||
let mut pts = vec![Point::ORIGIN, Point::new(2.0, 0.0)];
|
||||
pts.extend((1..=10).map(|i| Point::new(2.0, 0.0) + back * (3.0 * f64::from(i))));
|
||||
let path = PathData::single(SubPath::polyline(&pts, false));
|
||||
let opts =
|
||||
SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, free_start: true, ..Default::default() };
|
||||
let s = simplify_with(&path, &opts);
|
||||
let angle = start_angle(&s.subpaths[0]);
|
||||
assert!(angle.abs() <= 1.0, "the path leaves its start at {angle}°: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_straight_lines_option() {
|
||||
let c = dense_circle(50.0, 200);
|
||||
|
||||
@@ -5236,6 +5236,13 @@ round, such as a rectangle, stays as it is. Closed paths stay closed and open pa
|
||||
{"name":"run_command","arguments":{"command":"object.path.smooth","params":{"level":80}}}
|
||||
```
|
||||
|
||||
`path.smoothRegion` (the Smooth tool) keeps the end points of an open path where they are. When the stretch it refits
|
||||
reaches the first or last anchor of an open path, the curve there leaves or arrives along the smoothed path within
|
||||
`radius` of that end rather than along the first or last segment, so a jagged end turns with the rest of the path; it
|
||||
keeps the segment's direction when the smoothed path there turns back by 90° or more. Where the stretch ends inside a
|
||||
path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's
|
||||
direction.
|
||||
|
||||
In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a
|
||||
path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at
|
||||
the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open
|
||||
|
||||
@@ -1132,6 +1132,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points.
|
||||
`path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`).
|
||||
Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way.
|
||||
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 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