Files
Papay-Shooter/.claude/skills/character-pipeline/references/failure-modes.md
T
Nicholas ButzkeandClaude Opus 5 27c4117c25 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]>
2026-07-26 16:06:51 -04:00

10 KiB

How imports fail, and why the checks did not catch it

Seven characters shipped with "All checks passed" and four of them were visibly broken in game — lying on their backs, seven times too big, facing backwards, holding a gun that floated near their chest. Nothing in the verification suite was wrong. It just never asked the questions that mattered.

The generalised lesson, which is the whole of this page:

The suite verified that the character was well-formed — skeleton attached, weights authored, clips non-frozen, cloth unkeyed. It never verified that the character was correct: the right size, the right way up, the right way round, and reachable by the runtime. Structural validity and usable output are different properties, and a pipeline that only checks the first will ship the second broken every time a source deviates from the one it was written against.

Every check below is cheap. None of them existed.


1. Up is not always +Z — measure it, never assume it

Symptom: the character is enormous and lying on their back. Hit: aria, momo, hikari. Confidence: certain — measured, not inferred.

flatten_and_scale() sets the character's real-world size with

height = hi.z - lo.z          # Blender Z is up
s = target_height / height

which is right for a model that arrives Z-up in Blender, and catastrophic for one that does not. If the character is actually lying along Blender's Y, hi.z - lo.z measures their thickness — about 0.25 m — so s = 1.75 / 0.25 ≈ 7. The model is scaled seven-fold and left on its back. One wrong assumption, both symptoms.

The bind-pose bounding boxes say it plainly. A correct character is tall on Y and narrow on X and Z:

X Y Z
taila 1.24 1.75 0.69 correct
kiyoko 1.50 1.75 0.32 correct
mannequin 1.86 1.75 0.35 correct
aria 6.12 1.75 7.48 tall axis is Z — lying down, ~7x too big
momo 6.89 1.75 7.95 same
hikari 6.23 1.75 13.01 same, and worse

The 1.75 lands on Y for everyone because the exporter maps Blender Z to glTF Y. That is exactly what makes the bug invisible: the number you normalised always comes out right, whether or not it was the right number. A check on "is the height 1.75" passes on all seven of these.

Rule: derive the up axis from the SKELETON, and rotate the model upright before scaling anything. flatten_and_scale() now does this.

Measure it 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. Momo's did, so the first version of this fix stood her neatly on her head — correct size, correct proportions, upside down. Feet cannot be mistaken; they are the bottom of a standing character on every rig, and foot.L/R have resolved on every source met so far.

And compare it to WORLD up, not to the model's own proportions. The obvious test — "is the spine the longest axis of the bounding box?" — catches nothing here, because a model rotated as a whole is internally consistent: aria's spine is her longest axis, she is just lying down. She passes that test comfortably. The question is whether the character stands up in the world the game runs in, which means asserting the spine runs along Blender +Z, full stop.

Two more numbers are worth asserting for free: the other two extents should be under about 2.5 m, and the height itself should land in a human range. Those three together are what separated the four good characters from the three broken ones on the first run.

Where this comes from: all three casualties have bone names like Hips, Left leg, Upper Chest, Breast_L — a VRM that someone imported into Blender, renamed, and re-exported. Kiyoko kept raw VRoid J_Bip_* names and was fine. A Blender round-trip can bake an axis rotation into the export, and that family of files is common on Sketchfab. Treat "the bone names have been humanised" as a signal to check the axes.


2. The runtime must look bones up by ROLE, not by name

Symptom: the gun is not in the hands — it floats near the chest, and can point backwards. Hit: aria, momo, kiyoko, hikari. Confidence: certain — measured.

SkinnedPlayerModel.set_weapon() finds the hand with

var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])

Three hardcoded spellings. Against the shipped roster:

Skin hand.R resolved in the sidecar matched by _find_bone
taila, miku, mannequin DEF-hand.R yes
kiyoko J_Bip_R_Hand no
aria, momo, hikari Right wrist no

When it misses, set_weapon falls back to parenting the weapon to the model root at a fixed chest-height offset. The gun is then not attached to the character at all; it hangs in space near the torso and inherits none of the arm's motion.

This is the same class of bug as verify_character.py looking for legs by the substrings thigh/shin. tools/rig_map.py exists precisely so that nothing downstream has to guess a bone name, and the resolved roles are written to <model>.rig.json for exactly this purpose — but only some consumers read them.

