use animsmith_core::fixtures::{WALK_STRIDE, WalkBones, walk_doc};
use animsmith_core::glam::Vec3;
use animsmith_core::model::{
Bone, Document, Interpolation, Property, Track, TrackValues, Transform,
};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
const MIXAMO_BONES: WalkBones = WalkBones {
hips: "mixamorig:Hips",
left_foot: "mixamorig:LeftFoot",
right_foot: "mixamorig:RightFoot",
};
const MIXAMO_TAKE: &str = "mixamo.com";
fn config_path() -> String {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/mixamo.animsmith.toml")
.to_str()
.expect("utf-8 path")
.to_owned()
}
fn write_doc(dir: &Path, name: &str, doc: &Document) -> String {
let path = dir.join(name);
animsmith_gltf::write::write(doc, &path).expect("writes stand-in rig");
path.to_str().expect("utf-8 path").to_owned()
}
fn add_static_mixamo_bones(doc: &mut Document) {
let statics: [(&str, usize, Vec3); 6] = [
("mixamorig:Spine", 0, Vec3::new(0.0, 0.3, 0.0)),
("mixamorig:Head", 3, Vec3::new(0.0, 0.4, 0.0)),
("mixamorig:LeftToeBase", 1, Vec3::new(0.0, -0.05, 0.15)),
("mixamorig:RightToeBase", 2, Vec3::new(0.0, -0.05, 0.15)),
("mixamorig:LeftHand", 3, Vec3::new(0.5, 0.1, 0.0)),
("mixamorig:RightHand", 3, Vec3::new(-0.5, 0.1, 0.0)),
];
for (name, parent, translation) in statics {
doc.skeleton.bones.push(Bone {
name: name.into(),
parent: Some(parent),
rest: Transform {
translation,
..Transform::IDENTITY
},
inverse_bind: None,
});
}
}
fn write_walk(dir: &Path, name: &str, periods: f64) -> String {
let mut doc = walk_doc(&MIXAMO_BONES, MIXAMO_TAKE, periods, WALK_STRIDE, f64::sin);
add_static_mixamo_bones(&mut doc);
write_doc(dir, name, &doc)
}
fn write_traveling_walk(dir: &Path, name: &str, travel_m: f32) -> String {
let mut doc = walk_doc(&MIXAMO_BONES, MIXAMO_TAKE, 1.0, WALK_STRIDE, f64::sin);
add_static_mixamo_bones(&mut doc);
let rest = doc.skeleton.bones[0].rest.translation;
doc.clips[0].tracks.push(Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 1.0],
values: TrackValues::Vec3s(vec![rest, rest + Vec3::new(0.0, 0.0, travel_m)]),
});
write_doc(dir, name, &doc)
}
fn run(args: &[&str]) -> (Option<i32>, String) {
let output = Command::new(env!("CARGO_BIN_EXE_animsmith"))
.args(args)
.output()
.expect("runs animsmith");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
)
}
fn finding_ids(json: &str) -> Vec<(String, String)> {
let doc: Value = serde_json::from_str(json).expect("lint emits valid JSON");
doc["files"][0]["findings"]
.as_array()
.expect("findings array")
.iter()
.map(|f| {
(
f["check_id"].as_str().unwrap_or_default().to_owned(),
f["severity"].as_str().unwrap_or_default().to_owned(),
)
})
.collect()
}
#[test]
fn mixamo_profile_resolves_mixamorig_names() {
let tmp = tempfile::tempdir().expect("temp dir");
let walk = write_walk(tmp.path(), "walking.glb", 1.0);
let (code, out) = run(&["inspect", &walk]);
assert_eq!(code, Some(0), "inspect exits 0");
assert!(
out.contains("rig profile: mixamo (9 roles)"),
"inspect resolves all nine mixamo roles: {out}"
);
let (code, out) = run(&["measure", "--format", "json", &walk]);
assert_eq!(code, Some(0), "measure exits 0");
let doc: Value = serde_json::from_str(&out).expect("measure emits valid JSON");
let roles = &doc["files"][0]["rig"]["resolved_roles"];
assert_eq!(
roles["hips"], "mixamorig:Hips",
"hips resolves by exact name: {roles}"
);
assert!(
roles.get("root").is_none(),
"the mixamo profile has no Root role: {roles}"
);
let m = &doc["files"][0]["measurements"][MIXAMO_TAKE];
assert_eq!(
m["duration_s"].as_f64(),
Some(1.0),
"one-second cycle as the transcript shows: {m}"
);
assert_eq!(
m["frame_count"].as_u64(),
Some(33),
"grid resolution as the transcript shows: {m}"
);
assert_eq!(
m["speed_mps"].as_f64(),
Some(0.0),
"the in-place stand-in has zero hip travel: {m}"
);
assert!(
m["loop_seam_ratio"].as_f64().expect("seam ratio") < 1e-9,
"the full cycle closes exactly: {m}"
);
let phase = m["gait"]["phase"].as_f64().expect("gait phase");
assert!(
(phase - 0.75).abs() < 1e-6,
"analytic gait phase of the walk fixture: {m}"
);
let amplitude = m["gait"]["lr_amplitude_m"].as_f64().expect("lr amplitude");
assert!(
(amplitude - 0.2).abs() < 1e-3,
"analytic stride amplitude of the walk fixture: {m}"
);
}
#[test]
fn tutorial_contract_gates_the_walk() {
let tmp = tempfile::tempdir().expect("temp dir");
let clean = write_walk(tmp.path(), "walking.glb", 1.0);
let popped = write_walk(tmp.path(), "walking-popped.glb", 0.75);
let traveling = write_traveling_walk(tmp.path(), "walking-traveling.glb", 1.0);
let config = config_path();
let (code, out) = run(&["lint", "--config", &config, &clean]);
assert_eq!(code, Some(0), "clean walk passes the tutorial contract");
assert!(out.contains("clean"), "clean walk lints clean: {out}");
let (code, json) = run(&["lint", "--config", &config, "--format", "json", &popped]);
assert_eq!(code, Some(1), "popped loop fails the contract");
let ids = finding_ids(&json);
assert_eq!(
ids,
vec![("loop-seam".to_owned(), "error".to_owned())],
"the popped seam is the only finding: {ids:?}"
);
let (code, json) = run(&["lint", "--config", &config, "--format", "json", &traveling]);
assert_eq!(code, Some(1), "traveling walk violates the in-place pin");
let ids = finding_ids(&json);
assert!(
ids.contains(&("in-place".to_owned(), "error".to_owned())),
"in-place fires from the Hips fallback: {ids:?}"
);
}
#[test]
fn tutorial_mechanical_steps_are_noops_on_the_clean_walk() {
let tmp = tempfile::tempdir().expect("temp dir");
let clean = write_walk(tmp.path(), "walking.glb", 1.0);
let (code, out) = run(&["lint", &clean]);
assert_eq!(code, Some(0), "bare lint exits 0");
assert!(out.contains("clean"), "mechanical checks are clean: {out}");
let (code, out) = run(&["fix", "--dry-run", &clean]);
assert_eq!(code, Some(0), "no pending repairs exits 0");
let noop_lines = out
.lines()
.filter(|l| *l == "0 key(s) would be fixed across 0 track(s) -> no output written")
.count();
assert_eq!(
noop_lines, 2,
"one no-op summary line per default repair: {out}"
);
}
#[test]
fn contract_auto_loads_from_the_working_directory() {
let tmp = tempfile::tempdir().expect("temp dir");
write_walk(tmp.path(), "walking-popped.glb", 0.75);
std::fs::copy(config_path(), tmp.path().join("animsmith.toml")).expect("copies contract");
let output = Command::new(env!("CARGO_BIN_EXE_animsmith"))
.current_dir(tmp.path())
.args(["lint", "walking-popped.glb"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"auto-loaded contract fails the popped loop"
);
let out = String::from_utf8_lossy(&output.stdout);
assert!(out.contains("loop-seam"), "names loop-seam: {out}");
}