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
+45 -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,40 @@ 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 expr = s.expr.clone();
let prepared = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
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]