This commit is contained in:
Nicholas Butzke
2026-08-09 01:31:44 -04:00
parent 922983429e
commit 9358746582
43 changed files with 4324 additions and 481 deletions
+167 -10
View File
@@ -129,6 +129,13 @@ def is_cosmetic(name: str) -> bool:
for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True
# Mixamo-style face bones often put the side first and glue the
# cosmetic role behind it: ``LeftEye`` / ``RightEye``. Without
# this mirror case the spine walker follows the eye above Head and
# resolves a facial joint as the anatomical head.
prefix = t[:-len(c)] if t.endswith(c) else ""
if prefix in ("l", "r", "left", "right"):
return True
return False
@@ -149,6 +156,39 @@ def is_segment_of(name: str, all_names: set) -> bool:
return any(strip_gltf_suffix(n) == stem for n in all_names)
def is_twist_helper(name: str) -> bool:
"""True for a deformation helper that must not own an anatomy role.
Auto-Rig Pro exports the actual upper/lower limb as ``*_stretch`` and a
sibling ``*_twist`` bone for roll distribution. The old resolver saw the
longer word "forearm" in the twist helper first and drove that helper as
the whole limb, leaving the elbow chain folded. A twist bone is never the
joint itself, regardless of naming convention.
"""
return "twist" in tokens(name) or "roll" in tokens(name)
def is_attachment_helper(name: str) -> bool:
"""True for armor/IK/control bones whose name embeds an anatomy word.
Several animation libraries ship their visible equipment in the same
armature. Names such as ``footcover.L``, ``kneepad.R``,
``forearmslider.R`` and ``ik_hand_r`` all matched a body role just as well
as the real foot/knee/forearm/hand. Set iteration then made the winner
arbitrary, which is why lateral movement folded and the wall-run drove
armor controls instead of limbs.
"""
stem = canon(name)
helpers = ("cover", "pad", "plate", "slider", "piston", "adjust",
"scaler", "cannon", "module", "target", "control", "helper",
"leaf", "end")
if any(helper in stem for helper in helpers):
return True
ts = tokens(name)
return any(token in ("ik", "ikt", "ctrl") for token in ts) \
or stem.startswith("ik") or stem.endswith("ik") or stem.endswith("ikt")
class RigRoles:
"""Resolved anatomy of one armature."""
@@ -162,11 +202,78 @@ class RigRoles:
self.hips = None
self.head = None
self.neck = None
self._seed_mmd_standard_limbs()
self._resolve_limbs()
self._fill_limbs_by_anatomy()
self._resolve_spine()
self._override_mmd_standard_trunk()
self._resolve_fingers()
# ---------------------------------------------------------- standard MMD
def _exact_bone(self, *authored_names):
"""Return an exact source bone, allowing only glTF's numeric suffix.
Japanese MMD anatomy names do not transliterate into the English token
families used by Mixamo, Rigify and VRoid. They are nevertheless a
stable published convention, so exact matching is safer than trying to
infer them through MMD's hundreds of IK, physics and adjustment bones.
"""
for authored in authored_names:
for actual in self.names:
if strip_gltf_suffix(actual) == authored:
return actual
return None
def _seed_mmd_standard_limbs(self):
"""Seed the canonical body roles used by standard Japanese MMD rigs."""
# Require multiple unmistakable MMD body joints before applying the
# table. A prop that happens to contain one Japanese label must not be
# mistaken for a humanoid skeleton.
if not (self._exact_bone("左腕", "腕.L")
and self._exact_bone("右腕", "腕.R")
and self._exact_bone("左足", "足.L")
and self._exact_bone("右足", "足.R")):
return
names = {
("shoulder", "L"): ("左肩", "肩.L"),
("upper_arm", "L"): ("左腕", "腕.L"),
("forearm", "L"): ("左ひじ", "ひじ.L"),
("hand", "L"): ("左手首", "手首.L"),
("shoulder", "R"): ("右肩", "肩.R"),
("upper_arm", "R"): ("右腕", "腕.R"),
("forearm", "R"): ("右ひじ", "ひじ.R"),
("hand", "R"): ("右手首", "手首.R"),
("thigh", "L"): ("左足", "足.L"),
("shin", "L"): ("左ひざ", "ひざ.L"),
("foot", "L"): ("左足首", "足首.L"),
("toe", "L"): ("左つま先", "つま先.L"),
("thigh", "R"): ("右足", "足.R"),
("shin", "R"): ("右ひざ", "ひざ.R"),
("foot", "R"): ("右足首", "足首.R"),
("toe", "R"): ("右つま先", "つま先.R"),
}
for role, authored in names.items():
actual = self._exact_bone(*authored)
if actual:
self.limb[role] = actual
def _override_mmd_standard_trunk(self):
"""Keep the spine walker out of MMD eye, hair and control chains."""
hips = self._exact_bone("下半身")
head = self._exact_bone("")
upper = self._exact_bone("上半身")
if not (hips and head and upper):
return
self.hips = hips
self.spine = []
for authored in ("上半身", "上半身1", "上半身2", "", ""):
actual = self._exact_bone(authored)
if actual and actual not in self.spine:
self.spine.append(actual)
self.neck = self._exact_bone("")
self.head = head
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
@@ -192,23 +299,40 @@ class RigRoles:
"""
claims = []
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
if (is_cosmetic(name) or is_segment_of(name, self.names)
or is_twist_helper(name) or is_attachment_helper(name)):
continue
side = side_of(name)
if side is None:
continue
stem = canon(name)
for role in LIMB_ORDER:
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role]
if alt.replace("_", "") in stem]
# Mixamo glues the parent role into every finger name
# (``LeftHandIndex3``). A substring-only resolver therefore
# let an arbitrarily ordered fingertip claim ``hand.L/R`` and
# made all hand-relative equipment/IK disappear at runtime.
# Finger segments are descendants of a hand, never the hand.
if role == "hand" and any(
digit_stem in stem
for digit_stems in DIGITS.values()
for digit_stem in digit_stems):
continue
alternatives = [alt.replace("_", "")
for alt in LIMB_ROLES[role]]
hits = [(len(alt), alternative_index)
for alternative_index, alt in enumerate(alternatives)
if alt in stem]
if hits:
claims.append((len(max(hits, key=len)), role, side, name))
hit_length, alternative_index = max(
hits, key=lambda hit: (hit[0], -hit[1]))
claims.append((hit_length, alternative_index,
role, side, name))
# Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles
# equally well still resolves the way the list intends.
order = {role: i for i, role in enumerate(LIMB_ORDER)}
claims.sort(key=lambda c: (-c[0], order[c[1]]))
claims.sort(key=lambda c: (-c[0], c[1], order[c[2]]))
used = set()
for _, role, side, name in claims:
for _, _alternative, role, side, name in claims:
if (role, side) in self.limb or name in used:
continue
self.limb[(role, side)] = name
@@ -234,7 +358,8 @@ class RigRoles:
b = b.parent
if b is None or b.name not in self.names:
break
if is_cosmetic(b.name) or is_segment_of(b.name, self.names):
if (is_cosmetic(b.name) or is_segment_of(b.name, self.names)
or is_twist_helper(b.name) or is_attachment_helper(b.name)):
continue # a twist segment is not a joint of its own
chain.append(b.name)
# chain is now [shin, thigh, hips, ...] going up from the foot.
@@ -298,7 +423,14 @@ class RigRoles:
blocked = set(self.limb.values())
def candidate(name):
if name in blocked or is_cosmetic(name):
if name in blocked or is_cosmetic(name) or is_attachment_helper(name):
return False
# Exporters commonly append terminal marker joints such as
# ``HeadTop_End``. They carry no anatomy or skin and must not turn
# the marker into the resolved head (which lengthens every neck
# retarget and produces the characteristic folded torso).
if ("end" in tokens(name) or "leaf" in tokens(name)
or canon(name).endswith("core")):
return False
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
for t in tokens(name))
@@ -329,8 +461,33 @@ class RigRoles:
if not best:
return
self.hips = best[0]
self.spine = best[1:]
# Auto-Rig Pro exports ``c_traj -> root -> spine_01`` and hangs both
# thighs from that weighted ``root``. In that specific layout root is
# the pelvis, not a disposable scene control. Promote it only when the
# trunk walk actually starts on a named spine bone; this deliberately
# does not promote the animation libraries' ``root -> hips -> spine``.
base = self.bones[best[0]]
parent = base.parent
base_tokens = tokens(base.name)
thighs = [self.limb.get(("thigh", side)) for side in ("L", "R")]
def descends_from(name, ancestor):
bone = self.bones.get(name)
while bone is not None:
if bone == ancestor:
return True
bone = bone.parent
return False
promote_parent = (
parent is not None
and any("spine" in token for token in base_tokens)
and any(token in ("root", "pelvis", "hips")
for token in tokens(parent.name))
and all(name and descends_from(name, parent) for name in thighs)
)
self.hips = parent.name if promote_parent else best[0]
self.spine = best if promote_parent else best[1:]
if self.spine:
self.head = self.spine[-1]
named = [n for n in self.spine if "neck" in tokens(n)]