mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user