From 6ff41bcb10356f43ebb4d19659a4ed6a88424c7d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 15:26:41 +0100 Subject: [PATCH] Timeline: Convert To Bezier Path in the context menu of a parametric shape path Right-clicking a Rectangle, Ellipse or Polystar Path row offers After Effects' Convert To Bezier Path, running path.convertToBezier with that layer and path. A shape group's row offers it too and converts the static parametric paths inside it; keyframed or expression-driven ones are left alone, since converting drops their animation. Fixes #3237 --- crates/ui-egui/src/panels/timeline.rs | 71 +++++++++++++++- .../tests/it/ui_blank_context_menus.rs | 84 ++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 038dcc9b9..0770f5e1f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3716,12 +3716,14 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return }; let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)); // Effects, masks, shape items and animators drag to a new place among their siblings. - let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance); + let group = layer.props.find_group(*uid); + let movable = !layer.switches.locked && group.is_some_and(PropGroup::is_instance); let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() }); if movable && gr.drag_started_by(egui::PointerButton::Primary) { ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid })); } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); + shape_path_menu(&mut app.auto, &gr, layer, group, &mut *actions); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as // property rows and Effect Controls headers do (#2552: several effects and styles for @@ -3745,6 +3747,73 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } } +/// The shape items Convert To Bezier Path turns into editable paths. +const PARAMETRIC_PATHS: [&str; 3] = ["rect", "ellipse", "star"]; + +/// The parametric paths (Rectangle, Ellipse, Polystar Path) inside shape group `g`, nested groups +/// included, that convert without losing anything: a keyframed or expression-driven one would +/// lose its animation, so the group leaves it alone. +fn static_parametric_paths(g: &PropGroup) -> Vec { + // Depth-first, without recursion; only groups and their contents hold paths. + let mut out = Vec::new(); + let mut stack = vec![g]; + while let Some(n) = stack.pop() { + for c in n.groups() { + match c.match_id.as_str() { + m if PARAMETRIC_PATHS.contains(&m) => { + if !c.any_animated() { + out.push(c.uid); + } + } + "group" | "contents" => stack.push(c), + _ => {} + } + } + } + out +} + +/// Right-click on a Rectangle, Ellipse or Polystar Path row, or on a shape group holding such +/// paths: After Effects' Convert To Bezier Path, acting on that row (#3237). A path's own row +/// converts it even when animated (After Effects makes a static path at the current time); the +/// group's entry, an addition to After Effects, converts the static paths inside it. +fn shape_path_menu( + auto: &mut crate::automation::Registry, + resp: &egui::Response, + layer: &Layer, + group: Option<&PropGroup>, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let Some(g) = group.filter(|_| matches!(layer.source, LayerSource::Shape)) else { return }; + let uid = g.uid; + let items = if PARAMETRIC_PATHS.contains(&g.match_id.as_str()) { + vec![uid] + } else if g.match_id == "group" { + // Walked once, when the menu opens, not for every group row on every frame. + let key = egui::Id::unique(("tl-shape-path-menu", layer.id.0, uid)); + if resp.secondary_clicked() { + let found = static_parametric_paths(g); + resp.ctx.data_mut(|d| d.insert_temp(key, found)); + } + resp.ctx.data(|d| d.get_temp::>(key)).unwrap_or_default() + } else { + return; + }; + if items.is_empty() { + return; + } + let (cmd, label) = ("path.convertToBezier", "Convert To Bezier Path"); + let params = json!({"layer": layer.id.0, "items": items}); + crate::widgets::context_menu(resp, |ui| { + let r = ui.add_enabled(!layer.switches.locked, egui::Button::new(crate::i18n::context_item(cmd, ¶ms, label))); + auto.add(&format!("timeline.group.{uid}.menu.convertToBezier"), r.rect, label); + if r.clicked() { + actions.push((cmd.into(), params.clone())); + ui.close(); + } + }); +} + fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let t = g.tl.t; let graph_x0 = g.tl.graph_x0; diff --git a/crates/ui-egui/tests/it/ui_blank_context_menus.rs b/crates/ui-egui/tests/it/ui_blank_context_menus.rs index 84080be08..31312d5b2 100644 --- a/crates/ui-egui/tests/it/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/it/ui_blank_context_menus.rs @@ -1,4 +1,5 @@ -//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. +//! Right-click actions in the blank Project and Timeline areas, and on Timeline property and +//! shape path names. use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::ItemKind; @@ -12,6 +13,8 @@ fn harness() -> Harness<'static, EffectcraftApp> { let mut session = Session::default(); session.execute("comp.new", json!({"name":"Empty", "width":640,"height":360,"duration":4})).unwrap(); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + // English whatever the system language: the menu items found below by label are translated. + h.state_mut().session.prefs.general.language = "en".into(); h.run_steps(3); h } @@ -230,6 +233,85 @@ fn timeline_property_names_have_the_property_menu() { assert_eq!(h.state().dialog, Some(Dialog::Form)); } +/// #3237: right-clicking a Rectangle Path row in the Timeline offers After Effects' Convert To +/// Bezier Path, which turns that path into an editable one; the shape group holding it offers the +/// same and converts the parametric paths inside it. +#[test] +fn timeline_parametric_shape_path_menu_converts_to_bezier() { + use effectcraft_engine::project::{LayerId, PropGroup}; + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let contents = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.sub("contents").unwrap().clone(); + fn groups(g: &PropGroup, out: &mut Vec<(u64, String)>) { + out.push((g.uid, g.match_id.clone())); + for c in g.groups() { + groups(c, out); + } + } + let all = |h: &Harness<'_, EffectcraftApp>| { + let mut out = Vec::new(); + groups(&contents(h), &mut out); + out + }; + let find = |h: &Harness<'_, EffectcraftApp>, ids: &[&str]| all(h).into_iter().find(|(_, m)| ids.contains(&m.as_str())).map(|(u, _)| u); + let open = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().ui.timeline.open_layers.insert(layer); + for (uid, _) in all(&*h) { + h.state_mut().ui.timeline.open_groups.insert(uid); + } + h.run_steps(3); + }; + let choose = |h: &mut Harness<'_, EffectcraftApp>, row: u64| { + let p = rect(h, &format!("timeline.group.{row}.name")).center(); + right_click(h, p); + assert!(h.query_by_label("Convert To Bezier Path").is_some(), "the menu offers Convert To Bezier Path"); + let p = rect(h, &format!("timeline.group.{row}.menu.convertToBezier")).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let group = find(&h, &["group"]).expect("a shape group"); + let path = find(&h, &["rect"]).expect("a Rectangle Path"); + // The Rectangle Path's own row. + open(&mut h); + snapshot(&mut h, "shape-path-rows"); + choose(&mut h, path); + assert!(find(&h, &["rect"]).is_none(), "the rectangle is converted"); + assert!(find(&h, &["path"]).is_some(), "it is a bezier path now"); + assert_eq!(h.state().session.history.undo.last().map(|u| u.0.as_str()), Some("Convert To Bezier Path")); + // A bezier path has nothing to convert: its row has no such entry. + let bezier = find(&h, &["path"]).unwrap(); + open(&mut h); + let p = rect(&h, &format!("timeline.group.{bezier}.name")).center(); + right_click(&mut h, p); + assert!(h.query_by_label("Convert To Bezier Path").is_none(), "a bezier path has no Convert To Bezier Path"); + egui::Popup::close_all(&h.ctx); + // The shape group's row converts the rectangle inside it. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(find(&h, &["rect"]), Some(path), "undo restores the rectangle"); + open(&mut h); + choose(&mut h, group); + assert!(find(&h, &["rect"]).is_none(), "the group's rectangle is converted"); + assert!(find(&h, &["path"]).is_some()); + // A keyframed rectangle would lose its animation: the group leaves it out (and so has nothing + // to offer), while the path's own row still converts it, as After Effects does. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let size = contents(&h).find_group(path).unwrap().get("size").unwrap().uid; + h.state_mut().session.execute("prop.addKey", json!({"layer": layer, "path": format!("@{size}"), "time": 0.0, "value": [120, 80]})).unwrap(); + open(&mut h); + let p = rect(&h, &format!("timeline.group.{group}.name")).center(); + right_click(&mut h, p); + assert!(h.query_by_label("Convert To Bezier Path").is_none(), "nothing static to convert in the group"); + egui::Popup::close_all(&h.ctx); + h.run_steps(3); + choose(&mut h, path); + assert!(find(&h, &["rect"]).is_none(), "the path's own row converts an animated rectangle"); +} + /// The rows of a context menu touch: a gap between two entries highlighted neither and /// swallowed a click there. #[test]