Export hub, unified wardrobe, warm thumbs, body-type heights
EXPORT HUB. An 8-agent pass read every consumer's ACTUAL loader code (not its docs) and adversarially re-verified each contract, because writing a wrong filename into a live game directory corrupts shipping assets silently. The result reframed the whole feature: of six targets, only TWO ingest by file drop. · 3GOD depot is wireable today, but NOT the way the plan assumed. Its auth trusts the raw socket peer against a tailnet allow-list, so through the digalot.fyi Cloudflare front the peer is CF and every write 403s. Measured live: /api/list reports authed=false via CF, authed=true direct on http://100.94.195.115:8788. (thriftgod's shipped prop_campaign.py --publish defaults to the CF URL and is broken for exactly this reason.) This is the high-value target — thriftgod AND procity both read their props from it. · The depot's clean_name DELETES illegal characters instead of substituting, so 'shop!-cat.glb' becomes 'shop-cat.glb' — an existing, different mesh — and is then served silently. Reproduced the rule exactly and added a pre-flight collision check that refuses unless overwrite is set. · The other four are NOT file drops and the hub says so instead of showing a green tick over a no-op: djsim peds and procity peds load from hardcoded name arrays, thriftgod's props come from literal HERO_FLOOR/HERO_COUNTER strings, and not-tonight has no garment/doll/character art path at all (find public -type d returns only public and public/props). Each returns 409 naming the exact file and line to edit. · thriftgod, the "most important 3D consumer", has NO paper-doll, atlas or reskin consumer — zero grep hits across web/ and tools/. Worth knowing before generating art for it. · nsfw-tagged assets are refused on every target, including the ready ones. UNIFIED WARDROBE. The 392 doll layers lived in a separate catalogue search couldn't reach, so "find the coogi jumper" only ever looked at the 9 3D garments. Both tiers now share one manifest and one grid: 401 items, tier filter, slot counts spanning both. Doll slots map to canonical ones (top->torso, bottom->legs, shoes->feet, hat->head) and are NOT marked as guesses, because the filename prefix is what djsim itself resolves art by. THUMBS. /api/thumbs/warm pre-renders everything, deliberately SERIAL — each 3D thumb launches its own Blender and a grid's worth at once buries the machine. 346 rendered, 0 failed. 2D layers are cropped to their alpha bbox first: a doll layer is a garment-sized patch on a 704x1408 canvas, so a plain downscale gives a mostly-empty tile. BODY-TYPE HEIGHTS. small 1.58 / medium 1.72 / large 1.88 / tall 1.95 / child 1.30 / obese 1.74 / stocky 1.68, stored per body and applied at export. This is the other half of unitfix only repairing impossible scales: the library keeps real proportions, the game gets a sane height. Verified in-browser, no JS errors: 401-item grid, cross-tier search, tier filter, export hub returning the registry line for each unready target, nsfw refused on a ready target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
305fbf9447
commit
9012e3e652
201
server.py
201
server.py
@ -35,6 +35,48 @@ BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS
|
||||
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'))
|
||||
# The 3GOD depot MUST be addressed direct over the tailnet. Its auth trusts the raw socket peer
|
||||
# against an allow-list, so through the digalot.fyi Cloudflare front the peer is CF and every
|
||||
# write 403s — measured: /api/list reports authed=false via CF, authed=true direct. (The shipped
|
||||
# prop_campaign.py --publish defaults to the CF URL and is broken for this exact reason.)
|
||||
GOD3 = os.environ.get('WG_GOD3', 'http://100.94.195.115:8788')
|
||||
|
||||
# Verified against each consumer's actual loader code, not its docs. "ready" means a file drop
|
||||
# alone makes the asset appear; everything else needs a registry edit first, and exporting
|
||||
# without one produces a file nobody loads.
|
||||
EXPORT_TARGETS = {
|
||||
'djsim-doll': {'ready': True,
|
||||
'why': 'djsim resolves doll art by filename and self-heals on restart'},
|
||||
'3god': {'ready': True,
|
||||
'why': 'HTTP depot; thriftgod and procity both read their props from it'},
|
||||
'djsim-ped': {'ready': False,
|
||||
'why': 'peds load from a hardcoded array, not a directory scan',
|
||||
'registry': 'ultra 90sDJsim/web/world/crowd/rigs.js PED_NAMES (:20-27) — the '
|
||||
'array that owns the street; index.html:639 has a second copy'},
|
||||
'procity-ped': {'ready': False,
|
||||
'why': 'same pattern — loadPedFleet reads a fixed name list',
|
||||
'registry': 'PROCITY/web/models/peds/ + the ped name array; publish props via '
|
||||
'pipeline/publish.py on m3ultra (the only box with _normalized/)'},
|
||||
'thriftgod-prop': {'ready': False,
|
||||
'why': 'thriftgod has NO garment consumer; props are hardcoded hero lists',
|
||||
'registry': 'thriftgod/web/index.html HERO_FLOOR / HERO_COUNTER (:1322-1325) '
|
||||
'— the filename must match a literal string exactly'},
|
||||
'not-tonight': {'ready': False,
|
||||
'why': 'only ingests public/props/<name>.png and requires a manifest entry; '
|
||||
'there is no doll, garment or character art path at all',
|
||||
'registry': 'not-tonight/public/props/manifest.json (+ tools/props_import.py)'},
|
||||
}
|
||||
|
||||
|
||||
def god3_name(name):
|
||||
"""Reproduce the depot's clean_name: illegal characters are DELETED, not substituted.
|
||||
|
||||
That deletion is the hazard — 'shop!-cat.glb' cleans to 'shop-cat.glb' and silently serves an
|
||||
existing, different mesh. Case is significant and spaces/underscores survive verbatim.
|
||||
"""
|
||||
n = re.sub(r'[^A-Za-z0-9._ -]', '', os.path.basename(name))
|
||||
n = re.sub(r'^[^A-Za-z0-9]+', '', n)
|
||||
return n or 'asset.glb'
|
||||
# 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
|
||||
@ -127,6 +169,11 @@ def scan():
|
||||
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}
|
||||
if key == 'garments':
|
||||
row.update(manifest_for(p, f))
|
||||
elif key == 'bodies':
|
||||
rec = load_manifest()['items'].get(f, {})
|
||||
row['body_type'] = rec.get('body_type') or DEFAULT_BODY_TYPE
|
||||
row['height'] = body_height(rec)
|
||||
row['tags'] = rec.get('tags', [])
|
||||
rows.append(row)
|
||||
out[key] = rows
|
||||
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
|
||||
@ -159,6 +206,22 @@ def guess_slot(name):
|
||||
return None
|
||||
|
||||
|
||||
# Body types carry their own target height. This is the whole reason unitfix refuses to
|
||||
# normalise everything to one number: a 'small' and an 'obese' character are not the same height,
|
||||
# and flattening them would erase the range the library exists to hold. Export applies the height
|
||||
# for the body's declared type; unitfix only ever repairs physically impossible scales.
|
||||
BODY_TYPES = {
|
||||
'small': 1.58, 'medium': 1.72, 'large': 1.88, 'tall': 1.95,
|
||||
'child': 1.30, 'obese': 1.74, 'stocky': 1.68,
|
||||
}
|
||||
DEFAULT_BODY_TYPE = 'medium'
|
||||
|
||||
|
||||
def body_height(rec):
|
||||
t = (rec or {}).get('body_type') or DEFAULT_BODY_TYPE
|
||||
return (rec or {}).get('height') or BODY_TYPES.get(t, BODY_TYPES[DEFAULT_BODY_TYPE])
|
||||
|
||||
|
||||
def load_manifest():
|
||||
if os.path.exists(MANIFEST):
|
||||
try:
|
||||
@ -189,8 +252,46 @@ def manifest_for(path, name):
|
||||
return rec
|
||||
|
||||
|
||||
def thumb_img(src, tgt):
|
||||
"""Thumbnail a 2D asset — cropped to its content first.
|
||||
|
||||
Doll layers are staged on a 704x1408 canvas with the garment occupying a slot-sized patch of
|
||||
it, so a plain downscale renders a mostly-empty tile with a tiny garment in it. Cropping to
|
||||
the alpha bbox makes a jumper look like a jumper at 256px.
|
||||
"""
|
||||
from PIL import Image
|
||||
im = Image.open(src).convert('RGBA')
|
||||
bb = im.getbbox() # None on a fully transparent image
|
||||
if bb:
|
||||
im = im.crop(bb)
|
||||
im.thumbnail((256, 256), Image.LANCZOS)
|
||||
im.save(tgt)
|
||||
|
||||
|
||||
def doll_catalogue():
|
||||
return DOLL.catalogue() if DOLL else {}
|
||||
"""The 2D layers, enriched with the same manifest records the 3D garments use.
|
||||
|
||||
The two catalogues were separate, so tag search only covered 3D and the 392 doll layers were
|
||||
invisible to it. They share one manifest now; the doll's slot comes from its filename prefix
|
||||
(which is authoritative — djsim resolves art by it), so it is never a guess here.
|
||||
"""
|
||||
cat = DOLL.catalogue() if DOLL else {}
|
||||
m = load_manifest()
|
||||
for slot, items in cat.items():
|
||||
for it in items:
|
||||
rec = dict(m['items'].get(os.path.basename(it['path']), {}))
|
||||
it['slot'] = DOLL_SLOT_MAP.get(slot, slot) # doll slot names -> canonical slots
|
||||
it['layer'] = LAYER.get(it['slot'], 30)
|
||||
it['tags'] = rec.get('tags', [])
|
||||
it['title'] = rec.get('title') or it['key'].replace('_', ' ')
|
||||
it['nsfw'] = is_nsfw(it['path'])
|
||||
it['tier'] = '2d'
|
||||
return cat
|
||||
|
||||
|
||||
# djsim's doll uses top/bottom/shoes/hat/bag; the library's canonical slots are broader.
|
||||
DOLL_SLOT_MAP = {'top': 'torso', 'bottom': 'legs', 'shoes': 'feet', 'hat': 'head', 'bag': 'bag'}
|
||||
CANON_TO_DOLL = {v: k for k, v in DOLL_SLOT_MAP.items()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
@ -421,10 +522,7 @@ class H(BaseHTTPRequestHandler):
|
||||
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)
|
||||
thumb_img(p, tgt)
|
||||
else:
|
||||
run_blender(['thumb', glb_of(p), tgt, 256])
|
||||
except Exception as e:
|
||||
@ -458,6 +556,44 @@ class H(BaseHTTPRequestHandler):
|
||||
|
||||
body = json.loads(raw or b'{}')
|
||||
|
||||
if u.path == '/api/thumbs/warm': # pre-render missing thumbs so the grid isn't cold
|
||||
jid = new_job('thumbs', 'warming thumbnails')
|
||||
|
||||
def fx(jid):
|
||||
lib = scan()
|
||||
todo = []
|
||||
for key in ('garments', 'bodies', 'out'):
|
||||
for it in lib.get(key, []):
|
||||
p = it['path']
|
||||
k = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12]
|
||||
t = os.path.join(THUMBS, k + '.png')
|
||||
if not os.path.exists(t) or os.path.getmtime(t) < os.path.getmtime(p):
|
||||
todo.append((p, t))
|
||||
for d in (doll_catalogue() or {}).values():
|
||||
for it in d:
|
||||
p = it['path']
|
||||
k = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12]
|
||||
t = os.path.join(THUMBS, k + '.png')
|
||||
if not os.path.exists(t):
|
||||
todo.append((p, t))
|
||||
done = fail = 0
|
||||
# Deliberately SERIAL: each 3D thumb launches its own Blender, and firing a
|
||||
# grid's worth at once buries the machine (and every request blocks a server
|
||||
# thread). Slow and quiet beats fast and unusable.
|
||||
for i, (p, t) in enumerate(todo):
|
||||
JOBS[jid]['note'] = f'{i + 1}/{len(todo)} {os.path.basename(p)[:28]}'
|
||||
try:
|
||||
if p.lower().endswith(IMG_EXT):
|
||||
thumb_img(p, t)
|
||||
else:
|
||||
run_blender(['thumb', glb_of(p), t, 256])
|
||||
done += 1
|
||||
except Exception:
|
||||
fail += 1 # a single unreadable asset must not abort the sweep
|
||||
return f'{done} rendered, {fail} failed, {len(todo)} queued'
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
|
||||
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
|
||||
@ -499,6 +635,14 @@ class H(BaseHTTPRequestHandler):
|
||||
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]
|
||||
if body.get('body_type') in BODY_TYPES:
|
||||
rec['body_type'] = body['body_type']
|
||||
rec.pop('height', None) # type drives height unless overridden below
|
||||
if body.get('height'):
|
||||
try:
|
||||
rec['height'] = max(0.3, min(3.0, float(body['height'])))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
save_manifest(m)
|
||||
return self.j({'ok': True, 'rec': rec})
|
||||
|
||||
@ -655,6 +799,53 @@ class H(BaseHTTPRequestHandler):
|
||||
DOLL.compose(out, stems, base=base if os.path.exists(base) else None)
|
||||
return self.j({'ok': True, 'path': out})
|
||||
|
||||
if u.path == '/api/export': # the hub: one door, honest about each target
|
||||
tgt, p = body.get('target', ''), body.get('path', '')
|
||||
if tgt not in EXPORT_TARGETS:
|
||||
return self.j({'error': 'target must be one of ' + '|'.join(EXPORT_TARGETS)}, 400)
|
||||
t = EXPORT_TARGETS[tgt]
|
||||
if not t['ready']:
|
||||
# Verified against each consumer's real loader: these ingest by a HARDCODED array
|
||||
# or a manifest, so dropping a file does nothing at all. Saying so beats a
|
||||
# green tick over a no-op.
|
||||
return self.j({'ok': False, 'ready': False, 'target': tgt,
|
||||
'why': t['why'], 'registry': t.get('registry'),
|
||||
'note': 'not a file drop — the consumer needs a registry edit first'}, 409)
|
||||
if not allowed(p) or not os.path.isfile(p):
|
||||
return self.j({'error': 'path outside library'}, 403)
|
||||
if is_nsfw(p):
|
||||
return self.j({'error': 'refusing to publish an nsfw-tagged asset'}, 403)
|
||||
if tgt == '3god':
|
||||
name = god3_name(body.get('name') or os.path.basename(p))
|
||||
if not name.lower().endswith('.glb'):
|
||||
return self.j({'error': '3GOD wants a .glb'}, 400)
|
||||
jid = new_job('export', f'3god ← {name}')
|
||||
|
||||
def fx(jid):
|
||||
# The depot DELETES illegal characters rather than substituting, so two
|
||||
# different sources can clean to one name and the second silently serves the
|
||||
# first's mesh. Check before writing.
|
||||
try:
|
||||
listing = json.load(urllib.request.urlopen(GOD3 + '/api/list', timeout=30))
|
||||
have = {a.get('file') or a.get('name') for a in
|
||||
(listing.get('assets') or listing.get('items') or [])}
|
||||
except Exception:
|
||||
have = set()
|
||||
if name in have and not body.get('overwrite'):
|
||||
raise RuntimeError(f'{name} already exists in the depot — pass overwrite:true '
|
||||
f'if you really mean to replace it')
|
||||
data = open(p, 'rb').read()
|
||||
req = urllib.request.Request(
|
||||
GOD3 + '/api/upload?name=' + urllib.parse.quote(name), data=data,
|
||||
headers={'Content-Type': 'model/gltf-binary'}, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
out = r.read()[:200]
|
||||
JOBS[jid]['log'] = out.decode('utf-8', 'replace')
|
||||
return GOD3 + '/a/' + name
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid, 'name': name, 'depot': GOD3})
|
||||
return self.j({'error': 'unhandled target'}, 500)
|
||||
|
||||
if u.path == '/api/doll/export': # staged layers → 90sDJsim's live media/doll
|
||||
if not DOLL:
|
||||
return self.j({'error': '2D tier unavailable'}, 400)
|
||||
|
||||
@ -99,6 +99,11 @@
|
||||
<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>body type</label>
|
||||
<select id="bodyType"><option value="">—</option></select>
|
||||
<button id="bodyTypeSave" class="ghost">set</button></div>
|
||||
<div class="note" id="bodyTypeNote">export uses the type's height, so small/large/obese keep their
|
||||
real proportions instead of all being forced to one number.</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>
|
||||
@ -174,6 +179,7 @@
|
||||
<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>
|
||||
<button id="warmBtn" class="ghost">warm thumbs</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>
|
||||
@ -232,7 +238,14 @@
|
||||
|
||||
<div id="tab-out" style="display:none">
|
||||
<h2>finished outfits</h2><div id="outs"></div>
|
||||
<div class="note">these GLBs drop straight into thriftgod / djsim / procity — or publish to 3GOD.</div>
|
||||
<h2>export</h2>
|
||||
<div class="row"><label>target</label><select id="expTarget"></select></div>
|
||||
<div class="row"><button id="expBtn">export selected</button>
|
||||
<label style="width:auto"><input type="checkbox" id="expOver" style="width:auto"> overwrite</label></div>
|
||||
<div class="note" id="expNote">select a garment or outfit, then a target.</div>
|
||||
<div class="note">Verified against each consumer's real loader: only the <b>djsim doll</b> and the
|
||||
<b>3GOD depot</b> ingest by file drop. The others load from hardcoded arrays or a manifest, so a
|
||||
dropped file is a no-op until that registry is edited — the hub tells you which line.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -451,21 +464,35 @@ function visible(rows) {
|
||||
|
||||
// ---------- dressing-room grid ----------
|
||||
const SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag'];
|
||||
let SLOTFILTER = '', GARMQ = '';
|
||||
let SLOTFILTER = '', GARMQ = '', TIER = 'all';
|
||||
|
||||
// One wardrobe, two tiers. The 392 doll layers used to live in a separate catalogue that search
|
||||
// couldn't reach, so "find the coogi jumper" only ever looked at the 3D garments.
|
||||
function allGarments() {
|
||||
const three = (LIB.garments || []).map(g => ({ ...g, tier: g.tier || '3d' }));
|
||||
const two = Object.values(LIB.doll || {}).flat();
|
||||
return TIER === '3d' ? three : TIER === '2d' ? two : three.concat(two);
|
||||
}
|
||||
|
||||
function renderGarmentGrid() {
|
||||
const all = visible(LIB.garments);
|
||||
const all = visible(allGarments());
|
||||
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 =
|
||||
['all', '3d', '2d'].map(t =>
|
||||
`<button data-tier="${t}" class="${TIER === t ? 'on' : ''}" style="border-color:#5a4a20">${t}</button>`).join('') +
|
||||
'<span style="width:8px"></span>' +
|
||||
`<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 = () => {
|
||||
document.querySelectorAll('#slotTabs button[data-slot]').forEach(b => b.onclick = () => {
|
||||
SLOTFILTER = b.dataset.slot; renderGarmentGrid();
|
||||
});
|
||||
document.querySelectorAll('#slotTabs button[data-tier]').forEach(b => b.onclick = () => {
|
||||
TIER = b.dataset.tier; renderGarmentGrid();
|
||||
});
|
||||
|
||||
const q = GARMQ.trim().toLowerCase();
|
||||
const rows = all.filter(g => {
|
||||
@ -486,8 +513,13 @@ function renderGarmentGrid() {
|
||||
}).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); }
|
||||
const g = allGarments().find(x => x.path === el.dataset.p);
|
||||
if (!g) return;
|
||||
if (g.tier === '2d') { // a doll layer wears itself on the doll, not the 3D stage
|
||||
DOLLPICK[CANON_TO_DOLL[g.slot] || g.slot] = g.stem;
|
||||
toast('doll: wearing ' + (g.title || g.key));
|
||||
composeDoll(); showMeta(g);
|
||||
} else { previewGarment({ name: g.name, path: g.path }); showMeta(g); }
|
||||
});
|
||||
}
|
||||
|
||||
@ -498,6 +530,47 @@ function showMeta(g) {
|
||||
$('metaNote').textContent = g.slot_inferred ? 'slot guessed from the filename — confirm it' : '';
|
||||
}
|
||||
$('garmSearch').oninput = e => { GARMQ = e.target.value; renderGarmentGrid(); };
|
||||
// export hub — targets and their readiness come from the server, which learned them by reading
|
||||
// each consumer's actual loader
|
||||
const EXPORT_TARGETS = [
|
||||
['3god', '3GOD depot (thriftgod + procity read it)', true],
|
||||
['djsim-doll', '90sDJsim paper doll', true],
|
||||
['djsim-ped', '90sDJsim ped — needs registry', false],
|
||||
['procity-ped', 'PROCITY ped — needs registry', false],
|
||||
['thriftgod-prop', 'thriftgod prop — needs registry', false],
|
||||
['not-tonight', 'not-tonight — no garment path', false],
|
||||
];
|
||||
$('expTarget').innerHTML = EXPORT_TARGETS.map(([v, l, ok]) =>
|
||||
`<option value="${v}">${ok ? '✓' : '⚠'} ${l}</option>`).join('');
|
||||
$('expBtn').onclick = async () => {
|
||||
const it = GARM || BODY;
|
||||
if (!it) return toast('select a garment or body first');
|
||||
const target = $('expTarget').value;
|
||||
if (target === 'djsim-doll') return $('dollExport').click();
|
||||
const r = await fetch('/api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, path: it.path, overwrite: $('expOver').checked }) }).then(r => r.json());
|
||||
if (r.ready === false) {
|
||||
$('expNote').innerHTML = `<b>not a file drop.</b> ${r.why}<br>edit: <code>${r.registry || '?'}</code>`;
|
||||
return toast('⚠ ' + target + ' needs a registry edit');
|
||||
}
|
||||
if (r.error) { $('expNote').textContent = r.error; return toast('✗ ' + r.error); }
|
||||
$('expNote').textContent = `publishing as ${r.name} → ${r.depot}`;
|
||||
const out = await pollJob(r.job, 'export');
|
||||
if (out) $('expNote').textContent = 'published: ' + out;
|
||||
};
|
||||
async function pollJob(job, label) {
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const j = await fetch('/api/job/' + job).then(r => r.json());
|
||||
toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`);
|
||||
if (j.status === 'done') { refresh(); return j.out; }
|
||||
if (j.status === 'error') { toast('✗ ' + (j.log || '').split('\n').pop()); return null; }
|
||||
}
|
||||
}
|
||||
$('warmBtn').onclick = async () => {
|
||||
const out = await runJob('/api/thumbs/warm', {}, 'thumbs');
|
||||
if (out !== null) 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' },
|
||||
@ -543,6 +616,19 @@ function wireUpload(inputId, to) {
|
||||
wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments');
|
||||
|
||||
$('showNsfw').onchange = e => { SHOW_NSFW = e.target.checked; renderLists(); };
|
||||
|
||||
// body types carry their own target height — see BODY_TYPES in server.py
|
||||
const BODY_TYPES = { small: 1.58, medium: 1.72, large: 1.88, tall: 1.95, child: 1.30, obese: 1.74, stocky: 1.68 };
|
||||
$('bodyType').innerHTML = '<option value="">—</option>' +
|
||||
Object.entries(BODY_TYPES).map(([t, h]) => `<option value="${t}">${t} · ${h}m</option>`).join('');
|
||||
$('bodyTypeSave').onclick = async () => {
|
||||
if (!BODY) return toast('pick a body first');
|
||||
const t = $('bodyType').value; if (!t) return toast('choose a type');
|
||||
const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: BODY.path, body_type: t }) }).then(r => r.json());
|
||||
toast(r.error ? '✗ ' + r.error : `✓ ${BODY.name} is ${t} (${BODY_TYPES[t]}m on export)`);
|
||||
refresh();
|
||||
};
|
||||
$('tagBtn').onclick = async () => {
|
||||
const it = BODY || GARM;
|
||||
if (!it) return toast('select a body or garment first');
|
||||
@ -663,6 +749,7 @@ $('costumeSel').onchange = e => {
|
||||
// in the game with no code change. Composition happens server-side (PIL) so the DOM preview
|
||||
// and the exported PNG are guaranteed to be the same image.
|
||||
const DOLL_SLOTS = ['hat', 'top', 'bottom', 'shoes', 'bag'];
|
||||
const CANON_TO_DOLL = { torso: 'top', legs: 'bottom', feet: 'shoes', head: 'hat', bag: 'bag' };
|
||||
let DOLLPICK = {};
|
||||
|
||||
function renderDoll() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user