diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 00399da71..775dc9ae3 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -1,7 +1,8 @@ //! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and //! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the //! commands here set several of them in one undo step and add the groups to strokes saved before -//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers. +//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers, and the +//! Reverse Path Direction button (⇄) of Path / Rectangle / Ellipse / Polystar rows. use effectcraft_color::BlendMode; use effectcraft_keyframe::{Gradient, Value as KV}; @@ -54,6 +55,56 @@ fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke")) } +/// Shape items with a Path Direction (After Effects' "ADBE Vector Shape Direction"). +fn has_direction(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "rect" | "ellipse" | "star" | "path") +} + +/// Reverse Path Direction On/Off: `{layer?, prop?: shape uid, reversed?}`. Without `prop`, the +/// selected Path / Rectangle / Ellipse / Polystar items of the layer; without `reversed`, toggles +/// (from the first one). One undo step. Trim Paths then runs the other way round. +fn reverse_direction(s: &mut Session, p: &Value) -> Result { + let c = "shape.reversePathDirection"; + let (cid, lid) = layer_p(s, p, c)?; + let l = s.project.comp(cid).and_then(|comp| comp.layer(lid)).ok_or(EngineError::NoComp)?; + let contents = l.props.sub("contents").ok_or_else(|| bad(c, "not a shape layer"))?; + let uids: Vec = match p.get("prop").and_then(Value::as_u64) { + Some(uid) => { + let g = contents.find_group(uid).ok_or_else(|| bad(c, format!("no shape item @{uid}")))?; + if !has_direction(g) { + return Err(bad(c, format!("`{}` is not a Path, Rectangle, Ellipse or Polystar", g.name))); + } + vec![uid] + } + None => s.state.selected_props.iter().filter(|(sl, su)| *sl == lid && contents.find_group(*su).is_some_and(has_direction)).map(|(_, su)| *su).collect(), + }; + let Some(first) = uids.first().and_then(|u| contents.find_group(*u)) else { + return Err(bad(c, "select a Path, Rectangle, Ellipse or Polystar")); + }; + let reversed = match p.get("reversed") { + None => first.get("direction").is_none_or(|d| d.value != KV::Enum(1)), + Some(v) => v.as_bool().ok_or_else(|| bad(c, "`reversed`: true or false"))?, + }; + s.edit(if reversed { "Reverse Path Direction On" } else { "Reverse Path Direction Off" }, None, |proj, _| { + let mut next = proj.next_id; + let layer = layer_mut(proj, cid, lid)?; + for uid in &uids { + let g = layer.props.find_group_mut(*uid).ok_or_else(|| bad(c, "shape item vanished"))?; + let v = KV::Enum(u32::from(reversed)); + match g.get_mut("direction") { + Some(d) => { + d.value = v; + d.keys.clear(); + } + // Items saved without one get it (first, as built). + None => g.children.insert(0, Ids(&mut next).prop("direction", "Path Direction", v).with_ui(effectcraft_project::ParamUi::Hidden).into()), + } + } + proj.next_id = next; + Ok(json!({"reversed": reversed, "shapes": uids})) + }) +} + fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) { for sub in g.groups() { f(sub); @@ -544,6 +595,15 @@ pub fn specs() -> Vec { has_layers, fill_stroke ), + cmd!( + "shape.reversePathDirection", + "Reverse Path Direction", + [], + None, + "{layer?, prop?: Path/Rectangle/Ellipse/Polystar uid (default: the selected ones), reversed? (default: toggle)} → {reversed, shapes}", + has_layers, + reverse_direction + ), cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add), cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove), cmd!( diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index dc6d54b54..9e86e8ed4 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -430,3 +430,61 @@ fn work_area_navigation_has_after_effects_default_keys() { assert_eq!(b.defaults, [key.to_string()], "{id}"); } } + +/// Reverse Path Direction (the ⇄ button on Path / Rectangle / Ellipse / Polystar rows): an +/// ellipse is drawn clockwise from 12 o'clock, so Trim Paths End 60 % covers 3 o'clock and not +/// 9 o'clock; reversed it runs counter-clockwise and covers 9 o'clock instead. One undo step. +#[test] +fn reverse_path_direction_runs_trim_paths_the_other_way() { + let mut s = comp(200, 200); + let lid = + s.execute("layer.newShape", json!({"kind": "ellipse", "size": [120, 120], "fill": false, "stroke": [1, 1, 1], "strokeWidth": 8})).unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("layer.addShapeItem", json!({"layer": lid, "kind": "trim", "group": "contents/group"})).unwrap(); + let (trim_end, ellipse) = { + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + let group = l.props.group("contents/group/contents").unwrap(); + (group.sub("trim").unwrap().get("end").unwrap().uid, group.sub("ellipse").unwrap().uid) + }; + s.execute("prop.set", json!({"layer": lid, "prop": trim_end, "value": 60})).unwrap(); + // (3 o'clock, 9 o'clock) on the 60 px radius. + let sides = |s: &Session| (alpha_at(s, 160, 100, 0.0), alpha_at(s, 40, 100, 0.0)); + let (r, l) = sides(&s); + assert!(r > 0.5 && l < 0.05, "default: clockwise from the top (right {r}, left {l})"); + + let out = s.execute("shape.reversePathDirection", json!({"layer": lid, "prop": ellipse})).unwrap(); + assert_eq!(out["reversed"], json!(true)); + let (r, l) = sides(&s); + assert!(l > 0.5 && r < 0.05, "reversed: counter-clockwise from the top (right {r}, left {l})"); + let dir = |s: &Session| s.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find_group(ellipse).unwrap().get("direction").unwrap().value.clone(); + assert_eq!(dir(&s), effectcraft_keyframe::Value::Enum(1)); + let saved = roundtrip(&s); + let saved = saved + .comp(s.active_comp_id().unwrap()) + .unwrap() + .layer(LayerId(lid)) + .unwrap() + .props + .find_group(ellipse) + .unwrap() + .get("direction") + .unwrap() + .value + .clone(); + assert_eq!(saved, dir(&s), "saved with the project"); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(dir(&s), effectcraft_keyframe::Value::Enum(0), "one undo step"); + // The selected item, toggled; explicit values; only shape items with a direction. + s.execute("prop.select", json!({"layer": lid, "prop": ellipse})).unwrap(); + s.execute("shape.reversePathDirection", json!({})).unwrap(); + assert_eq!(dir(&s), effectcraft_keyframe::Value::Enum(1)); + s.execute("shape.reversePathDirection", json!({"layer": lid, "prop": ellipse, "reversed": true})).unwrap(); + assert_eq!(dir(&s), effectcraft_keyframe::Value::Enum(1), "reversed: true keeps it reversed"); + s.execute("shape.reversePathDirection", json!({})).unwrap(); + assert_eq!(dir(&s), effectcraft_keyframe::Value::Enum(0)); + let trim = s.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.group("contents/group/contents/trim").unwrap().uid; + assert!(s.execute("shape.reversePathDirection", json!({"layer": lid, "prop": trim})).is_err()); + assert!(s.execute("shape.reversePathDirection", json!({"layer": lid, "prop": 999_999})).is_err()); +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c5..f234c981e 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -87,6 +87,8 @@ function __blendLabel(v) { var __interpKey = { 6612: "linear", 6613: "bezier", 6614: "hold" }; var __matteKind = { 5013: "alpha", 5014: "alphaInverted", 5015: "luma", 5016: "lumaInverted" }; +// Shape items' Reverse Path Direction (hidden as a row; the ⇄ button sets it). +var __PATH_DIRECTION = "ADBE Vector Shape Direction"; var __matteEnum = { Alpha: 5013, AlphaInverted: 5014, Luma: 5015, LumaInverted: 5016 }; var __justKey = { 7413: "left", 7414: "center", 7415: "right", 7416: "justifyLeft", 7417: "justifyCenter", 7418: "justifyRight", 7419: "justifyAll" }; @@ -750,6 +752,8 @@ Property.prototype.property = function () { return null; }; // Script value → engine JSON for `prop.set` / `prop.addKey`. P.__toEngine = function (v, info, time) { var t = info.valueType; + // Path Direction: After Effects' 1 (Default; 2 is also normal) and 3 (Reversed) ↔ ours 0 / 1. + if (info.matchName === __PATH_DIRECTION) return __num(v) === 3 ? 1 : 0; if (t === "TEXT_DOCUMENT") { var td = v instanceof TextDocument ? v : new TextDocument(String(v)); return __get("textdoc", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: time, attrs: td.__attrs() }); @@ -781,6 +785,8 @@ Property.prototype.property = function () { return null; }; return __num(v); }; P.__fromEngine = function (v, info) { + // The object model shows popups 1-based: our reversed (1) arrives as 2, After Effects says 3. + if (info.matchName === __PATH_DIRECTION) return v === 2 ? 3 : 1; if (info.valueType === "TEXT_DOCUMENT") return TextDocument.__from(v); if (info.valueType === "SHAPE") { var s = new Shape(); @@ -801,7 +807,7 @@ Property.prototype.property = function () { return null; }; P.setValue = function (v) { var info = this.__info(); if (info.numKeys > 0) throw __err("Can not use setValue on \"" + info.name + "\": it has keyframes (use setValueAtTime or setValueAtKey)"); - if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + if (info.hidden && info.matchName !== __PATH_DIRECTION) throw __err("\"" + info.name + "\" can not be set"); __call("prop.set", this.__ref({ value: this.__toEngine(v, info) })); }; P.setValueAtTime = function (t, v) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index b50a7bfb2..65414f886 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -730,3 +730,42 @@ fn import_options_sequence_picks_one_still_or_the_run() { assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence); let _ = std::fs::remove_dir_all(&dir); } + +/// Reverse Path Direction: hidden as a row but settable from scripts with After Effects' values +/// (1 Default, 3 Reversed; 2 reads as normal), and read back the same way. +#[test] +fn path_direction_uses_after_effects_values() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("D", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group"); + var el = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Ellipse"); + var d = el.property("ADBE Vector Shape Direction"); + var out = [d.value]; + d.setValue(3); + out.push(d.value); + d.setValue(2); + out.push(d.value); + d.setValue(3); + d.setValue(1); + out.push(d.value); + d.setValue(3); + out.push(d.value, d.name); + out + "#, + ); + assert_eq!(o.result, json!([1, 3, 1, 1, 3, "Path Direction"])); + let (_, c) = comp_named(&s, "D"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/#1/contents/ellipse/direction").unwrap().value, KV::Enum(1), "3 is our reversed (1)"); + // Other hidden properties still can't be set. + let o = run_code( + &mut s, + r#"var c = app.project.items.addComp("E", 100, 100, 1, 1, 30); var g = c.layers.addShape().property("ADBE Root Vectors Group").addProperty("ADBE Vector Group"); g.property("ADBE Vector Blend Mode").setValue(2);"#, + "t.jsx", + ); + assert!(o.error.as_ref().is_some_and(|e| format!("{e:?}").contains("can not be set")), "{:?}", o.error); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 67dca7e4a..e6c94bd2d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2196,6 +2196,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); + direction_button(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); // Mask mode + inverted inline. if let Some(g) = layer.props.find_group(*uid) && let GroupKind::Mask { mode, inverted, .. } = g.kind @@ -3261,6 +3262,46 @@ fn dash_buttons( } } +/// Reverse Path Direction On/Off (⇄) on a Path / Rectangle / Ellipse / Polystar row, as in After +/// Effects: lit while the path runs the other way (Trim Paths then trims from the other end). +#[allow(clippy::too_many_arguments)] +fn direction_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + uid: u64, + x: f32, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + if !matches!(layer.source, LayerSource::Shape) { + return; + } + let Some(g) = layer.props.find_group(uid).filter(|g| matches!(g.match_id.as_str(), "rect" | "ellipse" | "star" | "path")) else { return }; + let on = g.get("direction").is_some_and(|d| d.value == effectcraft_engine::keyframe::Value::Enum(1)); + let t = app.tokens; + let tip = if on { "Reverse Path Direction On" } else { "Reverse Path Direction Off" }; + let r = Rect::from_center_size(pos2(x + 12.0, cy), vec2(18.0, 16.0)); + let resp = ui.interact(r, egui::Id::new(("tl-path-dir", uid)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + if on { + p.rect_filled(r.shrink(1.0), 2.0, t.accent.gamma_multiply(0.35)); + } + p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, if on { t.accent } else { t.separator }), StrokeKind::Inside); + // ⇄: a right arrow over a left arrow (drawn, not a font glyph). + let c = if on || resp.hovered() { t.text } else { t.text_dim }; + let s = Stroke::new(1.2, c); + let (l, rr, y1, y2) = (r.min.x + 4.5, r.max.x - 4.5, cy - 2.5, cy + 2.5); + p.line_segment([pos2(l, y1), pos2(rr, y1)], s); + p.line_segment([pos2(rr - 2.5, y1 - 2.5), pos2(rr, y1)], s); + p.line_segment([pos2(l, y2), pos2(rr, y2)], s); + p.line_segment([pos2(l + 2.5, y2 + 2.5), pos2(l, y2)], s); + app.auto.add(&format!("timeline.group.{uid}.reversePath"), r, tip); + if resp.clicked() { + actions.push(("shape.reversePathDirection".into(), json!({"layer": layer.id.0, "prop": uid}))); + } +} + /// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is /// a separator), in After Effects' order. const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dd9232d77..e61d73f2d 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -243,3 +243,41 @@ fn timeline_colour_eyedropper_samples_the_comp() { assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); } } + +/// The ⇄ Reverse Path Direction button on a shape item's row (After Effects): each click +/// toggles the item's Path Direction as one undo step; the direction itself isn't a row. +#[test] +fn reverse_path_direction_button_toggles_the_path_direction() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let (contents, group, ellipse, fill) = { + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap(); + let g = layer.props.group("contents/group").unwrap(); + let c = g.sub("contents").unwrap(); + (vec![layer.props.sub("contents").unwrap().uid, c.uid], g.uid, c.sub("ellipse").unwrap().uid, c.sub("fill").unwrap().uid) + }; + let mut h = harness(s); + open_layer(&mut h, l); + h.state_mut().ui.timeline.open_groups.extend(contents.into_iter().chain([group, ellipse])); + h.run_steps(2); + let dir = |h: &Harness<'_, EffectcraftApp>| { + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + layer.props.find_group(ellipse).unwrap().get("direction").unwrap().value.clone() + }; + let button = format!("timeline.group.{ellipse}.reversePath"); + assert!(h.state().auto.find(&format!("timeline.group.{fill}.reversePath")).is_none(), "only on path items"); + assert_eq!(dir(&h), effectcraft_engine::keyframe::Value::Enum(0)); + assert_eq!(h.state().auto.find(&button).map(|e| e.label.clone()).as_deref(), Some("Reverse Path Direction Off")); + click(&mut h, &button); + assert_eq!(dir(&h), effectcraft_engine::keyframe::Value::Enum(1)); + assert_eq!(h.state().auto.find(&button).map(|e| e.label.clone()).as_deref(), Some("Reverse Path Direction On")); + // Path Direction stays hidden as a property row. + let direction = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(ellipse).unwrap().get("direction").unwrap().uid; + assert!(h.state().auto.find(&format!("timeline.prop.{direction}.row")).is_none(), "no Path Direction row"); + let size = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(ellipse).unwrap().get("size").unwrap().uid; + assert!(h.state().auto.find(&format!("timeline.prop.{size}.row")).is_some(), "the ellipse is open (its Size row shows)"); + click(&mut h, &button); + assert_eq!(dir(&h), effectcraft_engine::keyframe::Value::Enum(0)); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(dir(&h), effectcraft_engine::keyframe::Value::Enum(1), "each click is one undo step"); +}