mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
Convert a shape layer's parametric path to a bezier path (#932)
`path.convertToBezier` only cleared `roto_bezier` on mask groups, so on a shape
layer it did nothing at all: a Rectangle, Ellipse or Polystar path stayed
parametric and its vertices could not be edited or animated. In After Effects
the command replaces such a path with an editable Path 1 of the same geometry.
The geometry is not re-derived. `parametric_path` calls the same
`effectcraft_path::{rect, ellipse, polystar}` functions `render::shapes::collect`
calls, with the same defaults for a property a group doesn't carry, so the path
that comes out is the shape that was on screen; `from_kurbo` and
`build::shape_path` then make an ordinary Path 1.
Masks are unchanged: shape items take precedence when selected, and with none
selected the command falls through to the old mask behaviour. Path Direction is
carried over rather than reset, since reversing a path is a separate command,
and the new path is left selected so its vertices can be edited straight away.
The parametric properties are read at the current time and the result is a
static path: a Size or Roundness animation cannot survive becoming a vertex
list, in After Effects either. Undo restores the parametric shape.
This commit is contained in:
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
|
||||
@@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"rotoBezier": on}))
|
||||
}
|
||||
|
||||
/// 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());
|
||||
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)),
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(
|
||||
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
f("points", 5.0),
|
||||
v2("position", [0.0; 2]),
|
||||
f("rotation", 0.0),
|
||||
f("innerRadius", 50.0),
|
||||
f("outerRadius", 100.0),
|
||||
f("innerRoundness", 0.0),
|
||||
f("outerRoundness", 0.0),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
// A closed parametric shape is one subpath; anything else would not be this shape.
|
||||
effectcraft_path::from_kurbo(&path).into_iter().next()
|
||||
}
|
||||
|
||||
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
// On a shape layer this replaces a Rectangle, Ellipse or Polystar path with an editable
|
||||
// Path 1 of the same geometry, as After Effects does (#932). The parametric properties are
|
||||
// read at the current time and the path that comes out is static: a size or roundness
|
||||
// animation cannot survive the conversion, in After Effects either.
|
||||
let (cid, items) = selected_shape_items(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<(LayerId, Uid)> = items
|
||||
.into_iter()
|
||||
.filter(|(l, u)| {
|
||||
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| PARAMETRIC.contains(&g.match_id.as_str()))
|
||||
})
|
||||
.collect();
|
||||
if shapes.is_empty() {
|
||||
return edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
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);
|
||||
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 };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
if !made.is_empty() {
|
||||
st.selected_props = made.clone();
|
||||
}
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(json!({"paths": made.len(), "of": n}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mask options
|
||||
|
||||
@@ -476,3 +476,110 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The first path item in a shape layer's Contents, at any depth: (uid, match_id). A new shape
|
||||
/// layer nests it in a group ("Rectangle 1" ▸ "Rectangle Path 1"), as After Effects does.
|
||||
fn first_shape(s: &Session, l: u64) -> (u64, String) {
|
||||
fn walk(g: &effectcraft_project::PropGroup) -> Option<(u64, String)> {
|
||||
if ["rect", "ellipse", "star", "path"].contains(&g.match_id.as_str()) {
|
||||
return Some((g.uid, g.match_id.clone()));
|
||||
}
|
||||
g.groups().find_map(walk)
|
||||
}
|
||||
walk(layer(s, l).props.sub("contents").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn shape_path_of(s: &Session, l: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let ly = layer(s, l);
|
||||
let g = ly.props.sub("contents").unwrap().find_group(uid).unwrap();
|
||||
let _ = &g;
|
||||
g.get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Convert To Bezier Path on a shape layer's parametric path gives an editable Path 1 with the
|
||||
/// same geometry, as After Effects does (#932). The command only turned RotoBezier off for masks,
|
||||
/// so a Rectangle, Ellipse or Polystar path could not be converted at all.
|
||||
#[test]
|
||||
fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
let convert = |args: serde_json::Value| {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", args).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, was) = first_shape(&s, l);
|
||||
assert_ne!(was, "path", "it starts out parametric");
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
let out = s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
assert_eq!(out["paths"].as_u64(), Some(1));
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path", "it is a bezier path now");
|
||||
// The new path is selected, so its vertices can be edited straight away.
|
||||
assert_eq!(s.state.selected_props, vec![(LayerId(l), new_uid)]);
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
assert!(path.closed, "a closed shape stays closed");
|
||||
(s, l, was, path)
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(first_shape(&s, l).1, was, "undo restores the parametric shape");
|
||||
|
||||
// An ellipse is four cubic arcs: a vertex on each axis, with tangents of 0.5523 x the radius
|
||||
// (the usual circle approximation). Straight lines here would be a diamond.
|
||||
let (_, _, _, ell) = convert(json!({"kind": "ellipse", "size": [120, 80]}));
|
||||
assert_eq!(ell.vertices, vec![[0.0, -40.0], [60.0, 0.0], [0.0, 40.0], [-60.0, 0.0]]);
|
||||
let kappa = 4.0 / 3.0 * (std::f64::consts::SQRT_2 - 1.0);
|
||||
for (i, want) in [60.0 * kappa, 40.0 * kappa, 60.0 * kappa, 40.0 * kappa].into_iter().enumerate() {
|
||||
let t = ell.in_tangents[i];
|
||||
assert!((radius(t) - want).abs() < 1e-6, "vertex {i}: tangent {t:?} should be {want}");
|
||||
}
|
||||
|
||||
// A five-point star alternates the outer and inner radius, so ten vertices.
|
||||
let (_, _, _, star) = convert(json!({"kind": "star", "size": [120, 120]}));
|
||||
assert_eq!(star.vertices.len(), 10);
|
||||
for (i, v) in star.vertices.iter().enumerate() {
|
||||
let want = if i % 2 == 0 { 60.0 } else { 30.0 };
|
||||
assert!((radius(*v) - want).abs() < 1e-6, "vertex {i} at {v:?} should be {want} from the centre");
|
||||
}
|
||||
|
||||
// A polygon has one vertex per point, all at the outer radius.
|
||||
let (_, _, _, poly) = convert(json!({"kind": "polygon", "size": [120, 120]}));
|
||||
assert!(poly.vertices.len() >= 3);
|
||||
assert!(poly.vertices.iter().all(|v| (radius(*v) - 60.0).abs() < 1e-6), "{:?}", poly.vertices);
|
||||
assert_ne!(poly.vertices.len(), star.vertices.len(), "a polygon is not a star");
|
||||
}
|
||||
|
||||
/// The shape's own values are read, not defaults: Roundness rounds the corners and Position moves
|
||||
/// the path, both at the time the conversion is made.
|
||||
#[test]
|
||||
fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
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);
|
||||
// Properties are addressed by their own uid.
|
||||
let pr_uid = |s: &Session, id: &str| {
|
||||
let ly = layer(s, l);
|
||||
ly.props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid
|
||||
};
|
||||
let (round, pos) = (pr_uid(&s, "roundness"), pr_uid(&s, "position"));
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{round}"), "value": 20.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{pos}"), "value": [10.0, 5.0]})).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);
|
||||
|
||||
// Rounded corners need more than four vertices, and curves between them.
|
||||
assert!(path.vertices.len() > 4, "roundness rounds the corners: {} vertices", path.vertices.len());
|
||||
assert!(path.in_tangents.iter().chain(&path.out_tangents).any(|t| *t != [0.0, 0.0]), "with curves");
|
||||
// Position moves the path: the bounds are centred on it, not on the origin.
|
||||
let xs: Vec<f64> = path.vertices.iter().map(|v| v[0]).collect();
|
||||
let ys: Vec<f64> = path.vertices.iter().map(|v| v[1]).collect();
|
||||
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
+2
-1
@@ -64,7 +64,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Animation presets: 54 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 54 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops) and read only our `.ecpreset`. Missing: Behaviors, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
|
||||
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #932 Convert to Bezier Path, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553 | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
|
||||
| 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
@@ -323,6 +323,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) |
|
||||
| 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 |
|
||||
| 2026-10-10 | minor | Alpha gate checked: no alpha blockers |
|
||||
| 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) |
|
||||
|
||||
Reference in New Issue
Block a user