mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
716a968e7a
commit
a14942de9c
@@ -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<Vec<Point>> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user