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>
This commit is contained in:
type-two 2026-07-24 20:25:44 +10:00
parent d6c0ba4c0e
commit 305fbf9447
4 changed files with 276 additions and 7 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ __pycache__/
.DS_Store
.env
tools/__pycache__/
.thumbs/

View File

@ -188,6 +188,42 @@ elif OP == 'fit':
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]
#

136
server.py
View File

@ -26,12 +26,15 @@ LIB = os.path.join(ROOT, 'library')
DIRS = {'bodies': os.path.join(LIB, 'bodies'), 'garments': os.path.join(LIB, 'garments'),
'gen': os.path.join(LIB, 'gen'), 'out': os.path.join(ROOT, 'out')}
OUTFITS = os.path.join(LIB, 'outfits') # saved dress-ups: body + attachments + clip + tints
THUMBS = os.path.join(ROOT, '.thumbs') # lazy 256px previews, keyed on source path hash
MANIFEST = os.path.join(LIB, 'index.json') # slot / layer / tags per asset — the wardrobe's spine
EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
os.path.expanduser('~/Documents/thriftgod/web/assets/models')]
CACHE = os.path.join(ROOT, '.glbcache')
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
OPS = os.path.join(ROOT, 'blender_ops.py')
TOOLS = os.path.join(ROOT, 'tools') # reskin trio (NPCFACTORY) + the doll compositor (90sDJsim)
MIRPAMO = os.environ.get('WG_MIRPAMO', os.path.expanduser('~/Documents/MIRPAMO/bin/mirpamo'))
# Creds come off disk, never off the command line. .env beside server.py (gitignored) holds the
# CF Workers AI pair; MB_TOKEN lives in backnforth/.env — read it there rather than making every
# launch remember `export MB_TOKEN=…` (without it the farm tiers silently die: no RMBG-2.0, no
@ -119,15 +122,73 @@ def scan():
for f in sorted(os.listdir(dd)):
if f.lower().endswith(exts):
p = os.path.join(dd, f)
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
'home': os.path.basename(dd),
'nsfw': is_nsfw(f),
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
'home': os.path.basename(dd), 'nsfw': is_nsfw(f),
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}
if key == 'garments':
row.update(manifest_for(p, f))
rows.append(row)
out[key] = rows
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
return out
# Canonical slots + draw order. The layer int drives BOTH the 2D doll stack and the 3D
# normal-push, so one garment's ordering is the same in either product.
SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag']
LAYER = {'skin': 0, 'legs': 20, 'feet': 25, 'torso': 30, 'outer': 40,
'head': 50, 'hand': 55, 'accessory': 60, 'bag': 70}
# Guessing a slot from the filename is a convenience, never an assertion — every guess is
# overridable and the manifest records which ones were inferred rather than set.
SLOT_HINTS = [
('head', r'hat|cap|beanie|helmet|hood|bucket|fedora|akubra|visor|glasses|sunnies'),
('feet', r'shoe|boot|sneaker|trainer|sandal|thong|volley|plimsoll|ugg|blundstone'),
('legs', r'pant|trouser|jean|short|skirt|trackie|jogger|legging|stubbies|cargo'),
('outer', r'jacket|coat|parka|windbreaker|hoodie|anorak|blazer|cardigan'),
('torso', r'shirt|tee|top|jumper|sweater|knit|singlet|polo|blouse|flanno|flannel'),
('bag', r'bag|backpack|rucksack|satchel|bumbag'),
('hand', r'glove|mitt|watch|bracelet'),
]
def guess_slot(name):
n = name.lower()
for slot, pat in SLOT_HINTS:
if re.search(pat, n):
return slot
return None
def load_manifest():
if os.path.exists(MANIFEST):
try:
return json.load(open(MANIFEST))
except ValueError:
pass
return {'schemaVersion': 1, 'items': {}}
def save_manifest(m):
tmp = MANIFEST + '.tmp'
with open(tmp, 'w') as f:
json.dump(m, f, indent=1, sort_keys=True)
os.replace(tmp, MANIFEST) # atomic — a half-written index would blank the wardrobe
def manifest_for(path, name):
"""The record for one asset: stored fields win, the rest are inferred and marked as such."""
m = load_manifest()
rec = dict(m['items'].get(os.path.basename(path), {}))
if 'slot' not in rec:
g = guess_slot(name)
if g:
rec['slot'], rec['slot_inferred'] = g, True
rec.setdefault('layer', LAYER.get(rec.get('slot'), 30))
rec.setdefault('tags', [])
rec['nsfw'] = is_nsfw(name)
return rec
def doll_catalogue():
return DOLL.catalogue() if DOLL else {}
@ -351,6 +412,24 @@ class H(BaseHTTPRequestHandler):
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'nope'}, 403)
return self.send(200, open(p, 'rb').read(), mimetypes.guess_type(p)[0] or 'application/octet-stream')
if u.path == '/thumb': # lazy 256px thumb, cached; the grid needs these
p = q.get('p', '')
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'nope'}, 403)
key = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12]
tgt = os.path.join(THUMBS, key + '.png')
if not os.path.exists(tgt) or os.path.getmtime(tgt) < os.path.getmtime(p):
try:
if p.lower().endswith(IMG_EXT):
from PIL import Image # 2D layers: a downscale, not a render
im = Image.open(p).convert('RGBA')
im.thumbnail((256, 256), Image.LANCZOS)
im.save(tgt)
else:
run_blender(['thumb', glb_of(p), tgt, 256])
except Exception as e:
return self.j({'error': str(e)[-200:]}, 500)
return self.send(200, open(tgt, 'rb').read(), 'image/png')
if u.path == '/glb': # stage loader: any model file → GLB (convert+cache)
p = q.get('p', '')
if not allowed(p) or not os.path.isfile(p):
@ -379,6 +458,50 @@ class H(BaseHTTPRequestHandler):
body = json.loads(raw or b'{}')
if u.path == '/api/rig': # bench step 1: raw mesh → rigged mixamorig
# MIRPAMO is the local offline Mixamo (auto-rig + retarget). Its skeleton is 22 bones
# with NO fingers, which is fine for locomotion but leaves cuff/forearm verts
# under-driven if you later hang a 65-bone donor garment on it — say so rather than
# let it surprise you downstream.
p = body.get('path', '')
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
if not os.path.exists(MIRPAMO):
return self.j({'error': 'MIRPAMO not found at ' + MIRPAMO}, 400)
jid = new_job('rig', 'rig ' + os.path.basename(p))
def fx(jid):
out = os.path.join(DIRS['bodies'],
tagged_name(slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigged.glb',
is_nsfw(p)))
cmd = [MIRPAMO, 'rig', p, '-o', out]
if body.get('tris'):
cmd += ['--tris', str(int(body['tris']))]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
JOBS[jid]['log'] = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:])
if r.returncode != 0 or not os.path.exists(out):
raise RuntimeError(JOBS[jid]['log'][-400:] or 'rig failed')
return out
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/meta': # set slot / layer / tags on a garment
p = body.get('path', '')
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
m = load_manifest()
rec = m['items'].setdefault(os.path.basename(p), {})
if body.get('slot') in SLOTS:
rec['slot'] = body['slot']
rec['layer'] = LAYER.get(body['slot'], 30)
rec.pop('slot_inferred', None) # an explicit choice is no longer a guess
if isinstance(body.get('tags'), list):
rec['tags'] = [str(t).strip().lower() for t in body['tags'] if str(t).strip()]
if body.get('title'):
rec['title'] = str(body['title'])[:80]
save_manifest(m)
return self.j({'ok': True, 'rec': rec})
if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset
p, nsfw = body.get('path', ''), bool(body.get('nsfw'))
if not allowed(p) or not os.path.isfile(p):
@ -611,6 +734,11 @@ class H(BaseHTTPRequestHandler):
tagged_name(slug(a.get('name') or 'outfit') + '.glb',
is_nsfw(a['body'])))
run_blender(['assemble', b, out] + gs, jid)
elif op == 'unitfix':
src = glb_of(a['path'], jid)
base = slug(os.path.basename(a['path']).rsplit('.', 1)[0])
out = os.path.join(DIRS['bodies'], tagged_name(base + '-m.glb', is_nsfw(a['path'])))
run_blender(['unitfix', src, out, a.get('height', 1.72)], jid)
elif op == 'convert':
src = a['path']
out = os.path.join(DIRS['bodies'], slug(os.path.basename(src).rsplit('.', 1)[0]) + '.glb')

