wardrobegod/blender_ops.py
type-two 037f6ef0af Fold NPCFACTORY in: vendor three.js, take the banks, and fix units without flattening body types
NPCFACTORY and wardrobegod were the same product — wardrobegod had already absorbed its reskin
engine, and keeping two benches means two half-libraries and two export paths. Folded, keeping
the wardrobegod name and codebase (370 lines there vs ~1,100 here).

· Vendored three.js r175 from NPCFACTORY, replacing the unpkg CDN importmap. This was real
  drift, not tidiness: a CDN import breaks offline and can't be lifted into a game build.
  NPCFACTORY lacked OrbitControls (it used PointerLock), so that one addon was fetched at the
  matching revision. Added a guarded /vendor/ static route. Verified in-browser: zero CDN
  requests, 5 vendor files, model still loads.
· Took the 17 rigged walk-animated NPCs and 6 parts. Bodies 4 -> 20.
· New `unitfix` op. Deliberately NOT scale-to-height: a 0.06m human is a UNIT error, but
  normalising everything to 1.72m would erase the small/medium/large/obese range the library is
  meant to carry. So it only corrects heights outside 0.5-3.0m — physically impossible for a
  human — and leaves real proportions alone as data. Verified both ways: hum_character 0.0576m
  -> 1.72m, tradie 1.000m left untouched.

Three bugs found while building it, two of them pre-existing:
· `is_helper`/`real_meshes`/`bbox_of` factored out. Material-less bone widgets (a radius-1
  42-vert Icosphere, so exactly 2.0 units tall) were being measured INSTEAD of the character —
  every body reported 2.000m. This poisoned the `scale` op too, which has been measuring
  widgets all along; NPCFACTORY's render_plates.py had independently worked around the same
  thing by framing on the dominant mesh.
· `transform_apply` under temp_override(selected_editable_objects=...) SEGFAULTS Blender 5.1.2
  on rigs with parented children. A segfault can't be caught, so it's avoided rather than
  handled: glTF encodes node scale natively, so setting the root transform is sufficient and
  every downstream measurement still reads correctly. Confirmed `scale` still round-trips
  (1.00m -> 1.72m, re-measured).
· My own bulk edit replaced only ONE of the two crash-prone call sites and reported "replaced 1"
  — I didn't check for a second, which is why `scale` worked while `unitfix` kept crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:36:03 +10:00

372 lines
17 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, 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 == '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}')