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 do not resolve reports a typed
5//! coverage gap — never a false failure.
6
7use crate::config::RigConfig;
8use crate::model::{BoneId, Skeleton};
9use serde::Deserialize;
10use std::collections::BTreeMap;
11
12/// Semantic bone roles used by checks and measurements.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum Role {
17    /// Scene or locomotion root.
18    Root,
19    /// Pelvis/hips control, used as the primary body reference.
20    Hips,
21    /// Spine control.
22    Spine,
23    /// Head control.
24    Head,
25    /// Left foot control.
26    LeftFoot,
27    /// Right foot control.
28    RightFoot,
29    /// Left toe control.
30    LeftToe,
31    /// Right toe control.
32    RightToe,
33    /// Left hand control.
34    LeftHand,
35    /// Right hand control.
36    RightHand,
37}
38
39impl Role {
40    /// Stable snake-case role name used in config and result messages.
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Role::Root => "root",
44            Role::Hips => "hips",
45            Role::Spine => "spine",
46            Role::Head => "head",
47            Role::LeftFoot => "left_foot",
48            Role::RightFoot => "right_foot",
49            Role::LeftToe => "left_toe",
50            Role::RightToe => "right_toe",
51            Role::LeftHand => "left_hand",
52            Role::RightHand => "right_hand",
53        }
54    }
55}
56
57/// How a role's bone is found by name. Matching also tries a
58/// namespace-stripped variant of each bone name (`"ns:Hips"` → `"Hips"`).
59#[derive(Debug, Clone)]
60#[non_exhaustive]
61pub enum NameMatcher {
62    /// Exact bone-name match, with namespace-stripped fallback.
63    Exact(&'static str),
64}
65
66impl NameMatcher {
67    fn matches(&self, bone_name: &str) -> bool {
68        let NameMatcher::Exact(wanted) = self;
69        if bone_name == *wanted {
70            return true;
71        }
72        // Namespace-stripped fallback: "mixamorig:Hips" ~ "Hips".
73        bone_name
74            .rsplit_once(':')
75            .is_some_and(|(_, stripped)| stripped == *wanted)
76    }
77}
78
79/// A named set of role-to-bone-name matchers.
80#[derive(Debug, Clone)]
81pub struct RigProfile {
82    /// Profile name used in configuration and result messages.
83    pub name: &'static str,
84    /// Role matchers tried against a skeleton.
85    pub bindings: Vec<(Role, NameMatcher)>,
86}
87
88/// Role → bone resolution for one skeleton.
89#[derive(Debug, Clone, Default)]
90pub struct ResolvedRoles {
91    /// Name of the profile that produced this resolution ("custom" for
92    /// inline role maps).
93    pub profile: String,
94    map: BTreeMap<Role, ResolvedBone>,
95}
96
97#[derive(Debug, Clone)]
98struct ResolvedBone {
99    id: BoneId,
100    name: String,
101}
102
103impl ResolvedRoles {
104    /// Bone id for a role, when resolved.
105    pub fn get(&self, role: Role) -> Option<BoneId> {
106        self.map.get(&role).map(|bone| bone.id)
107    }
108
109    /// Bone id and captured name for internal boundaries that must reject a
110    /// role map reused with a different skeleton.
111    pub(crate) fn get_with_name(&self, role: Role) -> Option<(BoneId, &str)> {
112        self.map
113            .get(&role)
114            .map(|bone| (bone.id, bone.name.as_str()))
115    }
116
117    /// Number of resolved roles.
118    pub fn len(&self) -> usize {
119        self.map.len()
120    }
121
122    /// Whether no roles resolved.
123    pub fn is_empty(&self) -> bool {
124        self.map.is_empty()
125    }
126
127    /// Iterate resolved `(role, bone_id)` pairs in role order.
128    pub fn iter(&self) -> impl Iterator<Item = (Role, BoneId)> + '_ {
129        self.map.iter().map(|(&role, bone)| (role, bone.id))
130    }
131
132    pub(crate) fn iter_with_names(&self) -> impl Iterator<Item = (Role, BoneId, &str)> + '_ {
133        self.map
134            .iter()
135            .map(|(&role, bone)| (role, bone.id, bone.name.as_str()))
136    }
137
138    /// Build from explicit role → bone-name pairs (for example a config
139    /// inline map). Pairs whose bone name is absent are ignored; when a role
140    /// appears more than once, the last resolved pair wins.
141    pub fn from_names(
142        skeleton: &Skeleton,
143        names: impl IntoIterator<Item = (Role, String)>,
144    ) -> Self {
145        let mut map = BTreeMap::new();
146        for (role, name) in names {
147            if let Some(id) = skeleton.bones.iter().position(|b| b.name == name) {
148                map.insert(
149                    role,
150                    ResolvedBone {
151                        id,
152                        name: skeleton.bones[id].name.clone(),
153                    },
154                );
155            }
156        }
157        Self {
158            profile: "custom".into(),
159            map,
160        }
161    }
162}
163
164impl RigProfile {
165    /// Resolve this profile against `skeleton` by matching bone names.
166    pub fn resolve(&self, skeleton: &Skeleton) -> ResolvedRoles {
167        let mut map = BTreeMap::new();
168        for (role, matcher) in &self.bindings {
169            if let Some(id) = skeleton.bones.iter().position(|b| matcher.matches(&b.name)) {
170                map.insert(
171                    *role,
172                    ResolvedBone {
173                        id,
174                        name: skeleton.bones[id].name.clone(),
175                    },
176                );
177            }
178        }
179        ResolvedRoles {
180            profile: self.name.into(),
181            map,
182        }
183    }
184}
185
186/// The built-in profiles.
187pub fn builtin_profiles() -> Vec<RigProfile> {
188    use NameMatcher::Exact;
189    use Role::*;
190    vec![
191        RigProfile {
192            name: "mixamo",
193            bindings: vec![
194                (Hips, Exact("mixamorig:Hips")),
195                (Spine, Exact("mixamorig:Spine")),
196                (Head, Exact("mixamorig:Head")),
197                (LeftFoot, Exact("mixamorig:LeftFoot")),
198                (RightFoot, Exact("mixamorig:RightFoot")),
199                (LeftToe, Exact("mixamorig:LeftToeBase")),
200                (RightToe, Exact("mixamorig:RightToeBase")),
201                (LeftHand, Exact("mixamorig:LeftHand")),
202                (RightHand, Exact("mixamorig:RightHand")),
203            ],
204        },
205        RigProfile {
206            name: "ue-mannequin",
207            bindings: vec![
208                (Root, Exact("root")),
209                (Hips, Exact("pelvis")),
210                (Spine, Exact("spine_01")),
211                (Head, Exact("head")),
212                (LeftFoot, Exact("foot_l")),
213                (RightFoot, Exact("foot_r")),
214                (LeftToe, Exact("ball_l")),
215                (RightToe, Exact("ball_r")),
216                (LeftHand, Exact("hand_l")),
217                (RightHand, Exact("hand_r")),
218            ],
219        },
220        RigProfile {
221            name: "humanoid",
222            bindings: vec![
223                (Root, Exact("root")),
224                (Hips, Exact("humanoid_ Pelvis")),
225                (Spine, Exact("humanoid_ Spine")),
226                (Head, Exact("humanoid_ Head")),
227                (LeftFoot, Exact("humanoid_ L Foot")),
228                (RightFoot, Exact("humanoid_ R Foot")),
229                (LeftToe, Exact("humanoid_ L Toe0")),
230                (RightToe, Exact("humanoid_ R Toe0")),
231                (LeftHand, Exact("humanoid_ L Hand")),
232                (RightHand, Exact("humanoid_ R Hand")),
233            ],
234        },
235    ]
236}
237
238/// Auto-detect: score every built-in by resolved-role coverage; the
239/// best profile wins if it resolves at least two roles. Ties keep the
240/// earlier (declaration-order) profile.
241pub fn detect_profile(skeleton: &Skeleton) -> Option<ResolvedRoles> {
242    builtin_profiles()
243        .iter()
244        .map(|p| p.resolve(skeleton))
245        .filter(|r| r.len() >= 2)
246        .max_by_key(ResolvedRoles::len)
247}
248
249/// Resolve a profile by name, or auto-detect for `"auto"`.
250pub fn resolve_named(skeleton: &Skeleton, profile: &str) -> Option<ResolvedRoles> {
251    if profile == "auto" {
252        return detect_profile(skeleton);
253    }
254    builtin_profiles()
255        .iter()
256        .find(|p| p.name == profile)
257        .map(|p| p.resolve(skeleton))
258}
259
260/// Resolve a configured rig profile and apply inline role overrides.
261///
262/// Inline role bindings win over bindings from the named or auto-detected
263/// profile. Names absent from `skeleton` are ignored. The returned profile is
264/// `"unknown"` when neither a profile nor inline binding resolves, `"custom"`
265/// for inline-only resolution, or `<profile>+custom` when both contribute.
266pub fn resolve_configured_roles(skeleton: &Skeleton, rig: &RigConfig) -> ResolvedRoles {
267    let base = resolve_named(skeleton, &rig.profile).unwrap_or_default();
268    let base_contributed = !base.is_empty();
269    let inline_contributed = rig
270        .roles
271        .values()
272        .any(|name| skeleton.bones.iter().any(|bone| bone.name == *name));
273
274    let mut pairs: Vec<_> = base
275        .iter_with_names()
276        .map(|(role, _, name)| (role, name.to_owned()))
277        .collect();
278    pairs.extend(rig.roles.iter().map(|(role, name)| (*role, name.clone())));
279
280    let mut resolved = ResolvedRoles::from_names(skeleton, pairs);
281    resolved.profile = match (base_contributed, inline_contributed) {
282        (false, false) => "unknown".into(),
283        (false, true) => "custom".into(),
284        (true, false) => base.profile,
285        (true, true) => format!("{}+custom", base.profile),
286    };
287    resolved
288}