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]>
227 lines
10 KiB
Markdown
227 lines
10 KiB
Markdown
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```gdscript
|
|
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.
|