Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
3 changed files with 148 additions and 5 deletions
Showing only changes of commit 700d0925d7 - Show all commits
+23 -5
View File
@@ -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
@@ -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
+109
View File
@@ -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