diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md index b917528..f02ab88 100644 --- a/.claude/skills/character-pipeline/SKILL.md +++ b/.claude/skills/character-pipeline/SKILL.md @@ -250,11 +250,29 @@ The six in `skins.json`, with what `surface_class_check` reports: still runs its legs ~40° off the direction of travel. - **momo's idle pose is wrong** — arms overhead and a pinched waist. Every assertion passes on her: she loads, animates, classifies and mounts a weapon. - It shows up only in `roster_capture`. Her source rig is the reason to suspect — - its bones are auto-named (`Item_O_Sphere_005`, `Unused_Noname_004`, - `Root_001_001`), so the role resolver has almost nothing to go on, and a limb - role claimed by the wrong bone would look exactly like this. Start by dumping - her resolved roles against her skeleton. + It shows up only in `roster_capture`. + + **Diagnosed, not fixed.** Her `driven_bones` contains `Root_001` through + `Root_007` — and those are her HAIR roots. The Godot surface dump shows + `Hair_A` dominated by `Root_001_001:3203`, `Root_007:3203`, `Root_005:2642`. + So the animation is keying bones that physics is supposed to own, which is + non-negotiable #2 being violated by the role resolver rather than by a clip. + The corroboration is in the surface table: her hair surfaces report only 1.8% + and 9.4% chain share, because most of their vertices belong to `Root_00N`, + which is in no chain at all. + + `Root_00N` matches no COSMETIC stem, so `is_cosmetic` does not catch it and + nothing keeps it out of the driven set. Fixing it by adding "root" to the + stems would be wrong — a rig whose actual root is called `Root` would lose its + hips. The fix is structural: a bone whose geometry is dominated by a mesh + classified `hair` is a hair bone, whatever it is called. The surface table + now makes that answerable at build time, which it was not when this rig was + imported. Not attempted here — it needs a Blender re-run and re-verification + of all six characters. + + Her `head` role is also wrong (`Unused_Noname_010`, when a real `Head` bone + exists and is in her spine chain), and her spine chain runs two junk bones + PAST the head. Probably the same import; worth fixing in the same pass. - **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent, no vertex groups. It rides in from the animation library. Harmless, and now skipped by construction rather than by name in `surface_map`, but the export diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md index 7a3cf75..a02a174 100644 --- a/.claude/skills/character-pipeline/references/verification.md +++ b/.claude/skills/character-pipeline/references/verification.md @@ -47,6 +47,22 @@ Two related traps: | `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — | | `roster_capture.gd` | one photo of every character, from the picker | — | | `ui_capture.gd` | one photo of every menu screen | — | +| `rest_pose_check.gd` | each rig's bind-pose limb directions vs. the library's | see below | + +## What `rest_pose_check` actually established + +It was written to test a suspicion — that the rest-relative retarget silently +assumes both rigs rest alike — and it disproved it. Miku's arms rest **41°** off +the animation library's and Taila's **32°**, and both animate correctly. The +delta retarget handles a rest-pose difference, which is what it is for. Do not +go looking there again. + +It also demonstrates the measurement trap in miniature. Written as "the direction +from a bone to its FIRST CHILD", it reported kiyoko's and aria's legs 71° off — +because a thigh's first child is as likely to be a skirt bone as a shin, and it +was measuring the hang of a skirt panel. Pointing it at the next limb BY ROLE +dropped both to 1°. The same rule as everywhere else in this pipeline: resolve +roles, never take whatever the rig happens to hand you. ## Assert the consequence, not the plumbing diff --git a/debug/rest_pose_check.gd b/debug/rest_pose_check.gd new file mode 100644 index 0000000..edf1478 --- /dev/null +++ b/debug/rest_pose_check.gd @@ -0,0 +1,109 @@ +extends SceneTree + +## Which way does each character's REST pose point their limbs? +## +## The retarget bakes every clip as a rest-relative delta: it works out what the +## clip does to the LIBRARY's rest pose and applies that rotation to THIS rig's +## rest pose. That is what stops a foreign bone roll twisting a limb, and it is +## right — but it carries an assumption nobody has checked, which is that the two +## rest poses are broadly alike. If a character's arms rest overhead and the +## library's rest at its sides, then "what the clip does relative to the sides" +## applied to overhead leaves the arms overhead, in every clip, forever. +## +## So: measure it. For each character, the world-space direction each limb points +## in its BIND pose, and the angle between that and the library's. +## +## godot --headless --path . -s res://debug/rest_pose_check.gd + +## limb role -> the role of the bone it points AT. Not "its first child": a +## thigh's first child is as likely to be a skirt bone as a shin, and taking it +## reported kiyoko's and aria's legs as 71° off the library when their legs are +## fine — it was measuring the hang of a skirt panel. +const LIMBS := { + "upper_arm.L": "forearm.L", "upper_arm.R": "forearm.R", + "forearm.L": "hand.L", "forearm.R": "hand.R", + "thigh.L": "shin.L", "thigh.R": "shin.R", + "shin.L": "foot.L", "shin.R": "foot.R", +} +## The reference is the mannequin: it comes from the animation library itself, so +## its rest pose IS the pose every clip was authored against. +const REFERENCE := "mannequin" + + +func _init() -> void: + await process_frame + var data = JSON.parse_string(FileAccess.get_file_as_string( + "res://assets/characters/skins/skins.json")) + + var dirs := {} + for entry in data["skins"]: + var d := _limb_directions(entry.get("model", "")) + if not d.is_empty(): + dirs[entry["id"]] = d + + var ref: Dictionary = dirs.get(REFERENCE, {}) + print("Rest-pose limb directions, and the angle from the animation library's own rig\n") + for id in dirs: + var parts: PackedStringArray = [] + var worst := 0.0 + for limb in LIMBS: + if not dirs[id].has(limb) or not ref.has(limb): + continue + var v: Vector3 = dirs[id][limb] + var deg := rad_to_deg(v.angle_to(ref[limb])) + worst = maxf(worst, deg) + parts.append("%s %3.0f°" % [limb, deg]) + parts.sort() + print("%-10s worst %5.1f° %s" % [id, worst, " ".join(parts)]) + print("\nA limb tens of degrees from the reference will play every clip with") + print("that offset baked in, because the clip only ever supplied the DELTA.") + quit() + + +## limb role -> the unit vector the bone points along, in bind pose, in the +## model's own space. +## +## Read from the sidecar's roles, never by bone name — a rig whose bones are +## called `Unused_Noname_010` is exactly the case this has to survive. +func _limb_directions(path: String) -> Dictionary: + if path == "" or not ResourceLoader.exists(path): + return {} + var rig = JSON.parse_string(FileAccess.get_file_as_string( + path.get_basename() + ".rig.json")) + if typeof(rig) != TYPE_DICTIONARY: + return {} + var scene: Node = load(path).instantiate() + var skel: Skeleton3D = _first_skeleton(scene) + var out := {} + if skel: + for limb in LIMBS: + var roles: Dictionary = rig.get("roles", {}) + var from_name: String = roles.get(limb, "") + var to_name: String = roles.get(LIMBS[limb], "") + if from_name == "" or to_name == "": + continue + var bi := skel.find_bone(from_name) + var ti := skel.find_bone(to_name) + if bi < 0 or ti < 0: + continue + # Where the limb points: at the NEXT limb along, by role. A bone's + # own axes say nothing on their own — every rig rolls them + # differently, which is the whole reason the retarget works in + # deltas — so the direction has to come from the geometry. + var here := skel.get_bone_global_rest(bi).origin + var there := skel.get_bone_global_rest(ti).origin + var v := there - here + if v.length() > 1e-5: + out[limb] = v.normalized() + scene.free() + return out + + +func _first_skeleton(node: Node) -> Skeleton3D: + if node is Skeleton3D: + return node + for c in node.get_children(): + var f := _first_skeleton(c) + if f: + return f + return null