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:
Hamza
2026-10-16 18:13:08 +05:00
parent 19d19a339e
commit 0c39995d0f
4 changed files with 194 additions and 6 deletions
+4
View File
@@ -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
+81 -5
View File
@@ -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
+107
View File
@@ -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
View File
@@ -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) |