use crate::finding::Severity;
use crate::metrics::MIN_STRIDE_STEP_M;
use crate::profile::Role;
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Pinned {
pub value: f64,
pub tolerance: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeveritySetting {
Off,
Note,
#[serde(alias = "warning")]
Warn,
Error,
}
impl SeveritySetting {
pub fn as_severity(self) -> Option<Severity> {
match self {
SeveritySetting::Off => None,
SeveritySetting::Note => Some(Severity::Note),
SeveritySetting::Warn => Some(Severity::Warning),
SeveritySetting::Error => Some(Severity::Error),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CheckSettings {
pub severity: Option<SeveritySetting>,
pub max_ratio: Option<f64>,
pub min_stride_step_m: Option<f64>,
pub max_position_delta_m: Option<f64>,
pub max_rotation_delta_deg: Option<f64>,
pub max_velocity_delta_mps: Option<f64>,
pub max_angular_velocity_delta_degps: Option<f64>,
pub min_rotation_deg: Option<f64>,
pub max_mean_rest_delta_deg: Option<f64>,
pub contact_height_m: Option<f64>,
pub max_slide_mps: Option<f64>,
pub node_selectors: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_positive_finite_option")]
pub expected_uniform_scale: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub uniform_scale_tolerance: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClipExpectations {
#[serde(rename = "loop")]
pub looping: Option<bool>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_position_delta_m: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_rotation_delta_deg: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_velocity_delta_mps: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_angular_velocity_delta_degps: Option<f64>,
pub duration_s: Option<Pinned>,
pub speed_mps: Option<Pinned>,
pub in_place: Option<bool>,
pub fps: Option<f64>,
pub animates_bones: Option<Vec<String>>,
}
impl ClipExpectations {
fn merged_with(&self, other: &ClipExpectations) -> ClipExpectations {
ClipExpectations {
looping: other.looping.or(self.looping),
max_loop_position_delta_m: other
.max_loop_position_delta_m
.or(self.max_loop_position_delta_m),
max_loop_rotation_delta_deg: other
.max_loop_rotation_delta_deg
.or(self.max_loop_rotation_delta_deg),
max_loop_velocity_delta_mps: other
.max_loop_velocity_delta_mps
.or(self.max_loop_velocity_delta_mps),
max_loop_angular_velocity_delta_degps: other
.max_loop_angular_velocity_delta_degps
.or(self.max_loop_angular_velocity_delta_degps),
duration_s: other.duration_s.or(self.duration_s),
speed_mps: other.speed_mps.or(self.speed_mps),
in_place: other.in_place.or(self.in_place),
fps: other.fps.or(self.fps),
animates_bones: other
.animates_bones
.clone()
.or_else(|| self.animates_bones.clone()),
}
}
}
fn deserialize_nonnegative_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<f64>::deserialize(deserializer)?;
if let Some(value) = value
&& !is_valid_loop_cap(value)
{
return Err(serde::de::Error::custom(
"must be a finite non-negative number",
));
}
Ok(value)
}
fn deserialize_positive_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<f64>::deserialize(deserializer)?;
if value.is_some_and(|value| !is_positive_finite(value)) {
return Err(serde::de::Error::custom(
"must be a finite number greater than zero",
));
}
Ok(value)
}
fn is_valid_loop_cap(value: f64) -> bool {
value.is_finite() && value >= 0.0
}
fn is_positive_finite(value: f64) -> bool {
value.is_finite() && value > 0.0
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GaitGroup {
pub clips: Vec<String>,
pub max_gait_phase_spread: f64,
#[serde(default)]
pub min_lr_amplitude_m: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TimeComplementSettings {
#[serde(deserialize_with = "deserialize_unit_interval")]
pub min_reflected_time_advantage: f64,
#[serde(deserialize_with = "deserialize_nonnegative_finite")]
pub min_lr_amplitude_m: f64,
}
fn deserialize_unit_interval<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if !is_unit_interval(value) {
return Err(serde::de::Error::custom(
"must be a finite number in the range [0, 1]",
));
}
Ok(value)
}
fn deserialize_nonnegative_finite<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if !is_valid_loop_cap(value) {
return Err(serde::de::Error::custom(
"must be a finite non-negative number",
));
}
Ok(value)
}
fn is_unit_interval(value: f64) -> bool {
value.is_finite() && (0.0..=1.0).contains(&value)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncGroup {
pub clips: Vec<String>,
pub max_duration_delta_s: f64,
pub max_frame_count_delta: u32,
pub max_fps_delta: f64,
#[serde(default)]
pub time_complement: Option<TimeComplementSettings>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigConfig {
#[serde(default = "default_profile")]
pub profile: String,
#[serde(default)]
pub roles: BTreeMap<Role, String>,
pub required_bones: Option<Vec<String>>,
}
fn default_profile() -> String {
"auto".into()
}
impl Default for RigConfig {
fn default() -> Self {
Self {
profile: default_profile(),
roles: BTreeMap::new(),
required_bones: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigValidationError {
#[error("check {check_id:?} field {field:?} must be finite and within its documented range")]
InvalidCheckSetting {
check_id: String,
field: &'static str,
},
#[error("clip selector {selector:?} field {field:?} must be a finite non-negative number")]
InvalidClipLoopCap {
selector: String,
field: &'static str,
},
#[error("sync group {group:?} field {field:?} must be a finite non-negative number")]
InvalidSyncGroupTolerance {
group: String,
field: &'static str,
},
#[error(
"sync group {group:?} time-complement field {field:?} must be finite and within its documented range"
)]
InvalidTimeComplementSetting {
group: String,
field: &'static str,
},
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub rig: RigConfig,
#[serde(default)]
pub checks: BTreeMap<String, CheckSettings>,
#[serde(default)]
pub clips: BTreeMap<String, ClipExpectations>,
#[serde(default)]
pub gait_groups: BTreeMap<String, GaitGroup>,
#[serde(default)]
pub sync_groups: BTreeMap<String, SyncGroup>,
}
impl Config {
pub fn validate(&self) -> Result<(), ConfigValidationError> {
for (check_id, settings) in &self.checks {
for (field, valid) in [
(
"expected_uniform_scale",
settings
.expected_uniform_scale
.is_none_or(is_positive_finite),
),
(
"uniform_scale_tolerance",
settings
.uniform_scale_tolerance
.is_none_or(is_valid_loop_cap),
),
] {
if !valid {
return Err(ConfigValidationError::InvalidCheckSetting {
check_id: check_id.clone(),
field,
});
}
}
}
for (selector, expectations) in &self.clips {
for (field, value) in [
(
"max_loop_position_delta_m",
expectations.max_loop_position_delta_m,
),
(
"max_loop_rotation_delta_deg",
expectations.max_loop_rotation_delta_deg,
),
(
"max_loop_velocity_delta_mps",
expectations.max_loop_velocity_delta_mps,
),
(
"max_loop_angular_velocity_delta_degps",
expectations.max_loop_angular_velocity_delta_degps,
),
] {
if value.is_some_and(|value| !is_valid_loop_cap(value)) {
return Err(ConfigValidationError::InvalidClipLoopCap {
selector: selector.clone(),
field,
});
}
}
}
for (group, sync) in &self.sync_groups {
for (field, value) in [
("max_duration_delta_s", sync.max_duration_delta_s),
("max_fps_delta", sync.max_fps_delta),
] {
if !is_valid_loop_cap(value) {
return Err(ConfigValidationError::InvalidSyncGroupTolerance {
group: group.clone(),
field,
});
}
}
if let Some(settings) = &sync.time_complement {
for (field, valid) in [
(
"min_reflected_time_advantage",
is_unit_interval(settings.min_reflected_time_advantage),
),
(
"min_lr_amplitude_m",
is_valid_loop_cap(settings.min_lr_amplitude_m),
),
] {
if !valid {
return Err(ConfigValidationError::InvalidTimeComplementSetting {
group: group.clone(),
field,
});
}
}
}
}
Ok(())
}
pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
let mut out = ClipExpectations::default();
for (pattern, exp) in &self.clips {
if pattern != clip && glob_match(pattern, clip) {
out = out.merged_with(exp);
}
}
if let Some(exact) = self.clips.get(clip) {
out = out.merged_with(exact);
}
out
}
pub fn check_settings(&self, id: &str) -> CheckSettings {
self.checks.get(id).cloned().unwrap_or_default()
}
pub fn loop_seam_min_stride_step_m(&self) -> f64 {
self.check_settings("loop-seam")
.min_stride_step_m
.unwrap_or(MIN_STRIDE_STEP_M)
}
}
pub fn glob_match(pattern: &str, name: &str) -> bool {
fn inner(p: &[u8], n: &[u8]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some((b'*', rest)) => (0..=n.len()).any(|skip| inner(rest, &n[skip..])),
Some((c, rest)) => n
.split_first()
.is_some_and(|(nc, nrest)| nc == c && inner(rest, nrest)),
}
}
inner(pattern.as_bytes(), name.as_bytes())
}