mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge pull request #7243 from dotCooCoo/fix/smooth-segment-under-drag
Smooth: a drag along a segment smooths it when its anchors are out of reach
This commit is contained in:
@@ -2,7 +2,7 @@ use vectorcraft_pathops::addons::pathtools as pt;
|
||||
|
||||
use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths};
|
||||
use super::{
|
||||
Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json,
|
||||
Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Rect, Result, Session, SimplifyOptions, SubPath, Value, json,
|
||||
number_or, po,
|
||||
};
|
||||
|
||||
@@ -37,10 +37,192 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
|
||||
out
|
||||
}
|
||||
|
||||
fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option<SubPath> {
|
||||
/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments
|
||||
/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach,
|
||||
/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach
|
||||
/// of the drag.
|
||||
const MAX_REACH_PIECES: usize = 1 << 17;
|
||||
|
||||
/// The box around the control points of `s`, which holds all of `s`.
|
||||
fn control_box(s: CubicBez) -> Rect {
|
||||
Rect::from_points(s.p0, s.p1).union_pt(s.p2).union_pt(s.p3)
|
||||
}
|
||||
|
||||
/// The stretches of the drag `pts` (stretch `k` runs from `pts[k]` to the next point; a drag of one
|
||||
/// point has the point as its only stretch).
|
||||
fn drag_stretches(pts: &[Point]) -> Vec<usize> {
|
||||
(0..pts.len().saturating_sub(1).max(1)).collect()
|
||||
}
|
||||
|
||||
/// The stretches of the drag `pts` among `of` whose bounds come within `radius` of `r` on both
|
||||
/// axes: every stretch within `radius` of a point in `r`, and perhaps a few more.
|
||||
fn stretches_near(pts: &[Point], of: &[usize], r: Rect, radius: f64) -> Vec<usize> {
|
||||
let r = r.inflate(radius, radius);
|
||||
of.iter()
|
||||
.copied()
|
||||
.filter(|&k| {
|
||||
let Some(&a) = pts.get(k) else { return false };
|
||||
let b = pts.get(k + 1).copied().unwrap_or(a);
|
||||
a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The distance from `q` to the nearest of the stretches `of` of the drag `pts`.
|
||||
fn dist_to_stretches(pts: &[Point], of: &[usize], q: Point) -> f64 {
|
||||
of.iter()
|
||||
.filter_map(|&k| {
|
||||
let a = *pts.get(k)?;
|
||||
Some(dist_to_polyline(&[a, pts.get(k + 1).copied().unwrap_or(a)], q))
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// The place on `s` (0 to 1) nearest to the stretches `of` of the drag `pts`, and its distance from
|
||||
/// them. For each stretch the nearest place is at one of the stretch's ends, at an end of `s`, or
|
||||
/// where `s` crosses the stretch's line or stops moving toward or away from it.
|
||||
fn nearest_to_stretches(s: CubicBez, pts: &[Point], of: &[usize]) -> (f64, f64) {
|
||||
let (c1, c2, c3) = (3.0 * (s.p1 - s.p0), 3.0 * ((s.p2 - s.p1) - (s.p1 - s.p0)), (s.p3 - s.p0) - 3.0 * (s.p2 - s.p1));
|
||||
let mut best = (0.5, f64::INFINITY);
|
||||
for &k in of {
|
||||
let Some(&a) = pts.get(k) else { continue };
|
||||
let b = pts.get(k + 1).copied().unwrap_or(a);
|
||||
let mut places: Vec<f64> = vec![0.0, 1.0, s.nearest(a, 1e-9).t, s.nearest(b, 1e-9).t];
|
||||
let d = b - a;
|
||||
if d.hypot() > 1e-12 {
|
||||
let (f0, f1, f2, f3) = (d.cross(s.p0 - a), d.cross(c1), d.cross(c2), d.cross(c3));
|
||||
places.extend(kurbo::common::solve_cubic(f0, f1, f2, f3));
|
||||
places.extend(kurbo::common::solve_quadratic(f1, 2.0 * f2, 3.0 * f3));
|
||||
}
|
||||
for u in places.into_iter().filter(|u| (0.0..=1.0).contains(u)) {
|
||||
let dist = dist_to_polyline(&[a, b], s.eval(u));
|
||||
if dist < best.1 {
|
||||
best = (u, dist);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// How many curved segments of `pd` may come within `radius` of the drag `pts`.
|
||||
fn segments_in_reach(pd: &PathData, pts: &[Point], radius: f64) -> usize {
|
||||
let all = drag_stretches(pts);
|
||||
pd.subpaths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
(0..sp.segment_count())
|
||||
.filter(|&i| {
|
||||
!vectorcraft_tools::draw2::segment_is_straight(sp, i) && !stretches_near(pts, &all, control_box(sp.segment(i)), radius).is_empty()
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Samples along the part of `c` from `t0` to `t1`, in order: (t, whether the point there is within
|
||||
/// `radius` of the stretches `of` of the drag `pts`). A piece no stretch comes near gives one sample
|
||||
/// out of reach; the others are halved until their control polygon is no longer than half the
|
||||
/// reach, or until `budget` pieces have been measured, and then give one sample: in reach at the
|
||||
/// piece's middle or at its place nearest the drag when either is in reach, otherwise out of reach
|
||||
/// at its middle. Each piece measures only the stretches near
|
||||
/// the piece it was cut from, so a long drag along a long curve costs about one pass over the drag
|
||||
/// per halving, plus the stretches near each piece.
|
||||
fn reach_samples(c: CubicBez, (t0, t1): (f64, f64), (pts, of): (&[Point], &[usize]), radius: f64, budget: &mut usize, out: &mut Vec<(f64, bool)>) {
|
||||
*budget = budget.saturating_sub(1);
|
||||
let s = c.subsegment(t0..t1);
|
||||
let mid = 0.5 * (t0 + t1);
|
||||
let near = stretches_near(pts, of, control_box(s), radius);
|
||||
if near.is_empty() {
|
||||
out.push((mid, false));
|
||||
return;
|
||||
}
|
||||
let len = s.p0.distance(s.p1) + s.p1.distance(s.p2) + s.p2.distance(s.p3);
|
||||
if len <= (radius / 2.0).max(1e-3) || *budget == 0 || mid <= t0 || mid >= t1 {
|
||||
// The piece's middle, or the place on it nearest the drag when the middle is out of reach.
|
||||
let sample = if dist_to_stretches(pts, &near, c.eval(mid)) <= radius {
|
||||
(mid, true)
|
||||
} else {
|
||||
let (u, dist) = nearest_to_stretches(s, pts, &near);
|
||||
if dist <= radius { (t0 + u * (t1 - t0), true) } else { (mid, false) }
|
||||
};
|
||||
out.push(sample);
|
||||
return;
|
||||
}
|
||||
reach_samples(c, (t0, mid), (pts, &near), radius, budget, out);
|
||||
reach_samples(c, (mid, t1), (pts, &near), radius, budget, out);
|
||||
}
|
||||
|
||||
/// `sp` cut where the reach `radius` of the drag `pts` crosses its curved segments, and whether each
|
||||
/// anchor of the result is in reach. An anchor in reach is added in the middle of each stretch of a
|
||||
/// segment in reach that does not run on from one of the segment's anchors in reach, and an anchor
|
||||
/// out of reach in the middle of each stretch out of reach between two stretches in reach, so that a
|
||||
/// stretch in reach is smoothed even when the segment's anchors are not, and the stretches between
|
||||
/// stay as they are. A segment is searched no more than half the reach apart where it comes within
|
||||
/// the reach of the drag ([`reach_samples`]), measuring up to `share` pieces of each segment.
|
||||
fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubPath, Vec<bool>) {
|
||||
let near = |q: Point| dist_to_polyline(pts, q) <= radius;
|
||||
let n = sp.anchors.len();
|
||||
let marked: Vec<bool> = sp.anchors.iter().map(|a| near(a.p)).collect();
|
||||
if n < 3 || !marked.iter().any(|m| *m) {
|
||||
let mut places = vec![];
|
||||
let all = drag_stretches(pts);
|
||||
for i in 0..sp.segment_count() {
|
||||
if vectorcraft_tools::draw2::segment_is_straight(sp, i) {
|
||||
continue;
|
||||
}
|
||||
let c = sp.segment(i);
|
||||
let (mut samples, mut budget) = (vec![], share);
|
||||
reach_samples(c, (0.0, 1.0), (pts, &all), radius, &mut budget, &mut samples);
|
||||
// Stretches of samples alike, in order: (in reach, first, last).
|
||||
let mut runs: Vec<(bool, usize, usize)> = vec![];
|
||||
for (k, s) in samples.iter().enumerate() {
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.0 == s.1 => r.2 = k,
|
||||
_ => runs.push((s.1, k, k)),
|
||||
}
|
||||
}
|
||||
let last = samples.len().saturating_sub(1);
|
||||
let (start, end) = (marked[i], marked[(i + 1) % n]);
|
||||
for (j, &(reached, first, to)) in runs.iter().enumerate() {
|
||||
let mid = samples[(first + to) / 2].0;
|
||||
let joined = (first == 0 && start) || (to == last && end);
|
||||
if reached && !joined {
|
||||
places.push((i, mid));
|
||||
}
|
||||
// A gap with reach on both sides: the segment's anchor or a stretch on each side.
|
||||
let before = if j == 0 { start } else { runs[j - 1].0 };
|
||||
let after = runs.get(j + 1).map_or(end, |r| r.0);
|
||||
if !reached && before && after {
|
||||
places.push((i, mid));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A place on one of the segment's ends would name that anchor rather than add one.
|
||||
places.retain(|(_, t)| (1e-5..=1.0 - 1e-5).contains(t));
|
||||
if places.is_empty() {
|
||||
return (sp.clone(), marked);
|
||||
}
|
||||
let (out, added) = pt::insert_at(sp, &places);
|
||||
// The anchors already there keep their marks. A new anchor split off at a sample in reach can
|
||||
// land a rounding error farther away than the sample.
|
||||
let mut kept = marked.into_iter();
|
||||
let marked = out
|
||||
.anchors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, a)| if added.contains(&j) { dist_to_polyline(pts, a.p) <= radius + 1e-9 * radius.max(1.0) } else { kept.next().unwrap_or(false) })
|
||||
.collect();
|
||||
(out, marked)
|
||||
}
|
||||
|
||||
fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option<SubPath> {
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let near = &|q: Point| dist_to_polyline(pts, q) <= radius;
|
||||
// A drag along a segment smooths it even when its anchors are out of reach.
|
||||
let (sp, marked) = mark_reached(sp, pts, radius, share);
|
||||
let sp = &sp;
|
||||
if !marked.iter().any(|m| *m) {
|
||||
return None;
|
||||
}
|
||||
if sp.closed {
|
||||
@@ -115,7 +297,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
for (a, b) in runs.into_iter().rev() {
|
||||
let lo = a.saturating_sub(1);
|
||||
let hi = (b + 1).min(n - 1);
|
||||
if hi - lo < 2 {
|
||||
if hi == lo {
|
||||
continue;
|
||||
}
|
||||
let mut piece = out[lo..=hi].to_vec();
|
||||
@@ -150,16 +332,21 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
|
||||
let sigma = radius * SMOOTH_SPREAD;
|
||||
let st = s.doc()?;
|
||||
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
|
||||
let mut plan = vec![];
|
||||
let (mut before, mut after) = (0, 0);
|
||||
let mut paths = vec![];
|
||||
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) {
|
||||
let (pd, _) = path_of(&st.doc, id)?;
|
||||
paths.push((id, path_of(&st.doc, id)?.0));
|
||||
}
|
||||
// Each curved segment in reach gets the same share of the search, whatever order they come in.
|
||||
let in_reach: usize = paths.iter().map(|(_, pd)| segments_in_reach(pd, &pts, radius)).sum();
|
||||
let share = (MAX_REACH_PIECES / in_reach.max(1)).max(1);
|
||||
for (id, pd) in paths {
|
||||
let mut changed = false;
|
||||
let subs: Vec<SubPath> = pd
|
||||
.subpaths
|
||||
.iter()
|
||||
.map(|sp| match smooth_subpath(sp, &near, tol, sigma) {
|
||||
.map(|sp| match smooth_subpath(sp, &pts, radius, share, tol, sigma) {
|
||||
Some(n) if n != *sp && n.anchors.len() >= 2 => {
|
||||
changed = true;
|
||||
n
|
||||
@@ -414,3 +601,37 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({ "id": ida.0 }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more
|
||||
/// pieces than the budget it is given, and a search with none left gives one sample.
|
||||
#[test]
|
||||
fn searching_a_segment_under_a_drag_stays_within_its_budget() {
|
||||
let c = CubicBez::new(Point::new(0.0, 0.0), Point::new(300000.0, 0.0), Point::new(700000.0, 0.0), Point::new(1000000.0, 0.0));
|
||||
let pts: Vec<Point> = (0..1000).map(|i| Point::new(f64::from(i) * 1000.0, 0.0)).collect();
|
||||
let all = drag_stretches(&pts);
|
||||
let mut budget = MAX_REACH_PIECES;
|
||||
let (mut first, mut second) = (vec![], vec![]);
|
||||
reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut first);
|
||||
reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut second);
|
||||
assert!(first.len() <= MAX_REACH_PIECES, "{} samples", first.len());
|
||||
assert_eq!((budget, second.len()), (0, 1), "the budget is spent, so the next search gives one sample");
|
||||
}
|
||||
|
||||
/// A small piece of curve is measured only against the few stretches of a drag of 100,000
|
||||
/// points that come near it, and a drag of one point has that point as its stretch.
|
||||
#[test]
|
||||
fn a_small_piece_is_measured_against_the_stretches_near_it() {
|
||||
let pts: Vec<Point> = (0..100_000).map(|i| Point::new(f64::from(i) * 5.0, 0.0)).collect();
|
||||
let all = drag_stretches(&pts);
|
||||
let near = stretches_near(&pts, &all, Rect::new(1000.0, 3.0, 1004.0, 6.0), 12.0);
|
||||
assert_eq!(near, (197..=203).collect::<Vec<_>>());
|
||||
assert!((dist_to_stretches(&pts, &near, Point::new(1002.0, 5.0)) - 5.0).abs() < 1e-12);
|
||||
let one = [Point::new(10.0, 10.0)];
|
||||
assert_eq!(drag_stretches(&one), vec![0]);
|
||||
assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1606,6 +1606,210 @@ 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");
|
||||
}
|
||||
|
||||
/// `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();
|
||||
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap()["ids"].clone()
|
||||
}
|
||||
|
||||
/// The corner, in degrees, at each anchor between two segments of `p`'s first open subpath.
|
||||
fn smooth_test_kinks(p: &PathData) -> Vec<f64> {
|
||||
let sp = &p.subpaths[0];
|
||||
(1..sp.segment_count())
|
||||
.map(|i| {
|
||||
let (a, b) = (sp.segment(i - 1), sp.segment(i));
|
||||
let tin = if a.p3.distance(a.p2) > 1e-9 { a.p3 - a.p2 } else { a.p3 - a.p1 };
|
||||
let tout = if b.p1.distance(b.p0) > 1e-9 { b.p1 - b.p0 } else { b.p2 - b.p0 };
|
||||
tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A 200 pt segment rising to a sharp peak at (200, 125) in its middle, then a 100 pt line:
|
||||
/// (100, 200) to (300, 200) to (400, 200).
|
||||
fn long_peak_segment(s: &mut Session) -> NodeId {
|
||||
let anchors = [
|
||||
json!({"x": 100.0, "y": 200.0, "out": [200.0, 100.0]}),
|
||||
json!({"x": 300.0, "y": 200.0, "in": [200.0, 100.0]}),
|
||||
json!({"x": 400.0, "y": 200.0}),
|
||||
];
|
||||
smooth_test_path(s, &anchors, false)
|
||||
}
|
||||
|
||||
/// The sharpest bend of `p` between x = 180 and 220: the most it turns, in radians per point, over
|
||||
/// points about half a point apart.
|
||||
fn sharpest_bend_near_200(p: &PathData) -> f64 {
|
||||
use kurbo::ParamCurve;
|
||||
let sp = &p.subpaths[0];
|
||||
let pts: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..=800).map(move |k| sp.segment(i).eval(f64::from(k) / 800.0))).collect();
|
||||
pts.windows(3)
|
||||
.filter(|w| (180.0..=220.0).contains(&w[1].x))
|
||||
.map(|w| (w[1] - w[0], w[2] - w[1]))
|
||||
.filter(|(u, v)| u.hypot() > 1e-6 && v.hypot() > 1e-6)
|
||||
.map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs() / (0.5 * (u.hypot() + v.hypot())))
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// A drag along the middle of a long segment smooths the stretch under it although both of the
|
||||
/// segment's anchors are out of reach; the rest of the segment and the line after it keep their shape.
|
||||
#[test]
|
||||
fn a_smooth_drag_along_a_segment_smooths_it_with_its_anchors_out_of_reach() {
|
||||
use kurbo::ParamCurve;
|
||||
let mut s = session();
|
||||
let id = long_peak_segment(&mut s);
|
||||
let before = path(&s, id);
|
||||
let drag = smooth_drag_along(126.0, 185.0, 215.0);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
|
||||
let after = path(&s, id);
|
||||
let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after));
|
||||
assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}");
|
||||
let kinks = smooth_test_kinks(&after);
|
||||
// The anchors where the reach was cut join smoothly; the corner before the line stays.
|
||||
assert!(kinks[..kinks.len() - 1].iter().all(|k| *k < 0.5), "{kinks:?}");
|
||||
let a = &after.subpaths[0].anchors;
|
||||
assert_eq!((a[0].p, a[0].h_out.y < 200.0), (Point::new(100.0, 200.0), true), "the first anchor keeps its place: {:?}", a[0]);
|
||||
let (end, last) = (a[a.len() - 2], a[a.len() - 1]);
|
||||
assert!(end.p == Point::new(300.0, 200.0) && !end.has_out(), "the segment's end keeps its place: {end:?}");
|
||||
assert_eq!(last, before.subpaths[0].anchors[2], "and the line after it stays");
|
||||
// Every point of the segment farther than the reach from the drag is still on the path.
|
||||
let seg = before.subpaths[0].segment(0);
|
||||
let sp = &after.subpaths[0];
|
||||
let on_after: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0))).collect();
|
||||
let off_drag = |q: Point| {
|
||||
drag.windows(2)
|
||||
.map(|w| {
|
||||
let ab = w[1] - w[0];
|
||||
(w[0] + ab * ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0)).distance(q)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
for q in (0..=200).map(|k| seg.eval(f64::from(k) / 200.0)).filter(|q| off_drag(*q) > 14.0) {
|
||||
let gap = on_after.iter().map(|r| r.distance(q)).fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 1.0, "{q:?} is {gap} pt off the smoothed path");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag that reaches a segment at its first anchor, leaves it and comes back to its middle smooths
|
||||
/// the middle too, and the stretch between stays as it was.
|
||||
#[test]
|
||||
fn a_smooth_drag_that_comes_back_to_a_segment_smooths_where_it_comes_back() {
|
||||
use kurbo::ParamCurve;
|
||||
let mut s = session();
|
||||
let id = long_peak_segment(&mut s);
|
||||
let before = path(&s, id);
|
||||
let drag = [Point::new(100.0, 200.0), Point::new(100.0, 320.0), Point::new(185.0, 320.0), Point::new(185.0, 126.0), Point::new(215.0, 126.0)];
|
||||
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
|
||||
let after = path(&s, id);
|
||||
let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after));
|
||||
assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}");
|
||||
let between = before.subpaths[0].segment(0).eval(0.3);
|
||||
let sp = &after.subpaths[0];
|
||||
let gap = (0..sp.segment_count())
|
||||
.flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0)))
|
||||
.map(|r| r.distance(between))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 0.5, "{between:?} is {gap} pt off the smoothed path");
|
||||
}
|
||||
|
||||
/// A click on a curve 100,000 pt long, between the places a fixed number of samples along it would
|
||||
/// look at, still finds the curve under it.
|
||||
#[test]
|
||||
fn a_smooth_drag_finds_a_very_long_curve_under_it() {
|
||||
use kurbo::ParamCurve;
|
||||
let mut s = session();
|
||||
let anchors = [json!({"x": 0.0, "y": 0.0, "out": [30000.0, -9000.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, -9000.0]})];
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let at = path(&s, id).subpaths[0].segment(0).eval(0.5 + 0.5 / 1024.0);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &[at]), json!([id.0]));
|
||||
}
|
||||
|
||||
/// A click just within the reach of a segment's peak, with both of the segment's anchors out of
|
||||
/// reach, smooths the segment although only a few points of it are within the reach.
|
||||
#[test]
|
||||
fn a_smooth_click_that_only_just_reaches_a_segment_smooths_it() {
|
||||
let mut s = session();
|
||||
let id = long_peak_segment(&mut s);
|
||||
let before = path(&s, id);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &[Point::new(200.0, 113.1)]), json!([id.0]));
|
||||
assert_ne!(path(&s, id), before);
|
||||
}
|
||||
|
||||
/// A drag that runs along a very long curve first and then crosses a second curve away from its
|
||||
/// middle, with both of its anchors out of reach, smooths the second curve too: each curve in reach
|
||||
/// gets the same share of the search, so the long one does not use it all.
|
||||
#[test]
|
||||
fn a_long_curve_searched_first_leaves_the_next_curve_its_share() {
|
||||
let mut s = session();
|
||||
let long = smooth_test_path(
|
||||
&mut s,
|
||||
&[json!({"x": 0.0, "y": 0.0, "out": [30000.0, 1.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, 1.0]})],
|
||||
false,
|
||||
);
|
||||
let next = smooth_test_path(
|
||||
&mut s,
|
||||
&[json!({"x": 100100.0, "y": -100.0, "out": [100130.0, 200.0]}), json!({"x": 100100.0, "y": 900.0, "in": [100130.0, 600.0]})],
|
||||
false,
|
||||
);
|
||||
s.execute("select.set", &json!({"ids": [long.0, next.0]})).unwrap();
|
||||
let before = path(&s, next);
|
||||
let mut drag: Vec<Point> = (0..=1000).map(|i| Point::new(f64::from(i) * 100.0, 0.0)).collect();
|
||||
drag.push(Point::new(100200.0, 0.0));
|
||||
let ids = smooth_pass_ids(&mut s, &drag);
|
||||
assert!(ids.as_array().is_some_and(|v| v.contains(&json!(next.0))), "{ids}");
|
||||
assert_ne!(path(&s, next), before);
|
||||
}
|
||||
|
||||
/// An open path of two anchors is smoothed: an S-curve smoothed along its whole length.
|
||||
#[test]
|
||||
fn smoothing_smooths_an_open_two_anchor_path() {
|
||||
use kurbo::ParamCurve;
|
||||
let mut s = session();
|
||||
let anchors = [json!({"x": 100.0, "y": 200.0, "out": [170.0, 130.0]}), json!({"x": 200.0, "y": 200.0, "in": [130.0, 270.0]})];
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let before = path(&s, id);
|
||||
let seg = before.subpaths[0].segment(0);
|
||||
let drag: Vec<Point> = (0..=20).map(|k| seg.eval(f64::from(k) / 20.0)).collect();
|
||||
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
|
||||
let after = path(&s, id);
|
||||
let (t0, t1) = (total_turning(&before), total_turning(&after));
|
||||
assert!(t1 < t0, "open: turning {t0} -> {t1}");
|
||||
let a = &after.subpaths[0].anchors;
|
||||
assert_eq!((a[0].p, a[a.len() - 1].p), (Point::new(100.0, 200.0), Point::new(200.0, 200.0)), "the ends stay");
|
||||
}
|
||||
|
||||
/// A closed path of two anchors is smoothed along the middle of one side and stays closed.
|
||||
#[test]
|
||||
fn smoothing_smooths_a_closed_two_anchor_path() {
|
||||
use kurbo::ParamCurve;
|
||||
let mut s = session();
|
||||
let anchors = [
|
||||
json!({"x": 100.0, "y": 300.0, "in": [100.0, 400.0], "out": [100.0, 200.0]}),
|
||||
json!({"x": 300.0, "y": 300.0, "in": [300.0, 160.0], "out": [300.0, 440.0]}),
|
||||
];
|
||||
let id = smooth_test_path(&mut s, &anchors, true);
|
||||
let before = path(&s, id);
|
||||
let top = before.subpaths[0].segment(0);
|
||||
let mid = top.eval(0.5);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &[mid - kurbo::Vec2::new(10.0, 0.0), mid + kurbo::Vec2::new(10.0, 0.0)]), json!([id.0]));
|
||||
let after = path(&s, id);
|
||||
assert!(after.subpaths[0].closed && after != before, "{after:?}");
|
||||
}
|
||||
|
||||
/// A drag that passes nowhere near a path's segments, or only over the middle of a straight
|
||||
/// segment, leaves the path as it was.
|
||||
#[test]
|
||||
fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() {
|
||||
let mut s = session();
|
||||
let id = long_peak_segment(&mut s);
|
||||
let before = path(&s, id);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(260.0, 100.0, 400.0)), json!([]), "60 pt off the path");
|
||||
assert_eq!(path(&s, id), before);
|
||||
let line = smooth_test_path(&mut s, &[json!({"x": 100.0, "y": 400.0}), json!({"x": 300.0, "y": 400.0}), json!({"x": 300.0, "y": 450.0})], false);
|
||||
let before = path(&s, line);
|
||||
assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(400.0, 190.0, 210.0)), json!([]), "a straight segment");
|
||||
assert_eq!(path(&s, line), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -42,6 +42,28 @@ pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313;
|
||||
/// The lowest Fidelity (points) the Smooth tool takes.
|
||||
pub const SMOOTH_MIN_FIDELITY: f64 = 0.05;
|
||||
|
||||
/// Whether segment `i` of `sp` is straight, so the Smooth tool leaves it as it is: its handles lie
|
||||
/// on the line through its ends, and the segment runs from one end to the other without turning back.
|
||||
pub fn segment_is_straight(sp: &SubPath, i: usize) -> bool {
|
||||
let c = sp.segment(i);
|
||||
let chord = c.p3 - c.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-9 {
|
||||
return c.p1.distance(c.p0) < 1e-9 && c.p2.distance(c.p0) < 1e-9;
|
||||
}
|
||||
// A millionth of a point, and the rounding error of a very long segment's coordinates.
|
||||
let eps = 1e-6 + 1e-9 * len;
|
||||
let off = |q: Point| chord.cross(q - c.p0).abs() / len;
|
||||
if off(c.p1) > eps || off(c.p2) > eps {
|
||||
return false;
|
||||
}
|
||||
// How far along the line the segment moves at its start, middle and end (the Bernstein
|
||||
// coefficients of its speed along it): it never moves backward when these allow no negative.
|
||||
let along = |q: Point| chord.dot(q - c.p0) / len;
|
||||
let (a, b, e) = (along(c.p1), along(c.p2) - along(c.p1), len - along(c.p2));
|
||||
a >= -eps && e >= -eps && (b >= -eps || b * b <= a * e + eps)
|
||||
}
|
||||
|
||||
/// The lowest Fidelity tool `id` takes.
|
||||
pub fn min_fidelity(id: &str) -> f64 {
|
||||
if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY }
|
||||
@@ -417,6 +439,22 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
|
||||
/// A segment is straight only when its handles lie on its line between its ends; a bend of 50 pt
|
||||
/// on a segment 100,000,000 pt long is a curve.
|
||||
#[test]
|
||||
fn segment_is_straight_needs_handles_on_the_line_between_the_ends() {
|
||||
let seg = |h1: (f64, f64), h2: (f64, f64), end: f64| {
|
||||
let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(h1.0, h1.1));
|
||||
let b = Anchor::with_handles(Point::new(end, 0.0), Point::new(h2.0, h2.1), Point::new(end, 0.0));
|
||||
segment_is_straight(&SubPath::new(vec![a, b], false), 0)
|
||||
};
|
||||
assert!(seg((0.0, 0.0), (100.0, 0.0), 100.0));
|
||||
assert!(seg((30.0, 0.0), (70.0, 0.0), 100.0));
|
||||
assert!(!seg((30.0, 1.0), (70.0, 0.0), 100.0));
|
||||
assert!(!seg((150.0, 0.0), (-50.0, 0.0), 100.0), "turns back along its line");
|
||||
assert!(!seg((3e7, 50.0), (7e7, 50.0), 1e8), "a long, gentle bend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catmull_rom_passes_through_points() {
|
||||
let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)];
|
||||
|
||||
@@ -757,13 +757,15 @@ fn redraw_targets(cx: &ToolContext, id: NodeId) -> Vec<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the selection contains a path the Smooth command can act on.
|
||||
/// Whether the selection contains a path the Smooth command can act on: one with a subpath of three
|
||||
/// or more anchors, or of two anchors joined by a curve.
|
||||
fn has_selected_smooth_path(cx: &ToolContext) -> bool {
|
||||
let editable = cx.doc.editable();
|
||||
let smoothable = |sp: &vectorcraft_geom::SubPath| sp.anchors.len() >= 3 || (0..sp.segment_count()).any(|i| !super::segment_is_straight(sp, i));
|
||||
cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| {
|
||||
let mut found = false;
|
||||
n.walk(&mut |child| {
|
||||
if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3))
|
||||
if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable))
|
||||
&& editable(child.id)
|
||||
{
|
||||
found = true;
|
||||
|
||||
@@ -1581,6 +1581,35 @@ fn paintbrush_alt_with_only_two_anchor_line_selected_draws_a_closed_stroke() {
|
||||
assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}");
|
||||
}
|
||||
|
||||
/// A selected two-anchor curve can be smoothed, so Alt Paintbrush smooths it with the Smooth cursor,
|
||||
/// as it does a segment along a line that runs past its end and turns back; a two-anchor line whose
|
||||
/// handles lie along it between its ends cannot, so Alt still draws the closed stroke.
|
||||
#[test]
|
||||
fn paintbrush_alt_with_a_two_anchor_curve_selected_smooths() {
|
||||
let p = paint();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let cases = [((340.0, 250.0), (360.0, 350.0), true), ((450.0, 300.0), (250.0, 300.0), true), ((330.0, 300.0), (370.0, 300.0), false)];
|
||||
for (h1, h2, smooths) in cases {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
|
||||
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
|
||||
vectorcraft_geom::PathEl::CurveTo(Point::new(h1.0, h1.1), Point::new(h2.0, h2.1), Point::new(400.0, 300.0)),
|
||||
]));
|
||||
d.insert(Some(layer), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut selected = Selection::default();
|
||||
selected.set([id]);
|
||||
let cx = cx(&d, &selected, &p);
|
||||
let mut t = GestureTool::new("paintbrush");
|
||||
let want = if smooths { Cursor::Smooth } else { Cursor::PaintbrushClose };
|
||||
assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), want, "handles {h1:?} {h2:?}");
|
||||
let acts = drag_held(&mut t, &cx, &[(300.0, 300.0), (350.0, 300.0), (400.0, 300.0)], alt);
|
||||
let smoothed = matches!(acts.last().and_then(|a| a.first()), Some(Action::Exec(c, _)) if c == "path.smoothRegion");
|
||||
assert_eq!(smoothed, smooths, "handles {h1:?} {h2:?}: {acts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of
|
||||
/// a selected open path it continues the path from that end to the pointer.
|
||||
#[test]
|
||||
|
||||
@@ -1118,6 +1118,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.
|
||||
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`).
|
||||
With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`).
|
||||
|
||||
Reference in New Issue
Block a user