diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 3f9c195c2..58eaa060d 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -243,7 +243,7 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { } 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 { 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) })?; diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 663429fb2..f1fcb5246 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -48,7 +48,6 @@ fn with_prop( 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( } } 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 { 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(×) { 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 { fn delete_keys(s: &mut Session, _: &Value) -> Result { 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 { } 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)) diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 1ff12237e..67f08eda0 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -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); + } +}