feat(characters): four VRoid characters imported and selectable

Kiyoko, Hikari, Aria and Momo, all CC-BY from Sketchfab, licences recorded
beside each skin. Seven GLB characters selectable now.

  kiyoko  13 meshes  20 cloth chains (62 bones)  0.0% cross-leg bleed
  aria    15 meshes  15 cloth chains (37 bones)  0.0%
  momo     5 meshes   9 cloth chains (35 bones)  0.0%
  hikari  13 meshes  10 cloth chains (37 bones)  0.2%, 12 twist bones

These are the first characters imported that were NOT authored against the
library's own bone spelling, and every one of them broke something that
had been quietly wrong all along. All four failures were in code that
guesses anatomy from names, which is exactly what tools/rig_map.py exists
to stop doing:

- LIMB ROLES went to the first role in LIMB_ORDER that matched at all, so
  shin's catch-all "leg" claimed UpperLeg before thigh's exact "upperleg"
  was ever consulted, and the thigh went unassigned. The result depended
  on the order bones arrived in. Claims are now granted longest-stem
  first. This also broke Mixamo (LeftUpLeg/LeftLeg) and had simply never
  been hit, because every character so far used Rigify DEF- names.

- Names cannot settle thigh-vs-shin at all. A bare "leg" is the SHIN on
  Mixamo and the THIGH on a rig whose shin is "knee" — both common, same
  token, opposite bones. RigRoles now walks the leg from the foot upward
  and fills in whatever the names could not, stepping over twist bones.

- verify_character.py looked for legs by the substrings "thigh"/"shin",
  which VRoid spells UpperLeg/LowerLeg. It declared every locomotion clip
  static while the legs animated perfectly, and the cross-leg bleed check
  found no leg vertex groups at all and passed vacuously. Two green-
  looking lies from one missing lookup; both now read the sidecar's
  resolved roles.

- The cosmetic/spring classifier matched whole tokens only, so Momo's
  HairFL / HairFR / HairF_Top tokenised to "hairfl" and matched nothing.
  She imported with six chains, all bust, and no hair. Both classifiers
  now share one rule that also accepts a two-character positional suffix,
  which is short enough that "forearm" and "earring" are still untouched.

Two more pipeline fixes:

- A source model's own clips leaked into the export. Clearing bpy.data
  .actions before the library import is not enough — hikari carried two on
  a second armature's NLA tracks, and NLA_TRACKS export mode ships
  anything in a track anywhere in the file. They export as rest-pose
  statues. Now everything not retargeted is stripped from every object.

- verify_character.py gained a check for cloth chains with no measurable
  extent. A chain whose bones are zero-length is dropped by the runtime
  and simulates nothing, while the sidecar still cheerfully reports it.

Known: hikari's ten cloth chains are all zero-length and her collider fit
found nothing, so her costume does not simulate — her rig has been through
two toolchains and its cosmetic bones are empty terminators. She animates
correctly otherwise. The new check now reports this instead of hiding it.

tools/retarget.py also carries local working-tree changes that predate
this session.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-26 13:45:27 -04:00
co-authored by Claude Opus 5
parent cd0d1b2d99
commit fc9e6f4275
62 changed files with 8315 additions and 15 deletions
+90 -6
View File
@@ -97,8 +97,25 @@ def tokens(name: str):
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))
"""Whole-token match, plus a short positional suffix — see COSMETIC.
A bare token match misses the very common habit of gluing a position onto
the word: DANDADAN's hair bones are HairFL / HairFR / HairF_Top (front-left,
front-right, front-top), which tokenise to "hairfl" and match nothing, so
her hair was not detected as hair at all and none of it moved.
Only a SHORT remainder counts — two characters at most, which covers l/r/f/b
and the fl/fr/bl/br pairs and nothing else. That is what keeps the original
rule intact: "forearm" still does not begin with any cosmetic stem, and
"earring" has a four-character remainder and is not swept in by accident.
"""
for t in tokens(name):
if t in COSMETIC:
return True
for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True
return False
def is_segment_of(name: str, all_names: set) -> bool:
@@ -131,11 +148,33 @@ class RigRoles:
self.head = None
self.neck = None
self._resolve_limbs()
self._fill_limbs_by_anatomy()
self._resolve_spine()
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
"""Claim limb roles by the MOST SPECIFIC spelling that matches.
Not by walking LIMB_ORDER and taking the first role that matches at all.
That let a catch-all stem in an early role beat an exact one in a later
role, and the result depended on the order bones happened to arrive in:
J_Bip_L_UpperLeg canon "upperleg"
shin matches "leg" <- claimed it, because shin is first
thigh matches "upperleg" <- never consulted
J_Bip_L_LowerLeg canon "lowerleg"
shin already taken; nothing in thigh matches -> unclaimed
so a VRoid rig resolved with no thighs at all and the build stopped. The
same trap catches Mixamo, whose LeftUpLeg is a thigh and LeftLeg a shin —
it stayed hidden only because every character imported so far used the
Rigify DEF- spellings, where each role has an exact stem of its own.
Every plausible claim is collected, then granted longest-stem first, so
"upperleg" always beats "leg" whatever order the bones are in.
"""
claims = []
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
continue
@@ -144,11 +183,56 @@ class RigRoles:
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
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role]
if alt.replace("_", "") in stem]
if hits:
claims.append((len(max(hits, key=len)), 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]]))
used = set()
for _, role, side, name in claims:
if (role, side) in self.limb or name in used:
continue
self.limb[(role, side)] = name
used.add(name)
# A leg is thigh -> shin -> foot, whatever the bones are called. Walk it.
#
# Names alone cannot settle this and it is not a matter of adding more
# spellings. A bare "leg" is the SHIN on a Mixamo rig (whose thigh is
# "UpLeg") and the THIGH on a rig whose shin is called "knee" — the same
# token means opposite bones, and both rigs are common. What does not vary
# is the skeleton: the foot's parent is the shin and the shin's parent is
# the thigh.
#
# Only ever FILLS IN what the names could not resolve; a confident name
# match is never overridden. Twist bones are stepped over, since a rig may
# put one between the thigh and the shin.
def _fill_limbs_by_anatomy(self):
for side in ("L", "R"):
chain = []
b = self.bones.get(self.limb.get(("foot", side), ""))
while b is not None and len(chain) < 6:
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):
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.
for role, up in (("shin", 0), ("thigh", 1)):
if (role, side) in self.limb or up >= len(chain):
continue
name = chain[up]
# Never claim a bone another role already owns, and never claim
# the trunk — a two-bone leg would otherwise take the hips.
if name in self.limb.values():
continue
if side_of(name) != side:
continue
self.limb[(role, side)] = name
# ---------------------------------------------------------------- spine