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, BTreeSet};
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/// Stable explanation of how one resolved role matched its delivered bone
58/// name.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum RoleResolutionPolicy {
62    /// A built-in binding matched exactly (including its established
63    /// namespace-stripped form).
64    Exact,
65    /// A built-in binding had no exact candidate and matched one unique
66    /// ASCII-case-insensitive candidate.
67    AsciiCaseInsensitive,
68    /// An exact user-supplied `[rig.roles]` binding supplied the name.
69    Explicit,
70}
71
72impl RoleResolutionPolicy {
73    /// Stable output-contract spelling.
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::Exact => "exact",
77            Self::AsciiCaseInsensitive => "ascii-case-insensitive",
78            Self::Explicit => "explicit",
79        }
80    }
81}
82
83/// Typed overall result of resolving a configured rig.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum ResolutionOutcome {
87    /// Every binding considered by the selected profile resolved.
88    Resolved,
89    /// A usable, injective role map was produced but one or more bindings did
90    /// not have a candidate.
91    Coverage,
92    /// One exact binding had more than one candidate.
93    AmbiguousExactMatch,
94    /// One case-insensitive binding had more than one candidate.
95    AmbiguousFoldedMatch,
96    /// Two roles would have selected the same bone.
97    RoleCollision,
98    /// More than one built-in profile had the same best automatic score.
99    AmbiguousProfile,
100}
101
102impl ResolutionOutcome {
103    /// Stable output-contract spelling.
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::Resolved => "resolved",
107            Self::Coverage => "coverage",
108            Self::AmbiguousExactMatch => "ambiguous_exact_match",
109            Self::AmbiguousFoldedMatch => "ambiguous_folded_match",
110            Self::RoleCollision => "role_collision",
111            Self::AmbiguousProfile => "ambiguous_profile",
112        }
113    }
114
115    const fn is_ambiguous(self) -> bool {
116        !matches!(self, Self::Resolved | Self::Coverage)
117    }
118}
119
120/// How a role's bone is found by name. Matching also tries a
121/// namespace-stripped variant of each bone name (`"ns:Hips"` → `"Hips"`).
122#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum NameMatcher {
125    /// Exact bone-name match, with namespace-stripped fallback.
126    Exact(&'static str),
127}
128
129impl NameMatcher {
130    fn exact_matches(&self, bone_name: &str) -> bool {
131        let NameMatcher::Exact(wanted) = self;
132        bone_name == *wanted
133            || bone_name
134                .rsplit_once(':')
135                .is_some_and(|(_, stripped)| stripped == *wanted)
136    }
137
138    fn ascii_case_insensitive_matches(&self, bone_name: &str) -> bool {
139        let NameMatcher::Exact(wanted) = self;
140        bone_name.eq_ignore_ascii_case(wanted)
141            || bone_name
142                .rsplit_once(':')
143                .is_some_and(|(_, stripped)| stripped.eq_ignore_ascii_case(wanted))
144    }
145}
146
147/// A named set of role-to-bone-name matchers.
148#[derive(Debug, Clone)]
149pub struct RigProfile {
150    /// Profile name used in configuration and result messages.
151    pub name: &'static str,
152    /// Role matchers tried against a skeleton.
153    pub bindings: Vec<(Role, NameMatcher)>,
154}
155
156/// Role → bone resolution for one skeleton.
157#[derive(Debug, Clone)]
158pub struct ResolvedRoles {
159    /// Name of the profile that produced this resolution ("custom" for
160    /// inline role maps).
161    pub profile: String,
162    map: BTreeMap<Role, ResolvedBone>,
163    outcome: ResolutionOutcome,
164}
165
166#[derive(Debug, Clone)]
167struct ResolvedBone {
168    id: BoneId,
169    name: String,
170    policy: RoleResolutionPolicy,
171}
172
173impl ResolvedRoles {
174    /// Bone id for a role, when resolved.
175    pub fn get(&self, role: Role) -> Option<BoneId> {
176        self.map.get(&role).map(|bone| bone.id)
177    }
178
179    /// Resolution policy for a role, when it resolved.
180    pub fn policy(&self, role: Role) -> Option<RoleResolutionPolicy> {
181        self.map.get(&role).map(|bone| bone.policy)
182    }
183
184    /// Typed result of the overall profile/configuration resolution.
185    pub const fn outcome(&self) -> ResolutionOutcome {
186        self.outcome
187    }
188
189    /// Bone id and captured name for internal boundaries that must reject a
190    /// role map reused with a different skeleton.
191    pub(crate) fn get_with_name(&self, role: Role) -> Option<(BoneId, &str)> {
192        self.map
193            .get(&role)
194            .map(|bone| (bone.id, bone.name.as_str()))
195    }
196
197    /// Number of resolved roles.
198    pub fn len(&self) -> usize {
199        self.map.len()
200    }
201
202    /// Whether no roles resolved.
203    pub fn is_empty(&self) -> bool {
204        self.map.is_empty()
205    }
206
207    /// Iterate resolved `(role, bone_id)` pairs in role order.
208    pub fn iter(&self) -> impl Iterator<Item = (Role, BoneId)> + '_ {
209        self.map.iter().map(|(&role, bone)| (role, bone.id))
210    }
211
212    pub(crate) fn iter_with_details(
213        &self,
214    ) -> impl Iterator<Item = (Role, BoneId, &str, RoleResolutionPolicy)> + '_ {
215        self.map
216            .iter()
217            .map(|(&role, bone)| (role, bone.id, bone.name.as_str(), bone.policy))
218    }
219
220    /// Build from explicit role → bone-name pairs (for example a config
221    /// inline map). Pairs whose bone name is absent are not bound, but make
222    /// the result coverage-incomplete; when a role appears more than once,
223    /// the last resolved pair wins. A final map that would bind two roles to
224    /// one bone is refused as a typed collision.
225    pub fn from_names(
226        skeleton: &Skeleton,
227        names: impl IntoIterator<Item = (Role, String)>,
228    ) -> Self {
229        let mut map = BTreeMap::new();
230        let mut coverage_gap = false;
231        for (role, name) in names {
232            if let Some(id) = skeleton.bones.iter().position(|b| b.name == name) {
233                map.insert(
234                    role,
235                    ResolvedBone {
236                        id,
237                        name: skeleton.bones[id].name.clone(),
238                        policy: RoleResolutionPolicy::Explicit,
239                    },
240                );
241            } else {
242                coverage_gap = true;
243            }
244        }
245        let outcome = injective_outcome(&map).unwrap_or(if coverage_gap || map.is_empty() {
246            ResolutionOutcome::Coverage
247        } else {
248            ResolutionOutcome::Resolved
249        });
250        Self {
251            profile: "custom".into(),
252            map: if outcome.is_ambiguous() {
253                BTreeMap::new()
254            } else {
255                map
256            },
257            outcome,
258        }
259    }
260}
261
262impl Default for ResolvedRoles {
263    fn default() -> Self {
264        unresolved("unknown", ResolutionOutcome::Coverage)
265    }
266}
267
268impl RigProfile {
269    /// Resolve this profile against `skeleton` using established exact matching
270    /// semantics. Built-in alias fallback is deliberately unavailable through
271    /// this public custom-profile API.
272    pub fn resolve(&self, skeleton: &Skeleton) -> ResolvedRoles {
273        self.resolve_with_options(skeleton, &BTreeSet::new(), false)
274    }
275
276    fn resolve_builtin_excluding(
277        &self,
278        skeleton: &Skeleton,
279        excluded_roles: &BTreeSet<Role>,
280    ) -> ResolvedRoles {
281        self.resolve_with_options(skeleton, excluded_roles, true)
282    }
283
284    fn resolve_with_options(
285        &self,
286        skeleton: &Skeleton,
287        excluded_roles: &BTreeSet<Role>,
288        allow_ascii_case_insensitive_fallback: bool,
289    ) -> ResolvedRoles {
290        let mut map = BTreeMap::new();
291        let mut coverage_gap = false;
292        for (role, matcher) in &self.bindings {
293            if excluded_roles.contains(role) {
294                continue;
295            }
296            let exact: Vec<_> = skeleton
297                .bones
298                .iter()
299                .enumerate()
300                .filter_map(|(id, bone)| matcher.exact_matches(&bone.name).then_some(id))
301                .collect();
302            let (id, policy) = match exact.as_slice() {
303                [id] => (*id, RoleResolutionPolicy::Exact),
304                [] if allow_ascii_case_insensitive_fallback => {
305                    let folded: Vec<_> = skeleton
306                        .bones
307                        .iter()
308                        .enumerate()
309                        .filter_map(|(id, bone)| {
310                            matcher
311                                .ascii_case_insensitive_matches(&bone.name)
312                                .then_some(id)
313                        })
314                        .collect();
315                    match folded.as_slice() {
316                        [id] => (*id, RoleResolutionPolicy::AsciiCaseInsensitive),
317                        [] => {
318                            coverage_gap = true;
319                            continue;
320                        }
321                        _ => return unresolved(self.name, ResolutionOutcome::AmbiguousFoldedMatch),
322                    }
323                }
324                [] => {
325                    coverage_gap = true;
326                    continue;
327                }
328                _ => return unresolved(self.name, ResolutionOutcome::AmbiguousExactMatch),
329            };
330            map.insert(
331                *role,
332                ResolvedBone {
333                    id,
334                    name: skeleton.bones[id].name.clone(),
335                    policy,
336                },
337            );
338        }
339        if injective_outcome(&map).is_some() {
340            return unresolved(self.name, ResolutionOutcome::RoleCollision);
341        }
342        ResolvedRoles {
343            profile: self.name.into(),
344            map,
345            outcome: if coverage_gap {
346                ResolutionOutcome::Coverage
347            } else {
348                ResolutionOutcome::Resolved
349            },
350        }
351    }
352}
353
354fn unresolved(profile: &str, outcome: ResolutionOutcome) -> ResolvedRoles {
355    ResolvedRoles {
356        profile: profile.into(),
357        map: BTreeMap::new(),
358        outcome,
359    }
360}
361
362fn injective_outcome(map: &BTreeMap<Role, ResolvedBone>) -> Option<ResolutionOutcome> {
363    let mut ids = BTreeSet::new();
364    map.values()
365        .any(|bone| !ids.insert(bone.id))
366        .then_some(ResolutionOutcome::RoleCollision)
367}
368
369/// The built-in profiles.
370pub fn builtin_profiles() -> Vec<RigProfile> {
371    use NameMatcher::Exact;
372    use Role::*;
373    vec![
374        RigProfile {
375            name: "mixamo",
376            bindings: vec![
377                (Hips, Exact("mixamorig:Hips")),
378                (Spine, Exact("mixamorig:Spine")),
379                (Head, Exact("mixamorig:Head")),
380                (LeftFoot, Exact("mixamorig:LeftFoot")),
381                (RightFoot, Exact("mixamorig:RightFoot")),
382                (LeftToe, Exact("mixamorig:LeftToeBase")),
383                (RightToe, Exact("mixamorig:RightToeBase")),
384                (LeftHand, Exact("mixamorig:LeftHand")),
385                (RightHand, Exact("mixamorig:RightHand")),
386            ],
387        },
388        RigProfile {
389            name: "ue-mannequin",
390            bindings: vec![
391                (Root, Exact("root")),
392                (Hips, Exact("pelvis")),
393                (Spine, Exact("spine_01")),
394                (Head, Exact("head")),
395                (LeftFoot, Exact("foot_l")),
396                (RightFoot, Exact("foot_r")),
397                (LeftToe, Exact("ball_l")),
398                (RightToe, Exact("ball_r")),
399                (LeftHand, Exact("hand_l")),
400                (RightHand, Exact("hand_r")),
401            ],
402        },
403        RigProfile {
404            name: "humanoid",
405            bindings: vec![
406                (Root, Exact("root")),
407                (Hips, Exact("humanoid_ Pelvis")),
408                (Spine, Exact("humanoid_ Spine")),
409                (Head, Exact("humanoid_ Head")),
410                (LeftFoot, Exact("humanoid_ L Foot")),
411                (RightFoot, Exact("humanoid_ R Foot")),
412                (LeftToe, Exact("humanoid_ L Toe0")),
413                (RightToe, Exact("humanoid_ R Toe0")),
414                (LeftHand, Exact("humanoid_ L Hand")),
415                (RightHand, Exact("humanoid_ R Hand")),
416            ],
417        },
418    ]
419}
420
421/// Auto-detect with a typed result. A profile must resolve at least two roles;
422/// equal best candidates are refused rather than chosen by declaration order.
423pub fn detect_profile_detailed(skeleton: &Skeleton) -> ResolvedRoles {
424    detect_profile_excluding(skeleton, &BTreeSet::new(), 0)
425}
426
427fn detect_profile_excluding(
428    skeleton: &Skeleton,
429    excluded_roles: &BTreeSet<Role>,
430    score_offset: usize,
431) -> ResolvedRoles {
432    let resolved: Vec<_> = builtin_profiles()
433        .iter()
434        .map(|profile| profile.resolve_builtin_excluding(skeleton, excluded_roles))
435        .collect();
436    let ambiguities: Vec<_> = resolved
437        .iter()
438        .filter_map(|roles| roles.outcome.is_ambiguous().then_some(roles.outcome))
439        .collect();
440    if ambiguities.len() == 1 {
441        return unresolved("unknown", ambiguities[0]);
442    }
443    if ambiguities.len() > 1 {
444        return unresolved("unknown", ResolutionOutcome::AmbiguousProfile);
445    }
446    let candidates: Vec<_> = resolved
447        .iter()
448        .filter(|roles| {
449            !roles.outcome.is_ambiguous() && !roles.is_empty() && roles.len() + score_offset >= 2
450        })
451        .cloned()
452        .collect();
453    let Some(best_score) = candidates.iter().map(ResolvedRoles::len).max() else {
454        return ResolvedRoles::default();
455    };
456    let mut best = candidates
457        .into_iter()
458        .filter(|roles| roles.len() == best_score);
459    let selected = best.next().expect("a best score has a candidate");
460    if best.next().is_some() {
461        unresolved("unknown", ResolutionOutcome::AmbiguousProfile)
462    } else {
463        selected
464    }
465}
466
467/// Auto-detect a built-in profile, preserving the historical optional API.
468/// Use [`detect_profile_detailed`] when a caller needs the typed outcome.
469pub fn detect_profile(skeleton: &Skeleton) -> Option<ResolvedRoles> {
470    let resolved = detect_profile_detailed(skeleton);
471    (!resolved.is_empty() && !resolved.outcome.is_ambiguous()).then_some(resolved)
472}
473
474/// Resolve a profile by name, or auto-detect for `"auto"`, retaining a typed
475/// ambiguity or coverage result for callers that publish it.
476pub fn resolve_named_detailed(skeleton: &Skeleton, profile: &str) -> ResolvedRoles {
477    if profile == "auto" {
478        return detect_profile_detailed(skeleton);
479    }
480    builtin_profiles()
481        .iter()
482        .find(|candidate| candidate.name == profile)
483        .map(|candidate| candidate.resolve_builtin_excluding(skeleton, &BTreeSet::new()))
484        .unwrap_or_default()
485}
486
487/// Resolve a profile by name, or auto-detect for `"auto"`.
488pub fn resolve_named(skeleton: &Skeleton, profile: &str) -> Option<ResolvedRoles> {
489    if profile != "auto"
490        && !builtin_profiles()
491            .iter()
492            .any(|candidate| candidate.name == profile)
493    {
494        return None;
495    }
496    let resolved = resolve_named_detailed(skeleton, profile);
497    (!resolved.outcome.is_ambiguous()).then_some(resolved)
498}
499
500/// Resolve a configured rig profile and apply inline role overrides.
501///
502/// Inline role bindings win over bindings from the named or auto-detected
503/// profile. They remain exact, while built-in bindings use only the documented
504/// ASCII case-tolerant fallback. A collision is refused instead of selecting a
505/// role by configuration or profile declaration order.
506pub fn resolve_configured_roles(skeleton: &Skeleton, rig: &RigConfig) -> ResolvedRoles {
507    let mut explicit = BTreeMap::new();
508    let mut explicit_coverage_gap = false;
509    let mut inline_contributed = false;
510    for (&role, name) in &rig.roles {
511        if let Some(id) = skeleton.bones.iter().position(|bone| bone.name == *name) {
512            inline_contributed = true;
513            explicit.insert(
514                role,
515                ResolvedBone {
516                    id,
517                    name: skeleton.bones[id].name.clone(),
518                    policy: RoleResolutionPolicy::Explicit,
519                },
520            );
521        } else {
522            explicit_coverage_gap = true;
523        }
524    }
525    if injective_outcome(&explicit).is_some() {
526        return unresolved("unknown", ResolutionOutcome::RoleCollision);
527    }
528    let overridden_roles: BTreeSet<_> = rig.roles.keys().copied().collect();
529    let base = if rig.profile == "auto" {
530        detect_profile_excluding(skeleton, &overridden_roles, explicit.len())
531    } else {
532        builtin_profiles()
533            .iter()
534            .find(|profile| profile.name == rig.profile)
535            .map(|profile| profile.resolve_builtin_excluding(skeleton, &overridden_roles))
536            .unwrap_or_default()
537    };
538    if base.outcome.is_ambiguous() {
539        return unresolved("unknown", base.outcome);
540    }
541    let base_contributed = !base.is_empty();
542    let mut map = base.map;
543    map.extend(explicit);
544    if injective_outcome(&map).is_some() {
545        return unresolved("unknown", ResolutionOutcome::RoleCollision);
546    }
547    let profile = match (base_contributed, inline_contributed) {
548        (false, false) => "unknown".into(),
549        (false, true) => "custom".into(),
550        (true, false) => base.profile,
551        (true, true) => format!("{}+custom", base.profile),
552    };
553    let outcome = if explicit_coverage_gap
554        || (base.outcome == ResolutionOutcome::Coverage
555            && (base_contributed || rig.profile != "auto"))
556        || map.is_empty()
557    {
558        ResolutionOutcome::Coverage
559    } else {
560        ResolutionOutcome::Resolved
561    };
562    ResolvedRoles {
563        profile,
564        map,
565        outcome,
566    }
567}