Skip to main content

animsmith_core/
profile.rs

1//! Rig profiles: checks never reference bone names, they reference
2//! *roles*. A profile maps roles to name matchers; built-ins cover the
3//! common rigs and auto-detection scores every built-in by resolved-role
4//! coverage. A check whose required roles don't resolve is skipped with
5//! a note — never a false failure.
6
7use crate::model::{BoneId, Skeleton};
8use serde::Deserialize;
9use std::collections::BTreeMap;
10
11/// Semantic bone roles used by checks and measurements.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum Role {
16    /// Scene or locomotion root.
17    Root,
18    /// Pelvis/hips control, used as the primary body reference.
19    Hips,
20    /// Spine control.
21    Spine,
22    /// Head control.
23    Head,
24    /// Left foot control.
25    LeftFoot,
26    /// Right foot control.
27    RightFoot,
28    /// Left toe control.
29    LeftToe,
30    /// Right toe control.
31    RightToe,
32    /// Left hand control.
33    LeftHand,
34    /// Right hand control.
35    RightHand,
36}
37
38impl Role {
39    /// Stable snake-case role name used in config and diagnostics.
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Role::Root => "root",
43            Role::Hips => "hips",
44            Role::Spine => "spine",
45            Role::Head => "head",
46            Role::LeftFoot => "left_foot",
47            Role::RightFoot => "right_foot",
48            Role::LeftToe => "left_toe",
49            Role::RightToe => "right_toe",
50            Role::LeftHand => "left_hand",
51            Role::RightHand => "right_hand",
52        }
53    }
54}
55
56/// How a role's bone is found by name. Matching also tries a
57/// namespace-stripped variant of each bone name (`"ns:Hips"` → `"Hips"`).
58#[derive(Debug, Clone)]
59#[non_exhaustive]
60pub enum NameMatcher {
61    /// Exact bone-name match, with namespace-stripped fallback.
62    Exact(&'static str),
63}
64
65impl NameMatcher {
66    fn matches(&self, bone_name: &str) -> bool {
67        let NameMatcher::Exact(wanted) = self;
68        if bone_name == *wanted {
69            return true;
70        }
71        // Namespace-stripped fallback: "mixamorig:Hips" ~ "Hips".
72        bone_name
73            .rsplit_once(':')
74            .is_some_and(|(_, stripped)| stripped == *wanted)
75    }
76}
77
78/// A named set of role-to-bone-name matchers.
79#[derive(Debug, Clone)]
80pub struct RigProfile {
81    /// Profile name used in configuration and diagnostics.
82    pub name: &'static str,
83    /// Role matchers tried against a skeleton.
84    pub bindings: Vec<(Role, NameMatcher)>,
85}
86
87/// Role → bone resolution for one skeleton.
88#[derive(Debug, Clone, Default)]
89pub struct ResolvedRoles {
90    /// Name of the profile that produced this resolution ("custom" for
91    /// inline role maps).
92    pub profile: String,
93    map: BTreeMap<Role, BoneId>,
94}
95
96impl ResolvedRoles {
97    /// Bone id for a role, when resolved.
98    pub fn get(&self, role: Role) -> Option<BoneId> {
99        self.map.get(&role).copied()
100    }
101
102    /// Number of resolved roles.
103    pub fn len(&self) -> usize {
104        self.map.len()
105    }
106
107    /// Whether no roles resolved.
108    pub fn is_empty(&self) -> bool {
109        self.map.is_empty()
110    }
111
112    /// Iterate resolved `(role, bone_id)` pairs in role order.
113    pub fn iter(&self) -> impl Iterator<Item = (Role, BoneId)> + '_ {
114        self.map.iter().map(|(&r, &b)| (r, b))
115    }
116
117    /// Build from explicit role → bone-name pairs (for example a config
118    /// inline map). Pairs whose bone name is absent are ignored; when a role
119    /// appears more than once, the last resolved pair wins.
120    pub fn from_names(
121        skeleton: &Skeleton,
122        names: impl IntoIterator<Item = (Role, String)>,
123    ) -> Self {
124        let mut map = BTreeMap::new();
125        for (role, name) in names {
126            if let Some(id) = skeleton.bones.iter().position(|b| b.name == name) {
127                map.insert(role, id);
128            }
129        }
130        Self {
131            profile: "custom".into(),
132            map,
133        }
134    }
135}
136
137impl RigProfile {
138    /// Resolve this profile against `skeleton` by matching bone names.
139    pub fn resolve(&self, skeleton: &Skeleton) -> ResolvedRoles {
140        let mut map = BTreeMap::new();
141        for (role, matcher) in &self.bindings {
142            if let Some(id) = skeleton.bones.iter().position(|b| matcher.matches(&b.name)) {
143                map.insert(*role, id);
144            }
145        }
146        ResolvedRoles {
147            profile: self.name.into(),
148            map,
149        }
150    }
151}
152
153/// The built-in profiles.
154pub fn builtin_profiles() -> Vec<RigProfile> {
155    use NameMatcher::Exact;
156    use Role::*;
157    vec![
158        RigProfile {
159            name: "mixamo",
160            bindings: vec![
161                (Hips, Exact("mixamorig:Hips")),
162                (Spine, Exact("mixamorig:Spine")),
163                (Head, Exact("mixamorig:Head")),
164                (LeftFoot, Exact("mixamorig:LeftFoot")),
165                (RightFoot, Exact("mixamorig:RightFoot")),
166                (LeftToe, Exact("mixamorig:LeftToeBase")),
167                (RightToe, Exact("mixamorig:RightToeBase")),
168                (LeftHand, Exact("mixamorig:LeftHand")),
169                (RightHand, Exact("mixamorig:RightHand")),
170            ],
171        },
172        RigProfile {
173            name: "ue-mannequin",
174            bindings: vec![
175                (Root, Exact("root")),
176                (Hips, Exact("pelvis")),
177                (Spine, Exact("spine_01")),
178                (Head, Exact("head")),
179                (LeftFoot, Exact("foot_l")),
180                (RightFoot, Exact("foot_r")),
181                (LeftToe, Exact("ball_l")),
182                (RightToe, Exact("ball_r")),
183                (LeftHand, Exact("hand_l")),
184                (RightHand, Exact("hand_r")),
185            ],
186        },
187        RigProfile {
188            name: "humanoid",
189            bindings: vec![
190                (Root, Exact("root")),
191                (Hips, Exact("humanoid_ Pelvis")),
192                (Spine, Exact("humanoid_ Spine")),
193                (Head, Exact("humanoid_ Head")),
194                (LeftFoot, Exact("humanoid_ L Foot")),
195                (RightFoot, Exact("humanoid_ R Foot")),
196                (LeftToe, Exact("humanoid_ L Toe0")),
197                (RightToe, Exact("humanoid_ R Toe0")),
198                (LeftHand, Exact("humanoid_ L Hand")),
199                (RightHand, Exact("humanoid_ R Hand")),
200            ],
201        },
202    ]
203}
204
205/// Auto-detect: score every built-in by resolved-role coverage; the
206/// best profile wins if it resolves at least two roles. Ties keep the
207/// earlier (declaration-order) profile.
208pub fn detect_profile(skeleton: &Skeleton) -> Option<ResolvedRoles> {
209    builtin_profiles()
210        .iter()
211        .map(|p| p.resolve(skeleton))
212        .filter(|r| r.len() >= 2)
213        .max_by_key(ResolvedRoles::len)
214}
215
216/// Resolve a profile by name, or auto-detect for `"auto"`.
217pub fn resolve_named(skeleton: &Skeleton, profile: &str) -> Option<ResolvedRoles> {
218    if profile == "auto" {
219        return detect_profile(skeleton);
220    }
221    builtin_profiles()
222        .iter()
223        .find(|p| p.name == profile)
224        .map(|p| p.resolve(skeleton))
225}