wardrobegod/blender_ops.py
type-two a682bd871b graft op: swap crude AI body parts for detailed ones, without the cut-and-bridge dead end
John wants to attach better hands/feet/faces to Mixamo-rigged TRELLIS characters and asked
whether it can be automated. It can, but not the way the standard tutorial workflow says.

Measured on his actual file (chubs-trelli-30k-sol-rigged.fbx): 240,615 verts, 33 bones, and only
EIGHT finger bones across both hands — Mixamo's reduced skeleton, which is exactly why the hands
look crude. Armature scale 0.01 against mesh scale 100.0, the classic cm/m mismatch.

Three things in the usual advice are wrong for these meshes specifically:
· "Bridge Edge Loops" needs matching vertex counts on both boundary loops. A TRELLIS cut edge is
  arbitrary unstructured soup, so bridging to a clean hand needs manual retopo — that step is
  where the evening goes.
· "Ctrl+P > With Automatic Weights" on the joined mesh re-weights the WHOLE character, destroying
  the skinning Mixamo just produced.
· "Ctrl+J the two armatures" collides every mixamorig bone name; Blender suffixes the newcomer
  .001 and the weights silently point at bones that no longer exist. That is the same failure
  that left a harvested garment frozen in bind pose earlier today.

So graft never cuts. It MASKS the body geometry near the target bone (vertex group + inverted
MASK modifier — reversible, and no boundary loop required), aligns the part with a single matrix
that scales about its own bbox centre onto the bone, transfers weights from the body with the
same data_transfer `fit` uses, and parents to the EXISTING armature.

Two bugs found while testing, both mine:
· scaling the part's root objects compounded with the donor's own armature/empty transforms and
  flung the part far enough to blow out the scene bbox (×111 instead of ×11). Now one matrix
  applied to the meshes themselves, after detaching them from the donor hierarchy world-position-safe.
· after bpy.data.objects.remove(), even reading o.name on a dropped object raises
  ReferenceError: StructRNA has been removed. Names are now resolved before removal.

Also: a "part" file is often a whole donor character — character_kit/rigged/hand1.glb holds a
prop, a full body AND a widget — so --mesh=<substr> picks the mesh and the op PRINTS what the
file contained and what it chose, rather than silently grafting a torso.

Runs on ultra per John's steer; this laptop's Blender is crash-prone (it segfaulted on me earlier
in this session).

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

531 lines
25 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, 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]}')
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 = None
for a in ARGS[5:]:
if a.startswith('--mesh='):
want_mesh = 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]
# Detach from the donor's own hierarchy first, keeping world position, so the transform below
# is not fighting a parent armature's scale (the donor is often 100x ours).
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))
for g in part_meshes:
g.matrix_world = T @ g.matrix_world
bpy.context.view_layer.update()
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 {bone.name}')
# Weights from the body, not Automatic Weights on the merged result.
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 {bone.name}')
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}')