diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index b7cb564d5..502cf962a 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4; +/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the +/// path to the ridges before and after it (zero at an open path's ends). +struct Ridge { + p: Point, + dir: Vec2, + gap_in: f64, + gap_out: f64, +} + /// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately -/// displaced along the normal. +/// displaced along the normal. Corner points join them with straight lines. Smooth points make a +/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the +/// way to the next ridge, so each span between them follows a half cosine (an S through the path) +/// whatever the size of the ridges (#2343). 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 }; @@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let mut subs = vec![]; for sp in &path.subpaths { let segs = sp.segment_count(); - let mut pts = vec![]; + let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64; + let mut pts: Vec = vec![]; 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); + let step = len / (ridges + 1) as f64; for j in 0..=ridges { 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 mut gap_in = step; let nrm = if j == 0 { // Average the normals of the segments meeting at the anchor. let prev = if i > 0 { - Some(sp.segment(i - 1)) + Some(i - 1) } else if sp.closed { - Some(sp.segment(segs - 1)) + Some(segs - 1) } else { None }; + gap_in = prev.map_or(0.0, gap); let n1 = normal_at(&c, 0.0); match prev { - Some(pc) => { - let s = n1 + normal_at(&pc, 1.0); + Some(pi) => { + let s = n1 + normal_at(&sp.segment(pi), 1.0); if s.hypot() > 1e-9 { s / s.hypot() } else { n1 } } None => n1, @@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { normal_at(&c, t) }; let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.eval(t) + nrm * (amp * sign)); + pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step }); k += 1; } } @@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { if segs > 0 { let c = sp.segment(segs - 1); let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign)); + let nrm = normal_at(&c, 1.0); + pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 }); } else { - pts.extend(sp.anchors.iter().map(|a| a.p)); + pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 })); } } - subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) }); + subs.push(if smooth { + let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect(); + SubPath::new(anchors, sp.closed) + } else { + SubPath::polyline(&pts.iter().map(|r| r.p).collect::>(), sp.closed) + }); } PathData::new(subs) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 9b90995bc..f075e4095 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() { } } +/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and +/// valley is a smooth point whose handles run parallel to the line, and the curve between them +/// follows a cosine, even where the ridges are much taller than they are apart. +#[test] +fn zigzag_smooth_is_a_wave() { + use vectorcraft_geom::kurbo::ParamCurve; + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + for size in [3.0, 30.0] { + let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line); + let sp = &out.subpaths[0]; + assert_eq!(sp.anchors.len(), 11); + for a in &sp.anchors { + assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}"); + } + for i in 0..sp.segment_count() { + let c = sp.segment(i); + for k in 0..=20 { + let q = c.eval(k as f64 / 20.0); + let want = -size * (std::f64::consts::PI * q.x / 10.0).cos(); + assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})"); + } + } + } +} + /// #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] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 09ac1f415..2379ba760 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | | Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | @@ -99,6 +99,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Zig Zag with Smooth points draws a continuous wave: every peak and valley is a smooth point with handles along the path, a third of the way to the next ridge, so each span follows a half cosine through the path instead of a straight diagonal with rounded tips, also at the ends of open paths and for ridges much taller than they are apart (#2343); score unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-15 | minor | Round Corners rounds corners whose anchor carries a handle (where a curve meets a line, or two curves meet) with the round cut Live Corners uses (#1144), reaching `radius` along each side; before, they were left sharp. Corners between handle-less anchors are rounded as before; score unchanged (estimated) | | 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding |