wardrobegod/blender_ops.py
type-two 8119e2de4c Phase 4: donor harvest — real cloth that bends, plus three assemble bugs it exposed
harvest op: split a garment off an already-rigged clothed character by material. Blender's
mesh.separate(type='MATERIAL') carries the vertex groups onto the separated piece, so the
garment lands ALREADY skinned to the donor's skeleton — no fit, no weight transfer, and it
deforms correctly at the elbow because it was authored to. Unused groups are purged after.

Triaged all 43 clothed donors on ultra (headless Blender, run where the files are rather than
moving 3 GB). Measured what the LoRA bench could only guess at: only 11/43 (25%) are separable.
The rest bake skin and cloth onto one fused atlas and cannot be split at all. The best targets
are the Ready Player Me avatars (Wolf3D_Outfit_Top/Bottom/Footwear — a clean 3-way split) and
the Mixamo characters (Topmat/Bottommat/Shoesmat); most of the man_*/woman_* set is fused.
Triage also over-reported at first: the skin regex missed 'mouth', so mouth materials showed up
as garments. Widened.

Proving it end to end exposed three real bugs in `assemble`, none of which erred — they all
failed silently, which is why they had survived:
· Bone names are NOT uniform across the fleet, contrary to the "one skeleton" contract in the
  README. character_kit and NPCFACTORY use 'mixamorig:Hips'; Ready Player Me donors use bare
  'Hips'; Blender suffixes '.001' on any import collision. Vertex groups therefore pointed at
  bones that did not exist and the garment sat frozen in bind pose while the body animated.
  Now matched on the bare name, so a garment harvested off one rig drives another.
· Donors are not authored at a common scale. character_kit rigs are ~0.06 m tall, a Ready
  Player Me donor ~1.5 m, so the harvested top arrived 27.41x oversized and swallowed the
  scene. Rescaled by a shared hips->head bone span, the same trick character_kit's
  assemble.fit_part uses.
· Material-less bone-widget meshes (a 42-vert 'Icosphere' in character_kit rigs) rode along and
  wrecked the scene bbox, which drives both the HUD height and the viewer's auto-framing — one
  stray widget framed the camera on the widget and rendered the character as a speck.
  NPCFACTORY's render_plates.py had already worked around the same thing.

Verified: a garment harvested from a Ready Player Me donor, assembled onto character_kit's
hum_character, deforming correctly mid-stride through a Running clip. Honest caveat unchanged —
the garment inherits its donor's proportions, so it reads loose on a slimmer body and there is
no cloth sim to save it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:07:32 +10:00

