diff --git a/.gitignore b/.gitignore index 11e4fb1..3142c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ .DS_Store .env tools/__pycache__/ +.thumbs/ diff --git a/blender_ops.py b/blender_ops.py index e9375c6..bcc0f8e 100644 --- a/blender_ops.py +++ b/blender_ops.py @@ -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 [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] # diff --git a/server.py b/server.py index 6f7fcff..bc22d1b 100644 --- a/server.py +++ b/server.py @@ -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') diff --git a/web/index.html b/web/index.html index cb85fed..86d8298 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@
-

body ops

+

bench — prep a body

+
+
+
fix units only corrects impossible heights (<0.5m or >3m) — real + small/medium/large proportions are kept. MIRPAMO rigs to 22 bones, no fingers: fine for + locomotion, but a 65-bone donor garment will leave cuffs under-driven.
+

LOD

+
decimate keeps skin weights — the safe local path for rigged meshes (never the farm /finish).
@@ -144,7 +167,14 @@

garments

-
+
+ +
+
+
+
+
+

rigid attach (preview)

@@ -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 = + `` + + SLOTS.filter(s => counts[s]).map(s => + ``).join('') + + (unset ? `` : ''); + 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 ? `${g.slot}${g.slot_inferred ? '?' : ''}` : ''; + return `
+ + ${slot}${g.nsfw ? 'NSFW' : ''} + ${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')} +
`; + }).join('') || '
nothing matches — clear the filter or generate one →
'; + + 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 = '' + + SLOTS.map(s => ``).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('') || '
drop a GLB/FBX above
'; if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `
${nb} anatomical base/s hidden
`); - $('garments').innerHTML = visible(LIB.garments).map(i => rowHtml(i, 'garments')).join('') || '
none yet — generate one →
'; + renderGarmentGrid(); $('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '
nothing generated yet
'; $('outs').innerHTML = visible(LIB.out).map(i => rowHtml(i, 'out')).join('') || '
no outfits assembled yet
'; // 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]) => + ``).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');