Shapes: per-corner roundness for Rectangle Path (#1896)

Add Per-Corner Roundness to Rectangle Path: a checkbox (off by default) and four Pixels properties (top-left, top-right, bottom-right, bottom-left). New rectangles initialise the four values from their Roundness, so ticking the box keeps the shape; the radii are animatable and expression-driven.\n\nrect_radii() builds the path clockwise from the top-left with kappa quarter-circle arcs. Overlapping radii scale down like CSS border-radius; negative and non-finite radii are square; rect() delegates to it, so uniform roundness clamps to half the shorter side as before.\n\nLottie rc has one r: four equal static radii export as that value; mixed or animated radii export roundness 0 with a warning. Import puts r into the four per-corner values so ticking the box on an imported rectangle keeps the shape.\n\nTests: path geometry and clamping, rendered per-corner pixels, Lottie equal/mixed/checkbox-off export.
This commit is contained in:
Camille Moine
2026-10-30 01:24:59 +01:00
parent 60f246d1a7
commit 3bc1caaedb
10 changed files with 304 additions and 22 deletions
+77
View File
@@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
p
}
/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right,
/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii
/// are 0; radii that would overlap are scaled down as CSS `border-radius` does.
pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath {
// Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners
// clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must
// not make a trim jump (#1896).
let w = if size[0].is_finite() { size[0].abs() } else { 0.0 };
let h = if size[1].is_finite() { size[1].abs() } else { 0.0 };
let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0);
let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0);
let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
// CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest
// edge ratio (a uniform radius then clamps to half the shorter side, as before).
let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 };
let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h));
if f < 1.0 {
for v in &mut r {
*v *= f;
}
}
let [tl, tr, br, bl] = r;
// Quarter-circle Bezier control distance.
const K: f64 = 0.552_284_749_830_793_6;
let mut p = BezPath::new();
p.move_to((x1, y0 + tr));
p.line_to((x1, y1 - br));
if br > 0.0 {
p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1));
}
p.line_to((x0 + bl, y1));
if bl > 0.0 {
p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl));
}
p.line_to((x0, y0 + tl));
if tl > 0.0 {
p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0));
}
p.line_to((x1 - tr, y0));
if tr > 0.0 {
p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr));
}
p.close_path();
p
}
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
to_kurbo(&ShapePath::ellipse(pos, size[0], size[1]))
}
@@ -419,6 +465,11 @@ mod tests {
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&round[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
// Per-corner roundness starts and runs in the same order: ticking the checkbox must not
// move a Trim Paths contour.
let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0);
let (a, b) = ends(&corners[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}");
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
let (a, b) = ends(&swapped[0]);
@@ -456,6 +507,32 @@ mod tests {
assert!((area - 200.0).abs() < 1.0, "{area}");
}
#[test]
fn rect_radii_uniform_matches_a_rounded_rect() {
for r in [0.0, 5.0, 25.0, 100.0] {
let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]);
let clamped = r.min(25.0);
let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped;
assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area());
assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6);
}
}
#[test]
fn rect_radii_mixes_corners_and_scales_overlaps() {
// Only the top-left and bottom-right corners round.
let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]);
let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0);
assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area());
// Radii wider than the top edge scale down together, like CSS border-radius.
let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]);
let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]);
assert!((p.area() - scaled.area()).abs() < 1e-9);
// Negative and non-finite radii are square corners.
let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]);
assert!((p.area().abs() - 2500.0).abs() < 1e-9);
}
#[test]
fn stroke_has_area() {
let r = rect([40.0, 40.0], [50.0, 50.0], 0.0);