Files
Papay-Shooter/tools/cloth_bones.py
Nicholas ButzkeandClaude Opus 5 7892669319 feat(pipeline): grow skirt and hair bone chains for a costume that has none
Miku shipped with 0 cloth chains against Taila's 35, so her twin tails
hung off her skull like a helmet. Nothing downstream could fix it: the
spring solver simulates cloth BONES, and a garment with none is welded to
whatever body bone it was weighted to. Every auto-rigged model is in that
state, and her source was an unrigged mesh.

tools/cloth_bones.py builds them, which is the job a technical artist does
by hand on a model like this. It finds the geometry by MATERIAL SLOT — the
artist already answered which surface is hair, and on a joined mesh (what
the auto-rig leaves behind) the slot is the only separation left. Hair is
split into connected islands, because a strand is a connected piece of
surface and clustering by position would merge two ponytails passing near
each other. A skirt is split into radial wedges instead, because a skirt
is ONE connected surface and islands would return the whole thing as a
single piece — the bell-shaped failure. Each clump gets a polyline fitted
down its middle by binning vertices by distance and taking centroids, so
the chain follows the piece's own curve rather than cutting the corner on
a bend, and vertices are re-weighted onto it while the first 22% keeps its
original body weight so the scalp stays on the skull.

On Miku: 19 chains, 57 bones from one `hair` slot. Sidecar 0 -> 19 chains.
Idle stability 0.007-0.018 deg/frame. Mesh intact, verified by render.

Opt-in, via `pipeline.py --grow-cloth`, and run before the retarget so
describe_rig() finds the chains by name exactly as it would an artist's.

Known limits, recorded in the skill: it cannot find a garment sharing a
material with the body (Miku's skirt is on her `body` slot, so she got
hair and no skirt), and grown chains are a fallback — an artist's chains
carry intent that no geometric fit recovers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:19:44 -04:00

