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:
type-two 2026-07-24 21:00:46 +10:00
parent 305fbf9447
commit 9012e3e652
2 changed files with 289 additions and 11 deletions

201
server.py
View File

@ -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)

View File

@ -99,6 +99,11 @@
<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>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() {