167 lines
6.6 KiB
Python
167 lines
6.6 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)
|
||
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}')
|