View File

@ -51,6 +51,22 @@
.fitchip { margin:3px 4px 0 0; font-weight:400 }
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; font-weight:700 }
/* dressing-room grid — a wardrobe you browse by looking, not by reading filenames */
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(76px,1fr)); gap:6px; margin:6px 0 }
.card { position:relative; border:1px solid var(--line); border-radius:8px; background:var(--panel);
cursor:pointer; overflow:hidden; aspect-ratio:1 }
.card:hover { border-color:var(--gold) } .card.on { border-color:var(--gold); background:#26210f }
.card img { width:100%; height:100%; object-fit:contain; display:block; background:#0d0b07 }
.card .cap { position:absolute; left:0; right:0; bottom:0; background:#000000c9; color:var(--ink);
font-size:9px; padding:2px 3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.card .slot { position:absolute; top:2px; left:2px; background:#0009; color:var(--gold);
font-size:8px; padding:0 3px; border-radius:3px; letter-spacing:.5px }
.card .warn { position:absolute; top:2px; right:2px; background:#7a2233; color:#ffd9e0;
font-size:8px; padding:0 3px; border-radius:3px }
.slottabs { display:flex; flex-wrap:wrap; gap:3px; margin:4px 0 }
.slottabs button { background:none; border:1px solid var(--line); color:var(--dim);
font-size:10px; padding:3px 6px; font-weight:400 }
.slottabs button.on { border-color:var(--gold); color:var(--gold) }
/* 2D paper-doll stage — takes over the viewport while the doll tab is open */
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:#14120e; z-index:3 }
@ -77,9 +93,16 @@
<button id="tagBtn" class="ghost" style="margin-left:auto">tag / untag</button>
</div>
<div id="bodies"></div>
<h2>body ops</h2>
<h2>bench — prep a body</h2>
<div class="row"><button id="unitBtn" class="ghost">1 · fix units</button>
<button id="rigBtn" class="ghost">2 · rig (MIRPAMO)</button></div>
<div class="note">fix units only corrects impossible heights (&lt;0.5m or &gt;3m) — real
small/medium/large proportions are kept. MIRPAMO rigs to 22 bones, <b>no fingers</b>: fine for
locomotion, but a 65-bone donor garment will leave cuffs under-driven.</div>
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
<h2>LOD</h2>
<div class="row"><label>target tris</label><input id="decT" value="18000"><button id="decBtn" class="ghost">decimate</button></div>
<div class="row" id="lodPresets"></div>
<div class="note">decimate keeps skin weights — the safe local path for rigged meshes (never the farm /finish).</div>
</div>
@ -144,7 +167,14 @@
<div id="tab-wardrobe">
<h2>garments</h2>
<input type="file" id="upGarm" accept=".glb,.gltf,.fbx,.obj,.png,.jpg" style="margin-bottom:6px">
<div id="garments"></div>
<div class="slottabs" id="slotTabs"></div>
<input id="garmSearch" placeholder="search name or tag…" style="margin-bottom:4px">
<div id="garments" class="grid"></div>
<div class="row"><label>slot</label>
<select id="metaSlot"><option value="">— unset —</option></select></div>
<div class="row"><label>tags</label><input id="metaTags" placeholder="knit, 90s, oversized"></div>
<div class="row"><button id="metaSave" class="ghost">save details</button>
<span class="note" id="metaNote"></span></div>
<h2>rigid attach (preview)</h2>
<div class="row"><label>bone</label><select id="boneSel"><option></option></select></div>
<div class="row"><label>scale</label><input id="atScale" type="range" min="0.05" max="2" step="0.01" value="0.25"></div>
@ -418,11 +448,70 @@ function rowHtml(it, kind) {
function visible(rows) {
return (rows || []).filter(r => SHOW_NSFW || !r.nsfw);
}
// ---------- dressing-room grid ----------
const SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag'];
let SLOTFILTER = '', GARMQ = '';
function renderGarmentGrid() {
const all = visible(LIB.garments);
const counts = {};
all.forEach(g => { if (g.slot) counts[g.slot] = (counts[g.slot] || 0) + 1; });
const unset = all.filter(g => !g.slot).length;
$('slotTabs').innerHTML =
`<button data-slot="" class="${SLOTFILTER === '' ? 'on' : ''}">all ${all.length}</button>` +
SLOTS.filter(s => counts[s]).map(s =>
`<button data-slot="${s}" class="${SLOTFILTER === s ? 'on' : ''}">${s} ${counts[s]}</button>`).join('') +
(unset ? `<button data-slot="?" class="${SLOTFILTER === '?' ? 'on' : ''}">unset ${unset}</button>` : '');
document.querySelectorAll('#slotTabs button').forEach(b => b.onclick = () => {
SLOTFILTER = b.dataset.slot; renderGarmentGrid();
});
const q = GARMQ.trim().toLowerCase();
const rows = all.filter(g => {
if (SLOTFILTER === '?' && g.slot) return false;
if (SLOTFILTER && SLOTFILTER !== '?' && g.slot !== SLOTFILTER) return false;
if (!q) return true;
return (g.name + ' ' + (g.tags || []).join(' ') + ' ' + (g.title || '')).toLowerCase().includes(q);
});
$('garments').innerHTML = rows.map(g => {
const on = GARM && GARM.path === g.path;
// '?' marks a slot we guessed from the filename rather than one that was actually set
const slot = g.slot ? `<span class="slot">${g.slot}${g.slot_inferred ? '?' : ''}</span>` : '';
return `<div class="card ${on ? 'on' : ''}" data-p="${g.path}" title="${g.name}">
<img loading="lazy" src="/thumb?p=${encodeURIComponent(g.path)}" alt="">
${slot}${g.nsfw ? '<span class="warn">NSFW</span>' : ''}
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')}</span>
</div>`;
}).join('') || '<div class="note">nothing matches — clear the filter or generate one →</div>';
document.querySelectorAll('#garments .card').forEach(el => el.onclick = () => {
const g = (LIB.garments || []).find(x => x.path === el.dataset.p);
if (g) { previewGarment({ name: g.name, path: g.path }); showMeta(g); }
});
}
function showMeta(g) {
$('metaSlot').innerHTML = '<option value="">— unset —</option>' +
SLOTS.map(s => `<option value="${s}" ${g.slot === s ? 'selected' : ''}>${s}</option>`).join('');
$('metaTags').value = (g.tags || []).join(', ');
$('metaNote').textContent = g.slot_inferred ? 'slot guessed from the filename — confirm it' : '';
}
$('garmSearch').oninput = e => { GARMQ = e.target.value; renderGarmentGrid(); };
$('metaSave').onclick = async () => {
if (!GARM) return toast('pick a garment first');
const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: GARM.path, slot: $('metaSlot').value,
tags: $('metaTags').value.split(',').map(s => s.trim()).filter(Boolean) })
}).then(r => r.json());
toast(r.error ? '✗ ' + r.error : '✓ details saved');
refresh();
};
function renderLists() {
const nb = (LIB.bodies || []).filter(r => r.nsfw).length;
$('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `<div class="note">${nb} anatomical base/s hidden</div>`);
$('garments').innerHTML = visible(LIB.garments).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
renderGarmentGrid();
$('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = visible(LIB.out).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
@ -479,6 +568,21 @@ async function runJob(url, payload, label) {
if (j.status === 'error') { toast('✗ ' + label + ': ' + (j.log || '').split('\n').pop()); return null; }
}
}
// LOD presets: the tiers the games actually budget for, so you stop typing numbers
const LODS = [['hero', 60000], ['npc', 18000], ['crowd', 6000], ['tiny', 2000]];
$('lodPresets').innerHTML = LODS.map(([n, t]) =>
`<button class="ghost" data-lod="${t}" style="flex:1;font-size:11px">${n}<br><span class="sub">${(t / 1000)}k</span></button>`).join('');
document.querySelectorAll('[data-lod]').forEach(b => b.onclick = () => {
$('decT').value = b.dataset.lod;
if (BODY) runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +b.dataset.lod } }, 'decimate');
else toast('pick a body first');
});
$('unitBtn').onclick = () => BODY
? runJob('/api/blender', { op: 'unitfix', args: { path: BODY.path } }, 'unitfix')
: toast('pick a body first');
$('rigBtn').onclick = () => BODY
? runJob('/api/rig', { path: BODY.path, tris: +$('decT').value || 0 }, 'rig (minutes)')
: toast('pick a body first');
$('scaleBtn').onclick = () => BODY && runJob('/api/blender', { op: 'scale', args: { path: BODY.path, height: +$('scaleH').value } }, 'scale');
$('decBtn').onclick = () => BODY && runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +$('decT').value } }, 'decimate');
$('fitBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'garment', inflate: +$('fitInf').value } }, 'fit') : toast('pick a body AND a garment');