fix(pipeline): check that a character is CORRECT, not merely well-formed
Seven characters shipped "All checks passed" and four were visibly broken
in game — lying on their backs at seven times scale, facing backwards, or
holding a gun that floated near their chest. Nothing in the suite was
wrong; it just never asked the questions that mattered. That distinction
is the whole lesson, and it is now written down in the skill as
references/failure-modes.md, generalised per failure.
Two root causes are measured and certain:
- flatten_and_scale() normalises the bounding box along Blender Z because
Z is up. For a model that arrives lying along Y that measures the
character's THICKNESS, so it scales by ~7 and leaves them on their back.
One assumption, both symptoms. The trap is that the normalised number
always comes out right — the exporter maps Blender Z to glTF Y, so "is
the height 1.75" passes on a character who is 7.5 m tall lying down.
All three casualties are VRM files that went through a Blender
round-trip and came back with a baked axis rotation.
- SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded
spellings, which match none of the four non-Rigify rigs — their hands
resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand".
When it misses, the weapon is parented to the model root at a fixed
chest offset, so it is not attached to the character at all. Same class
of bug as the leg check that name-matched thigh/shin. rig_map.py exists
so nothing downstream has to guess a bone name; only some consumers read
the roles it publishes.
Three new hard checks, none needing more than the vertices and the
sidecar:
character stands up in world space — against WORLD up, not against the
model's own proportions. "Is the spine the longest axis?" catches
nothing: a model rotated as a whole is internally consistent and
passes it comfortably.
character is a plausible size / height
every role the runtime needs is resolved
They separate the four good characters from the three broken ones on the
first run. Also fixed the posture measurement to read vertices rather than
object.bound_box, which is cached and still stale right after an import —
it reported a 1.75 m character as 1.18 m tall.
Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by
two independent mechanisms and verified by neither), miku's grown hair
chains stretch under animation (generated chains are never validated
against the geometry they drive), and the mannequin's rifle hold does not
convince despite resolving correctly.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2ce2175d99
commit
2b3e29dd30
@@ -38,6 +38,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
@@ -171,6 +172,73 @@ check(len(meshes) > 1, "model keeps its per-part meshes",
|
||||
if len(meshes) == 1 else ""),
|
||||
advisory=True)
|
||||
|
||||
# ------------------------------------------------------------------- posture
|
||||
#
|
||||
# Is this character the right size and the right way up?
|
||||
#
|
||||
# Nothing asked before, and it is the single cheapest check in the file. The
|
||||
# scale step normalises the bounding box along Blender's Z because Z is up — for
|
||||
# a model that arrives lying along Y that measures the character's THICKNESS, so
|
||||
# it gets scaled by ~7 and left on its back. Three of seven shipped that way.
|
||||
#
|
||||
# The trap is that the normalised number always comes out right: the export maps
|
||||
# Blender Z to glTF Y, so "is the height 1.75" passes on a character who is
|
||||
# 7.5 m tall lying down. The real question is whether the axis that was
|
||||
# normalised is the one the SKELETON is tall along.
|
||||
# From the VERTICES, not from object.bound_box — that is cached and is still
|
||||
# stale immediately after an import, which quietly reported a 1.75 m character
|
||||
# as 1.18 m tall.
|
||||
lo = [1e9] * 3
|
||||
hi = [-1e9] * 3
|
||||
for m in meshes:
|
||||
mw = m.matrix_world
|
||||
for v in m.data.vertices:
|
||||
p = mw @ v.co
|
||||
for k in range(3):
|
||||
lo[k] = min(lo[k], p[k])
|
||||
hi[k] = max(hi[k], p[k])
|
||||
ext = [hi[k] - lo[k] for k in range(3)]
|
||||
|
||||
up_axis = None
|
||||
_hips = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("hips", ""))
|
||||
_head = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("head", ""))
|
||||
if _hips and _head:
|
||||
d = [abs(_head.head_local[k] - _hips.head_local[k]) for k in range(3)]
|
||||
up_axis = d.index(max(d))
|
||||
|
||||
if up_axis is not None and max(ext) > 1e-4:
|
||||
tall = ext.index(max(ext))
|
||||
others = [ext[k] for k in range(3) if k != up_axis]
|
||||
detail = ("%.2f m tall along %s, %.2f x %.2f m across"
|
||||
% (ext[up_axis], "XYZ"[up_axis], others[0], others[1]))
|
||||
# Against WORLD up (Blender Z), not against the widest axis. A model that has
|
||||
# been rotated as a whole is internally consistent — its spine IS its longest
|
||||
# axis — so comparing the two agrees with itself and catches nothing. The
|
||||
# question is whether the character stands up in the world the game runs in.
|
||||
check(up_axis == 2, "character stands up in world space",
|
||||
detail + ("" if up_axis == 2 else
|
||||
" — the spine runs along %s, not Z, so the character is "
|
||||
"lying down; whatever was normalised to the target height "
|
||||
"was not their height" % "XYZ"[up_axis]))
|
||||
check(max(others) < 2.5, "character is a plausible size", detail)
|
||||
check(1.2 < ext[up_axis] < 2.4, "character is a plausible height", detail)
|
||||
|
||||
# --------------------------------------------------- reachable from the runtime
|
||||
#
|
||||
# The game looks these up to attach a weapon and to drive the pose layer. It used
|
||||
# to do it with hardcoded spellings, so four characters whose roles resolved
|
||||
# perfectly still could not hold a gun. The roles are the contract; if one the
|
||||
# runtime needs is missing from the sidecar, the character will be subtly broken
|
||||
# in a way no other check here can see.
|
||||
_roles = SIDECAR_INFO.get("roles", {})
|
||||
if _roles:
|
||||
needed = ["hips", "head", "hand.R", "hand.L", "upper_arm.R", "upper_arm.L",
|
||||
"forearm.R", "forearm.L", "thigh.L", "thigh.R", "shin.L", "shin.R"]
|
||||
absent = [r for r in needed
|
||||
if not _roles.get(r) or _roles[r] not in arm.data.bones]
|
||||
check(not absent, "every role the runtime needs is resolved",
|
||||
"missing or unresolvable: %s" % absent)
|
||||
|
||||
# ------------------------------------------------------------------- skeleton
|
||||
def is_rootish(b):
|
||||
return b is None or any(t in b.name.lower()
|
||||
|
||||
Reference in New Issue
Block a user