Merge pull request #1897 from ki2pixel/shape-per-corner-roundness

Shapes: per-corner roundness for Rectangle Path (#1896)
This commit is contained in:
BFlat
2026-10-30 08:59:39 +07:00
committed by GitHub
10 changed files with 303 additions and 22 deletions
+43 -20
View File
@@ -5,6 +5,7 @@
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::layer_menu::target_masks;
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
/// path is the shape that was on screen rather than an approximation of it; the defaults match
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
/// roundness).
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"rect" => {
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
if ctx.b(layer, g, "perCorner") {
effectcraft_path::rect_radii(
size,
pos,
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
)
} else {
effectcraft_path::rect(size, pos, f("roundness", 0.0))
}
}
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
star: ctx.e(layer, g, "type") == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
}
let t = s.time();
let n = shapes.len();
// Read the geometry before editing, with the same expression-aware context the renderer
// uses: an expression-driven corner radius, size or position must convert to what is on
// screen, not to the property's underlying value.
let prepared = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
let mut prepared = Vec::new();
for (lid, uid) in &shapes {
let Some(l) = comp.layer(*lid) else { continue };
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
let Some(path) = parametric_path(&ctx, l, old) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
}
prepared
};
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
let mut next = proj.next_id;
let mut made = Vec::new();
for (lid, uid) in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
for (lid, uid, path, direction, enabled) in prepared {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
let Some(old) = contents.find_group(*uid) else { continue };
let Some(path) = parametric_path(old, lt) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
let enabled = old.enabled;
let mut g = build::shape_path(&mut Ids(&mut next), path);
if let Some(pr) = g.prop_mut("direction") {
pr.value = KV::Enum(direction);
}
g.enabled = enabled;
let new_uid = g.uid;
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
parent.children[at] = Node::Group(g);
made.push((*lid, new_uid));
made.push((lid, new_uid));
}
proj.next_id = next;
// The new paths are what is selected, so the vertices can be edited straight away.
+64
View File
@@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
}
/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so
/// the editable path is the shape that was on screen.
#[test]
fn convert_to_bezier_honours_per_corner_roundness() {
let mut s = comp();
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
let per_corner = uid_of(&s, "perCorner");
let top_left = uid_of(&s, "topLeftRoundness");
let bottom_right = uid_of(&s, "bottomRightRoundness");
for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] {
s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the converted path has the per-corner geometry");
}
/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven
/// Per-Corner checkbox and radius convert to the geometry on screen.
#[test]
fn convert_to_bezier_evaluates_corner_expressions() {
struct Corners;
impl effectcraft_render::ExprHost for Corners {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
prop: &effectcraft_project::Property,
value: &KV,
) -> std::result::Result<KV, String> {
Ok(match prop.match_id.as_str() {
"perCorner" => KV::Bool(true),
"topLeftRoundness" => KV::Scalar(20.0),
_ => value.clone(),
})
}
}
let mut s = comp();
s.expr = Some(Arc::new(Corners));
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] {
let u = uid_of(&s, id);
s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap();
}
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap();
assert_eq!(path, expected, "the expression-evaluated corner is converted");
}
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
/// shared merge key; one undo must revert the whole drag, not the last frame.
#[test]
+22 -1
View File
@@ -113,6 +113,9 @@ impl Out<'_, '_> {
fn f(&self, m: &str) -> f64 {
self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0)
}
fn flag(&self, m: &str) -> bool {
self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false)
}
fn animated(&self, m: &str) -> bool {
self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression())
}
@@ -232,7 +235,20 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve
dir(&mut o);
o.put("s", "size", &anim::arr2);
o.put("p", "position", &anim::arr2);
o.put("r", "roundness", &anim::num);
if o.flag("perCorner") {
let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"];
let radii = names.map(|m| o.f(m));
// Lottie `rc` has one `r`: four equal static radii export as that value.
// `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number.
if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 {
o.raw("r", json!({"a": 0, "k": r(radii[0])}));
} else {
o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label));
o.raw("r", json!({"a": 0, "k": 0}));
}
} else {
o.put("r", "roundness", &anim::num);
}
}
"el" => {
dir(&mut o);
@@ -447,6 +463,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
set(im, &mut g, "size", j.get("s"), &anim::to_vec2);
set(im, &mut g, "position", j.get("p"), &anim::to_vec2);
set(im, &mut g, "roundness", j.get("r"), &anim::to_scalar);
// Lottie has one roundness: keep the per-corner values in step so ticking the box
// on an imported rectangle keeps the shape.
for m in ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"] {
set(im, &mut g, m, j.get("r"), &anim::to_scalar);
}
g
}
"el" => {
+36
View File
@@ -896,3 +896,39 @@ fn muted_keys_are_omitted_from_the_export() {
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
}
#[test]
fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() {
let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| {
let (mut p, cid) = setup();
let l = shape_layer(&mut p, cid, move |ids| {
let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness);
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
for (m, v) in
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
{
g.prop_mut(m).unwrap().value = Value::Scalar(v);
}
let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])]
});
push(&mut p, cid, l);
(p, cid)
};
let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() };
// Four equal static radii: `rc.r` carries the value, no warning.
let (p, cid) = shape([12.0; 4], 5.0, true);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0}));
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
// Mixed radii: not representable, roundness 0 exported with a warning.
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0}));
assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}");
// The checkbox off exports the single Roundness and ignores the four values.
let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false);
let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert_eq!(rc(&json)["r"]["k"], json!(5.0));
assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}");
}
+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);
+7
View File
@@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec<PropGroup>) -> PropGrou
}
pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup {
// Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape.
let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 };
indexed(ids.group("rect", "Rectangle Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels))
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
.with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels))
.with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
.with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels))
}
pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup {
+14 -1
View File
@@ -269,7 +269,20 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
let p = if ctx.b(layer, g, "perCorner") {
effectcraft_path::rect_radii(
size,
pos,
[
ctx.f(layer, g, "topLeftRoundness", 0.0),
ctx.f(layer, g, "topRightRoundness", 0.0),
ctx.f(layer, g, "bottomRightRoundness", 0.0),
ctx.f(layer, g, "bottomLeftRoundness", 0.0),
],
)
} else {
effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0))
};
push_shape(ctx, layer, g, p, arena, &mut live);
}
"ellipse" => {
+30
View File
@@ -715,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() {
let b = shape_buf(&p, cid);
assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height);
}
#[test]
fn rect_per_corner_roundness_rounds_the_named_corners() {
let render = |per_corner: bool, radii: [f64; 4], roundness: f64| {
let (p, cid) = shape_comp_sized(200, 100, move |ids| {
let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness);
g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner);
for (m, v) in
[("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])]
{
g.prop_mut(m).unwrap().value = Value::Scalar(v);
}
vec![g, build::shape_fill(ids, RED)]
});
frame(&p, cid)
};
// Only the top-left corner rounds: its corner pixels fade, the other three stay filled.
let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0);
assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2));
for (x, y) in [(148, 2), (148, 98), (52, 98)] {
assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y));
}
// The checkbox off ignores the four values (the single Roundness still rules).
let off = render(false, [30.0; 4], 0.0);
assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off));
// Four equal per-corner radii render like the same Roundness.
let per = render(true, [25.0; 4], 0.0);
let uni = render(false, [0.0; 4], 25.0);
assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni));
}
@@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() {
" Size",
" Position",
" Roundness",
" Per-Corner Roundness",
" Top Left Roundness",
" Top Right Roundness",
" Bottom Right Roundness",
" Bottom Left Roundness",
" >Fill 1",
" Composite",
" Fill Rule",
@@ -340,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() {
let fill = fill.expect("a fill colour");
fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec<u64>) {
for c in g.groups() {
// The rectangle's path rows would push Fill below the rows the Timeline fits; the
// eyedropper only needs Fill, so leave that group collapsed.
if c.name == "Rectangle Path 1" {
continue;
}
out.push(c.uid);
groups(c, out);
}