301 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""WARDROBEGOD blender ops — headless Blender does the heavy lifting the browser can't.
blender -b --python blender_ops.py -- <op> <args...>
ops:
convert <in> <out.glb> any format Blender reads → GLB (textures packed)
scale <in> <out.glb> <height_m> uniform scale so bbox height = height_m, applied
decimate <in> <out.glb> <target_tris> Decimate modifier — PRESERVES vertex weights/rig
fit <body> <garment> <out.glb> <mode> [inflate_mm]
weight-transfer the body's skinning onto the garment (nearest-face interpolated),
parent to the body's armature. mode=merge → one GLB (dressed body);
mode=garment → garment+skeleton only (a wardrobe item, reusable in assemble)
harvest <donor> <out.glb> <material_substr> [keep_skin_regex]
split a garment off an ALREADY-RIGGED clothed character by material. Blender copies
the vertex groups onto the separated mesh, so the garment lands already skinned to
the donor's skeleton — no fit, no weight transfer, and it bends correctly because it
was authored to. Trade-off: it inherits the donor's proportions.
assemble <body> <out.glb> <garment.glb>... dressed combo: garments must have been fitted
against the same skeleton (bone names match)
House rules: never fit against an unrigged body (we abort); rigged meshes are never sent to
the farm /finish (this file is the safe local path for them).
"""
import bpy, re, sys, os
argv = sys.argv[sys.argv.index('--') + 1:]
OP, ARGS = argv[0], argv[1:]
def clean():
bpy.ops.wm.read_factory_settings(use_empty=True)
def load(path):
"""Import path, return the set of objects it brought in."""
before = set(bpy.data.objects)
ext = os.path.splitext(path)[1].lower()
if ext == '.fbx':
bpy.ops.import_scene.fbx(filepath=path)
elif ext in ('.glb', '.gltf'):
bpy.ops.import_scene.gltf(filepath=path)
elif ext == '.obj':
bpy.ops.wm.obj_import(filepath=path)
else:
raise SystemExit(f'unsupported format: {ext}')
return list(set(bpy.data.objects) - before)
def export(out):
os.makedirs(os.path.dirname(out), exist_ok=True)
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB')
print(f'WROTE {out}')
def apply_mod(obj, name):
with bpy.context.temp_override(object=obj, active_object=obj,
selected_editable_objects=[obj]):
bpy.ops.object.modifier_apply(modifier=name)
def meshes(objs):
return [o for o in objs if o.type == 'MESH']
def armatures(objs):
return [o for o in objs if o.type == 'ARMATURE']
def total_tris(objs):
return sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in meshes(objs))
if OP == 'convert':
clean(); load(ARGS[0]); export(ARGS[1])
elif OP == 'scale':
clean()
objs = load(ARGS[0]); target = float(ARGS[2])
import mathutils
mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3)
for o in meshes(objs):
for c in o.bound_box:
w = o.matrix_world @ mathutils.Vector(c)
mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w))
h = mx.z - mn.z
if h <= 0:
raise SystemExit('flat object — no height to scale')
s = target / h
roots = [o for o in objs if not o.parent]
for r in roots:
r.scale = [c * s for c in r.scale]
with bpy.context.temp_override(selected_editable_objects=roots + meshes(objs) + armatures(objs)):
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
print(f'scaled ×{s:.3f}{target}m')
export(ARGS[1])
elif OP == 'decimate':
clean()
objs = load(ARGS[0]); target = int(ARGS[2])
tris = total_tris(objs)
ratio = min(1.0, target / max(tris, 1))
for o in meshes(objs):
m = o.modifiers.new('dec', 'DECIMATE')
m.ratio = ratio
# keep the Armature modifier LAST so skinning still evaluates after the cut
while o.modifiers[0].name != 'dec':
with bpy.context.temp_override(object=o):
bpy.ops.object.modifier_move_up(modifier='dec')
apply_mod(o, 'dec')
print(f'decimated {tris:,}{total_tris(objs):,} tris (ratio {ratio:.3f}) — weights kept')
export(ARGS[1])
elif OP == 'fit':
clean()
body_objs = load(ARGS[0])
arms = armatures(body_objs)
if not arms:
raise SystemExit('body has no armature — rig it first (MIRPAMO/Mixamo), then fit')
arm = arms[0]
body = max(meshes(body_objs), key=lambda o: len(o.vertex_groups), default=None)
if body is None or not body.vertex_groups:
raise SystemExit('body has no skin weights to copy')
garm_objs = load(ARGS[1])
mode = ARGS[3] if len(ARGS) > 3 else 'merge'
inflate = float(ARGS[4]) / 1000.0 if len(ARGS) > 4 else 0.0
for g in meshes(garm_objs):
if inflate: # a few mm of air so the body doesn't poke through
d = g.modifiers.new('puff', 'DISPLACE'); d.strength = inflate; d.mid_level = 0
apply_mod(g, 'puff')
with bpy.context.temp_override(object=body, active_object=body,
selected_editable_objects=[g, body],
selected_objects=[g, body]):
bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_create=True,
vert_mapping='POLYINTERP_NEAREST',
layers_select_src='ALL', layers_select_dst='NAME')
g.parent = arm
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
# garment armatures that came along with the import are dupes — drop them
for a in armatures(garm_objs):
bpy.data.objects.remove(a, do_unlink=True)
if mode == 'garment':
bpy.data.objects.remove(body, do_unlink=True)
for o in meshes(body_objs):
if o.name in bpy.data.objects:
bpy.data.objects.remove(o, do_unlink=True)
print(f'fitted {len(meshes(garm_objs))} garment mesh(es), mode={mode}, inflate={inflate * 1000:.0f}mm')
export(ARGS[2])
elif OP == 'harvest':
# harvest <donor> <out.glb> <material_substr> [keep_skin_regex]
#
# The trick this whole tier rests on: bpy.ops.mesh.separate(type='MATERIAL') carries the
# vertex groups onto the separated piece. So a shirt split off a mixamorig donor arrives
# ALREADY skinned to the exact skeleton every clip in the bank drives — no weight transfer,
# no fitting, no shrinkwrap. It deforms correctly at the elbow because it was authored to.
#
# Cost, stated honestly: the garment is captive to that donor's proportions. On a slimmer
# body it will clip or float; there is no cloth sim here to save it.
clean()
objs = load(ARGS[0])
want = ARGS[2].lower()
skin_re = re.compile(ARGS[3] if len(ARGS) > 3 else r'skin|body|face|eye|hair|teeth|tongue|lash|brow|nail|mouth|head|hand|beard', re.I)
arms = armatures(objs)
if not arms:
raise SystemExit('donor has no armature — it cannot donate a skinned garment')
arm = arms[0]
kept = []
for m in list(meshes(objs)):
names = [ms.name for ms in m.data.materials if ms]
if not any(want in n.lower() for n in names):
continue
if len(names) > 1: # split this mesh along its material seams
bpy.ops.object.select_all(action='DESELECT')
m.select_set(True)
bpy.context.view_layer.objects.active = m
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.separate(type='MATERIAL')
bpy.ops.object.mode_set(mode='OBJECT')
# after separating, keep only pieces whose material matches and which aren't skin
for m in list(meshes(list(bpy.data.objects))):
names = [ms.name for ms in m.data.materials if ms]
hit = any(want in n.lower() for n in names)
skin = any(skin_re.search(n) for n in names)
if hit and not skin:
kept.append(m)
if not kept:
raise SystemExit(f'no garment mesh matched material {ARGS[2]!r} '
f'(donor may bake skin+cloth onto one atlas — nothing to separate)')
# purge everything else, keeping the armature so the weights stay meaningful
for o in list(bpy.data.objects):
if o.type == 'MESH' and o not in kept:
bpy.data.objects.remove(o, do_unlink=True)
elif o.type == 'ARMATURE' and o is not arm:
bpy.data.objects.remove(o, do_unlink=True)
for g in kept:
g.parent = arm
for mod in list(g.modifiers):
if mod.type == 'ARMATURE':
mod.object = arm
if not any(mod.type == 'ARMATURE' for mod in g.modifiers):
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
# drop vertex groups the garment no longer uses — a separated sleeve keeps the whole
# donor's group list, which bloats the GLB and confuses later merges
used = {gi.group for v in g.data.vertices for gi in v.groups if gi.weight > 0.0001}
for vg in list(g.vertex_groups):
if vg.index not in used:
g.vertex_groups.remove(vg)
print(f'harvested {len(kept)} mesh(es) for {ARGS[2]!r}: '
f'{total_tris(kept):,} tris, {len(kept[0].vertex_groups)} bones driving')
export(ARGS[1])
elif OP == 'assemble':
clean()
body_objs = load(ARGS[0])
arms = armatures(body_objs)
if not arms:
raise SystemExit('body has no armature')
arm = arms[0]
def bare(n):
"""'mixamorig:LeftArm.001' -> 'leftarm'. The fleet is NOT uniform: character_kit and
NPCFACTORY rigs use the 'mixamorig:' prefix, Ready Player Me donors use bare 'Hips',
and Blender suffixes '.001' on any import collision. Matching on the bare name is what
actually makes a garment harvested off one rig drive on another."""
n = re.sub(r'\.\d{3}$', '', n)
return re.sub(r'^.*[:_]', '', n).lower()
body_bones = {b.name for b in arm.data.bones}
bare_map = {bare(b.name): b.name for b in arm.data.bones}
for gpath in ARGS[2:]:
gobjs = load(gpath)
# Both rigs are mixamorig, so importing the garment collides on EVERY bone name and
# Blender silently suffixes the newcomer '.001'. Re-parenting then leaves the garment's
# vertex groups pointing at bones that no longer exist, and the result is a mesh frozen
# in bind pose while the body animates — no error, just a garment that refuses to move.
# Map each group back onto the body's real bone name before dropping the dupe armature.
# Donors are not authored at a common scale — character_kit rigs are ~0.06m tall while a
# Ready Player Me donor is ~1.5m, so a harvested garment can arrive 25x oversized and
# swallow the whole scene. Rescale by the ratio of a shared bone chain (hips->head)
# before parenting, the same trick character_kit's assemble.fit_part uses.
garm_arms = armatures(gobjs)
def span(a):
bb = {bare(b.name): b for b in a.data.bones}
h, d = bb.get('hips'), bb.get('head')
if h and d:
return (a.matrix_world @ d.head_local - a.matrix_world @ h.head_local).length
zs = [(a.matrix_world @ b.head_local).z for b in a.data.bones]
return (max(zs) - min(zs)) if zs else 0.0
ratio = 1.0
if garm_arms:
gs, bs = span(garm_arms[0]), span(arm)
if gs > 1e-9 and bs > 1e-9:
ratio = bs / gs
if abs(ratio - 1.0) > 0.02:
print(f' rescaling garment x{ratio:.4f} (donor rig {1 / ratio:.2f}x the body rig)')
for g in meshes(gobjs):
for vg in g.vertex_groups:
if vg.name in body_bones:
continue
tgt = bare_map.get(bare(vg.name))
if tgt:
vg.name = tgt
else:
print(f' WARN: {g.name} group {vg.name!r} has no bone on the body rig')
if abs(ratio - 1.0) > 1e-6:
g.scale = [c * ratio for c in g.scale]
with bpy.context.temp_override(object=g, active_object=g,
selected_editable_objects=[g]):
bpy.ops.object.transform_apply(location=True, rotation=False, scale=True)
g.parent = arm
g.matrix_parent_inverse = arm.matrix_world.inverted()
for m in g.modifiers:
if m.type == 'ARMATURE':
m.object = arm
if not any(m.type == 'ARMATURE' for m in g.modifiers):
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
for a in armatures(gobjs):
bpy.data.objects.remove(a, do_unlink=True)
# the body armature may itself have been renamed by a collision — put it back, or the
# exported clips reference a node name that no longer exists
for b in arm.data.bones:
base = re.sub(r'\.\d{3}$', '', b.name)
if base != b.name and base not in {x.name for x in arm.data.bones}:
b.name = base
# Drop bone-widget / marker meshes: no material and a handful of verts. These ride along in
# character_kit rigs (a 42-vert 'Icosphere'), contribute nothing visually, and wreck the
# scene bbox — which drives both the HUD height and the viewer's auto-framing, so one stray
# widget makes the camera frame the widget and the character render as a speck.
for o in list(bpy.data.objects):
if o.type == 'MESH' and not [m for m in o.data.materials if m] and len(o.data.vertices) < 100:
print(f' dropped helper mesh {o.name} ({len(o.data.vertices)} verts, no material)')
bpy.data.objects.remove(o, do_unlink=True)
print(f'assembled body + {len(ARGS) - 2} garment file(s)')
export(ARGS[1])
else:
raise SystemExit(f'unknown op {OP}')