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:
parent
d6c0ba4c0e
commit
305fbf9447
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,3 +6,4 @@ __pycache__/
|
||||
.DS_Store
|
||||
.env
|
||||
tools/__pycache__/
|
||||
.thumbs/
|
||||
|
||||
@ -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
136
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')
|
||||
|
||||
110
web/index.html
110
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 @@
|
||||
<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 (<0.5m or >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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user