animsmith_core/config.rs
1//! Typed configuration: rig selection, per-check settings, per-clip
2//! expectations, and typed clip groups. The TOML file (`animsmith.toml`) is
3//! *one* constructor of this — embedding pipelines build it
4//! programmatically through this module and keep their own contract
5//! formats on their side.
6//!
7//! The structs derive `Deserialize` so a frontend can parse any
8//! serde-compatible format (the CLI uses TOML); the core itself never
9//! touches a file format. [`crate::CheckCtx::new`] does not resolve
10//! [`Config::rig`]; the embedding frontend resolves roles first through
11//! [`crate::profile`] and passes the resulting [`crate::ResolvedRoles`].
12
13use crate::finding::Severity;
14use crate::metrics::MIN_STRIDE_STEP_M;
15use crate::profile::Role;
16use serde::Deserialize;
17use std::collections::BTreeMap;
18
19/// A pinned expectation: declared value ± tolerance.
20#[derive(Debug, Clone, Copy, Deserialize)]
21pub struct Pinned {
22 /// Expected value.
23 pub value: f64,
24 /// Allowed absolute deviation from [`Pinned::value`].
25 pub tolerance: f64,
26}
27
28/// Severity override for a check; `Off` disables it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum SeveritySetting {
32 /// Remove the check from the run set.
33 Off,
34 /// Force non-diagnostic findings to notes.
35 Note,
36 /// Force non-diagnostic findings to warnings.
37 #[serde(alias = "warning")]
38 Warn,
39 /// Force non-diagnostic findings to errors.
40 Error,
41}
42
43impl SeveritySetting {
44 /// Convert this setting into a finding severity.
45 ///
46 /// Returns `None` for [`SeveritySetting::Off`] because disabling a
47 /// check is handled before execution.
48 pub fn as_severity(self) -> Option<Severity> {
49 match self {
50 SeveritySetting::Off => None,
51 SeveritySetting::Note => Some(Severity::Note),
52 SeveritySetting::Warn => Some(Severity::Warning),
53 SeveritySetting::Error => Some(Severity::Error),
54 }
55 }
56}
57
58/// Per-check settings: a severity override plus the union of the
59/// built-in checks' tunables (only the owning check reads each field).
60#[derive(Debug, Clone, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct CheckSettings {
63 /// Per-check severity override. `None` leaves the check's default
64 /// severity intact.
65 pub severity: Option<SeveritySetting>,
66 /// `loop-seam`: ratio above which the seam is a pop (default 1.5).
67 pub max_ratio: Option<f64>,
68 /// `loop-seam`: stride floor in metres (default 0.02).
69 pub min_stride_step_m: Option<f64>,
70 /// `frozen-bone`: rotation floor in degrees (default 1.0).
71 pub min_rotation_deg: Option<f64>,
72 /// `bind-pose`: mean first-frame deviation cap in degrees
73 /// (default 45).
74 pub max_mean_rest_delta_deg: Option<f64>,
75 /// `foot-slide`: contact height above the per-clip foot minimum
76 /// (default 0.03 m).
77 pub contact_height_m: Option<f64>,
78 /// `foot-slide`: allowed stance-speed deviation (default 0.3 m/s).
79 pub max_slide_mps: Option<f64>,
80}
81
82/// What the author declares about one clip (or a glob of clips).
83#[derive(Debug, Clone, Default, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct ClipExpectations {
86 /// The clip is a cyclic loop; loop checks apply.
87 #[serde(rename = "loop")]
88 pub looping: Option<bool>,
89 /// Declared locomotion speed (m/s) carried by the clip's root
90 /// motion.
91 pub speed_mps: Option<Pinned>,
92 /// The clip is authored in place (no net root travel); consumed by
93 /// the `in-place` check (and exempts an in-place clip from
94 /// `root-motion-speed`).
95 pub in_place: Option<bool>,
96 /// Authored frame rate; consumed by the `fps` check (keys must land
97 /// on the `1/fps` grid).
98 pub fps: Option<f64>,
99 /// Bones that must carry keyframes and actually move
100 /// (`missing-bones` presence + `frozen-bone` rotation floor).
101 pub animates_bones: Option<Vec<String>>,
102}
103
104impl ClipExpectations {
105 /// Overlay `other` on `self` (other's set fields win).
106 fn merged_with(&self, other: &ClipExpectations) -> ClipExpectations {
107 ClipExpectations {
108 looping: other.looping.or(self.looping),
109 speed_mps: other.speed_mps.or(self.speed_mps),
110 in_place: other.in_place.or(self.in_place),
111 fps: other.fps.or(self.fps),
112 animates_bones: other
113 .animates_bones
114 .clone()
115 .or_else(|| self.animates_bones.clone()),
116 }
117 }
118}
119
120/// A set of clips whose gait phases must agree (a directional blend
121/// ring).
122#[derive(Debug, Clone, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct GaitGroup {
125 /// Clip names that should share a gait phase.
126 pub clips: Vec<String>,
127 /// Maximum circular spread of the members' gait phases, in cycle
128 /// fraction `[0, 0.5]`.
129 pub max_gait_phase_spread: f64,
130 /// Members with L−R amplitude under this (metres) are excluded from
131 /// the spread (their phase is noise, not signal).
132 #[serde(default)]
133 pub min_lr_amplitude_m: f64,
134}
135
136/// Rig selection: a named profile ("auto" to detect) and/or an inline
137/// role map (which wins over the profile for the roles it names).
138#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct RigConfig {
141 /// Built-in profile name, or `"auto"` to select the best built-in
142 /// match.
143 #[serde(default = "default_profile")]
144 pub profile: String,
145 /// Inline role-to-bone-name bindings. These are interpreted as
146 /// explicit overrides by callers that merge them with a profile.
147 #[serde(default)]
148 pub roles: BTreeMap<Role, String>,
149}
150
151fn default_profile() -> String {
152 "auto".into()
153}
154
155impl Default for RigConfig {
156 fn default() -> Self {
157 Self {
158 profile: default_profile(),
159 roles: BTreeMap::new(),
160 }
161 }
162}
163
164/// The whole configuration. Field names match the `animsmith.toml`
165/// sections.
166#[derive(Debug, Clone, Default, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct Config {
169 /// Declarative rig profile and inline role bindings. Frontends resolve
170 /// these into [`crate::ResolvedRoles`] before creating a check context;
171 /// the core runner does not apply them automatically.
172 #[serde(default)]
173 pub rig: RigConfig,
174 /// Per-check settings keyed by stable check id.
175 #[serde(default)]
176 pub checks: BTreeMap<String, CheckSettings>,
177 /// Keyed by clip name or glob (`*` wildcards). An exact-name entry
178 /// overrides glob entries; among globs, later (lexicographically
179 /// greater) keys win on conflict.
180 #[serde(default)]
181 pub clips: BTreeMap<String, ClipExpectations>,
182 /// Named gait groups consumed by the `gait-group` check.
183 #[serde(default)]
184 pub gait_groups: BTreeMap<String, GaitGroup>,
185}
186
187impl Config {
188 /// Effective expectations for a clip: glob matches (in key order)
189 /// overlaid, exact match last.
190 pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
191 let mut out = ClipExpectations::default();
192 for (pattern, exp) in &self.clips {
193 if pattern != clip && glob_match(pattern, clip) {
194 out = out.merged_with(exp);
195 }
196 }
197 if let Some(exact) = self.clips.get(clip) {
198 out = out.merged_with(exact);
199 }
200 out
201 }
202
203 /// Settings for a check id, or defaults when the id is not present.
204 pub fn check_settings(&self, id: &str) -> CheckSettings {
205 self.checks.get(id).cloned().unwrap_or_default()
206 }
207
208 /// Effective stride floor for loop-seam metrics, in metres.
209 pub fn loop_seam_min_stride_step_m(&self) -> f64 {
210 self.check_settings("loop-seam")
211 .min_stride_step_m
212 .unwrap_or(MIN_STRIDE_STEP_M)
213 }
214}
215
216/// Minimal `*`-wildcard matcher (no character classes; `*` matches any
217/// run including empty).
218pub fn glob_match(pattern: &str, name: &str) -> bool {
219 fn inner(p: &[u8], n: &[u8]) -> bool {
220 match p.split_first() {
221 None => n.is_empty(),
222 Some((b'*', rest)) => (0..=n.len()).any(|skip| inner(rest, &n[skip..])),
223 Some((c, rest)) => n
224 .split_first()
225 .is_some_and(|(nc, nrest)| nc == c && inner(rest, nrest)),
226 }
227 }
228 inner(pattern.as_bytes(), name.as_bytes())
229}