1use crate::config::RigConfig;
8use crate::model::{BoneId, Skeleton};
9use serde::Deserialize;
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum Role {
17 Root,
19 Hips,
21 Spine,
23 Head,
25 LeftFoot,
27 RightFoot,
29 LeftToe,
31 RightToe,
33 LeftHand,
35 RightHand,
37}
38
39impl Role {
40 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum RoleResolutionPolicy {
62 Exact,
65 AsciiCaseInsensitive,
68 Explicit,
70}
71
72impl RoleResolutionPolicy {
73 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum ResolutionOutcome {
87 Resolved,
89 Coverage,
92 AmbiguousExactMatch,
94 AmbiguousFoldedMatch,
96 RoleCollision,
98 AmbiguousProfile,
100}
101
102impl ResolutionOutcome {
103 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#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum NameMatcher {
125 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#[derive(Debug, Clone)]
149pub struct RigProfile {
150 pub name: &'static str,
152 pub bindings: Vec<(Role, NameMatcher)>,
154}
155
156#[derive(Debug, Clone)]
158pub struct ResolvedRoles {
159 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 pub fn get(&self, role: Role) -> Option<BoneId> {
176 self.map.get(&role).map(|bone| bone.id)
177 }
178
179 pub fn policy(&self, role: Role) -> Option<RoleResolutionPolicy> {
181 self.map.get(&role).map(|bone| bone.policy)
182 }
183
184 pub const fn outcome(&self) -> ResolutionOutcome {
186 self.outcome
187 }
188
189 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 pub fn len(&self) -> usize {
199 self.map.len()
200 }
201
202 pub fn is_empty(&self) -> bool {
204 self.map.is_empty()
205 }
206
207 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 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 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
369pub 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
421pub 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
467pub 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
474pub 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
487pub 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
500pub 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}