Absorb the 3GOD depot; retire the name, keep the service

3GOD was "three.js god" — a quick way to eyeball a GLB and drop it into a game. wardrobegod's
stage, grid and publish flow cover that far better now, so the bench is retired. What survives is
the half that quietly became infrastructure.

Absorbed into depot/: server.py (271 lines, verbatim), its hardening tests, and viewer.html kept
as superseded reference only.

Retiring the REPO is safe; retiring the SERVICE is not. The live instance on the dealgod VPS
holds 773 assets and two shipping games fetch from it at runtime — thriftgod web/index.html:1176
and procity loaders.js:7 both hardcode https://digalot.fyi/3god. So the /3god URL stays even
though the name is gone: changing it means editing two shipping codebases for no benefit, and the
name now survives only as a URL path nobody looks at. The VPS service is deployed, not sourced
live from the Gitea checkout, so archiving that repo changes nothing at runtime.

Checked before discarding the UI: the depot's /api/meta tag store had 0 tags and 0 notes across
all 773 assets, so there was nothing to migrate. Tagging lives in library/index.json now.

depot/README.md records the two things that are easy to get wrong and expensive to rediscover:
publishing must go direct over the tailnet (the Cloudflare front 403s because auth trusts the raw
socket peer — thriftgod's own prop_campaign.py --publish is broken for exactly this), and
clean_name DELETES illegal characters so 'shop!-cat.glb' silently resolves to an existing
different mesh.

Verified after the change: both the public and tailnet endpoints still answer 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-24 22:16:05 +10:00
parent 7c5fc53224
commit db47546c2b
4 changed files with 902 additions and 0 deletions

59
depot/README.md Normal file
View File

@ -0,0 +1,59 @@
# depot — the asset CDN (formerly "3GOD")
3GOD was "three.js god": an easy way to eyeball a GLB and drop it into a game. **wardrobegod's
stage covers the viewing half far better now**, so the *bench* is retired. What survives is the
part that quietly became infrastructure: a small HTTP asset server that two shipping games fetch
from at runtime.
- `server.py` (271 lines) — the depot itself. Absorbed verbatim from `3GOD/server.py`.
- `viewer.html` — the old browse/upload UI. Kept for reference only; **superseded** by
wardrobegod's stage, grid and publish flow. Not deployed by us.
- `test_hardening.py` — its security tests (SSRF allowlist, tag XSS escape, meta race).
## Why this is not just deletable
The live instance runs on the dealgod VPS (container `3god`, assets bind-mounted at
`/opt/3god/assets`, published on `100.94.195.115:8788`, fronted at `https://digalot.fyi/3god`)
and holds **773 assets**. Two shipping games read it *at runtime*:
| consumer | line |
|---|---|
| thriftgod | `web/index.html:1176``const DEPOT = 'https://digalot.fyi/3god';` |
| procity | `web/js/.../loaders.js:7` — same URL; `LOCAL_DEPOT` is null unless `?localdepot=1` |
**Keep the `/3god` URL even though the name is retired.** It is hardcoded in both games; changing
it means editing two shipping codebases for no benefit. The name now survives only as a URL path
nobody looks at.
## Publishing (what wardrobegod does)
```
POST http://100.94.195.115:8788/api/upload?name=<file>.glb # raw body, direct over tailnet
```
**Not** via `https://digalot.fyi/3god`. Auth trusts the raw socket peer against a tailnet
allow-list, so through the Cloudflare front the peer is CF and every write 403s. Measured:
`/api/list` reports `authed=false` via CF, `authed=true` direct. (thriftgod's own shipped
`tools/prop_campaign.py --publish` defaults to the CF URL and is broken for this reason.)
## The filename hazard
`clean_name` **deletes** illegal characters rather than substituting them, and matching is
case-sensitive. So `shop!-cat.glb` becomes `shop-cat.glb` — an existing, *different* mesh — and
is then served with no error at all. `[A-Za-z0-9._ -]` survive verbatim; the first character must
be alphanumeric; a missing extension gets `.glb` appended.
wardrobegod reproduces this rule in `god3_name()` and refuses to publish over an existing name
unless `overwrite:true` is passed. Do not bypass that check.
## Migration note
The depot's `/api/meta` tag/note store was checked before retiring the UI: **0 of 773 assets had
any tags or notes**, so nothing needed migrating into wardrobegod's manifest. Tagging now lives in
`library/index.json`.
## Retiring the repo
`ssh://git@100.71.119.27:222/monster/3GOD.git` (Gitea on the old box) can be archived once this
copy is committed. The running VPS service is unaffected — it is deployed, not sourced live from
that checkout. ultra's `~/Documents/3GOD` is a dev copy holding 4 files and its `:8788` is closed.

271
depot/server.py Normal file
View File

@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""3GOD — the prop house. One .glb depot + inspection room shared by THRIFTGOD,
90sDJsim and the robotmonster 3D store. Stdlib only, no DB a folder of .glb files
plus a meta.json sidecar. Reads are public (the games load assets client-side in
every visitor's browser); writes need the password.
GOD3_PW=... python3 server.py [port] # default 8788
"""
import hashlib, hmac, ipaddress, json, os, re, socket, sys, threading, time, urllib.error, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote, urlparse
ROOT = os.path.dirname(os.path.abspath(__file__))
DIR = os.path.join(ROOT, 'assets')
THUMBS = os.path.join(DIR, '.thumbs') # viewer-captured 256px jpegs
os.makedirs(THUMBS, exist_ok=True)
META = os.path.join(DIR, 'meta.json')
OPEN = os.environ.get('GOD3_OPEN') == '1' # open-door testing: writes need no password
if OPEN:
print('🚪 GOD3_OPEN=1 — the prop house door is wide open (testing mode)')
PW = os.environ.get('GOD3_PW')
if not PW: # no shipped default — a locked depot on 'skeletonkey' is not locked
if not OPEN:
sys.exit('refusing to start: set GOD3_PW=<secret>, or GOD3_OPEN=1 for passwordless local testing.')
PW = 'skeletonkey' # OPEN bypasses auth anyway; token value is moot
TOKEN = hmac.new(PW.encode(), b'3god-ok', hashlib.sha256).hexdigest()
TS_ALLOW = {'100.89.131.57', '100.91.239.7', '100.102.229.52', '100.69.21.128', # workstations/laptops
'100.71.119.27', '100.94.195.115'} # VPS: botchat, dealgod
# tailnet peers: direct connection over tailscale = auto-write.
# ponytail: trusts the raw socket peer, NOT any X-Forwarded-For / CF-Connecting-IP header (spoofable). So it only grants
# when the machine hits this port DIRECTLY over the tailnet — through the digalot.fyi Cloudflare front the peer is CF, never 100.x.
MAX = 50 * 1024 * 1024
SAFE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._ -]*\.(glb|png|jpe?g|webp)$')
EXTS = ('.glb', '.png', '.jpg', '.jpeg', '.webp')
CT = {'.glb': 'model/gltf-binary', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.webp': 'image/webp'}
def looks_ok(data, name):
"""Magic bytes match the extension — no mystery files on the shelf."""
if name.endswith('.glb'):
return data[:4] == b'glTF'
if name.endswith('.png'):
return data[:8] == b'\x89PNG\r\n\x1a\n'
if name.endswith(('.jpg', '.jpeg')):
return data[:2] == b'\xff\xd8'
if name.endswith('.webp'):
return data[:4] == b'RIFF' and data[8:12] == b'WEBP'
return False
_META_LOCK = threading.Lock() # ThreadingHTTPServer → guard the load-mutate-save
def meta_load():
try:
return json.load(open(META))
except Exception:
return {}
def meta_save(m):
tmp = f'{META}.{os.getpid()}.tmp' # atomic: a torn write can never be read back as {}
with open(tmp, 'w') as f:
json.dump(m, f, indent=1)
os.replace(tmp, META)
def clean_name(n):
n = re.sub(r'[^a-zA-Z0-9._ -]', '', os.path.basename(n or '')).strip()
if n and not n.lower().endswith(EXTS):
n += '.glb'
return n if SAFE.match(n or '') else None
def cloud_url(u):
"""Paste-friendly: turn Drive/Dropbox share links into direct downloads."""
m = re.search(r'drive\.google\.com/file/d/([\w-]+)', u)
if m:
return f'https://drive.google.com/uc?export=download&id={m.group(1)}'
if 'dropbox.com' in u:
return re.sub(r'[?&]dl=0', '', u) + ('&' if '?' in u else '?') + 'dl=1'
return u
def public_host(url):
"""SSRF guard: True only for http(s) URLs whose host resolves to public IPs.
Blocks file://, ftp://, localhost, link-local (169.254.x metadata), and private ranges.
ponytail: does NOT close DNS-rebinding (re-resolve to internal after this check) pin the
resolved IP and connect to it if 3GOD ever fetches on behalf of untrusted callers."""
p = urlparse(url)
if p.scheme not in ('http', 'https') or not p.hostname:
return False
try:
infos = socket.getaddrinfo(p.hostname, p.port or (443 if p.scheme == 'https' else 80),
proto=socket.IPPROTO_TCP)
except OSError:
return False
for *_, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0])
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
or ip.is_multicast or ip.is_unspecified):
return False
return bool(infos)
class _GuardedRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if not public_host(newurl): # re-validate every redirect hop
raise urllib.error.HTTPError(newurl, code, 'redirect to non-public host blocked', headers, fp)
return super().redirect_request(req, fp, code, msg, headers, newurl)
_OPENER = urllib.request.build_opener(_GuardedRedirect) # http/https only (no file/ftp handlers), hops re-checked
class H(BaseHTTPRequestHandler):
def log_message(self, fmt, *a):
pass
def _send(self, code, body, ctype='application/json', extra=None):
data = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', len(data))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(data)
def authed(self):
if OPEN:
return True
if self.client_address[0] in TS_ALLOW: # trusted tailnet peer (direct connection only)
return True
c = self.headers.get('Cookie', '')
m = re.search(r'3g=([0-9a-f]{64})', c)
return bool(m and hmac.compare_digest(m.group(1), TOKEN))
def do_GET(self):
path = self.path.split('?')[0]
if path == '/' or path == '/index.html':
return self._send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(),
'text/html; charset=utf-8')
if path == '/api/list':
m = meta_load()
out = []
for f in sorted(os.listdir(DIR)):
if not f.endswith(EXTS):
continue
st = os.stat(os.path.join(DIR, f))
out.append(dict({'tags': [], 'note': ''}, **m.get(f, {}),
file=f, size=st.st_size, added=int(st.st_mtime),
thumb=os.path.isfile(os.path.join(THUMBS, f + '.jpg'))))
return self._send(200, {'assets': out, 'authed': self.authed()},
extra={'Access-Control-Allow-Origin': '*'})
if path.startswith('/t/'): # thumbnail (viewer-captured)
f = clean_name(unquote(path[3:]))
fp = f and os.path.join(THUMBS, f + '.jpg')
if not (fp and os.path.isfile(fp)):
return self._send(404, {'error': 'no thumb yet'})
return self._send(200, open(fp, 'rb').read(), 'image/jpeg',
{'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600'})
if path.startswith('/a/') or path.startswith('/dl/'):
f = clean_name(unquote(path.split('/', 2)[2]))
fp = f and os.path.join(DIR, f)
if not (fp and os.path.isfile(fp)):
return self._send(404, {'error': 'no such prop'})
extra = {'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=86400'}
if path.startswith('/dl/'):
extra['Content-Disposition'] = f'attachment; filename="{f}"'
return self._send(200, open(fp, 'rb').read(),
CT.get(os.path.splitext(f)[1], 'application/octet-stream'), extra)
return self._send(404, {'error': 'lost in the prop house'})
def do_POST(self):
path, _, q = self.path.partition('?')
n = int(self.headers.get('Content-Length') or 0)
if n > MAX:
return self._send(413, {'error': 'over 50MB — decimate it, digger'})
raw = self.rfile.read(n) if n else b''
if path == '/api/login':
body = json.loads(raw or '{}')
if hmac.compare_digest(str(body.get('password', '')), PW):
return self._send(200, {'ok': True},
extra={'Set-Cookie': f'3g={TOKEN}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax'})
time.sleep(1) # gentle brake on guessing
return self._send(403, {'error': 'not the magic word'})
if not self.authed():
return self._send(403, {'error': 'password first'})
if path == '/api/upload': # raw body, ?name=file.glb|png|jpg|webp
name = clean_name(unquote(dict(p.split('=', 1) for p in q.split('&') if '=' in p).get('name', '')))
if not name:
return self._send(400, {'error': 'name it something.glb (or .png/.jpg/.webp)'})
if not looks_ok(raw, name):
return self._send(400, {'error': f'that is not really a {os.path.splitext(name)[1]} file'})
open(os.path.join(DIR, name), 'wb').write(raw)
return self._send(200, {'ok': True, 'file': name})
if path == '/api/thumb': # viewer snapshots the loaded model
name = clean_name(unquote(dict(p.split('=', 1) for p in q.split('&') if '=' in p).get('name', '')))
if not (name and os.path.isfile(os.path.join(DIR, name))):
return self._send(404, {'error': 'no such prop'})
if raw[:2] != b'\xff\xd8':
return self._send(400, {'error': 'thumbs are jpeg'})
open(os.path.join(THUMBS, name + '.jpg'), 'wb').write(raw)
return self._send(200, {'ok': True})
body = json.loads(raw or '{}')
if path == '/api/fetch': # paste a URL, server pulls it in
name = clean_name(body.get('name', ''))
if not name:
return self._send(400, {'error': 'name it something.glb'})
target = cloud_url(body.get('url', ''))
if not public_host(target):
return self._send(400, {'error': 'only public http(s) URLs allowed (no file://, localhost or internal IPs)'})
try:
req = urllib.request.Request(target, headers={'User-Agent': '3GOD/1.0'})
with _OPENER.open(req, timeout=60) as r:
data = r.read(MAX + 1)
except Exception as e:
return self._send(400, {'error': f'fetch failed: {e}'})
if len(data) > MAX:
return self._send(413, {'error': 'over 50MB'})
if not looks_ok(data, name):
return self._send(400, {'error': 'that URL is not the file type its name claims (got HTML? make the link public/direct)'})
open(os.path.join(DIR, name), 'wb').write(data)
return self._send(200, {'ok': True, 'file': name})
if path == '/api/del':
f = clean_name(body.get('file', ''))
fp = f and os.path.join(DIR, f)
if fp and os.path.isfile(fp):
os.remove(fp)
tp = os.path.join(THUMBS, f + '.jpg')
if os.path.isfile(tp):
os.remove(tp)
with _META_LOCK:
m = meta_load()
m.pop(f, None)
meta_save(m)
return self._send(200, {'ok': True})
if path == '/api/meta': # tags/notes + viewer-measured stats
f = clean_name(body.get('file', ''))
if not (f and os.path.isfile(os.path.join(DIR, f))):
return self._send(404, {'error': 'no such prop'})
with _META_LOCK:
m = meta_load()
row = m.setdefault(f, {})
for k in ('tags', 'note', 'clips', 'tris', 'dims', 'rot', 'scale'):
if k in body:
row[k] = body[k]
if isinstance(row.get('tags'), list): # cap + stringify (real XSS defense is escaping at render)
row['tags'] = [str(t)[:40] for t in row['tags'][:20]]
if 'note' in row:
row['note'] = str(row['note'])[:500]
meta_save(m)
return self._send(200, {'ok': True})
return self._send(404, {'error': 'lost in the prop house'})
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8788
print(f'3GOD prop house on :{port}{len([f for f in os.listdir(DIR) if f.endswith(EXTS)])} props on the shelf')
ThreadingHTTPServer(('0.0.0.0', port), H).serve_forever()

30
depot/test_hardening.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Self-check for the SSRF guard added to server.py. No framework, no network:
literal-IP URLs resolve via getaddrinfo without a DNS lookup, so this is hermetic.
GOD3_OPEN=1 python3 test_hardening.py # OPEN so import doesn't sys.exit on missing GOD3_PW
"""
import os
os.environ.setdefault('GOD3_OPEN', '1') # let server.py import without a real GOD3_PW
import server
# --- blocked: non-http schemes (file://, ftp://) — the /etc/passwd + FileHandler vector ---
assert server.public_host('file:///etc/passwd') is False
assert server.public_host('ftp://ftp.example.com/x') is False
assert server.public_host('gopher://x/') is False
assert server.public_host('') is False
# --- blocked: internal / metadata / loopback targets (literal IPs, no DNS) ---
assert server.public_host('http://127.0.0.1/') is False # loopback
assert server.public_host('http://[::1]/') is False # loopback v6
assert server.public_host('http://169.254.169.254/latest/meta-data/') is False # cloud metadata
assert server.public_host('http://10.0.0.5/') is False # private
assert server.public_host('http://192.168.1.1/') is False # private
assert server.public_host('http://172.16.0.9/') is False # private
assert server.public_host('http://0.0.0.0/') is False # unspecified
# --- allowed: public literal IPs (hermetic — no DNS) ---
assert server.public_host('http://8.8.8.8/model.glb') is True
assert server.public_host('https://1.1.1.1/x.glb') is True
print('ok — SSRF guard blocks file/ftp + loopback/private/metadata, allows public http(s)')

542
depot/viewer.html Normal file
View File

@ -0,0 +1,542 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3GOD — the prop house</title>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.175.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.175.0/examples/jsm/"
}}
</script>
<style>
:root { --bg:#141210; --panel:#1d1a14; --line:#4a4130; --gold:#ffd75e; --dim:#c9b478; --fg:#eee; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px system-ui; overflow:hidden; }
#side { position:fixed; left:0; top:0; bottom:0; width:320px; background:var(--panel);
border-right:1px solid var(--line); display:flex; flex-direction:column; z-index:2; }
#side h1 { margin:0; padding:16px 16px 4px; font:800 22px system-ui; color:var(--gold); letter-spacing:1px; }
#side h1 small { display:block; font:400 11px system-ui; color:var(--dim); letter-spacing:0; }
#shelf { flex:1; overflow-y:auto; padding:8px; }
.prop { padding:9px 10px; border:1px solid transparent; border-radius:10px; cursor:pointer; }
.prop:hover { background:#26221a; }
.prop.sel { border-color:var(--gold); background:#2c2820; }
.prop b { color:var(--fg); }
.prop .sub { color:var(--dim); font-size:12px; }
.prop .tag { display:inline-block; background:#2c2820; border:1px solid var(--line); border-radius:8px;
padding:0 6px; font-size:11px; color:var(--dim); margin:2px 3px 0 0; }
#dropzone { margin:8px; padding:14px; border:2px dashed var(--line); border-radius:12px; text-align:center;
color:var(--dim); font-size:13px; cursor:pointer; }
#dropzone.hot { border-color:var(--gold); color:var(--gold); }
#urlrow { display:flex; gap:6px; margin:0 8px 10px; }
#urlrow input { flex:1; background:var(--bg); border:1px solid var(--line); border-radius:8px;
color:var(--fg); padding:7px 9px; font:13px system-ui; outline:none; }
button { background:#2c2820; color:var(--gold); border:1px solid var(--line); border-radius:10px;
padding:7px 12px; font:600 13px system-ui; cursor:pointer; }
button:hover { background:#3a3426; }
button.warn { color:#ff8a7a; }
#stage { position:fixed; left:320px; right:0; top:0; bottom:0; }
canvas { display:block; }
#stats { position:fixed; right:16px; top:16px; background:rgba(20,18,16,.88); border:1px solid var(--line);
border-radius:12px; padding:12px 14px; font:12px ui-monospace,monospace; color:var(--dim);
display:none; max-width:280px; z-index:3; white-space:pre-line; }
#stats b { color:var(--fg); }
#stats .bad { color:#ff8a7a; font-weight:700; }
#stats .good { color:#8fe08f; }
#animbar { position:fixed; left:340px; right:16px; bottom:16px; background:rgba(20,18,16,.88);
border:1px solid var(--line); border-radius:14px; padding:10px 14px; display:none;
gap:10px; align-items:center; flex-wrap:wrap; z-index:3; }
#animbar select { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:6px; }
#animbar input[type=range] { width:140px; accent-color:var(--gold); }
#animbar label { color:var(--dim); font-size:12px; }
#gizmobar button, #spacebar button { padding:5px 9px; font-size:12px; }
.gz.on, #gzSpace.on { background:var(--gold); color:var(--bg); }
#login { position:fixed; inset:0; background:var(--bg); z-index:10; display:flex; align-items:center; justify-content:center; }
#login .box { background:var(--panel); border:1px solid var(--line); border-radius:16px; padding:32px; text-align:center; }
#login h1 { color:var(--gold); font:800 34px system-ui; margin:0 0 2px; letter-spacing:2px; }
#login p { color:var(--dim); margin:0 0 18px; font-size:13px; }
#login input { background:var(--bg); border:1px solid var(--line); border-radius:10px; color:var(--fg);
padding:10px 12px; font:15px system-ui; outline:none; width:220px; text-align:center; }
#login .err { color:#ff8a7a; font-size:13px; height:18px; margin-top:8px; }
#metarow { padding:8px; border-top:1px solid var(--line); display:none; gap:6px; flex-direction:column; }
#metarow input { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg);
padding:6px 9px; font:13px system-ui; outline:none; }
#metarow .btns { display:flex; gap:6px; }
#toast { position:fixed; left:50%; bottom:70px; transform:translateX(-50%); background:#2c2820;
border:1px solid var(--gold); color:var(--fg); border-radius:12px; padding:10px 18px;
display:none; z-index:11; font-size:14px; }
</style>
</head>
<body>
<div id="login"><div class="box">
<h1>3GOD</h1><p>the prop house · say the magic word</p>
<input id="pw" type="password" placeholder="password" autofocus>
<div class="err" id="loginErr"></div>
</div></div>
<div id="side">
<h1>3GOD<small>the prop house · one shelf, three worlds</small></h1>
<div id="dropzone">⬆ drop a .glb or image here<br>(or click to pick)</div>
<input id="filepick" type="file" accept=".glb,.png,.jpg,.jpeg,.webp" multiple style="display:none">
<div id="urlrow"><input id="url" placeholder="…or paste a URL (Drive/Dropbox ok)"><button id="fetchBtn">pull</button></div>
<div id="shelf"></div>
<div id="metarow">
<input id="tagsIn" placeholder="tags, comma,separated">
<input id="noteIn" placeholder="note (who made it, licence…)">
<div class="btns">
<button id="saveMeta">💾 save</button>
<button id="dlBtn">⬇ download</button>
<button id="delBtn" class="warn">🗑 delete</button>
</div>
</div>
</div>
<div id="stage"></div>
<div id="spacebar" style="position:fixed;left:340px;top:16px;z-index:3;display:flex;gap:8px;align-items:center;flex-wrap:wrap;
background:rgba(20,18,16,.88);border:1px solid var(--line);border-radius:12px;padding:8px 12px">
<label style="color:var(--dim);font-size:12px">🏬 test space</label>
<select id="spaceSel" style="background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:6px">
<option value="none">empty grid</option>
<option value="shop">op shop (game)</option>
<option value="street">street</option>
<option value="home">home base</option>
</select>
<button id="rerollBtn" title="another shop's look" style="display:none">🎲 reroll</button>
</div>
<div id="gizmobar" style="position:fixed;left:340px;top:60px;z-index:3;display:flex;gap:6px;align-items:center;
background:rgba(20,18,16,.88);border:1px solid var(--line);border-radius:12px;padding:7px 10px">
<label style="color:var(--dim);font-size:12px">gizmo</label>
<button class="gz" data-m="off">none</button>
<button class="gz" data-m="rotate">🔄 rotate</button>
<button class="gz" data-m="translate">✥ move</button>
<button class="gz" data-m="scale">⤢ scale</button>
<button id="gzSpace" title="local / world axes">local</button>
<button id="gzReset" title="back to as-loaded">⟲ reset</button>
<button id="gzBake" title="save this orientation for the games" style="color:var(--gold)">💾 bake</button>
</div>
<div id="stats"></div>
<div id="animbar">
<label>🎬 clip</label><select id="clipSel"></select>
<button id="playBtn"></button>
<label>speed</label><input id="speed" type="range" min="0" max="2" step="0.05" value="1">
<span id="speedVal" style="color:var(--dim);font-size:12px">1.00×</span>
<label style="margin-left:12px"><input id="refToggle" type="checkbox" checked> 1.8m digger</label>
<label><input id="gridToggle" type="checkbox" checked> grid</label>
</div>
<div id="toast"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const $ = id => document.getElementById(id);
const api = (p, body) => fetch(p, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) } : {}).then(r => r.json());
function toast(msg, ms = 3000) { const t = $('toast'); t.textContent = msg; t.style.display = 'block';
clearTimeout(t._h); t._h = setTimeout(() => t.style.display = 'none', ms); }
// ---------- login ----------
$('pw').addEventListener('keydown', async e => {
if (e.key !== 'Enter') return;
const r = await api('api/login', { password: $('pw').value });
if (r.ok) { $('login').style.display = 'none'; loadShelf(); }
else $('loginErr').textContent = r.error || 'nope';
});
// ---------- the stage ----------
const stage = $('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x141210);
const camera = new THREE.PerspectiveCamera(55, 1, 0.01, 500);
camera.position.set(2.2, 1.6, 3);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.8, 0);
controls.screenSpacePanning = true; // pan moves in the screen plane (feels right)
// hold Shift or Option/Alt = pan instead of orbit (trackpad-friendly). Two-finger scroll still zooms.
const setPan = on => { if (!controls.enabled) return; controls.mouseButtons.LEFT = on ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE; };
addEventListener('keydown', e => { if (e.key === 'Shift' || e.key === 'Alt' || e.altKey) setPan(true); });
addEventListener('keyup', e => { if (!e.shiftKey && !e.altKey) setPan(false); });
addEventListener('blur', () => setPan(false)); // don't get stuck in pan if focus leaves mid-hold
scene.add(new THREE.HemisphereLight(0xfff2dd, 0x40382a, 1.1));
const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(4, 6, 3); scene.add(key);
// ---------- Blender-style transform gizmo (rotate / move / scale on any axis) ----------
const gizmo = new TransformControls(camera, renderer.domElement);
gizmo.setSize(0.8); gizmo.setSpace('local');
gizmo.addEventListener('dragging-changed', e => controls.enabled = !e.value); // don't orbit while dragging a ring
scene.add(gizmo.getHelper ? gizmo.getHelper() : gizmo); // r169+ needs the helper
gizmo.getHelper && (gizmo.getHelper().visible = false);
function gzMode(m) {
document.querySelectorAll('.gz').forEach(b => b.classList.toggle('on', b.dataset.m === m));
const helper = gizmo.getHelper ? gizmo.getHelper() : gizmo;
if (m === 'off' || !model) { gizmo.detach(); helper.visible = false; return; }
gizmo.setMode(m); gizmo.attach(model); helper.visible = true;
}
document.querySelectorAll('.gz').forEach(b => b.onclick = () => gzMode(b.dataset.m));
$('gzSpace').onclick = () => { const w = gizmo.space === 'local'; gizmo.setSpace(w ? 'world' : 'local');
$('gzSpace').textContent = w ? 'world' : 'local'; $('gzSpace').classList.toggle('on', w); };
$('gzReset').onclick = () => { if (!model) return;
model.rotation.set(0, 0, 0); model.scale.set(1, 1, 1); reground(); };
$('gzBake').onclick = async () => {
if (!current || !model) return;
const e = model.rotation, s = model.scale;
await api('api/meta', { file: current, rot: [+e.x.toFixed(4), +e.y.toFixed(4), +e.z.toFixed(4)],
scale: [+s.x.toFixed(4), +s.y.toFixed(4), +s.z.toFixed(4)] });
toast('💾 orientation baked — the games will load it this way'); loadShelf();
};
function reground() { // keep the model planted after a transform
if (!model) return;
const b = new THREE.Box3().setFromObject(model);
model.position.y -= b.min.y;
}
const grid = new THREE.GridHelper(20, 20, 0x4a4130, 0x2c2820);
scene.add(grid);
// the 1.8m reference digger — scale truth, checklist item 3
const ref = new THREE.Group();
{
const mat = new THREE.MeshStandardMaterial({ color: 0xffd75e, transparent: true, opacity: 0.28, roughness: 0.9 });
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.22, 1.05, 6, 12), mat);
body.position.y = 0.75;
const head = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10), mat);
head.position.y = 1.63;
ref.add(body, head);
const ring = new THREE.Mesh(new THREE.RingGeometry(0.3, 0.34, 24),
new THREE.MeshBasicMaterial({ color: 0xffd75e, transparent: true, opacity: 0.25, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI / 2; ring.position.y = 0.01;
ref.add(ring);
ref.position.x = -1.2;
scene.add(ref);
}
$('refToggle').onchange = e => ref.visible = e.target.checked;
$('gridToggle').onchange = e => grid.visible = e.target.checked;
// ---------- context test spaces: see the prop where it'll actually live ----------
let space = null;
function wallpaperTex(c1, c2) { // little canvas dot-pattern — period-right, zero downloads
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d');
g.fillStyle = c1; g.fillRect(0, 0, 128, 128);
g.fillStyle = c2;
for (let y = 8; y < 128; y += 24) for (let x = 8; x < 128; x += 24) {
g.beginPath(); g.arc(x + (y % 48 ? 6 : 0), y, 3, 0, 7); g.fill();
}
const t = new THREE.CanvasTexture(c);
t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = THREE.SRGBColorSpace;
return t;
}
const smat = (c, r = 0.85) => new THREE.MeshStandardMaterial({ color: c, roughness: r });
function sbox(w, h, d, m, x, y, z, g) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); g.add(b); return b; }
function wallsOf(g, W, D, H, wp) {
wp.repeat.set(W / 2.5, H / 2.5);
const wm = new THREE.MeshStandardMaterial({ map: wp, roughness: 0.95 });
sbox(W, H, 0.08, wm, 0, H / 2, -D / 2, g);
sbox(0.08, H, D, wm.clone(), -W / 2, H / 2, 0, g);
sbox(0.08, H, D, wm.clone(), W / 2, H / 2, 0, g);
sbox(W, 0.06, D, smat(0xd8d2c4, 1), 0, H, 0, g); // ceiling
}
// ---------- the ACTUAL game op-shop shell (ported from THRIFTGOD buildShop; real textures) ----------
const ASSET = 'https://digalot.fyi/assets/gen/'; // same origin in prod; 404→colour fallback locally
const gtex = new THREE.TextureLoader();
const G_WALLPAPERS = ['floral-cream', 'stripe-sage', 'damask-mauve', 'geo-orange', 'trellis-blue', 'woodchip-white', 'floral-gold', 'diamond-green'];
const G_FLOORS = ['carpet-swirl', 'carpet-mustard', 'carpet-greygreen', 'lino-check', 'lino-cork'];
const G_ART = ['velvet-elvis', 'sad-clown', 'big-eye-kid', 'tapestry-horse', 'brown-landscape', 'macrame-owl', 'ship', 'blue-boy', 'last-supper', 'kookaburra', 'gumtree', 'cat-portrait', 'crying-boy', 'spanish-lady', 'poker-dogs', 'sunset-beach'];
const G_CARPETS = ['#6b5b45', '#5d6b45', '#75565a', '#4f6270', '#6b6045'];
const G_WALLS = ['#cfc4ae', '#c9d2c4', '#d6c9c9', '#c4ccd2', '#d2cbb8'];
const G_SHAPES = [[7, 9, 8, 10, 3.0], [6, 7, 11, 14, 3.0], [10, 12, 7, 9, 3.2], [9, 11, 10, 13, 3.5], [6, 7, 7, 8, 2.6]];
function mul32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
let shopSeed = 12345;
function gameShop(seed, g) {
const rnd = mul32(seed);
const sh = G_SHAPES[Math.floor(rnd() * G_SHAPES.length)];
const W = sh[0] + Math.round(rnd() * (sh[1] - sh[0])), D = sh[2] + Math.round(rnd() * (sh[3] - sh[2])), H = sh[4];
const carpet = G_CARPETS[Math.floor(rnd() * G_CARPETS.length)], wallC = G_WALLS[Math.floor(rnd() * G_WALLS.length)];
const floorName = G_FLOORS[Math.floor(rnd() * G_FLOORS.length)], paperName = G_WALLPAPERS[Math.floor(rnd() * G_WALLPAPERS.length)];
const floorMat = smat(carpet, 0.95);
gtex.load(ASSET + 'tex-' + floorName + '.jpg?v=1', tx => { tx.wrapS = tx.wrapT = THREE.RepeatWrapping; tx.repeat.set(Math.ceil(W / 3), Math.ceil(D / 3)); tx.colorSpace = THREE.SRGBColorSpace; floorMat.map = tx; floorMat.color.set(0xbbbbbb); floorMat.needsUpdate = true; }, undefined, () => {});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D), floorMat); floor.rotation.x = -Math.PI / 2; g.add(floor);
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D), smat('#d9d4c8', 1)); ceil.rotation.x = Math.PI / 2; ceil.position.y = H; g.add(ceil);
[[0, -D / 2, 0, W], [0, D / 2, Math.PI, W], [-W / 2, 0, Math.PI / 2, D], [W / 2, 0, -Math.PI / 2, D]].forEach(([x, z, ry, w]) => {
const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(wallC), roughness: 0.9 });
gtex.load(ASSET + 'wall-' + paperName + '.jpg?v=1', tx => { tx.wrapS = tx.wrapT = THREE.RepeatWrapping; tx.repeat.set(w / 2.5, H / 2.5); tx.colorSpace = THREE.SRGBColorSpace; m.map = tx; m.color.set(0xcccccc); m.needsUpdate = true; }, undefined, () => {});
const me = new THREE.Mesh(new THREE.PlaneGeometry(w, H), m); me.position.set(x, H / 2, z); me.rotation.y = ry; g.add(me);
});
const nArt = 2 + Math.floor(rnd() * 3); // seeded op-shop kitsch (velvet elvis, sad clown…)
for (let i = 0; i < nArt; i++) {
const west = rnd() < 0.5, zz = -D / 2 + 2 + rnd() * (D - 4);
const am = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.7 });
gtex.load(ASSET + 'art-' + G_ART[Math.floor(rnd() * G_ART.length)] + '.jpg?v=1', tx => { tx.colorSpace = THREE.SRGBColorSpace; am.map = tx; am.needsUpdate = true; }, undefined, () => {});
const sz = 0.8 + rnd() * 0.5;
const pl = new THREE.Mesh(new THREE.PlaneGeometry(sz, sz), am);
pl.position.set(west ? -W / 2 + 0.03 : W / 2 - 0.03, 1.5 + rnd() * 0.5, zz);
pl.rotation.y = west ? Math.PI / 2 : -Math.PI / 2; pl.rotation.z = (rnd() - 0.5) * 0.06; g.add(pl);
}
sbox(0.7, 1.0, 2.2, smat('#5d7a8c', 0.6), W / 2 - 0.9, 0.5, D / 4, g); // the counter, east wall
g.add(new THREE.AmbientLight(0xfff4de, 0.85));
[[-W / 4, -D / 4], [W / 4, -D / 4], [-W / 4, D / 4], [W / 4, D / 4]].forEach(([x, z]) => {
const l = new THREE.PointLight(0xfff0d0, 22, 10, 1.4); l.position.set(x, H - 0.25, z); g.add(l);
sbox(0.7, 0.06, 0.18, smat('#f5f2ea', 0.4), x, H - 0.06, z, g);
});
}
function setSpace(kind) {
if (space) { scene.remove(space); space = null; }
grid.visible = kind === 'none' && $('gridToggle').checked;
scene.background = new THREE.Color(kind === 'street' ? 0x87b5d5 : 0x141210);
$('rerollBtn').style.display = kind === 'shop' ? 'inline-block' : 'none';
if (kind === 'none') return;
space = new THREE.Group();
if (kind === 'shop') {
gameShop(shopSeed, space); // the real game shell, real wallpaper/carpet/art
} else if (kind === 'street') {
sbox(30, 0.06, 4, smat(0x9a938a, 0.95), 0, -0.031, 0.6, space); // footpath under the prop
sbox(30, 0.05, 7, smat(0x45454a, 0.95), 0, -0.04, -4.9, space); // the road
sbox(30, 0.06, 3, smat(0x9a938a, 0.95), 0, -0.031, -9.9, space); // far footpath
[[-4.5, 0x8a4a3a], [0, 0x4a6a5a], [4.5, 0x7a6a3a]].forEach(([x, c]) => {
sbox(4.2, 3.4, 0.4, smat(c, 0.9), x, 1.7, -11.6, space); // shops across the road — a backdrop, not a blindfold
sbox(4.4, 0.12, 1.5, smat(0xd8d2c4, 0.8), x, 2.5, -10.7, space); // awning
sbox(3.0, 1.4, 0.05, new THREE.MeshStandardMaterial({ color: 0x223038, roughness: 0.1, metalness: 0.4 }), x, 1.15, -11.38, space);
});
const sun = new THREE.DirectionalLight(0xfff2dd, 1.2); sun.position.set(-6, 10, 4); space.add(sun);
} else if (kind === 'home') {
sbox(5, 0.05, 4, smat(0x5c6a4a, 0.95), 0, -0.026, 0, space); // that green carpet
wallsOf(space, 5, 4, 2.5, wallpaperTex('#7a8a6a', '#5a6a4c'));
sbox(1.9, 0.5, 1.0, smat(0x6a4a3a), -1.4, 0.25, -1.3, space); // bed
sbox(1.9, 0.16, 1.0, smat(0xb08a8a, 0.9), -1.4, 0.58, -1.3, space); // doona
sbox(0.45, 0.1, 0.7, smat(0xf0e8dc, 0.9), -2.05, 0.71, -1.3, space);// pillow
sbox(1.2, 0.75, 0.6, smat(0x7a5c46), 1.6, 0.375, -1.55, space); // desk
const lamp = new THREE.PointLight(0xffd9a0, 8, 6); lamp.position.set(1.6, 1.4, -1.4); space.add(lamp);
}
scene.add(space);
}
$('spaceSel').onchange = e => setSpace(e.target.value);
$('rerollBtn').onclick = () => { shopSeed = (Math.random() * 1e9) | 0; setSpace('shop'); };
function fit() {
const w = innerWidth - 320, h = innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
addEventListener('resize', fit); fit();
const clock = new THREE.Clock();
let mixer = null, action = null, current = null, model = null, easel = null;
const isImg = f => /\.(png|jpe?g|webp)$/i.test(f);
(function tick() {
requestAnimationFrame(tick);
if (mixer) mixer.update(clock.getDelta()); else clock.getDelta();
controls.update();
renderer.render(scene, camera);
})();
// ---------- loading a prop ----------
const draco = new DRACOLoader();
draco.setDecoderPath('https://unpkg.com/three@0.175.0/examples/jsm/libs/draco/');
const loader = new GLTFLoader();
loader.setDRACOLoader(draco);
async function show(file) {
if (isImg(file)) return showImg(file);
current = file;
document.querySelectorAll('.prop').forEach(el => el.classList.toggle('sel', el.dataset.f === file));
if (model) { scene.remove(model); model = null; }
if (easel) { scene.remove(easel); easel = null; }
gzMode('off'); // drop the gizmo off the old model
mixer = action = null;
$('animbar').style.display = 'none';
$('stats').style.display = 'block';
$('stats').innerHTML = 'loading…';
let gltf;
try { gltf = await loader.loadAsync('a/' + encodeURIComponent(file)); }
catch (e) { $('stats').innerHTML = `<span class="bad">failed to load: ${e.message || e}</span>`; return; }
model = gltf.scene;
const saved = SHELF.find(a => a.file === file) || {};
if (saved.rot) model.rotation.set(saved.rot[0], saved.rot[1], saved.rot[2]); // baked orientation
if (saved.scale) model.scale.set(saved.scale[0], saved.scale[1], saved.scale[2]);
scene.add(model);
// measure — the acceptance checklist, automated
const box = new THREE.Box3().setFromObject(model);
const dim = box.getSize(new THREE.Vector3());
let tris = 0, texes = new Set();
model.traverse(o => {
if (o.isMesh) {
tris += (o.geometry.index ? o.geometry.index.count : o.geometry.attributes.position.count) / 3;
const m = o.material;
[].concat(m).forEach(mm => ['map', 'normalMap', 'roughnessMap', 'metalnessMap'].forEach(k => {
if (mm && mm[k] && mm[k].image) texes.add(`${mm[k].image.width}×${mm[k].image.height}`);
}));
}
});
tris = Math.round(tris);
const clips = gltf.animations || [];
const isChar = clips.length > 0;
const triCap = isChar ? 15000 : 5000;
const originOff = Math.abs(box.min.y) > dim.y * 0.15 && Math.abs(box.min.y) > 0.05;
const fmt = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n;
const m = v => v.toFixed(2) + 'm';
$('stats').innerHTML =
`<b>${file}</b>\n` +
`size ${m(dim.x)} × ${m(dim.y)} × ${m(dim.z)} ` +
(dim.y > 12 || Math.max(dim.x, dim.y, dim.z) < 0.03 ? '<span class="bad">scale sus — check §0.3</span>' : '<span class="good"></span>') + '\n' +
`tris ${fmt(tris)} / ${fmt(triCap)} ` + (tris <= triCap ? '<span class="good"></span>' : '<span class="bad">heavy — decimate</span>') + '\n' +
`origin at base ` + (originOff ? '<span class="bad">off — floats/sinks ' + m(box.min.y) + '</span>' : '<span class="good"></span>') + '\n' +
`textures ${texes.size ? [...texes].join(', ') : 'none/vertex colours'}\n` +
`clips ${clips.length ? clips.map(c => c.name).join(', ') : 'none (static prop)'}`;
// sit it on the floor next to the reference digger
model.position.y -= box.min.y;
// frame the camera
const r = Math.max(dim.x, dim.y, dim.z);
camera.position.set(r * 1.4 + 0.8, r * 0.9 + 0.6, r * 1.6 + 1);
controls.target.set(0, dim.y / 2, 0);
// animations — the three.js-example experience
if (clips.length) {
mixer = new THREE.AnimationMixer(model);
const sel = $('clipSel');
sel.innerHTML = clips.map((c, i) => `<option value="${i}">${c.name || 'clip ' + i}</option>`).join('');
const play = i => { if (action) action.stop(); action = mixer.clipAction(clips[i]); action.play(); $('playBtn').textContent = '⏸'; };
sel.onchange = () => play(+sel.value);
$('playBtn').onclick = () => { if (!action) return;
action.paused = !action.paused; $('playBtn').textContent = action.paused ? '▶' : '⏸'; };
$('speed').oninput = e => { mixer.timeScale = +e.target.value; $('speedVal').textContent = (+e.target.value).toFixed(2) + '×'; };
mixer.timeScale = +$('speed').value;
play(0);
$('animbar').style.display = 'flex';
}
// cache the measurements on the shelf card
api('api/meta', { file, tris, dims: [+dim.x.toFixed(2), +dim.y.toFixed(2), +dim.z.toFixed(2)],
clips: clips.map(c => c.name) }).then(loadShelf);
// meta editor
$('metarow').style.display = 'flex';
const row = SHELF.find(a => a.file === file) || {};
$('tagsIn').value = (row.tags || []).join(', ');
$('noteIn').value = row.note || '';
// auto-thumbnail: give textures a beat to decode, then snapshot the framed view
setTimeout(() => {
if (current !== file || !model) return; // already moved on
renderer.render(scene, camera);
const src = renderer.domElement, sq = Math.min(src.width, src.height);
const c = document.createElement('canvas'); c.width = c.height = 256;
c.getContext('2d').drawImage(src, (src.width - sq) / 2, (src.height - sq) / 2, sq, sq, 0, 0, 256, 256);
c.toBlob(b => b && fetch('api/thumb?name=' + encodeURIComponent(file), { method: 'POST', body: b })
.then(r => r.json()).then(r => { if (r.ok && !row.thumb) loadShelf(); }).catch(() => {}), 'image/jpeg', 0.75);
}, 700);
}
// ---------- images: skins & the easel ----------
async function showImg(file) {
current = file;
document.querySelectorAll('.prop').forEach(el => el.classList.toggle('sel', el.dataset.f === file));
let tex;
try { tex = await new THREE.TextureLoader().loadAsync('a/' + encodeURIComponent(file)); }
catch (e) { toast('⚠️ image failed to load'); return; }
tex.colorSpace = THREE.SRGBColorSpace;
tex.flipY = false; // glTF UV convention — skins land right-side-up
if (model) { // paint the loaded model
let n = 0;
model.traverse(o => { if (o.isMesh) [].concat(o.material).forEach(m => {
m.map = tex; m.needsUpdate = true; n++; }); });
toast(`🎨 skinned ${n} material${n === 1 ? '' : 's'} — reclick the model to reset`);
} else { // nothing loaded → the easel
if (easel) scene.remove(easel);
const et = tex.clone(); et.flipY = true; et.needsUpdate = true;
const a = tex.image.width / tex.image.height;
easel = new THREE.Mesh(new THREE.PlaneGeometry(a, 1),
new THREE.MeshBasicMaterial({ map: et, side: THREE.DoubleSide }));
easel.position.set(0, 0.75, 0);
scene.add(easel);
controls.target.set(0, 0.75, 0);
}
$('stats').style.display = 'block';
$('stats').innerHTML = `<b>${file}</b>\n${tex.image.width}×${tex.image.height}px · ` +
(model ? 'applied as skin' : 'on the easel — load a model then reclick to skin it');
$('metarow').style.display = 'flex';
const row = SHELF.find(x => x.file === file) || {};
$('tagsIn').value = (row.tags || []).join(', ');
$('noteIn').value = row.note || '';
}
// ---------- the shelf ----------
let SHELF = [];
async function loadShelf() {
const r = await api('api/list');
if (!r.authed) return; // list is public; the page isn't useful till login
SHELF = r.assets;
const kb = n => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB';
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
$('shelf').innerHTML = SHELF.map(a => `
<div class="prop${a.file === current ? ' sel' : ''}" data-f="${a.file}">
${isImg(a.file) ? `<img src="a/${encodeURIComponent(a.file)}" style="height:46px;border-radius:6px;display:block;margin-bottom:3px">`
: a.thumb ? `<img src="t/${encodeURIComponent(a.file)}?v=${Date.now()}" style="height:46px;border-radius:6px;display:block;margin-bottom:3px">` : ''}
<b>${a.file}</b>
<div class="sub">${kb(a.size)}${isImg(a.file) ? ' · 🎨 skin' : ''}${a.tris ? ' · ' + (a.tris >= 1000 ? (a.tris / 1000).toFixed(1) + 'k' : a.tris) + ' tris' : ''}${a.clips && a.clips.length ? ' · 🎬 ' + a.clips.length : ''}</div>
<div>${(a.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
</div>`).join('') || '<div style="color:var(--dim);padding:14px;text-align:center">the shelf is bare —<br>drop your first .glb up top</div>';
document.querySelectorAll('.prop').forEach(el => el.onclick = () => show(el.dataset.f));
}
// ---------- upload: drag-drop / picker / URL ----------
async function upload(file) {
if (!/\.(glb|png|jpe?g|webp)$/i.test(file.name)) { toast('.glb or .png/.jpg/.webp only, digger'); return; }
toast(`uploading ${file.name}…`, 60000);
const r = await fetch('api/upload?name=' + encodeURIComponent(file.name), { method: 'POST', body: file }).then(r => r.json());
toast(r.error ? '⚠️ ' + r.error : `✓ ${r.file} is on the shelf`);
if (!r.error) { await loadShelf(); show(r.file); }
}
const dz = $('dropzone');
dz.onclick = () => $('filepick').click();
$('filepick').onchange = e => [...e.target.files].forEach(upload);
dz.ondragover = e => { e.preventDefault(); dz.classList.add('hot'); };
dz.ondragleave = () => dz.classList.remove('hot');
dz.ondrop = e => { e.preventDefault(); dz.classList.remove('hot'); [...e.dataTransfer.files].forEach(upload); };
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => e.preventDefault());
$('fetchBtn').onclick = async () => {
const url = $('url').value.trim();
if (!url) return;
const name = prompt('name it (something.glb):', url.split('/').pop().split('?')[0] || 'prop.glb');
if (!name) return;
toast('pulling from the cloud…', 60000);
const r = await api('api/fetch', { url, name });
toast(r.error ? '⚠️ ' + r.error : `✓ ${r.file} is on the shelf`);
if (!r.error) { $('url').value = ''; await loadShelf(); show(r.file); }
};
// ---------- meta / download / delete ----------
$('saveMeta').onclick = async () => {
if (!current) return;
await api('api/meta', { file: current, tags: $('tagsIn').value.split(',').map(s => s.trim()).filter(Boolean),
note: $('noteIn').value.trim() });
toast('💾 saved'); loadShelf();
};
$('dlBtn').onclick = () => current && (location.href = 'dl/' + encodeURIComponent(current));
$('delBtn').onclick = async () => {
if (!current || !confirm(`really bin ${current}?`)) return;
await api('api/del', { file: current });
toast(`🗑 ${current} binned`);
if (model) { scene.remove(model); model = null; }
if (easel) { scene.remove(easel); easel = null; }
$('stats').style.display = $('animbar').style.display = 'none'; $('metarow').style.display = 'none';
current = null; loadShelf();
};
// already logged in? (cookie survives)
api('api/list').then(r => { if (r.authed) { $('login').style.display = 'none'; loadShelf(); } });
</script>
</body>
</html>