Delivering the honest state after a long debugging run. Real fixes that stand on their own: · MASK RADIUS WAS COMPARED IN THE WRONG SPACE. Vertices were tested against a world-metre radius using local coordinates, so the mask silently found nothing whenever the object carried a scale — 8126 verts matched as FBX (mesh scale 100) and ZERO after a GLB round-trip. Now compared in world space. · BODY MESH WAS SELECTED BY VERTEX-GROUP COUNT. A previously grafted part inherits every one of the body's groups from the weight transfer, so on a second pass the HAND was being chosen as "the body" — masking the other wrist then found nothing and the old hand silently survived under the new one. Now selected by vertex count among skinned meshes, and the choice is printed. · join() leaves the part mesh's ARMATURE modifier dangling at None and re-parents it to whatever empty sat above it, collapsing it to the origin. Placement is now saved and re-bound explicitly. · --mirror added (the asset is a .R hand; the .L needs it) with outward normal recalculation, since a negative-scale mirror leaves every face wound backwards. · mixamoprep added: bakes graft MASK modifiers (a modifier is not a deletion — the geometry comes back in the export otherwise), joins to ONE mesh, strips the armature and stale vertex groups, scales to height, writes FBX. Mixamo's auto-rigger wants exactly that shape of input. STILL NOT WORKING, and I am not going to claim otherwise: placing THIS hand asset. Its stored rest geometry is not hand-shaped — measured 84 units tall with the armature modifier off versus ~0.08 with it on, so the rig is what poses it into a hand. Removing the modifier exports the spiky rest mesh; applying it did not bake the shape either. Every headless attempt to detach, transform and re-bake it has corrupted the geometry. Recommendation rather than more blind iteration: open rigged_hand.glb once in the Blender GUI on ultra, apply the pose/armature by hand, and export a clean STATIC hand.glb. Positioning a static mesh is the part that already works. The asset is an awkward Sketchfab export (mesh and rig nested several empties deep, non-hand rest pose); one manual pass makes everything downstream trivial and repeatable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
703 lines
35 KiB
Python
703 lines
35 KiB
Python
"""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, mathutils, 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 is_helper(o):
|
||
"""Bone-widget / marker mesh: no material and a handful of verts.
|
||
|
||
These ride along in character_kit and NPCFACTORY rigs (a radius-1 42-vert 'Icosphere', so
|
||
exactly 2.0 units tall) and they poison every bbox measurement — height readouts, camera
|
||
auto-framing, and scale normalisation all silently measure the widget instead of the
|
||
character. NPCFACTORY's render_plates.py hit this too and worked around it by framing on
|
||
the dominant mesh only.
|
||
"""
|
||
return (o.type == 'MESH' and not [m for m in o.data.materials if m]
|
||
and len(o.data.vertices) < 100)
|
||
|
||
|
||
def real_meshes(objs):
|
||
"""Meshes that are actually the model — helpers excluded. Falls back to all meshes so a
|
||
genuinely material-less model still measures rather than returning nothing."""
|
||
ms = meshes(objs)
|
||
return [o for o in ms if not is_helper(o)] or ms
|
||
|
||
|
||
def apply_scale(roots, objs):
|
||
"""Scale a model by setting the ROOT transform — deliberately without transform_apply.
|
||
|
||
Blender 5.1.2 SEGFAULTS inside object_transform_apply_exec when the selection contains both
|
||
an armature and its own parented children (reproducible on character_kit's hum_character,
|
||
both with temp_override and with a plain select-then-apply). A segfault kills the process, so
|
||
it has to be avoided rather than caught.
|
||
|
||
Not baking costs us nothing here: glTF encodes node scale natively, so the exported file is
|
||
the right size and every downstream measurement (bbox, height, the viewer) reads it
|
||
correctly. Only the roots are scaled — children inherit, and scaling them too would
|
||
double-apply.
|
||
"""
|
||
# The caller has already set r.scale; there is deliberately nothing else to do. An earlier
|
||
# view_layer.update() here was itself crashing on rigs with parented children.
|
||
return
|
||
|
||
|
||
def bbox_of(objs):
|
||
import mathutils
|
||
mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3)
|
||
for o in real_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))
|
||
return mn, mx
|
||
|
||
|
||
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])
|
||
mn, mx = bbox_of(objs) # same helper-widget exclusion — this op measured them too
|
||
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]
|
||
apply_scale(roots, objs)
|
||
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 == 'graft':
|
||
# graft <body> <part> <out.glb> <bone> [radius_m] [--keep]
|
||
#
|
||
# Swap a crude AI-generated body part (hands, feet, head) for a detailed one, WITHOUT the
|
||
# cut-and-bridge workflow every tutorial recommends. Bridge Edge Loops needs matching vertex
|
||
# counts on both boundary loops; a TRELLIS cut edge is arbitrary unstructured soup, so that
|
||
# step is where the evening goes. Instead:
|
||
# · never cut — MASK the body's geometry near the target bone (a vertex group + MASK
|
||
# modifier), so the old hand simply stops being drawn and is reversible
|
||
# · align the part to the bone, normalising the cross-rig scale
|
||
# · transfer weights from the body to the part (the same data_transfer `fit` uses) rather
|
||
# than Ctrl+P Automatic Weights, which would re-weight the WHOLE character and destroy
|
||
# the skinning Mixamo just produced
|
||
# · parent to the body's existing armature; never join two mixamorig armatures, because
|
||
# the name collision silently rewrites the bones the weights point at
|
||
clean()
|
||
body_objs = load(ARGS[0])
|
||
arms = armatures(body_objs)
|
||
if not arms:
|
||
raise SystemExit('body has no armature — rig it first, then graft')
|
||
arm = arms[0]
|
||
# Pick the body by VERTEX COUNT, not vertex-group count. A previously grafted part inherits
|
||
# every one of the body's groups from the weight transfer, so counting groups can select the
|
||
# hand as "the body" on a second pass — after which masking the other wrist finds nothing and
|
||
# the old geometry silently survives under the new part.
|
||
skinned = [o for o in real_meshes(body_objs) if o.vertex_groups]
|
||
body = max(skinned or real_meshes(body_objs), key=lambda o: len(o.data.vertices), default=None)
|
||
if body is None:
|
||
raise SystemExit('no body mesh found')
|
||
print(f'graft: body mesh = {body.name} ({len(body.data.vertices):,} verts)')
|
||
bone_name = ARGS[3]
|
||
bl = {b.name.lower(): b for b in arm.data.bones}
|
||
bone = bl.get(bone_name.lower()) or next(
|
||
(b for n, b in bl.items() if bone_name.lower() in n), None)
|
||
if bone is None:
|
||
raise SystemExit(f'no bone matching {bone_name!r}. have: {sorted(bl)[:12]}…')
|
||
# Hold the NAME, not the Bone. bpy.ops.object.join() reallocates arm.data.bones, after which
|
||
# a held Bone pointer silently refers to a different entry — that is how a graft aimed at
|
||
# mixamorig:RightHand ended up parented under mixamorig:RightHandIndex4.
|
||
TARGET_BONE = bone.name
|
||
radius = float(ARGS[4]) if len(ARGS) > 4 else 0.12
|
||
|
||
import mathutils
|
||
anchor = arm.matrix_world @ bone.head_local
|
||
|
||
part_objs = load(ARGS[1])
|
||
# A "part" file is often a whole donor character (character_kit's rigged/hand1.glb carries a
|
||
# prop, a full body AND a widget). Let the caller name the mesh; otherwise take the largest
|
||
# real mesh and SAY which one was chosen rather than silently grafting a torso.
|
||
want_mesh, bind, mirror = None, 'body', False
|
||
for a in ARGS[5:]:
|
||
if a.startswith('--mesh='):
|
||
want_mesh = a.split('=', 1)[1].lower()
|
||
elif a.startswith('--bind='):
|
||
bind = a.split('=', 1)[1].lower()
|
||
elif a == '--mirror':
|
||
mirror = True # a .R hand asset becomes the .L one
|
||
cands = real_meshes(part_objs)
|
||
if not cands:
|
||
raise SystemExit('part file has no mesh')
|
||
if want_mesh:
|
||
part_meshes = [o for o in cands if want_mesh in o.name.lower()]
|
||
if not part_meshes:
|
||
raise SystemExit(f'no part mesh matching {want_mesh!r}; have '
|
||
f'{[o.name for o in cands]}')
|
||
else:
|
||
part_meshes = [max(cands, key=lambda o: len(o.data.vertices))]
|
||
print(f'graft: part file holds {[o.name for o in cands]}; using '
|
||
f'{[o.name for o in part_meshes]}')
|
||
drop = [o for o in cands if o not in part_meshes]
|
||
part_arms = armatures(part_objs) # capture BEFORE removing, or the list goes stale
|
||
# Resolve NAMES up front: after bpy.data.objects.remove(), even reading o.name on a dropped
|
||
# object raises ReferenceError: StructRNA has been removed.
|
||
keep_names = [o.name for o in part_objs if o not in drop]
|
||
for o in drop: # drop the meshes we are not grafting
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
part_objs = [bpy.data.objects[n] for n in keep_names if n in bpy.data.objects]
|
||
|
||
# bind=body discards the donor rig, so detach first (keeping world position) or the transform
|
||
# below fights the donor armature's scale. bind=body only — under bind=part the mesh must stay
|
||
# bound to its own rig, and we move that rig instead so the two never double-apply.
|
||
if bind in ('body', 'none'):
|
||
for g in part_meshes:
|
||
if g.parent:
|
||
wm = g.matrix_world.copy()
|
||
g.parent = None
|
||
g.matrix_world = wm
|
||
bpy.context.view_layer.update()
|
||
|
||
# One transform, applied to the meshes themselves: scale about the part's own bbox centre to
|
||
# match the bone's length, then translate that centre onto the bone. Nudging root objects'
|
||
# .location instead compounds with whatever the donor's armature/empty already carried.
|
||
blen = (bone.tail_local - bone.head_local).length * (arm.matrix_world.to_scale().x or 1.0)
|
||
blen = blen or 0.1
|
||
pmn, pmx = bbox_of(part_meshes)
|
||
psize = max((pmx - pmn).length, 1e-6)
|
||
ratio = (blen * 2.5) / psize
|
||
centre = (pmn + pmx) / 2
|
||
M = mathutils.Matrix.Scale(-1.0, 4, (1, 0, 0)) if mirror else mathutils.Matrix.Identity(4)
|
||
T = (mathutils.Matrix.Translation(anchor)
|
||
@ mathutils.Matrix.Scale(ratio, 4)
|
||
@ M
|
||
@ mathutils.Matrix.Translation(-centre))
|
||
if bind in ('body', 'none'):
|
||
for g in part_meshes:
|
||
g.matrix_world = T @ g.matrix_world
|
||
bpy.context.view_layer.update()
|
||
# under bind=part the armature carries the transform (applied below) and the mesh rides along
|
||
pmn2, pmx2 = bbox_of(part_meshes)
|
||
print(f'graft: scaled x{ratio:.4f}, part bbox now '
|
||
f'{tuple(round(c, 3) for c in pmn2)}..{tuple(round(c, 3) for c in pmx2)} '
|
||
f'around anchor {tuple(round(c, 3) for c in anchor)}')
|
||
|
||
# MASK the body's old geometry around the bone instead of deleting it — reversible, and it
|
||
# sidesteps needing a clean boundary loop entirely.
|
||
grp = body.vertex_groups.new(name='graft_hide_' + TARGET_BONE.split(':')[-1])
|
||
# Compare in WORLD space. Testing `v.co` against a world radius silently mis-masks whenever
|
||
# the object carries a scale: the same body measured 8126 verts as FBX (mesh scale 100) and
|
||
# ZERO after a GLB round-trip, because 0.1 world metres is 0.001 local units at that scale.
|
||
mw = body.matrix_world
|
||
hidden = [v.index for v in body.data.vertices
|
||
if ((mw @ v.co) - anchor).length < radius]
|
||
if hidden:
|
||
grp.add(hidden, 1.0, 'REPLACE')
|
||
m = body.modifiers.new('graft_mask', 'MASK')
|
||
m.vertex_group = grp.name
|
||
m.invert_vertex_group = True # keep everything EXCEPT the masked region
|
||
print(f'graft: masked {len(hidden)} body verts within {radius}m of {TARGET_BONE}')
|
||
|
||
# --bind=part: bring the part's OWN skeleton in and hang it off the target bone. This is the
|
||
# mode that actually articulates — a detailed hand carries ~65 finger bones where Mixamo's
|
||
# reduced rig has 8 across both hands, so binding to the body's wrist gives nice geometry that
|
||
# cannot move its fingers. Joining is only safe because the part's bones are NOT mixamorig-
|
||
# named (_rootJoint / thumb_base.R_03 …); two mixamorig rigs would collide and silently
|
||
# repoint every weight, which is why `assemble` refuses to join and remaps instead.
|
||
if bind == 'part' and part_arms:
|
||
parm = part_arms[0]
|
||
# Transform the TOP-MOST ancestor of everything we keep. Sketchfab-style exports nest the
|
||
# rig and mesh several empties deep (RootNode > Sketchfab_model > *.fbx > ...), so
|
||
# "objects with no parent" finds the wrong node and the mesh stays at the origin — a
|
||
# stray sliver on the floor while the bones land correctly on the wrist.
|
||
def top(o):
|
||
while o.parent:
|
||
o = o.parent
|
||
return o
|
||
|
||
for r in {top(o).name for o in list(part_meshes) + [parm]}:
|
||
obj = bpy.data.objects[r]
|
||
obj.matrix_world = T @ obj.matrix_world
|
||
bpy.context.view_layer.update()
|
||
root_bones = [b.name for b in parm.data.bones if b.parent is None]
|
||
clash = {b.name for b in parm.data.bones} & {b.name for b in arm.data.bones}
|
||
if clash:
|
||
raise SystemExit(f'part rig shares {len(clash)} bone name(s) with the body '
|
||
f'({sorted(clash)[:3]}…). Joining would silently repoint weights — '
|
||
f'use --bind=body instead.')
|
||
# join() deletes the part's armature OBJECT. Measured consequence: the part mesh's
|
||
# ARMATURE modifier is left pointing at None and the mesh gets re-parented to whatever
|
||
# empty sat above it (Sketchfab exports nest one), so it collapses from the wrist back to
|
||
# rest position at the origin. Save the placement and re-bind explicitly afterwards.
|
||
placed = {g.name: g.matrix_world.copy() for g in part_meshes}
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
parm.select_set(True); arm.select_set(True)
|
||
bpy.context.view_layer.objects.active = arm # active survives the join
|
||
bpy.ops.object.join()
|
||
bpy.context.view_layer.update()
|
||
for name, wm in placed.items():
|
||
g = bpy.data.objects.get(name)
|
||
if not g:
|
||
continue
|
||
for m in g.modifiers:
|
||
if m.type == 'ARMATURE' and m.object is None:
|
||
m.object = arm # re-bind the dangling modifier
|
||
g.parent = arm
|
||
g.matrix_world = wm # restore the placement join destroyed
|
||
bpy.context.view_layer.update()
|
||
bpy.context.view_layer.objects.active = arm
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
eb = arm.data.edit_bones
|
||
tgt = eb.get(TARGET_BONE)
|
||
for rb in root_bones:
|
||
if rb in eb and tgt:
|
||
eb[rb].parent = tgt
|
||
eb[rb].use_connect = False # keep offset; connecting would snap it
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
print(f'graft: joined {len(root_bones)} part-rig root(s) under {TARGET_BONE} '
|
||
f'— body rig now {len(arm.data.bones)} bones, fingers articulate')
|
||
export(ARGS[2])
|
||
raise SystemExit(0)
|
||
|
||
# --bind=none: position only. Correct choice when the result is going straight back to Mixamo
|
||
# for a fresh auto-rig, because the binding is about to be thrown away anyway — and skipping
|
||
# it avoids the armature modifier deforming the part from a bind pose it never had.
|
||
if bind == 'none':
|
||
for g in part_meshes:
|
||
# Drop the donor's ARMATURE modifier first — left attached it keeps deforming the
|
||
# mesh from a bind pose whose armature we are about to discard, which is what turned
|
||
# the hand into a spike. Then unparent KEEPING the world matrix, or the accumulated
|
||
# parent transform vanishes and the mesh jumps back to its local coordinates.
|
||
# APPLY the donor's armature modifier, do not remove it. This asset's stored rest
|
||
# geometry is not hand-shaped — it is the armature that poses it into a hand (bbox
|
||
# measured 84 units tall with the modifier off, ~0.08 with it on). Removing the
|
||
# modifier therefore exports the spiky rest mesh; applying it bakes the real shape
|
||
# into the vertices, which is what we want before discarding the rig.
|
||
for m in list(g.modifiers):
|
||
if m.type == 'ARMATURE':
|
||
apply_mod(g, m.name)
|
||
wm = g.matrix_world.copy()
|
||
g.parent = None
|
||
g.matrix_world = wm
|
||
for a in part_arms:
|
||
if a.name in bpy.data.objects:
|
||
bpy.data.objects.remove(a, do_unlink=True)
|
||
bpy.context.view_layer.update()
|
||
mn3, mx3 = bbox_of(part_meshes)
|
||
print(f'graft: positioned {len(part_meshes)} mesh(es) at {TARGET_BONE}, no binding '
|
||
f'(re-rig downstream); part now {tuple(round(c, 3) for c in mn3)}..'
|
||
f'{tuple(round(c, 3) for c in mx3)}')
|
||
export(ARGS[2])
|
||
raise SystemExit(0)
|
||
|
||
# --bind=body (default): weights from the body, NOT Automatic Weights on a merged mesh.
|
||
for g in part_meshes:
|
||
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
|
||
if mirror:
|
||
# A negative-scale mirror leaves every face wound backwards, so the part renders
|
||
# inside-out. Recalculate outward normals on the mirrored geometry.
|
||
for g in part_meshes:
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
g.select_set(True)
|
||
bpy.context.view_layer.objects.active = g
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
print('graft: mirrored — normals recalculated outward')
|
||
for a in part_arms: # never join two mixamorig rigs
|
||
if a.name in bpy.data.objects:
|
||
bpy.data.objects.remove(a, do_unlink=True)
|
||
print(f'grafted {len(part_meshes)} mesh(es) onto {TARGET_BONE}')
|
||
export(ARGS[2])
|
||
|
||
elif OP == 'mixamoprep':
|
||
# mixamoprep <in> <out.fbx> [height_m]
|
||
#
|
||
# Mixamo's auto-rigger wants ONE mesh and no existing skeleton — hand it several objects or a
|
||
# rig and it either refuses or rigs the wrong thing. So: bake any graft MASK modifiers (or the
|
||
# geometry we "removed" comes back in the export), join every real mesh into one, drop the
|
||
# armature entirely, and write FBX.
|
||
clean()
|
||
objs = load(ARGS[0])
|
||
keep = real_meshes(objs)
|
||
if not keep:
|
||
raise SystemExit('nothing to export')
|
||
for o in keep: # bake masks first — a modifier is not a deletion
|
||
for m in list(o.modifiers):
|
||
if m.type == 'MASK':
|
||
apply_mod(o, m.name)
|
||
elif m.type == 'ARMATURE':
|
||
o.modifiers.remove(m) # re-rigging from scratch; old binding is noise
|
||
for o in list(bpy.data.objects): # armatures and helper widgets both confuse the rigger
|
||
if o.type == 'ARMATURE' or (o.type == 'MESH' and o not in keep):
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
keep = [o for o in keep if o.name in bpy.data.objects]
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
for o in keep:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = keep[0]
|
||
if len(keep) > 1:
|
||
bpy.ops.object.join()
|
||
merged = bpy.context.view_layer.objects.active
|
||
for vg in list(merged.vertex_groups): # stale groups name bones that no longer exist
|
||
merged.vertex_groups.remove(vg)
|
||
if len(ARGS) > 2:
|
||
mn, mx = bbox_of([merged])
|
||
h = mx.z - mn.z
|
||
if h > 0:
|
||
s = float(ARGS[2]) / h
|
||
merged.scale = [c * s for c in merged.scale]
|
||
print(f'mixamoprep: scaled x{s:.4f} to {ARGS[2]}m')
|
||
tris = sum(len(p.vertices) - 2 for p in merged.data.polygons)
|
||
print(f'mixamoprep: one mesh, {len(merged.data.vertices):,} verts / {tris:,} tris, no armature')
|
||
if tris > 1500000:
|
||
print('mixamoprep: WARNING — Mixamo rejects very dense meshes; decimate first')
|
||
os.makedirs(os.path.dirname(ARGS[1]), exist_ok=True)
|
||
bpy.ops.export_scene.fbx(filepath=ARGS[1], path_mode='COPY', embed_textures=True)
|
||
print(f'WROTE {ARGS[1]}')
|
||
|
||
elif OP == 'thumb':
|
||
# thumb <in> <out.png> [px]
|
||
# Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the
|
||
# framing). EEVEE + transparent film so thumbs composite onto any panel background.
|
||
clean()
|
||
objs = load(ARGS[0])
|
||
px = int(ARGS[2]) if len(ARGS) > 2 else 256
|
||
import mathutils
|
||
for o in bpy.data.objects: # bind pose — a mid-clip thumb is unreadable
|
||
if o.animation_data:
|
||
o.animation_data_clear()
|
||
bpy.context.view_layer.update()
|
||
mn, mx = bbox_of(objs)
|
||
c = (mn + mx) / 2
|
||
size = max((mx - mn).x, (mx - mn).z) or 1.0
|
||
sc = bpy.context.scene
|
||
sc.render.engine = 'BLENDER_EEVEE'
|
||
sc.render.resolution_x = sc.render.resolution_y = px
|
||
sc.render.film_transparent = True
|
||
w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True
|
||
w.node_tree.nodes['Background'].inputs[0].default_value = (1, 1, 1, 1)
|
||
w.node_tree.nodes['Background'].inputs[1].default_value = 1.0
|
||
cam = bpy.data.objects.new('cam', bpy.data.cameras.new('c'))
|
||
cam.data.type = 'ORTHO'; cam.data.ortho_scale = size * 1.15
|
||
bpy.context.collection.objects.link(cam)
|
||
cam.location = c + mathutils.Vector((0, -size * 3, 0))
|
||
cam.rotation_euler = mathutils.Vector((0, 1, 0)).to_track_quat('-Z', 'Y').to_euler()
|
||
cam.rotation_euler = (1.5708, 0, 0) # look down +Y at the front of the model
|
||
sc.camera = cam
|
||
sun = bpy.data.objects.new('sun', bpy.data.lights.new('l', 'SUN'))
|
||
sun.data.energy = 3.0; sun.rotation_euler = (0.9, 0.2, 0.5)
|
||
bpy.context.collection.objects.link(sun)
|
||
sc.render.filepath = ARGS[1]
|
||
bpy.ops.render.render(write_still=True)
|
||
print(f'THUMB {ARGS[1]} ({px}px)')
|
||
|
||
elif OP == 'unitfix':
|
||
# unitfix <in> <out.glb> [target_m]
|
||
#
|
||
# Deliberately NOT scale-to-height. A 0.06m human is a UNIT error (character_kit rigs are
|
||
# 6cm, NPCFACTORY banks 1.0m, TRELLIS output varies) and silently breaks assemble — that's
|
||
# what produced a 27x oversized garment. But normalising every body to one height would
|
||
# erase the small/medium/large/obese range the library is meant to carry, so we only correct
|
||
# scales that are physically impossible for a human and leave real proportions alone.
|
||
clean()
|
||
objs = load(ARGS[0])
|
||
target = float(ARGS[2]) if len(ARGS) > 2 else 1.72
|
||
mn, mx = bbox_of(objs) # helper widgets excluded, or we'd measure a 2.0-unit Icosphere
|
||
h = mx.z - mn.z
|
||
if h <= 0:
|
||
raise SystemExit('flat object — no height to measure')
|
||
PLAUSIBLE = (0.5, 3.0) # any human outside this is a broken unit scale, not a body type
|
||
if PLAUSIBLE[0] <= h <= PLAUSIBLE[1]:
|
||
print(f'height {h:.3f}m is plausible — left alone (body-type variation is data, not an error)')
|
||
s = 1.0
|
||
else:
|
||
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]
|
||
apply_scale(roots, objs)
|
||
print(f'UNIT FIX: {h:.4f}m is impossible for a human -> scaled x{s:.4f} to {target}m')
|
||
print(f'unitfix done (scale {s:.4f})')
|
||
export(ARGS[1])
|
||
|
||
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 is_helper(o):
|
||
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}')
|