feat: implement automated 3D character pipeline with retargeting and rig management tools

This commit is contained in:
Nicholas Butzke
2026-07-24 20:52:35 -04:00
parent afc954e129
commit 374d9f9822
19 changed files with 3220 additions and 221 deletions
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Work out which bone on one rig means the same thing as which bone on another.
This is the piece that lets us STOP throwing away a character's own skeleton.
The old pipeline discarded any foreign rig (tools/strip_rig.py) because
merge_animations.py retargeted by exact bone NAME, so a rig that named things
differently produced a rest-pose statue. Rebinding the mesh to the library
skeleton then destroyed everything an artist had done: per-part weights, skirt
and hair bone chains, limb twist bones. Solving the naming problem here is what
makes keeping the original rig possible.
Roles are resolved STRUCTURALLY wherever a name would lie:
* `hips` is the lowest common ancestor of both thighs and the head, not
whatever is called "hips". Rigify calls it `DEF-spine`; Mixamo calls it
`mixamorig:Hips`; both land on the same bone this way.
* The spine chain is walked from the hips upward, refusing to turn down a
limb or a cosmetic chain (hair/skirt/face). The bone it ends on is the
head. That matters because a stock Rigify rig has NO bone with "head" in
its name — the head is `DEF-spine.006`.
* Chains of different length are matched by normalised position along the
chain, so a 4-bone torso drives a 3-bone one and vice versa.
Only bones the CLIPS need are mapped. Everything else on the character rig —
skirt chains, hair chains, twist bones, face bones — is deliberately left
unmapped so it rests relative to its parent and is free to be driven by
secondary motion at runtime. That is the whole point: the locomotion library
animates the body, physics animates the cloth.
"""
import re
# Names that are never part of the body proper. Walking the spine must not turn
# down one of these, and they must never claim a limb role.
#
# Matched as whole NAME TOKENS, never as substrings. A plain `"ear" in name`
# test marks every `DEF-forearm.L` cosmetic — which silently cost both rigs
# their forearms and is exactly the class of bug this file exists to avoid.
COSMETIC = ("hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
"scarf", "sleeve", "breast", "bust", "ear", "horn", "wing",
"face", "cheek", "nose", "mouth", "eye", "brow", "jaw", "tongue",
"teeth", "tooth", "lip", "chin", "accessory", "prop", "weapon",
"bell", "strap", "belt", "buckle", "feather", "antenna")
# role -> ordered alternative stems. Ordered because "leg" must not win before
# "upleg" has had its chance: Mixamo's LeftUpLeg is a thigh and its LeftLeg is
# a shin, so the more specific spelling has to be tested first.
LIMB_ROLES = {
"thigh": ("upleg", "upperleg", "thigh", "leg_upper", "upper_leg", "hip"),
"shin": ("lowerleg", "lowleg", "shin", "calf", "knee", "leg_lower", "leg"),
"foot": ("foot", "ankle"),
"toe": ("toebase", "toe", "ball"),
"shoulder": ("shoulder", "clavicle", "collar"),
"upper_arm": ("upperarm", "upper_arm", "arm_upper", "armupper", "arm"),
"forearm": ("forearm", "lowerarm", "lowarm", "arm_lower", "elbow"),
"hand": ("hand", "wrist"),
}
# Longest-first inside each role, so "upperarm" is tried before "arm".
LIMB_ORDER = ["toe", "foot", "shin", "thigh", "shoulder", "hand", "forearm", "upper_arm"]
_PREFIXES = re.compile(
r"^(def[-_]|org[-_]|mch[-_]|ctrl[-_]|mixamorig\d*[:_]|bip\d*[-_ ]|"
r"j_bip_[clr]_|j_sec_[clr]_|valvebiped\.|bone_|b_)", re.I)
# The glTF importer appends _<node index> to every bone name; strip it so
# `DEF-spine.006_2` reads as `DEF-spine.006`.
_GLTF_SUFFIX = re.compile(r"_\d+$")
def strip_gltf_suffix(name: str) -> str:
return _GLTF_SUFFIX.sub("", name)
def canon(name: str) -> str:
"""Bone name reduced to a comparable stem: no rig prefix, no separators."""
n = strip_gltf_suffix(name).lower()
while True:
stripped = _PREFIXES.sub("", n)
if stripped == n:
break
n = stripped
return re.sub(r"[^a-z0-9]", "", n)
def side_of(name: str):
"""'L', 'R' or None. Checked on the ORIGINAL name so `.L` survives."""
n = strip_gltf_suffix(name).lower()
if re.search(r"(^|[._\- ])l($|[._\- 0-9])", n) or "left" in n:
return "L"
if re.search(r"(^|[._\- ])r($|[._\- 0-9])", n) or "right" in n:
return "R"
return None
def tokens(name: str):
"""Name split into alphabetic words: `DEF-hair.L.001` -> def, hair, l."""
return [t for t in re.split(r"[^a-z]+", strip_gltf_suffix(name).lower()) if t]
def is_cosmetic(name: str) -> bool:
"""Whole-token match only — see the note on COSMETIC."""
return any(t in COSMETIC for t in tokens(name))
def is_segment_of(name: str, all_names: set) -> bool:
"""True for twist/segment bones like `DEF-upper_arm.L.001`.
Rigify subdivides a limb into a main bone plus numbered twist bones. Those
must never claim the limb's role — the clip would drive the twist bone and
the real limb would stay put. Detected by construction rather than by a
name list: strip a trailing `.001` and see whether the parent spelling is
itself a bone on this rig.
"""
base = strip_gltf_suffix(name)
m = re.match(r"^(.*)\.(\d{3})$", base)
if not m or m.group(2) == "000":
return False
stem = m.group(1)
return any(strip_gltf_suffix(n) == stem for n in all_names)
class RigRoles:
"""Resolved anatomy of one armature."""
def __init__(self, arm):
self.arm = arm
self.bones = {b.name: b for b in arm.data.bones}
self.names = set(self.bones)
self.limb = {} # ("thigh", "L") -> bone name
self.spine = [] # hips-exclusive chain, ordered, ending on the head
self.hips = None
self.head = None
self.neck = None
self._resolve_limbs()
self._resolve_spine()
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
continue
side = side_of(name)
if side is None:
continue
stem = canon(name)
for role in LIMB_ORDER:
if (role, side) in self.limb:
continue
if any(alt.replace("_", "") in stem for alt in LIMB_ROLES[role]):
self.limb[(role, side)] = name
break
# ---------------------------------------------------------------- spine
def _resolve_spine(self):
"""Find the trunk as the LONGEST chain of non-limb, non-cosmetic bones.
Deliberately not "lowest common ancestor of the two thighs", which is
the obvious thing and is wrong on the rigs we actually get. A Rigify
DEF-rig exports its chain ROOTS parented straight to the armature root
(Rigify drives them by constraint, not by hierarchy), so on Taila the
thighs, shoulders, skirt and hair all hang off `rootJoint` and the LCA
is that root — which makes the hips the root bone and the spine one
bone long. Taking the longest trunk chain instead reads the same rig
correctly whether or not its hierarchy survived export.
"""
blocked = set(self.limb.values())
def candidate(name):
if name in blocked or is_cosmetic(name):
return False
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
for t in tokens(name))
def walk(start):
chain = [start]
cur = self.bones[start]
while True:
nxt = [c for c in cur.children if candidate(c.name)]
if not nxt:
break
# If the walk forks, follow whichever branch reaches highest —
# the torso continues upward, a stray nub does not.
cur = max(nxt, key=self._branch_height)
chain.append(cur.name)
return chain
best = []
for name in self.names:
if not candidate(name):
continue
parent = self.bones[name].parent
if parent is not None and candidate(parent.name):
continue # not the base of a chain
chain = walk(name)
if len(chain) > len(best):
best = chain
if not best:
return
self.hips = best[0]
self.spine = best[1:]
if self.spine:
self.head = self.spine[-1]
named = [n for n in self.spine if "neck" in tokens(n)]
self.neck = named[0] if named else (
self.spine[-2] if len(self.spine) > 1 else None)
def _branch_height(self, bone):
"""How high this branch reaches, in WORLD space.
Measured on bone HEADS. glTF has no concept of a bone tail — joints are
just nodes — so the tails Blender's importer shows are synthesised, and
on Taila every skirt bone comes back with an identical 0.78 m tail.
Heads are the only authored positions here.
World space, not armature-local: the importer leaves the Y-up-to-Z-up
correction on the armature OBJECT, so a local-space test picked the
pelvis over the spine and the walk stopped one bone in.
"""
mw = self.arm.matrix_world
best = (mw @ bone.matrix_local.translation).z
for c in bone.children:
best = max(best, self._branch_height(c))
return best
# --------------------------------------------------------------- report
def missing_core(self):
need = [("thigh", "L"), ("thigh", "R"), ("shin", "L"), ("shin", "R"),
("foot", "L"), ("foot", "R"), ("upper_arm", "L"), ("upper_arm", "R"),
("forearm", "L"), ("forearm", "R"), ("hand", "L"), ("hand", "R")]
miss = [f"{r}.{s}" for r, s in need if (r, s) not in self.limb]
if not self.hips:
miss.append("hips")
if not self.head:
miss.append("head")
return miss
def describe(self):
lines = [f" hips {self.hips}", f" spine {' -> '.join(self.spine)}"]
for key in sorted(self.limb):
lines.append(f" {key[0]}.{key[1]:<10s} {self.limb[key]}")
return "\n".join(lines)
def _match_chains(src_chain, tgt_chain):
"""Pair up two ordered chains of possibly different length.
Each target bone takes the source bone nearest it in NORMALISED position,
so a 4-bone torso can be driven by a 3-bone one. Because the retarget
copies ABSOLUTE world orientation rather than composing local rotations,
two target bones sharing one source bone simply end up parallel — the chain
still finishes where the source says it does, it does not double the bend.
"""
pairs = []
if not src_chain or not tgt_chain:
return pairs
for i, tgt in enumerate(tgt_chain):
t = (i + 0.5) / len(tgt_chain)
j = min(range(len(src_chain)),
key=lambda k: abs((k + 0.5) / len(src_chain) - t))
pairs.append((src_chain[j], tgt))
return pairs
def build_map(src: RigRoles, tgt: RigRoles, overrides: dict = None) -> dict:
"""target bone name -> source bone name, for the core body only."""
mapping = {}
if src.hips and tgt.hips:
mapping[tgt.hips] = src.hips
# Split both spines at the neck so a head never drives a chest, then match
# torso-to-torso and neck-to-neck by position.
def split(roles):
chain = roles.spine
if not chain:
return [], [], None
head = chain[-1]
rest = chain[:-1]
if roles.neck and roles.neck in rest:
i = rest.index(roles.neck)
return rest[:i], rest[i:], head
return rest, [], head
s_torso, s_neck, s_head = split(src)
t_torso, t_neck, t_head = split(tgt)
for a, b in _match_chains(s_torso, t_torso):
mapping[b] = a
for a, b in _match_chains(s_neck or s_torso[-1:], t_neck):
mapping[b] = a
if s_head and t_head:
mapping[t_head] = s_head
for key, tgt_name in tgt.limb.items():
src_name = src.limb.get(key)
if src_name:
mapping[tgt_name] = src_name
# Fingers and anything else that happens to share a spelling: map by
# canonical stem + side. Cheap, and it makes a shared trigger-finger pose
# come across when both rigs have fingers.
src_by_stem = {}
for n in src.names:
src_by_stem.setdefault((canon(n), side_of(n)), n)
for n in tgt.names:
if n in mapping or is_cosmetic(n) or is_segment_of(n, tgt.names):
continue
hit = src_by_stem.get((canon(n), side_of(n)))
if hit:
mapping[n] = hit
if overrides:
for tgt_name, src_name in overrides.items():
if src_name is None:
mapping.pop(tgt_name, None)
else:
mapping[tgt_name] = src_name
return mapping