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>
This commit is contained in:
parent
5c80e65d58
commit
8119e2de4c
138
blender_ops.py
138
blender_ops.py
@ -10,13 +10,18 @@ ops:
|
||||
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, sys, os
|
||||
import bpy, re, sys, os
|
||||
|
||||
argv = sys.argv[sys.argv.index('--') + 1:]
|
||||
OP, ARGS = argv[0], argv[1:]
|
||||
@ -141,6 +146,70 @@ elif OP == 'fit':
|
||||
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])
|
||||
@ -148,17 +217,82 @@ elif OP == 'assemble':
|
||||
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): # bone names match by contract — drop the dupe
|
||||
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])
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user