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.
439 lines
17 KiB
Rust
439 lines
17 KiB
Rust
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")
|
|
}
|
|
|
|
fn error(script: &str) -> String {
|
|
aac::evaluate_to_json(script).expect_err("script should fail")
|
|
}
|
|
|
|
/// A minimal but valid graph, for mutating into invalid shapes.
|
|
fn valid_graph() -> ControllerGraph {
|
|
let script = r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let speed = aac.float_param("Speed", 0.0);
|
|
let ctrl = aac.new_controller();
|
|
let base = ctrl.layer("Base");
|
|
let a = base.state("A", 0, 0);
|
|
let b = base.state("B", 1, 0);
|
|
let clip = aac.clip("clip");
|
|
clip.looping(true);
|
|
a.set_clip(clip);
|
|
a.transition_to(b).when(speed > 0.5).duration(0.1);
|
|
"#;
|
|
serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes")
|
|
}
|
|
|
|
#[test]
|
|
fn example_script_describes_the_whole_controller() {
|
|
let value = json(AVATAR);
|
|
|
|
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(), 4);
|
|
assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
|
|
|
|
let layers = value["controller"]["layers"].as_array().unwrap();
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0]["name"], "Base");
|
|
|
|
let states = layers[0]["state_machine"]["states"].as_array().unwrap();
|
|
assert_eq!(states.len(), 4);
|
|
assert_eq!(states[0]["name"], "Idle");
|
|
assert_eq!(states[0]["position"]["x"], 0);
|
|
|
|
// A clip used both as a state motion and as a blend tree child.
|
|
assert_eq!(states[0]["motion"]["type"], "clip");
|
|
assert_eq!(states[0]["motion"]["name"], "idle_anim");
|
|
assert_eq!(states[2]["motion"]["type"], "blend_tree");
|
|
assert_eq!(states[2]["motion"]["name"], "locomotion");
|
|
|
|
// `speed > 0.1` becomes a greater-than condition.
|
|
let transition = &states[0]["transitions"][0];
|
|
assert_eq!(transition["conditions"][0]["parameter"], "Speed");
|
|
assert_eq!(transition["conditions"][0]["mode"], "greater");
|
|
assert_eq!(transition["conditions"][0]["threshold"], 0.1);
|
|
assert_eq!(transition["duration"], 0.25);
|
|
assert_eq!(transition["has_exit_time"], false);
|
|
|
|
// `when_all([...])` produces two conditions on one transition.
|
|
let both = states[1]["transitions"][1]["conditions"].as_array().unwrap();
|
|
assert_eq!(both.len(), 2);
|
|
assert_eq!(both[1]["parameter"], "IsSitting");
|
|
assert_eq!(both[1]["mode"], "if_not");
|
|
|
|
let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap();
|
|
assert_eq!(any_state.len(), 1);
|
|
assert_eq!(any_state[0]["to"], "Sit");
|
|
|
|
let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap();
|
|
assert_eq!(sub.len(), 1);
|
|
assert_eq!(sub[0]["name"], "Gestures");
|
|
assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2);
|
|
|
|
// Blend shape keys landed in time order on one curve.
|
|
let clip = find_clip(&value, "idle_anim");
|
|
let curve = curve_of(clip, "blendShape.Smile");
|
|
assert_eq!(curve["target"], "skinned_mesh_renderer");
|
|
assert_eq!(curve["keys"].as_array().unwrap().len(), 2);
|
|
assert_eq!(curve["keys"][0]["time"], 0.0);
|
|
assert_eq!(curve["keys"][1]["time"], 1.0);
|
|
|
|
// A toggle is a one-frame constant: two keys with the same value.
|
|
let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive");
|
|
assert_eq!(toggle["target"], "game_object");
|
|
let keys = toggle["keys"].as_array().unwrap();
|
|
assert_eq!(keys.len(), 2);
|
|
assert_eq!(keys[0]["value"], keys[1]["value"]);
|
|
}
|
|
|
|
fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value {
|
|
value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap()
|
|
}
|
|
|
|
fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
|
|
clip["curves"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|c| c["property"] == property)
|
|
.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(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let c = aac.clip("c");
|
|
c.keyframe("Body", "m_IsActive", 2.0, 1.0);
|
|
c.keyframe("Body", "m_IsActive", 0.5, 0.0);
|
|
c.keyframe("Body", "m_IsActive", 1.0, 1.0);
|
|
"#,
|
|
);
|
|
let times: Vec<f64> = value["clips"][0]["curves"][0]["keys"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|k| k["time"].as_f64().unwrap())
|
|
.collect();
|
|
assert_eq!(times, vec![0.5, 1.0, 2.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn blend_tree_shapes() {
|
|
let value = json(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let x = aac.float_param("X", 0.0);
|
|
let y = aac.float_param("Y", 0.0);
|
|
let direct = aac.float_param("Direct", 0.0);
|
|
let ctrl = aac.new_controller();
|
|
let base = ctrl.layer("Base");
|
|
let s = base.state("S", 0, 0);
|
|
let one = aac.clip("one");
|
|
let two = aac.clip("two");
|
|
let inner = aac.blend_tree("inner");
|
|
inner.simple_1d(x);
|
|
inner.add_motion(one, 0.0);
|
|
let outer = aac.blend_tree("outer");
|
|
outer.simple_directional_2d(x, y);
|
|
outer.add_motion(two, 0.0, 1.0);
|
|
outer.add_motion(inner, 2.0, 3.0);
|
|
outer.automatic_thresholds(true);
|
|
s.set_motion(outer);
|
|
let flat = aac.blend_tree("flat");
|
|
flat.direct();
|
|
flat.add_motion_direct(one, direct);
|
|
"#,
|
|
);
|
|
let trees = value["blend_trees"].as_array().unwrap();
|
|
let outer = trees.iter().find(|t| t["name"] == "outer").unwrap();
|
|
assert_eq!(outer["blend_type"], "simple_directional_2d");
|
|
assert_eq!(outer["param_x"], "X");
|
|
assert_eq!(outer["param_y"], "Y");
|
|
assert_eq!(outer["use_automatic_thresholds"], true);
|
|
let children = outer["children"].as_array().unwrap();
|
|
assert_eq!(children.len(), 2);
|
|
assert_eq!(children[0]["threshold_y"], 1.0);
|
|
assert_eq!(children[1]["motion"]["type"], "blend_tree");
|
|
assert_eq!(children[1]["motion"]["name"], "inner");
|
|
|
|
let flat = trees.iter().find(|t| t["name"] == "flat").unwrap();
|
|
assert_eq!(flat["blend_type"], "direct");
|
|
assert_eq!(flat["param_x"], "");
|
|
assert_eq!(flat["children"][0]["direct_param"], "Direct");
|
|
}
|
|
|
|
#[test]
|
|
fn json_round_trips_through_the_graph_type() {
|
|
// Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits.
|
|
let json = aac::evaluate_to_json(AVATAR).expect("evaluates");
|
|
let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes");
|
|
let reserialized = serde_json::to_string_pretty(&graph).expect("serializes");
|
|
assert_eq!(reserialized, json);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_configuration_is_reported() {
|
|
assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set"));
|
|
assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set"));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_names_are_rejected_at_the_call_site() {
|
|
assert!(error(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let a = aac.float_param("Speed", 0.0);
|
|
let b = aac.float_param("Speed", 0.0);
|
|
"#
|
|
)
|
|
.contains("already declared"));
|
|
|
|
assert!(error(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let a = aac.clip("c");
|
|
let b = aac.clip("c");
|
|
"#
|
|
)
|
|
.contains("already declared"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_curve_property_is_rejected() {
|
|
let message = error(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let c = aac.clip("c");
|
|
c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0);
|
|
"#,
|
|
);
|
|
assert!(message.contains("cannot tell which component"), "unexpected: {message}");
|
|
}
|
|
|
|
#[test]
|
|
fn and_operator_is_not_available() {
|
|
// The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`.
|
|
let message = error(
|
|
r#"
|
|
let aac = AnimatorAsCode();
|
|
aac.system_name("S");
|
|
aac.asset_key("K");
|
|
let a = aac.float_param("A", 0.0);
|
|
let b = aac.bool_param("B", false);
|
|
let ctrl = aac.new_controller();
|
|
let base = ctrl.layer("Base");
|
|
let s = base.state("S", 0, 0);
|
|
let t = base.state("T", 1, 0);
|
|
s.transition_to(t).when(a > 0.0 && b == true);
|
|
"#,
|
|
);
|
|
assert!(message.contains("bool"), "unexpected: {message}");
|
|
}
|
|
|
|
/// The validator is a safety net for graphs that do not come from the typed Rhai DSL.
|
|
#[test]
|
|
fn validator_rejects_broken_graphs() {
|
|
let mut graph = valid_graph();
|
|
graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string();
|
|
let message = aac::export::to_json(&graph).expect_err("dangling target");
|
|
assert!(message.contains("not a state in that layer"), "unexpected: {message}");
|
|
|
|
let mut graph = valid_graph();
|
|
graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string();
|
|
let message = aac::export::to_json(&graph).expect_err("undeclared parameter");
|
|
assert!(message.contains("not declared"), "unexpected: {message}");
|
|
|
|
let mut graph = valid_graph();
|
|
graph.clips.push(graph.clips[0].clone());
|
|
let message = aac::export::to_json(&graph).expect_err("duplicate clip");
|
|
assert!(message.contains("declared more than once"), "unexpected: {message}");
|
|
|
|
let mut graph = valid_graph();
|
|
graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() });
|
|
let message = aac::export::to_json(&graph).expect_err("dangling motion");
|
|
assert!(message.contains("not declared"), "unexpected: {message}");
|
|
|
|
// Forward references across states must resolve.
|
|
let graph = valid_graph();
|
|
aac::export::to_json(&graph).expect("a valid graph stays valid");
|
|
}
|
|
|
|
/// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or
|
|
/// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set.
|
|
#[test]
|
|
fn json_keys_are_the_ones_the_unity_dtos_expect() {
|
|
let value = json(AVATAR);
|
|
|
|
fn keys(value: &Value) -> Vec<&str> {
|
|
value.as_object().expect("object").keys().map(String::as_str).collect()
|
|
}
|
|
|
|
assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]);
|
|
assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]);
|
|
assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]);
|
|
assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]);
|
|
assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]);
|
|
|
|
let tree = &value["blend_trees"][0];
|
|
assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]);
|
|
assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]);
|
|
assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]);
|
|
|
|
let layer = &value["controller"]["layers"][0];
|
|
assert_eq!(keys(layer), ["name", "state_machine"]);
|
|
assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]);
|
|
assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]);
|
|
assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]);
|
|
assert_eq!(
|
|
keys(&layer["state_machine"]["states"][0]["transitions"][0]),
|
|
[
|
|
"can_transition_to_self",
|
|
"conditions",
|
|
"duration",
|
|
"exit_time",
|
|
"has_exit_time",
|
|
"ordered_interruption",
|
|
"source_interruption",
|
|
"to"
|
|
]
|
|
);
|
|
assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]);
|
|
}
|