mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge pull request #1897 from ki2pixel/shape-per-corner-roundness
Shapes: per-corner roundness for Rectangle Path (#1896)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user