shapes: Reverse Path Direction (⇄) on Path/Rectangle/Ellipse/Polystar rows

- shape.reversePathDirection {layer?, prop?, reversed?}: one undoable step; toggles the
  selected items without prop. The Timeline shows the ⇄ button on shape item rows; Path
  Direction stays hidden as a property row. Trim Paths then runs the other way.
- Scripting: ADBE Vector Shape Direction is settable despite being hidden, with After
  Effects' values (1 Default, 2 normal, 3 Reversed) mapped to ours (0/1) and read back as 1/3.
- AEP import: AE's Reversed (3) imported as 2 (not reversed); now maps to 1.
- Go to Work Area Start/End get AE's defaults Shift+Home / Shift+End.

Upstream port: the Shift+Home / Shift+End work-area bindings and the After Effects-import part are left out (they build on fork work not upstream yet).

(cherry picked from commit 7782ab015a01613c1685cb6dda48d48c3b1db67b)

(cherry picked from commit c21cb5b74430cae267a46fdc835a4cf1ed32fc9a)
(cherry picked from commit f79c41ef0f366d3cc7b4c90ca3faefdd8a6b07fd)
This commit is contained in:
Elias
2026-10-11 11:50:21 +00:00
committed by Claude
parent 1663e70532
commit 7c639a431c
6 changed files with 244 additions and 2 deletions
+61 -1
View File
@@ -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<Value> {
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<Uid> = 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<CommandSpec> {
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!(
+58
View File
@@ -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());
}
+7 -1
View File
@@ -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) {
+39
View File
@@ -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);
}
+41
View File
@@ -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)] = &[
@@ -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");
}