#1685: synchronize Polystar controls across animation edit routes

This commit is contained in:
Kristian Myram
2026-10-30 02:29:16 +01:00
parent e6f2cce7d5
commit 86b357162c
3 changed files with 108 additions and 10 deletions
+4 -1
View File
@@ -243,7 +243,7 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
let n = s.edit("Add Keyframe", None, |proj, _| {
let n = s.edit("Add Keyframe", None, |proj, state| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
@@ -259,6 +259,9 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
pr.set_value_at(lt, v);
}
n += 1;
if pr.match_id == "type" {
super::prop::sync_polystar_property(proj, state, cid, *lid, *uid, t)?;
}
}
Ok(n)
})?;
+35 -9
View File
@@ -48,7 +48,6 @@ fn with_prop<T>(
let t = s.time_of(cid);
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, state| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
@@ -64,17 +63,32 @@ fn with_prop<T>(
}
}
if pr.match_id == "type" {
let removed = sync_polystar_type(l, uid, lt, &mut next)?;
if !removed.is_empty() {
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
}
sync_polystar_property(proj, state, cid, lid, uid, t)?;
}
proj.next_id = next;
Ok(r)
})
}
/// Keep Polystar controls and their selection references in the property's edit transaction.
pub(super) fn sync_polystar_property(
proj: &mut effectcraft_project::Project,
state: &mut crate::EditorState,
cid: ItemId,
lid: LayerId,
uid: Uid,
time: Tick,
) -> Result<()> {
let mut next = proj.next_id;
let layer = layer_mut(proj, cid, lid)?;
let removed = sync_polystar_type(layer, uid, layer.layer_time(time), &mut next)?;
if !removed.is_empty() {
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
}
proj.next_id = next;
Ok(())
}
/// A static Polygon has no inner controls; switching to Star creates them in schema order
/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at
/// every time, including when the current value is Polygon.
@@ -661,12 +675,16 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
s.project.comp(*cid).and_then(|c| c.layer(*lid).and_then(|l| super::link::reference(c, l, l, *uid, true))).unwrap_or_else(|| "value".into())
})
.collect();
let results = s.edit("Expression", merge_p(p), |proj, _| {
let times: Vec<_> = targets.iter().map(|(cid, _, _)| s.time_of(*cid)).collect();
let results = s.edit("Expression", merge_p(p), |proj, state| {
let mut out = vec![];
for ((cid, lid, uid), def) in targets.iter().zip(&defs) {
for (((cid, lid, uid), def), time) in targets.iter().zip(&defs).zip(&times) {
let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?;
apply_expr(pr, &text, enabled, def);
out.push(json!(pr.expr.as_ref().map(|e| e.text.clone())));
if pr.match_id == "type" {
sync_polystar_property(proj, state, *cid, *lid, *uid, *time)?;
}
}
Ok(out)
})?;
@@ -1042,9 +1060,11 @@ fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
let time = s.time();
s.edit("Delete Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut removed = 0usize;
let mut changed = std::collections::BTreeSet::new();
for k in &sel {
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
@@ -1054,9 +1074,15 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
}
pr.keys.remove(i);
removed += 1;
if pr.match_id == "type" {
changed.insert((k.layer, k.prop));
}
}
}
st.selected_keys.clear();
for (layer, prop) in changed {
sync_polystar_property(proj, st, cid, layer, prop, time)?;
}
// The keys actually removed: a selected key that is gone (or listed twice) isn't counted,
// so a caller can tell that a delete changed nothing.
Ok(json!(removed))
+69
View File
@@ -697,3 +697,72 @@ fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
}
#[test]
fn polystar_expression_routes_synchronize_controls_in_the_same_undo_step() {
for explicit in [false, true] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
let uid = before.get("type").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), uid)];
let params = if explicit { json!({"layer": lid, "prop": uid, "expression": "1"}) } else { json!({"expression": "1"}) };
s.execute("prop.setExpression", params).unwrap();
let animated = group(&s);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.execute("prop.setExpression", json!({"layer": lid, "prop": uid, "expression": ""})).unwrap();
assert!(group(&s).get("innerRadius").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
}
}
#[test]
fn polystar_animation_menu_adds_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), before.get("type").unwrap().uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let animated = group(&s);
assert_eq!(animated.get("type").unwrap().keys.len(), 1);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
}
#[test]
fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() {
for command in ["keys.delete", "edit.clear"] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let animated = group(&s);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: type_uid, time: Tick::ZERO }];
s.execute(command, json!({})).unwrap();
let polygon = group(&s);
assert!(polygon.get("innerRadius").is_none() && polygon.get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty() && s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert_eq!(group(&s), polygon);
}
}