"""WARDROBEGOD blender ops — headless Blender does the heavy lifting the browser can't. blender -b --python blender_ops.py -- ops: convert any format Blender reads → GLB (textures packed) scale uniform scale so bbox height = height_m, applied decimate Decimate modifier — PRESERVES vertex weights/rig fit [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 [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 ... 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, 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 == 'thumb': # thumb [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 [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 [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}')