mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
Simplify: a refitted curve becomes a line only when its joins stay smooth
Simplify, Object > Path > Smooth, the Smooth tool and freehand strokes turned every refitted curve whose handles lay near its chord into a straight line. A short curve whose end directions were off the chord passed that test, and the anchor between it and the next curve became a corner the path did not have: a Smooth tool drag along a line jagged by up to 6 pt left corners of 7.7 to 40.6 degrees. A curve now becomes a line only when the line runs within 0.5 degrees of the directions the path arrives and leaves along. A nearly straight piece that would turn more stays a flat curve. Convert to Straight Lines is unchanged.
This commit is contained in:
@@ -1367,6 +1367,37 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and
|
||||
/// a line: every anchor between the path's ends turns by at most 1°.
|
||||
#[test]
|
||||
fn smooth_tool_leaves_no_corner_where_a_curve_meets_a_line() {
|
||||
let mut s = session();
|
||||
let mut seed = 7u64;
|
||||
let anchors: Vec<_> = (0..=60)
|
||||
.map(|i| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let noise = ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0;
|
||||
json!({"x": 100.0 + f64::from(i) * 5.0, "y": 200.0 + 6.0 * noise})
|
||||
})
|
||||
.collect();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let drag_points: Vec<(f64, f64)> = (0..=64).map(|i| (90.0 + f64::from(i) * 5.0, 200.0)).collect();
|
||||
drag(&mut s, "smooth", &drag_points, Mods::default());
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.anchors.len() < 61, "the path is smoothed: {}", sp.anchors.len());
|
||||
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);
|
||||
for i in 1..sp.segment_count() {
|
||||
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);
|
||||
let turn = arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees();
|
||||
let kinds = (sp.segment_is_line(i - 1), sp.segment_is_line(i));
|
||||
assert!(turn <= 1.0, "anchor {i} turns {turn}° (lines before and after: {kinds:?}): {:?}", sp.anchors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
|
||||
/// sides next to the bump stay straight lines from their far ends, which keep their place.
|
||||
#[test]
|
||||
|
||||
@@ -328,14 +328,33 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Op
|
||||
}
|
||||
// The fit may be `tol * 0.98` from the lines between the samples, those lines may be
|
||||
// `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up
|
||||
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line.
|
||||
// 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.
|
||||
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
|
||||
for c in fit_cubics(&pts, t0, t1, tol * 0.98) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
let fitted = fit_cubics(&pts, t0, t1, tol * 0.98);
|
||||
let mut arrive = t0;
|
||||
for (k, &c) in fitted.iter().enumerate() {
|
||||
let leave = fitted.get(k + 1).map_or(t1, start_tangent);
|
||||
let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave);
|
||||
arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) };
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
}
|
||||
}
|
||||
|
||||
/// Most degrees between the line from a refitted curve's start to its end and the directions the
|
||||
/// path arrives and leaves along, for the curve to become that line: so every join next to such a
|
||||
/// line turns by at most this much. VectorCraft's own value.
|
||||
const LINE_JOIN_DEG: f64 = 0.5;
|
||||
|
||||
/// Whether the line from `c.p0` to `c.p3` is within [`LINE_JOIN_DEG`] of `arrive`, the direction the
|
||||
/// path arrives at `c.p0` along, and of `leave`, the direction it leaves `c.p3` along. A curve whose
|
||||
/// ends are at one point, or a direction of no length, gives `false`.
|
||||
fn line_keeps_joins(c: &CubicBez, arrive: Vec2, leave: Vec2) -> bool {
|
||||
let (Some(dir), Some(arrive), Some(leave)) = (unit(c.p3 - c.p0), unit(arrive), unit(leave)) else { return false };
|
||||
let min = LINE_JOIN_DEG.to_radians().cos();
|
||||
dir.dot(arrive) >= min && dir.dot(leave) >= min
|
||||
}
|
||||
|
||||
/// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted
|
||||
/// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the
|
||||
/// ends so that the first and last points stay where they are. Consecutive samples within
|
||||
|
||||
@@ -417,6 +417,51 @@ fn simplify_keeps_square_corners() {
|
||||
assert!(!has_curves(&s));
|
||||
}
|
||||
|
||||
/// The largest turn, in degrees, at an anchor of `sp` that is not an end of an open subpath: the
|
||||
/// angle between the direction a segment arrives along and the one the next leaves along.
|
||||
fn largest_join_turn(sp: &SubPath) -> 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);
|
||||
let n = sp.segment_count();
|
||||
let first = usize::from(!sp.closed);
|
||||
(first..n)
|
||||
.map(|i| {
|
||||
let (prev, next) = (sp.segment((i + n - 1) % n), 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 refitted curve becomes a line only when the line keeps the smooth joins on both sides of it:
|
||||
/// with no corners kept, the refit of a jagged line or of an arc running into a line turns by at most
|
||||
/// 0.5° at every anchor. Points that all lie on one line still become one line.
|
||||
#[test]
|
||||
fn simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth() {
|
||||
let mut seed = 7u64;
|
||||
let mut noise = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
|
||||
};
|
||||
let jagged: Vec<Point> = (0..=60).map(|i| Point::new(100.0 + f64::from(i) * 5.0, 200.0 + 6.0 * noise())).collect();
|
||||
let mut arc_into_line: Vec<Point> =
|
||||
(0..=40).map(|i| Point::new(50.0 * (f64::from(i) / 80.0 * PI).cos(), 50.0 * (f64::from(i) / 80.0 * PI).sin())).collect();
|
||||
arc_into_line.extend((1..=20).map(|i| Point::new(-f64::from(i) * 2.0, 50.0)));
|
||||
for pts in [&jagged, &arc_into_line] {
|
||||
let path = PathData::single(SubPath::polyline(pts, false));
|
||||
for tolerance in [1.0, 2.5, 5.0] {
|
||||
let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, ..Default::default() });
|
||||
let turn = largest_join_turn(&s.subpaths[0]);
|
||||
assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}");
|
||||
assert!(max_deviation(&path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(&path, &s));
|
||||
}
|
||||
}
|
||||
let in_line: Vec<Point> = [0.0, 3.0, 10.0, 11.0, 40.0, 41.5, 90.0, 100.0].map(|x| Point::new(x, 0.5 * x)).to_vec();
|
||||
let s = simplify_with(&PathData::single(SubPath::polyline(&in_line, false)), &SimplifyOptions { tolerance: 1.0, ..Default::default() });
|
||||
assert_eq!(s.anchor_count(), 2, "{s:?}");
|
||||
assert!(!has_curves(&s), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_straight_lines_option() {
|
||||
let c = dense_circle(50.0, 200);
|
||||
|
||||
@@ -1204,6 +1204,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Nearly unchanged appearance is expected when reducing points. The checklist is partial, without raising scores.
|
||||
Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape.
|
||||
Simplify keeps its result within the tolerance of the path, on long arcs and on paths of many anchors alike (`fit.rs` tests `no_fitted_piece_is_farther_than_the_tolerance` and `long_arcs_stay_within_the_tolerance`). A closed subpath that refits to one curve is dropped, so this does not hold for it. A segment that would need more than 65,536 samples at that tolerance stays as it is, and with Convert to Straight Lines it becomes evenly spaced lines instead, up to 65,536, which past the limits in `docs/mcp.md` (Simplify) can be farther than the tolerance from the segment.
|
||||
Simplify, Object › Path › Smooth, the Smooth tool and freehand strokes (`path.freehand`) turn a refitted curve into a straight line only when the line runs within 0.5° of the directions the path arrives and leaves along, so no corner appears where a curve meets a line; a nearly straight piece that would turn more stays a flat curve (`ops.rs` test `simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth`).
|
||||
The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines.
|
||||
Object › Path › Simplify opens a slider under the selection ([Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html), [Auto simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/auto-simplify-paths.html)) with Auto-Simplify, a rail from the fewest anchor points to the closest fit, and More Options, which opens the dialog at the slider's tolerance. The rail's levels are VectorCraft's own scale (`object.path.simplify {level}`): level 50 is 1 pt, and every 10 levels halve or double the tolerance, from 32 pt at 0 to 1/32 pt at 100. Auto-Simplify picks the lowest level whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt; the slider opens at that level, with its result previewed, as the manual-simplify page describes. The dialog's "Open this dialog with these settings next time" makes the menu item open the dialog on the settings of its last OK.
|
||||
With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`.
|
||||
|
||||
Reference in New Issue
Block a user