wardrobegod/blender_ops.py
type-two 305fbf9447 Phase 5: the dressing room — thumbnails, a manifest, and the rig/LOD/dress bench on one page
The wardrobe had grown past the point where a list of filenames is usable: 20 bodies and a
garments list you had to read rather than look at. Three things close that.

THUMBNAILS. New `thumb` blender op — fixed front ortho camera, EEVEE, transparent film, bind
pose (a thumb caught mid-clip is unreadable), framed on the real mesh with helper widgets
excluded or a bone widget decides the crop. /thumb?p= renders lazily and caches on a hash of
the source path. 2D layers skip Blender entirely and downscale through PIL — 12ms vs a process
launch. First paint of a fresh grid is slow because every 3D card spawns a render; after that
they're cached. Worth knowing before it looks broken.

MANIFEST (library/index.json, written atomically so a half-write can't blank the wardrobe).
Canonical slots and a layer int that drives BOTH the 2D doll stack and 3D ordering, so a
garment sorts the same in either product. Slots are GUESSED from the filename as a convenience
and the guess is marked as such — the UI shows `head?` with a "confirm it" note, and setting a
slot explicitly clears the inferred flag. A guess presented as fact is how a library quietly
fills with wrong data. Props (boombox, camera, thermos) correctly come back unset rather than
being forced into a garment slot.

THE BENCH, in the order you actually work: fix units -> rig (MIRPAMO) -> LOD -> dress. LOD is
four preset buttons at the tiers the games budget for (hero 60k / npc 18k / crowd 6k / tiny 2k)
instead of typing tri counts. /api/rig shells out to MIRPAMO, and the UI states its real
limitation up front: 22 bones, no fingers — fine for locomotion, but a 65-bone donor garment
will leave cuffs under-driven, which is exactly the mismatch that bit us in Phase 4.

Grid, slot tabs with counts, search over name+tags, metadata editor, LOD presets and the bench
buttons all verified in-browser with no JS errors. nsfw tagging from the previous commit carries
through: tagged assets stay hidden behind the toggle and out of the grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:25:44 +10:00

408 lines
19 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 == 'thumb':
# thumb <in> <out.png> [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 <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}')