Add animation store for pre-made clips

aac.AnimationStore(folder) seeds a clip from an existing .anim asset and
returns a normal ClipBuilder, so pre-made clips work anywhere generated
clips do and can still be edited (looping, keyframes, toggles, blend
shapes). The Unity side clones the asset into the container and applies
the script's changes to the clone, leaving the original untouched.

- ClipData gains optional source; looping becomes Option<bool> so an
  untouched store clip keeps the asset's own looping setting.
- Add rust/examples/animation_store.rhai, a worked store example.
This commit is contained in:
2026-09-18 14:52:02 +01:00
parent 42a1234e7f
commit 2c3b670ab9
10 changed files with 377 additions and 17 deletions
+118 -1
View File
@@ -2,6 +2,7 @@ use aac::graph::*;
use serde_json::Value;
const AVATAR: &str = include_str!("../examples/avatar.rhai");
const ANIMATION_STORE: &str = include_str!("../examples/animation_store.rhai");
fn json(script: &str) -> Value {
serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json")
@@ -37,7 +38,7 @@ fn example_script_describes_the_whole_controller() {
assert_eq!(value["system_name"], "MyAvatar");
assert_eq!(value["asset_key"], "AAC_");
assert_eq!(value["parameters"].as_array().unwrap().len(), 3);
assert_eq!(value["clips"].as_array().unwrap().len(), 3);
assert_eq!(value["clips"].as_array().unwrap().len(), 4);
assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
let layers = value["controller"]["layers"].as_array().unwrap();
@@ -107,6 +108,122 @@ fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
.unwrap()
}
#[test]
fn animation_store_clips_are_referenced_not_generated() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips/");
let walk = store.clip("Walk");
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(walk);
"#,
);
let clips = value["clips"].as_array().unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0]["name"], "Assets/Doloro/Clips/Walk.anim");
assert_eq!(clips[0]["source"], "Assets/Doloro/Clips/Walk.anim");
assert!(clips[0]["curves"].as_array().unwrap().is_empty());
// Untouched, it keeps the source asset's looping setting.
assert!(clips[0].get("looping").is_none());
// A generated clip keeps its old shape: no `source` key at all.
let generated = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let c = aac.clip("c");
"#,
);
assert!(generated["clips"][0].get("source").is_none());
}
#[test]
fn animation_store_resolves_one_clip_per_asset() {
// The same asset asked for twice, with and without the extension, stays a single clip.
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips");
let x = store.clip("Walk");
let y = store.clip("Walk.anim");
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(x);
"#,
);
assert_eq!(value["clips"].as_array().unwrap().len(), 1);
}
#[test]
fn animation_store_clips_can_be_edited_on_a_clone() {
let value = json(
r#"
let aac = AnimatorAsCode();
aac.system_name("S");
aac.asset_key("K");
let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk");
walk.looping(true);
walk.keyframe("Body", "m_IsActive", 0.0, 1.0);
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");
let s = base.state("S", 0, 0);
s.set_clip(walk);
"#,
);
let clip = &value["clips"][0];
// The source is still carried, so the Unity side knows which asset to clone.
assert_eq!(clip["source"], "Assets/Doloro/Clips/Walk.anim");
assert_eq!(clip["looping"], true);
assert_eq!(clip["curves"][0]["property"], "m_IsActive");
}
/// The store example is a whole controller, so evaluating it exercises every store path end to end.
#[test]
fn animation_store_example_sources_every_clip() {
let value = json(ANIMATION_STORE);
assert_eq!(value["system_name"], "AnimationStore");
assert_eq!(value["parameters"].as_array().unwrap().len(), 5);
assert_eq!(value["controller"]["layers"].as_array().unwrap().len(), 2);
// Every clip comes from an asset, and each asset is declared exactly once.
let clips = value["clips"].as_array().unwrap();
let sources: Vec<&str> = clips
.iter()
.map(|clip| clip["source"].as_str().expect("every clip has a source"))
.collect();
let unique: std::collections::HashSet<&str> = sources.iter().copied().collect();
assert_eq!(unique.len(), sources.len(), "the same asset must not be declared twice");
assert_eq!(clips.len(), 11);
// An edited clip carries its override and curves; an untouched one carries neither.
let walk = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Walk.anim");
assert_eq!(walk["looping"], true);
assert_eq!(curve_of(walk, "m_IsActive")["target"], "game_object");
let idle = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Idle.anim");
assert!(idle.get("looping").is_none());
assert!(idle["curves"].as_array().unwrap().is_empty());
// A store clip feeds a state and a blend tree, and a tree may nest a tree declared before it.
let trees = value["blend_trees"].as_array().unwrap();
assert_eq!(trees.len(), 4);
let movement = trees.iter().find(|t| t["name"] == "movement").unwrap();
assert_eq!(movement["children"].as_array().unwrap().len(), 3);
let full_body = trees.iter().find(|t| t["name"] == "full_body").unwrap();
assert_eq!(full_body["children"][0]["motion"]["type"], "blend_tree");
assert_eq!(full_body["children"][0]["motion"]["name"], "movement");
}
#[test]
fn keyframes_are_sorted_by_time() {
let value = json(