wardrobegod/blender_ops.py
type-two d4cd8e7ad7 remesh op: game-LOD TRELLIS heroes — solidify past voxel, voxel remesh, decimate floor 1/3, smart-UV, Cycles bake
Thin-shell physics learned the hard way: fabric thinner than the voxel
perforates in the remesh itself; decimating any thin slab below ~1/3
collapses its surfaces. Verified on the baseball cap: 483k -> 41k tris,
watertight, textured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 21:28:40 +10:00

797 lines
39 KiB
Python
Raw Permalink 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]
# 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])
elif OP == 'remesh':
# remesh <in> <out.glb> <target_tris> [tex_size]
#
# Game-LOD a TRELLIS hero mesh. Plain Decimate moth-eats TRELLIS output at
# ANY ratio (verified 25k and 50k on the baseball cap) because the surface
# is a paper-thin double shell — front and back faces collapse into each
# other. So: voxel-remesh into one solid watertight skin first (kills the
# UVs), decimate THAT, give it fresh smart-project UVs, and Cycles-bake the
# original's textured surface back onto it (selected->active). Rigid props
# only — vertex weights do not survive; use decimate for rigged meshes.
src, out, target = ARGS[0], ARGS[1], int(ARGS[2])
tex_size = int(ARGS[3]) if len(ARGS) > 3 else 2048
load(src)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
orig = max(meshes, key=lambda o: len(o.data.vertices))
for o in meshes:
if o is not orig:
bpy.data.objects.remove(o, do_unlink=True)
for o in [o for o in bpy.data.objects if o.type in ('EMPTY', 'ARMATURE')]:
bpy.data.objects.remove(o, do_unlink=True)
orig.rotation_mode = 'XYZ'
lod = orig.copy()
lod.data = orig.data.copy()
lod.name = 'lod'
bpy.context.scene.collection.objects.link(lod)
# Voxel-remesh into ONE solid skin at a voxel fine enough to keep thin
# features (brims, straps): 0.8% of bbox — 39mm voxels ate the cap brim
# entirely, 8mm kept it. Then decimate no lower than 1/3: below that the
# thin solid slab's two surfaces collapse into each other (the same
# moth-eating plain Decimate causes). The target is therefore a WISH —
# the shell's physics set the floor, and the op reports what it kept.
bpy.context.view_layer.objects.active = lod
vox = max(orig.dimensions) * 0.008
# SOLIDIFY FIRST: TRELLIS fabric is ~2mm thick — anywhere the shell is
# thinner than the voxel, the remesh itself perforates (the cap crown
# tore at 8mm voxel in every variant while the double-folded brim held).
# Thickening the shell past the voxel size guarantees a watertight solid.
sol = lod.modifiers.new('sol', 'SOLIDIFY')
sol.thickness = vox * 1.6
sol.offset = 0.0
bpy.ops.object.modifier_apply(modifier='sol')
rm = lod.modifiers.new('remesh', 'REMESH')
rm.mode = 'VOXEL'
rm.voxel_size = vox
bpy.ops.object.modifier_apply(modifier='remesh')
tris = sum(len(pg.vertices) - 2 for pg in lod.data.polygons)
if tris > target:
dec = lod.modifiers.new('dec', 'DECIMATE')
dec.ratio = max(target / max(tris, 1), 0.33)
bpy.ops.object.modifier_apply(modifier='dec')
bpy.ops.object.select_all(action='DESELECT')
lod.select_set(True)
bpy.context.view_layer.objects.active = lod
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
img = bpy.data.images.new('lod_bake', tex_size, tex_size, alpha=False)
mat = bpy.data.materials.new('lod_mat')
mat.use_nodes = True
nt = mat.node_tree
bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
tn = nt.nodes.new('ShaderNodeTexImage')
tn.image = img
nt.nodes.active = tn
lod.data.materials.clear()
lod.data.materials.append(mat)
sc = bpy.context.scene
sc.render.engine = 'CYCLES'
sc.cycles.samples = 16 # flat diffuse transfer needs few samples
sc.cycles.device = 'CPU'
sc.render.bake.use_selected_to_active = True
sc.render.bake.cage_extrusion = max(orig.dimensions) * 0.03
sc.render.bake.max_ray_distance = max(orig.dimensions) * 0.08
sc.render.bake.use_pass_direct = False
sc.render.bake.use_pass_indirect = False
bpy.ops.object.select_all(action='DESELECT')
orig.select_set(True)
lod.select_set(True)
bpy.context.view_layer.objects.active = lod
bpy.ops.object.bake(type='DIFFUSE')
nt.links.new(tn.outputs['Color'], bsdf.inputs['Base Color'])
img.pack()
bpy.data.objects.remove(orig, do_unlink=True)
final_tris = sum(len(pg.vertices) - 2 for pg in lod.data.polygons)
print(f'remeshed: voxel {vox * 1000:.1f}mm -> {tris:,} tris -> decimated {final_tris:,} tris, baked {tex_size}px')
export(out)
else:
raise SystemExit(f'unknown op {OP}')