diff --git a/ROADMAP.md b/ROADMAP.md index e21039c0..1887d7b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index c74c3bb5..96767d96 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result { 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 { + 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 { - 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 diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index edf77355..84321ee7 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -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 = path.vertices.iter().map(|v| v[0]).collect(); + let ys: Vec = 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)); +} diff --git a/docs/gaps.md b/docs/gaps.md index fb0637b2..fd92c22a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) | @@ -335,6 +335,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-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 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 |