wardrobegod/depot/viewer.html
type-two db47546c2b 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>
2026-07-24 22:16:05 +10:00

543 lines
31 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>