388 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Grow skirt and hair BONE CHAINS on a model that shipped without any.
A character whose source arrived unrigged goes through tools/autorig.py, which
fits a body skeleton and weights everything to it. The body then animates
correctly and the costume does not move at all: hair is welded to the skull and a
skirt is welded to the hips, because there is nothing there to move them. Miku
ships exactly like this — 0 cloth chains against Taila's 35 — so her twin tails
hang off her head like a helmet.
Nothing downstream can fix that. characters/spring_bones.gd simulates cloth
BONES, and if the garment has none there is nothing to simulate. This builds
them, which is the job a technical artist does by hand on a model like this.
The chains it grows are the same shape the Hoyoverse-class rigs use, because that
shape is dictated by what the geometry is:
HAIR one chain per strand, found as connected islands of hair geometry,
each running from the scalp down the strand's own curve.
SKIRT a radial grid — the garment is split into panels around the body's up
axis and each panel gets a chain from the waistband to the hem, which
is what lets a skirt open around a leg instead of swinging as a bell.
Vertices are then re-weighted onto the new chain with a falloff that keeps the
anchored end (scalp, waistband) on the body, so nothing detaches.
Usage:
blender --background --python tools/cloth_bones.py -- \
<rigged.glb> <output.glb> [--hair-segments 3] [--skirt-segments 4]
[--skirt-panels 12] [--classes hair,skirt]
Run this BEFORE tools/retarget.py. retarget.py's describe_rig() then finds the
chains by name exactly as it would an artist's, and writes them to the sidecar.
"""
import bpy
import bmesh
import json
import math
import os
import sys
from collections import defaultdict, deque
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gltf_fix
import rig_map
from rig_map import RigRoles
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 2:
print(__doc__)
sys.exit(1)
SOURCE, OUTPUT = argv[0], argv[1]
def _opt(flag, default):
return type(default)(argv[argv.index(flag) + 1]) if flag in argv else default
HAIR_SEGMENTS = _opt("--hair-segments", 3)
SKIRT_SEGMENTS = _opt("--skirt-segments", 4)
SKIRT_PANELS = _opt("--skirt-panels", 12)
CLASSES = _opt("--classes", "hair,skirt").split(",")
# A strand shorter than this is a fringe or an ornament, not something that
# should swing. Simulating them costs the same as simulating a ponytail and only
# ever produces jitter around the face.
MIN_STRAND_LENGTH = 0.06
MIN_STRAND_VERTS = 12
# How much of the anchored end stays welded to the body. A strand's first
# fraction blends from "entirely the body bone" to "entirely the chain", so the
# scalp and the waistband never separate from the character.
ROOT_BLEND = 0.22
def clear_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def find_rig():
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if not arms:
print("ERROR: no armature — run the auto-rig first")
sys.exit(1)
arm = max(arms, key=lambda a: len(a.data.bones))
meshes = [o for o in bpy.data.objects if o.type == "MESH"
and any(m.type == "ARMATURE" and m.object == arm for m in o.modifiers)]
if not meshes:
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
return arm, meshes
def cosmetic_slots(mesh, wanted):
"""Material slot indices whose name says what that geometry IS.
The artist already answered this. A slot called "hair" is hair; guessing from
position or from how far something hangs would be a guess, and on a joined
mesh — which is what the auto-rig leaves behind — the material slot is the
ONLY separation left.
"""
out = {}
for i, slot in enumerate(mesh.material_slots):
name = (slot.name or "").lower()
for cls in wanted:
if cls in name:
out[i] = cls
return out
def islands(mesh, vert_ids):
"""Connected components of `vert_ids`, over the mesh's own edges.
A strand of hair is a connected piece of surface. Clustering by position
instead would merge two ponytails that pass near each other and split a
single one that bends.
"""
adj = defaultdict(list)
keep = set(vert_ids)
for e in mesh.data.edges:
a, b = e.vertices
if a in keep and b in keep:
adj[a].append(b)
adj[b].append(a)
seen, out = set(), []
for v in vert_ids:
if v in seen:
continue
comp, q = [], deque([v])
seen.add(v)
while q:
n = q.popleft()
comp.append(n)
for m in adj[n]:
if m not in seen:
seen.add(m)
q.append(m)
out.append(comp)
return out
def radial_panels(mesh, vert_ids, centre, up, count):
"""Split a garment into `count` wedges around the body's up axis.
This is the skirt grid. A panel is a wedge rather than a connected island
because a skirt IS one connected surface — islands would return the whole
thing as a single piece, which is the bell-shaped failure.
"""
ref = Vector((1.0, 0.0, 0.0))
ref = (ref - up * ref.dot(up)).normalized()
side = up.cross(ref).normalized()
out = defaultdict(list)
for v in vert_ids:
d = mesh.data.vertices[v].co - centre
d = d - up * d.dot(up)
if d.length < 1e-6:
continue
ang = math.atan2(d.dot(side), d.dot(ref))
out[int((ang + math.pi) / (2 * math.pi) * count) % count].append(v)
return [v for v in out.values() if len(v) >= 6]
def fit_polyline(mesh, vert_ids, root_point, segments):
"""A polyline down the middle of a clump of geometry.
Vertices are binned by distance from the anchored end and each bin's centroid
becomes a joint, so the chain follows the piece's own CURVE. A straight line
from root to tip would cut the corner on a ponytail that bends, and every
vertex on the outside of that bend would then be weighted to a bone travelling
the wrong way.
"""
co = [mesh.data.vertices[v].co for v in vert_ids]
d = [(c - root_point).length for c in co]
lo, hi = min(d), max(d)
if hi - lo < 1e-5:
return []
pts = [root_point.copy()]
for s in range(1, segments + 1):
a = lo + (hi - lo) * (s - 1) / segments
b = lo + (hi - lo) * s / segments
bucket = [c for c, dist in zip(co, d) if a <= dist <= b + 1e-9]
if not bucket:
# An empty band means the geometry does not reach here; carry the
# direction on rather than collapsing the bone to zero length, which
# would drop it out of the chain at runtime.
if len(pts) >= 2:
pts.append(pts[-1] + (pts[-1] - pts[-2]))
continue
pts.append(sum(bucket, Vector()) / len(bucket))
return pts if len(pts) >= 2 else []
def nearest_body_group(mesh, vert_ids, arm):
"""Which body bone this clump is currently welded to.
That bone is the right parent for the new chain, and its weight is what the
root blend fades out of — so the transition is to exactly what was holding
this geometry before.
"""
tally = defaultdict(float)
gname = {g.index: g.name for g in mesh.vertex_groups}
bones = set(b.name for b in arm.data.bones)
for v in vert_ids:
for g in mesh.data.vertices[v].groups:
n = gname.get(g.group, "")
if n in bones:
tally[n] += g.weight
return max(tally, key=tally.get) if tally else None
def make_chain(arm, name, points, parent_name):
"""Create one bone chain along `points`. Returns the bone names."""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
made = []
prev = arm.data.edit_bones.get(parent_name)
for i in range(len(points) - 1):
bn = name if i == 0 else "%s.seg%d" % (name, i)
eb = arm.data.edit_bones.new(bn)
eb.head = points[i]
eb.tail = points[i + 1]
if eb.length < 1e-5:
arm.data.edit_bones.remove(eb)
continue
eb.parent = prev
eb.use_connect = False
prev = eb
made.append(bn)
bpy.ops.object.mode_set(mode="OBJECT")
return made
def weight_chain(mesh, vert_ids, points, bone_names, anchor_bone):
"""Move this clump's weights off the body and onto its new chain.
Each vertex is projected onto the polyline and given the two bones either side
of where it lands, blended by how far between them it is — the same linear
split a hand-painted chain gets. Near the anchored end the ORIGINAL body
weight is kept and faded out over ROOT_BLEND, so the scalp stays on the skull
and the waistband stays on the hips.
"""
groups = []
for bn in bone_names:
groups.append(mesh.vertex_groups.get(bn) or mesh.vertex_groups.new(name=bn))
anchor = mesh.vertex_groups.get(anchor_bone) if anchor_bone else None
# Cumulative length along the polyline, so position is measured in metres
# rather than in segment index — segments are not equal lengths.
seg_len = [(points[i + 1] - points[i]).length for i in range(len(points) - 1)]
total = sum(seg_len) or 1.0
for v in vert_ids:
co = mesh.data.vertices[v].co
best, best_d, best_run = 0, 1e18, 0.0
run = 0.0
for i in range(len(points) - 1):
a, b = points[i], points[i + 1]
ab = b - a
L2 = ab.length_squared
t = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, (co - a).dot(ab) / L2))
p = a + ab * t
d = (co - p).length
if d < best_d:
best_d, best, best_run = d, i, run + seg_len[i] * t
run += seg_len[i]
along = best_run / total
# Clear whatever held this vertex before, then re-add the anchor share.
for g in mesh.data.vertices[v].groups:
grp = mesh.vertex_groups[g.group]
if grp.name not in bone_names:
grp.remove([v])
hold = 0.0
if anchor and along < ROOT_BLEND:
hold = 1.0 - along / ROOT_BLEND
anchor.add([v], hold, "REPLACE")
# Split what is left between the two bones either side of the landing.
f = best_run / total * len(bone_names)
i0 = max(0, min(len(bone_names) - 1, int(f)))
i1 = min(len(bone_names) - 1, i0 + 1)
frac = f - i0
groups[i0].add([v], (1.0 - hold) * (1.0 - frac), "REPLACE")
if i1 != i0:
groups[i1].add([v], (1.0 - hold) * frac, "REPLACE")
def grow(arm, meshes, roles):
head = roles.head
trunk = roles.spine[0] if roles.spine else roles.hips
made_chains = 0
made_bones = 0
for mesh in meshes:
slots = cosmetic_slots(mesh, CLASSES)
if not slots:
continue
# Vertices per class, taken from the polygons that use each slot.
per_class = defaultdict(set)
for poly in mesh.data.polygons:
cls = slots.get(poly.material_index)
if cls:
per_class[cls].update(poly.vertices)
for cls, verts in per_class.items():
verts = list(verts)
if len(verts) < MIN_STRAND_VERTS:
continue
world = mesh.matrix_world
if cls == "hair":
anchor_default = head
clumps = islands(mesh, verts)
else:
anchor_default = trunk
centre = sum((mesh.data.vertices[v].co for v in verts),
Vector()) / len(verts)
clumps = radial_panels(mesh, verts, centre,
Vector((0.0, 0.0, 1.0)), SKIRT_PANELS)
idx = 0
for clump in clumps:
if len(clump) < MIN_STRAND_VERTS:
continue
anchor = nearest_body_group(mesh, clump, arm) or anchor_default
if anchor is None or anchor not in arm.data.bones:
continue
# The anchored end is the end nearest the bone currently holding
# it — the scalp for hair, the waistband for a skirt.
bone_head = (arm.matrix_world
@ arm.data.bones[anchor].matrix_local).translation
bone_head = world.inverted() @ bone_head
root = min((mesh.data.vertices[v].co for v in clump),
key=lambda c: (c - bone_head).length)
segs = HAIR_SEGMENTS if cls == "hair" else SKIRT_SEGMENTS
pts = fit_polyline(mesh, clump, root, segs)
if not pts:
continue
span = sum((pts[i + 1] - pts[i]).length for i in range(len(pts) - 1))
if span < MIN_STRAND_LENGTH:
continue
name = "DEF-%s.%03d" % (cls, idx)
idx += 1
names = make_chain(arm, name, [world @ p for p in pts], anchor)
if not names:
continue
weight_chain(mesh, clump, pts, names, anchor)
made_chains += 1
made_bones += len(names)
print(" %-6s %-22s %d clumps -> %d chains"
% (cls, mesh.name, len(clumps), idx))
return made_chains, made_bones
def main():
clear_scene()
bpy.ops.import_scene.gltf(filepath=gltf_fix.prepare(SOURCE,
os.path.dirname(OUTPUT)))
arm, meshes = find_rig()
roles = RigRoles(arm)
missing = roles.missing_core()
if missing:
print("ERROR: could not identify %s on this rig" % missing)
sys.exit(1)
before = len(arm.data.bones)
chains, bones = grow(arm, meshes, roles)
print("Grew %d cloth chains (%d bones); armature %d -> %d bones"
% (chains, bones, before, len(arm.data.bones)))
if chains == 0:
print("NOTE: no cosmetic material slots matched %s — nothing to grow. "
"Check the model's material names." % CLASSES)
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_apply=False,
export_skins=True,
export_animations=False,
)
print("Wrote", OUTPUT)
main()