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    /// Number of resolved roles.
110    pub fn len(&self) -> usize {
111        self.map.len()
112    }
113
114    /// Whether no roles resolved.
115    pub fn is_empty(&self) -> bool {
116        self.map.is_empty()
117    }
118
119    /// Iterate resolved `(role, bone_id)` pairs in role order.
120    pub fn iter(&self) -> impl Iterator<Item = (Role, BoneId)> + '_ {
121        self.map.iter().map(|(&role, bone)| (role, bone.id))
122    }
123
124    pub(crate) fn iter_with_names(&self) -> impl Iterator<Item = (Role, BoneId, &str)> + '_ {
125        self.map
126            .iter()
127            .map(|(&role, bone)| (role, bone.id, bone.name.as_str()))
128    }
129
130    /// Build from explicit role → bone-name pairs (for example a config
131    /// inline map). Pairs whose bone name is absent are ignored; when a role
132    /// appears more than once, the last resolved pair wins.
133    pub fn from_names(
134        skeleton: &Skeleton,
135        names: impl IntoIterator<Item = (Role, String)>,
136    ) -> Self {
137        let mut map = BTreeMap::new();
138        for (role, name) in names {
139            if let Some(id) = skeleton.bones.iter().position(|b| b.name == name) {
140                map.insert(
141                    role,
142                    ResolvedBone {
143                        id,
144                        name: skeleton.bones[id].name.clone(),
145                    },
146                );
147            }
148        }
149        Self {
150            profile: "custom".into(),
151            map,
152        }
153    }
154}
155
156impl RigProfile {
157    /// Resolve this profile against `skeleton` by matching bone names.
158    pub fn resolve(&self, skeleton: &Skeleton) -> ResolvedRoles {
159        let mut map = BTreeMap::new();
160        for (role, matcher) in &self.bindings {
161            if let Some(id) = skeleton.bones.iter().position(|b| matcher.matches(&b.name)) {
162                map.insert(
163                    *role,
164                    ResolvedBone {
165                        id,
166                        name: skeleton.bones[id].name.clone(),
167                    },
168                );
169            }
170        }
171        ResolvedRoles {
172            profile: self.name.into(),
173            map,
174        }
175    }
176}
177
178/// The built-in profiles.
179pub fn builtin_profiles() -> Vec<RigProfile> {
180    use NameMatcher::Exact;
181    use Role::*;
182    vec![
183        RigProfile {
184            name: "mixamo",
185            bindings: vec![
186                (Hips, Exact("mixamorig:Hips")),
187                (Spine, Exact("mixamorig:Spine")),
188                (Head, Exact("mixamorig:Head")),
189                (LeftFoot, Exact("mixamorig:LeftFoot")),
190                (RightFoot, Exact("mixamorig:RightFoot")),
191                (LeftToe, Exact("mixamorig:LeftToeBase")),
192                (RightToe, Exact("mixamorig:RightToeBase")),
193                (LeftHand, Exact("mixamorig:LeftHand")),
194                (RightHand, Exact("mixamorig:RightHand")),
195            ],
196        },
197        RigProfile {
198            name: "ue-mannequin",
199            bindings: vec![
200                (Root, Exact("root")),
201                (Hips, Exact("pelvis")),
202                (Spine, Exact("spine_01")),
203                (Head, Exact("head")),
204                (LeftFoot, Exact("foot_l")),
205                (RightFoot, Exact("foot_r")),
206                (LeftToe, Exact("ball_l")),
207                (RightToe, Exact("ball_r")),
208                (LeftHand, Exact("hand_l")),
209                (RightHand, Exact("hand_r")),
210            ],
211        },
212        RigProfile {
213            name: "humanoid",
214            bindings: vec![
215                (Root, Exact("root")),
216                (Hips, Exact("humanoid_ Pelvis")),
217                (Spine, Exact("humanoid_ Spine")),
218                (Head, Exact("humanoid_ Head")),
219                (LeftFoot, Exact("humanoid_ L Foot")),
220                (RightFoot, Exact("humanoid_ R Foot")),
221                (LeftToe, Exact("humanoid_ L Toe0")),
222                (RightToe, Exact("humanoid_ R Toe0")),
223                (LeftHand, Exact("humanoid_ L Hand")),
224                (RightHand, Exact("humanoid_ R Hand")),
225            ],
226        },
227    ]
228}
229
230/// Auto-detect: score every built-in by resolved-role coverage; the
231/// best profile wins if it resolves at least two roles. Ties keep the
232/// earlier (declaration-order) profile.
233pub fn detect_profile(skeleton: &Skeleton) -> Option<ResolvedRoles> {
234    builtin_profiles()
235        .iter()
236        .map(|p| p.resolve(skeleton))
237        .filter(|r| r.len() >= 2)
238        .max_by_key(ResolvedRoles::len)
239}
240
241/// Resolve a profile by name, or auto-detect for `"auto"`.
242pub fn resolve_named(skeleton: &Skeleton, profile: &str) -> Option<ResolvedRoles> {
243    if profile == "auto" {
244        return detect_profile(skeleton);
245    }
246    builtin_profiles()
247        .iter()
248        .find(|p| p.name == profile)
249        .map(|p| p.resolve(skeleton))
250}
251
252/// Resolve a configured rig profile and apply inline role overrides.
253///
254/// Inline role bindings win over bindings from the named or auto-detected
255/// profile. Names absent from `skeleton` are ignored. The returned profile is
256/// `"unknown"` when neither a profile nor inline binding resolves, `"custom"`
257/// for inline-only resolution, or `<profile>+custom` when both contribute.
258pub fn resolve_configured_roles(skeleton: &Skeleton, rig: &RigConfig) -> ResolvedRoles {
259    let base = resolve_named(skeleton, &rig.profile).unwrap_or_default();
260    let base_contributed = !base.is_empty();
261    let inline_contributed = rig
262        .roles
263        .values()
264        .any(|name| skeleton.bones.iter().any(|bone| bone.name == *name));
265
266    let mut pairs: Vec<_> = base
267        .iter_with_names()
268        .map(|(role, _, name)| (role, name.to_owned()))
269        .collect();
270    pairs.extend(rig.roles.iter().map(|(role, name)| (*role, name.clone())));
271
272    let mut resolved = ResolvedRoles::from_names(skeleton, pairs);
273    resolved.profile = match (base_contributed, inline_contributed) {
274        (false, false) => "unknown".into(),
275        (false, true) => "custom".into(),
276        (true, false) => base.profile,
277        (true, true) => format!("{}+custom", base.profile),
278    };
279    resolved
280}