Skip to main content

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}