diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 3d4cce37..aa72ad2d 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -1,6 +1,7 @@ //! Distort & Transform effects. use serde_json::Value; +use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; use crate::util::*; @@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { map_nonlinear(path, pieces, f) } -/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal. +const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4; + +/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately +/// displaced along the normal. pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let size = num(p, "size", 10.0).clamp(-1e4, 1e4); let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size }; @@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let mut k = 0usize; for i in 0..segs { let c = sp.segment(i); + // Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up + // near the anchors, and so does any uneven curve. + let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY); for j in 0..=ridges { - let t = j as f64 / (ridges + 1) as f64; + let s = j as f64 / (ridges + 1) as f64; + let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s }; let nrm = if j == 0 { // Average the normals of the segments meeting at the anchor. let prev = if i > 0 { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 5d7c86a4..578b09ce 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() { assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}"); } +#[test] +fn zigzag_spaces_ridges_evenly_along_a_straight_line() { + // #1554: on a straight line every ridge is the same distance from the line and as far from + // the next one, whether the points are corners or smooth. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false)); + for points in ["corner", "smooth"] { + let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line); + let ps: Vec = out.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(ps.len(), 6, "{points}"); + for (k, q) in ps.iter().enumerate() { + assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}"); + assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}"); + } + } +} + +/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in +/// turn all the way round. +#[test] +fn zigzag_on_a_circle_is_regular() { + let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0)); + let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle); + let pts: Vec = out.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts.len(), 16); + let start = pts[0].y.atan2(pts[0].x); + let mut radii = vec![]; + for (i, q) in pts.iter().enumerate() { + let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5; + assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths"); + radii.push(q.to_vec2().hypot().round()); + } + assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}"); +} + +/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts. +#[test] +fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() { + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let c = curve.subpaths[0].segment(0); + let len = c.arclen(1e-6); + let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve); + let along: Vec = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect(); + assert_eq!(along.len(), 5); + for (k, share) in along.iter().enumerate() { + assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}"); + } +} + #[test] fn offset_path_grows_and_shrinks() { let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();