1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
//! `root-motion-speed` — a clip's declared locomotion speed must match
//! its measured horizontal root displacement over its duration.
//! Runtimes scale playback by the declared speed to keep foot plants
//! locked to world velocity; a stale pin plays the clip visibly too
//! fast or too slow.
use crate::check::{Check, CheckCtx};
use crate::checks::root_motion_gap;
use crate::evaluation::{
Applicability, CheckOutput, CoverageGap, CoverageGapCode, EvaluationScope, EvaluationScopeCode,
};
use crate::finding::{Finding, Severity};
use crate::metrics::root_motion_speed_mps;
/// A declared speed with a measurement under this (m/s) is a stray pin:
/// the clip carries no meaningful root motion at all.
pub const STRAY_PIN_FLOOR_MPS: f64 = 0.5;
pub struct RootMotionSpeed;
impl RootMotionSpeed {
/// Clips whose declared `speed_mps` this check judges (root-motion
/// clips — treadmill speeds belong to `foot-slide`).
fn has_pending_work(ctx: &CheckCtx) -> bool {
ctx.clip_expectations()
.iter()
.any(|e| e.speed_mps.is_some() && e.in_place != Some(true))
}
}
impl Check for RootMotionSpeed {
fn id(&self) -> &'static str {
"root-motion-speed"
}
fn applicability(&self, ctx: &CheckCtx) -> Applicability {
if Self::has_pending_work(ctx) {
Applicability::Applicable
} else {
Applicability::NotApplicable
}
}
fn evaluate(&self, ctx: &CheckCtx) -> CheckOutput {
let mut findings = Vec::new();
let mut evaluated_scopes = Vec::new();
let mut gaps = Vec::new();
for (index, clip) in ctx.doc.clips.iter().enumerate() {
let expectations = ctx.expectations(index);
let Some(pin) = expectations.speed_mps else {
continue;
};
if expectations.in_place == Some(true) {
// A treadmill clip's declared speed describes the
// stance sweep, not root displacement — `foot-slide`
// validates it there.
continue;
}
let scope =
EvaluationScope::new(EvaluationScopeCode::ROOT_MOTION_SPEED).subject(&clip.name);
if let Some(gap) = root_motion_gap(ctx.roles) {
gaps.push(gap.scope(scope));
continue;
}
let measured = ctx
.grid(index)
.and_then(|grid| root_motion_speed_mps(&grid, ctx.roles));
let Some(measured) = measured else {
gaps.push(
CoverageGap::new(
CoverageGapCode::MEASUREMENT_UNAVAILABLE,
"root-motion speed could not be measured",
)
.scope(scope),
);
continue;
};
evaluated_scopes.push(scope);
if measured < STRAY_PIN_FLOOR_MPS && pin.value >= STRAY_PIN_FLOOR_MPS {
findings.push(
Finding::new(
self.id(),
Severity::Error,
format!(
"declared speed {:.2} m/s but the clip carries almost no \
root motion ({measured:.2} m/s) — stray pin, or an \
in-place clip declared as root-motion",
pin.value
),
)
.clip(&clip.name)
.measured(measured)
.expected(pin.value),
);
continue;
}
if (measured - pin.value).abs() > pin.tolerance {
findings.push(
Finding::new(
self.id(),
Severity::Error,
format!(
"measured root-motion speed {measured:.2} m/s disagrees with \
the declared {:.2} ± {:.2} m/s — playback scaled by this pin \
will slide or moonwalk",
pin.value, pin.tolerance
),
)
.clip(&clip.name)
.measured(measured)
.expected(pin.value),
);
}
}
CheckOutput::from_coverage(findings, evaluated_scopes, gaps)
}
}