From a14942de9ce3d8c5306e5c084e53ff7b6d2457bb Mon Sep 17 00:00:00 2001 From: Kamil Kwiatkowski <10109522+limackk@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:14:44 +0200 Subject: [PATCH] Effects: Round Corners also rounds corners where a curve meets a line A corner whose anchor carries a handle (where a curve meets a line, or two curves meet) was left sharp. It now gets Live Corners' round cut (geom::corners, #1144), reaching `radius` along each side and trimming a curved side. Corners between handle-less anchors are rounded exactly as before, and a closed path still starts where its first corner's rounding starts. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stylize.rs | 39 +++++++++++++++++++++-- crates/effects/src/tests.rs | 60 +++++++++++++++++++++++++++++++++++ docs/effects-parity.md | 3 +- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index b5f01f06d..2db14d10f 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -3,7 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo; use vectorcraft_geom::shapes::{self, KAPPA}; -use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners}; use crate::util::*; @@ -24,15 +24,21 @@ pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData { } } -/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. +/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. A corner +/// whose anchor carries a handle (where a curve meets a line, or two curves meet) gets Live +/// Corners' round cut instead ([`corners::cut_corners`], #1144): it meets each side `radius` from +/// the corner, measured along the side, and trims a curved side to what is left of it. pub fn round_corners(path: &PathData, radius: f64) -> PathData { if radius <= 0.0 { return path.clone(); } let mut subs = vec![]; + // Per anchor of the result: an anchor with a handle, kept as it was, for the cut below. + let mut with_handle = vec![]; for sp in &path.subpaths { let n = sp.anchors.len(); if n < 3 && (n != 2 || sp.closed) { + with_handle.extend(sp.anchors.iter().map(|a| a.has_in() || a.has_out())); subs.push(sp.clone()); continue; } @@ -41,6 +47,7 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData { let a = sp.anchors[i]; let interior = sp.closed || (i > 0 && i + 1 < n); if !interior || a.has_in() || a.has_out() { + with_handle.push(a.has_in() || a.has_out()); out.push(a); continue; } @@ -52,12 +59,14 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData { let (vp, vn) = (tp - a.p, tn - a.p); let (lp, ln) = (vp.hypot(), vn.hypot()); if lp < 1e-9 || ln < 1e-9 { + with_handle.push(false); out.push(a); continue; } let (up, un) = (vp / lp, vn / ln); // Nearly straight: nothing to round. if up.dot(un) < -0.9999 { + with_handle.push(false); out.push(a); continue; } @@ -65,12 +74,36 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData { let pa = a.p + up * d; let pb = a.p + un * d; let k = d * KAPPA; + with_handle.extend([false, false]); out.push(Anchor::with_handles(pa, pa, pa - up * k)); out.push(Anchor::with_handles(pb, pb - un * k, pb)); } subs.push(SubPath::new(out, sp.closed)); } - PathData::new(subs) + let rounded = PathData::new(subs); + // The corners left: those whose anchor has a handle. Their cut reaches `radius` along each + // side, as above; setback = r · (1 + cos) / sin, solved for the arc's radius r. + let mut radii = vec![0.0; with_handle.len()]; + for c in corners::path_corners(&rounded) { + if with_handle.get(c.index) == Some(&true) + && let Some(r) = radii.get_mut(c.index) + { + *r = radius * c.sin() / (1.0 + c.cos()); + } + } + if radii.iter().all(|r| *r == 0.0) { + return rounded; + } + let (mut out, sources) = corners::cut_corners(&rounded, &radii, &[]); + // `cut_corners` starts a closed subpath whose first corner it cuts at the end of that cut; + // start at its start instead, as the rounding above does, so what depends on where a path + // starts (dashes, arrowheads after the effect) doesn't move. + for (sp, from) in out.subpaths.iter_mut().zip(&sources) { + if sp.closed && sp.anchors.len() > 1 && from.first() == from.last() { + sp.anchors.rotate_right(1); + } + } + out } fn flatten(path: &PathData) -> Vec> { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index c89144583..1416d91ce 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -594,6 +594,66 @@ fn round_corners_replaces_corners() { assert_eq!(run("stylize.roundCorners", json!({}), &el), el); } +/// A D: a straight back, a curved bowl, and two corners where the bowl meets the straight +/// sides (the anchors there carry the bowl's handles). +fn d_shape() -> PathData { + use vectorcraft_geom::Anchor; + let p = Point::new; + PathData::new(vec![SubPath::new( + vec![ + Anchor::corner(p(0.0, 0.0)), + Anchor::with_handles(p(50.0, 0.0), p(50.0, 0.0), p(80.0, 20.0)), + Anchor::with_handles(p(50.0, 100.0), p(80.0, 80.0), p(50.0, 100.0)), + Anchor::corner(p(0.0, 100.0)), + ], + true, + )]) +} + +#[test] +fn round_corners_also_rounds_where_a_curve_meets_a_line() { + let d = d_shape(); + let out = run("stylize.roundCorners", json!({"radius": 8}), &d); + // All four corners are cut, the two curve-line ones too: two anchors each. + assert_eq!(out.anchor_count(), 8); + assert!(all_finite(&out)); + // Every cut leaves both sides smoothly: no corner is left anywhere. + assert!(vectorcraft_geom::corners::path_corners(&out).is_empty(), "{out:?}"); + let (before, after) = (d.to_bezpath().area().abs(), out.to_bezpath().area().abs()); + assert!(after < before && after > before - 4.0 * 8.0 * 8.0, "{before} -> {after}"); + // The cut at a curve-line corner meets the straight side 8 from the corner. + let p = Point::new; + assert!(out.subpaths[0].anchors.iter().any(|a| a.p.distance(p(42.0, 0.0)) < 1e-9), "{out:?}"); +} + +#[test] +fn round_corners_keeps_straight_corners_as_they_were() { + // The D's straight corners are rounded as before, next to the new cuts: same anchors, same + // quarter-circle handles, and the path still starts at its first corner's rounding. + use vectorcraft_geom::Anchor; + let p = Point::new; + let k = 8.0 * shapes::KAPPA; + let out = stylize::round_corners(&d_shape(), 8.0); + let a = &out.subpaths[0].anchors; + assert_eq!(a[0], Anchor::with_handles(p(0.0, 8.0), p(0.0, 8.0), p(0.0, 8.0 - k))); + assert_eq!(a[1], Anchor::with_handles(p(8.0, 0.0), p(8.0 - k, 0.0), p(8.0, 0.0))); + // A path of straight corners only is rounded exactly as before. + assert_eq!(stylize::round_corners(&square(), 10.0).subpaths[0].anchors.len(), 8); +} + +#[test] +fn round_corners_keeps_where_a_closed_path_starts() { + // The D starting at a curve-line corner: the result starts where that corner's cut begins, + // on the side coming from the previous corner, as a straight corner's rounding does. + let mut d = d_shape(); + d.subpaths[0].anchors.rotate_left(1); + let out = stylize::round_corners(&d, 8.0); + let start = out.subpaths[0].anchors[0].p; + assert!(start.distance(Point::new(42.0, 0.0)) < 1e-9, "{start:?}"); + // An infinite radius rounds as much as fits, curve-line corners too. + assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9)); +} + #[test] fn twist_fixes_the_outer_circle_and_rotates_inside() { let out = run("distort.twist", json!({"angle": 0}), &square()); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index ff2e39a87..a6b8d397f 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-14 · **Change:** minor (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-15 · **Change:** minor (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 @@ -99,6 +99,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) | | 2026-10-14 | minor | Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); scores unchanged (estimated) |