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:
parent
7c5fc53224
commit
db47546c2b
59
depot/README.md
Normal file
59
depot/README.md
Normal 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
271
depot/server.py
Normal 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
30
depot/test_hardening.py
Normal 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
542
depot/viewer.html
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
Loading…
Reference in New Issue
Block a user