Rule: every bone lookup anywhere in the runtime or the tools goes through the sidecar roles, with a name heuristic only as a last-resort fallback. Grep for find_bone, findn(, and any tuple of bone-name spellings; each one is a rig this project has not met yet.


3. Facing is inferred and never verified

Symptom: the character runs backwards. Hit: kiyoko. Confidence: probable — the mechanism is understood, the specific cause is not yet isolated.

Two independent things decide which way a character ends up pointing: retarget.py::facing_correction() computes a yaw to align the character's rest pose with the library's, and SkinnedPlayerModel.facing_flip then applies a blanket 180° because "glTF forward is +Z; players face -Z". If the source already faces the other way, the two compose to a character running backwards — and nothing anywhere measures the finished result.

Rule: facing is measurable from the skeleton — the toes are forward of the ankles, and the nose/head is forward of the spine. Assert it on the OUTPUT, after every transform has been applied. A blanket constant like facing_flip is a guess about the source that must be replaced by a measurement.


4. Generated cloth chains must be validated against the geometry they drive

Symptom: hair stretches wildly during animation. Hit: miku. Confidence: probable.

tools/cloth_bones.py grows chains for a costume that has none, then clears each vertex's existing body weights and re-assigns it to the fitted chain, keeping the original only over the first 22%. That is correct when the polyline actually follows the clump. When it does not — a large or forked island, a mis-picked root end — vertices land on a bone travelling somewhere else entirely, and linear-blend skinning turns that into stretching.

The tool reports how many chains it grew. It never checks whether they work.

Rule: after growing chains, verify per vertex that its assigned bone stays near it — pose the chain a few degrees and assert the vertex moves with its bone rather than away from it. And never destroy the original weights without a fallback: a generated chain should blend against the body weight it replaced, so a bad fit degrades to "stiff" rather than to "torn".


5. A single-piece rig still needs its arms checked

Symptom: the gun is held, but not convincingly — the stock is not in the shoulder and the hands are not on the grip. Hit: mannequin. Confidence: uncertain — its hand bone resolves, so this is not #2.

The rifle hold places the weapon from the shoulder joint and the aim direction, then solves both arms onto the grip and foregrip with two-bone IK. It is tuned against proportions like Taila's. A rig with different arm lengths, a different rest pose (A-pose vs T-pose) or a different bone roll will put the hands somewhere plausible for the maths and wrong for the eye.

Rule: the hold has measurable success criteria — the distance from each hand bone to the grip point it was solved onto. Assert those, per character, rather than judging by eye.


6. Some sources are not salvageable, and the gate should say so

Hit: hikari. She failed every way at once — stretched and warped, tiny, far away, gun backwards. Her rig has been through at least two toolchains: her cosmetic bones are zero-length terminators, she carried a second armature with its own clips, and her feet and her spine disagree about which way is up, so the stand-up correction cannot resolve her either. She is now REJECTED by the gate and removed from the roster rather than shipped broken.

Rule: a source that fails several unrelated checks is not a tuning problem, it is a bad file. Spend the effort on finding a cleaner source, not on repairing this one — and make sure the gate blocks it, because the failure mode before this work was that everything passed and the breakage was only visible in game.

The vetting snippet in separation.md catches most of these before download: several meshes, 50+ joints, cosmetic bones present. Add a look for duplicate armatures and for bone chains whose bones are all at the same position.

7. Known-unsolved, and honestly so

Taila's legs still clip through the front of her skirt in a run, slide and dash (~95 mm). See cloth-and-hair.md. The solver sees the contact and pushes on it every iteration; what remains is a standing fight between the collision and the garment's shape constraints, not a missing check.


The check that would have caught most of this

One pass over the finished GLB, before it is ever registered:

POSTURE   tallest axis of the bind-pose AABB == the hips->head axis,
          and the other two are under ~2.5 m           (catches #1)
SCALE     height within a few percent of --height       (catches #1)
FACING    toes forward of ankles, along the world forward the game
          expects, AFTER facing_flip                    (catches #3)
REACHABLE every role the runtime looks up — hands, head, spine — resolves
          through the sidecar and not by name           (catches #2)
CLOTH     every chain has measurable extent, and its vertices track it (#4)

None of these needs Blender or the engine; the bind-pose AABB and the inverse bind matrices in the GLB are enough for the first four.