Instrumented the graft stage by stage on ultra rather than guessing further. Measured:
mesh AFTER T : (-0.434 .. -0.352) <- correct, anchor was -0.393. The transform was always right.
mesh AFTER join : (-0.024 .. 0.002) <- snapped to origin
mesh parent now : Armature mods: [('ARMATURE', None)]
So bpy.ops.object.join() deletes the part's armature OBJECT, leaves the part mesh's ARMATURE
modifier pointing at None, and re-parents the mesh to whatever empty sat above it (Sketchfab
exports nest several). Fixed by saving each part mesh's world matrix before the join, then
re-binding the modifier to the body armature and restoring the placement.
That fixes the PLACEMENT — the hand now lands on the wrist — but exposes the real problem
underneath, which is NOT fixed: the hand mesh is violently stretched. The body armature carries
object scale 0.01 and the hand rig 1.0, so joining bakes the hand bones into the body's space
while the mesh's bind pose still expects the original bone positions. The armature modifier then
deforms it from a bind pose that no longer matches. Restoring the object matrix cannot fix this:
on a skinned mesh the bones drive the geometry, not the object transform.
STATUS, stated plainly:
--bind=body WORKS. Detailed geometry on the existing rig, no finger articulation.
--bind=part places correctly, deforms wrongly. DO NOT USE.
The better route, and the one to take given John is happy to re-rig: graft the hand geometry with
--bind=body, then send the combined mesh back through Mixamo for a fresh full auto-rig. That
sidesteps the cross-armature rebind entirely, lets the tool that is actually good at binding do
the binding, and returns mixamorig-NAMED finger bones — which the existing clip bank can drive,
unlike this hand's _rootJoint/thumb_base.R_03 naming, which nothing in the bank references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
602 lines
29 KiB
Python
602 lines
29 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]
|
||
body = max(real_meshes(body_objs), key=lambda o: len(o.vertex_groups), default=None)
|
||
if body is None:
|
||
raise SystemExit('no body mesh found')
|
||
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 = None, 'body'
|
||
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()
|
||
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 == 'body':
|
||
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
|
||
T = (mathutils.Matrix.Translation(anchor)
|
||
@ mathutils.Matrix.Scale(ratio, 4)
|
||
@ mathutils.Matrix.Translation(-centre))
|
||
if bind == 'body':
|
||
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_' + bone.name.split(':')[-1])
|
||
inv = body.matrix_world.inverted()
|
||
local_anchor = inv @ anchor
|
||
hidden = [v.index for v in body.data.vertices
|
||
if (v.co - local_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=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
|
||
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 == '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}')
|