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>
372 lines
17 KiB
Python
372 lines
17 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)
|
||
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}')
|