wardrobegod/blender_ops.py
type-two 58354d9b66 WARDROBEGOD v1 — wardrobe generator bench (bodies, fits, garment gen pipeline)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:53:43 +10:00

167 lines
6.6 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)
assemble <body> <out.glb> <garment.glb>... dressed combo: garments must have been fitted
against the same skeleton (bone names match)
House rules: never fit against an unrigged body (we abort); rigged meshes are never sent to
the farm /finish (this file is the safe local path for them).
"""
import bpy, sys, os
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 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])
import mathutils
mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3)
for o in 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))
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]
with bpy.context.temp_override(selected_editable_objects=roots + meshes(objs) + armatures(objs)):
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
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 == 'assemble':
clean()
body_objs = load(ARGS[0])
arms = armatures(body_objs)
if not arms:
raise SystemExit('body has no armature')
arm = arms[0]
for gpath in ARGS[2:]:
gobjs = load(gpath)
for g in meshes(gobjs):
g.parent = arm
for m in g.modifiers:
if m.type == 'ARMATURE':
m.object = arm
if not any(m.type == 'ARMATURE' for m in g.modifiers):
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
for a in armatures(gobjs): # bone names match by contract — drop the dupe
bpy.data.objects.remove(a, do_unlink=True)
print(f'assembled body + {len(ARGS) - 2} garment file(s)')
export(ARGS[1])
else:
raise SystemExit(f'unknown op {OP}')