fix(pipeline): stand the character up before scaling; reach bones by role

Fixes the two root causes behind four of the seven reported breakages, and
rejects the source that cannot be fixed.

STAND UP FIRST. flatten_and_scale() now derives the up axis from the
skeleton and rotates the model upright before measuring anything. Aria and
Momo are correct: 1.75 m tall, 1.43 x 0.41 and 1.52 x 0.39 across, verified
by render.

The up vector is measured from the FEET to the HIPS, not from the hips to
the head. The head is not a reliable landmark — the spine walk ends on the
last non-cosmetic bone in the chain, which on a rig with a facial skeleton
can sit BELOW the hips. Momo's did, so the first cut of this fix stood her
neatly on her head: right size, right proportions, upside down. Feet cannot
be mistaken.

REACH BONES BY ROLE. SkinnedPlayerModel gained _role_bone(), and set_weapon
uses it. Four characters could not hold a gun because one hardcoded lookup
knew three spellings and their hands are called "Right wrist" and
"J_Bip_R_Hand" — both resolved perfectly in the sidecar the whole time.

HIKARI IS REJECTED. She now fails the gate: her feet and spine disagree
about which way is up, so the stand-up correction cannot resolve her
either, on top of zero-length cosmetic bones and a second armature that was
smuggling its own clips into the export. That is not a tuning problem, it
is a file that has been through two toolchains. De-registered and removed
rather than shipped broken — which is what the gate is for.

Six GLB skins remain, all passing. Smoke 0 failures, 11/11 movement tests.

Still open, recorded in the skill: kiyoko faces backwards, miku's grown
hair stretches under animation, the mannequin's rifle hold does not
convince, and taila's front skirt clipping.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-26 16:06:51 -04:00
co-authored by Claude Opus 5
parent 2b3e29dd30
commit 27c4117c25
16 changed files with 2025 additions and 2673 deletions
+52 -2
View File
@@ -335,7 +335,7 @@ def rebuild_hierarchy(arm, roles):
# ----------------------------------------------------------------- normalize
def flatten_and_scale(arm, meshes, target_height):
def flatten_and_scale(arm, meshes, target_height, roles=None):
"""Bake the import hierarchy away and set the character's real-world size.
Sketchfab wraps everything in scaled/rotated empties. Left in place they
@@ -363,6 +363,56 @@ def flatten_and_scale(arm, meshes, target_height):
apply_all()
# STAND THE CHARACTER UP before measuring anything.
#
# The scale below normalises the bounding box along Z because Z is up in
# Blender. For a model that arrives lying along another axis that measures
# the character's THICKNESS — about 0.25 m — so it gets scaled by ~7 and left
# on its back. One assumption, both symptoms, and invisible afterwards
# because the exporter maps Blender Z to glTF Y: "is the height 1.75" comes
# out true on a character who is 7.5 m tall lying down.
#
# Which way is up is not a convention to assume, it is a property of the
# skeleton: the head is above the hips. Snapped to the nearest axis rather
# than aligned exactly, so a character with a slight lean in their rest pose
# is stood up, not straightened.
# Measured from the FEET to the HIPS, not from the hips to the head.
#
# The head is not a reliable landmark. The spine walk ends on whatever the
# last non-cosmetic bone in the chain is, and on a rig with a facial skeleton
# that can be a bone sitting BELOW the hips — which points this vector
# downwards and stands the character neatly on her head. Momo did exactly
# that. Feet cannot be mistaken: they are the bottom of a standing character
# on every rig, and foot.L/R resolve on every source met so far.
if roles is not None and roles.hips:
hips_b = arm.data.bones.get(roles.hips)
feet = [arm.data.bones.get(roles.limb.get(("foot", s), ""))
for s in ("L", "R")]
feet = [f for f in feet if f]
ref = None
if feet:
ref = sum((f.head_local for f in feet), Vector()) / len(feet)
elif roles.head and arm.data.bones.get(roles.head):
# No feet — fall back to the head, and accept the risk above.
ref = hips_b.head_local - (
arm.data.bones[roles.head].head_local - hips_b.head_local)
if hips_b and ref is not None:
d = hips_b.head_local - ref
k = max(range(3), key=lambda i: abs(d[i]))
src = Vector((0.0, 0.0, 0.0))
src[k] = 1.0 if d[k] > 0 else -1.0
up = Vector((0.0, 0.0, 1.0))
if src.dot(up) < 0.999:
axis = src.cross(up)
if axis.length < 1e-6:
axis = Vector((1.0, 0.0, 0.0)) # upside down
R = Matrix.Rotation(src.angle(up), 4, axis.normalized())
for obj in [arm] + meshes:
obj.matrix_world = R @ obj.matrix_world
apply_all()
print(f"Stood the character up: feet->hips ran along "
f"{'XYZ'[k]}{'+' if d[k] > 0 else '-'}, rotated to Z+")
lo = Vector((1e9, 1e9, 1e9))
hi = -lo.copy()
for m in meshes:
@@ -1047,7 +1097,7 @@ def main():
rebuild_hierarchy(arm, roles)
subdivide_cloth_panels(arm, meshes, roles)
unbind_cloth_from_legs(arm, meshes, roles)
flatten_and_scale(arm, meshes, TARGET_HEIGHT)
flatten_and_scale(arm, meshes, TARGET_HEIGHT, roles)
fix_unlit_materials(meshes)
roles = RigRoles(arm) # rest positions moved; re-read