Kit thumbnails + prop colliders

js/kitthumbs.js renders each kit GLB to a 128x96 sprite once (offscreen
renderer, framed by bounding sphere), POSTs to /api/kit-thumb, and all 52
are committed under assets/kit_thumbs/ so every checkout gets instant thumbs;
the generator only runs for props without one (future kit drops). KIT drawer
shows them with category groups and filter.

js/colliders.js derives world-space colliders from kit manifest hints +
a table for builtin props (bins, planters, trunks, fences...). Test ride
resolves circle-vs-box/circle push-out — you scrub and stop instead of
riding through furniture. Exports gain an additive COLLIDERS array for
bookquoy to adopt when its player is ready; existing contract untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 21:13:36 +10:00
parent 188921278e
commit d68fa84f92
59 changed files with 181 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
assets/kit_thumbs/qp_18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/kit_thumbs/qp_24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -74,6 +74,7 @@
.mbitem{display:flex;align-items:center;gap:6px;background:var(--panel);
border:1px solid var(--line);border-radius:5px;padding:5px 8px}
.mbname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
.kitthumb{width:44px;height:33px;object-fit:contain;border-radius:3px;background:#141a21;flex-shrink:0}
</style>
</head>
<body>

86
js/colliders.js Normal file
View File

@ -0,0 +1,86 @@
// SKATEMAKER PRO — prop colliders. Furniture you can't ride through: bins, planters,
// tree trunks, bollards. Derived (never stored): kit props carry a `collider` hint in
// the manifest; builtin props have a table here. Test ride resolves rider-vs-collider
// as 2D circle-vs-shape push-out, and exports embed the world-space list as COLLIDERS
// so the game can adopt the same rule when it's ready (additive — nothing existing
// in the level.js contract changes).
//
// world entry: { type:'box', x, z, rot, hw, hd, h } | { type:'circle', x, z, r, h }
export const BUILTIN_COLLIDERS = {
jersey: { type: 'box', hw: 0.31, hd: 1.0, h: 0.94 },
picnic: { type: 'box', hw: 0.8, hd: 0.77, h: 0.78 },
bench: { type: 'box', hw: 0.8, hd: 0.22, h: 0.75 },
trashcan: { type: 'circle', r: 0.36, h: 0.96 },
hydrant: { type: 'circle', r: 0.18, h: 0.78 },
fence: { type: 'box', hw: 1.2, hd: 0.06, h: 1.85 },
floodlight: { type: 'circle', r: 0.13, h: 6.5 },
bleachers: { type: 'box', hw: 1.5, hd: 0.84, h: 1.0 },
fountain: { type: 'circle', r: 0.23, h: 0.9 },
fig: { type: 'circle', r: 0.45, h: 2.6 },
// parkingblock / grate / shadesail posts: low or sparse — ride over/through is fine
};
// kit manifest collider -> normalized local shape
function kitShape(c) {
if (!c || c.type === 'none') return null;
if (c.type === 'cylinder') return { type: 'circle', r: c.r, h: c.h };
if (c.type === 'box') return { type: 'box', hw: c.hw, hd: c.hd, h: c.h };
return null;
}
// all world-space colliders for a park. kitMap: id -> manifest def (or null/empty)
export function propColliders(park, kitMap = {}) {
const out = [];
for (const pr of park.props || []) {
let shape = null;
if (pr.src.startsWith('builtin:')) shape = BUILTIN_COLLIDERS[pr.src.slice(8)];
else if (pr.src.startsWith('kit:')) shape = kitShape(kitMap[pr.src.slice(4)]?.collider);
if (!shape) continue;
const sc = pr.scale || 1;
const e = { ...shape, x: pr.x, z: pr.z, rot: pr.rot || 0, h: shape.h * sc };
if (e.type === 'circle') e.r *= sc; else { e.hw *= sc; e.hd *= sc; }
out.push(e);
}
return out;
}
// push a rider circle (px, pz, radius R, foot height y) out of colliders.
// returns null (no contact) or the corrected [x, z].
export function resolveCollision(colliders, px, pz, R, y) {
let hit = null;
for (const c of colliders) {
if (y > c.h) continue; // sailing over it
if (c.type === 'circle') {
const dx = px - c.x, dz = pz - c.z;
const d = Math.hypot(dx, dz), min = c.r + R;
if (d < min) {
const k = d > 1e-4 ? (min / d) : 1, nx = d > 1e-4 ? dx / d : 1;
px = c.x + (d > 1e-4 ? dx * k : min); pz = c.z + (d > 1e-4 ? dz * k : 0);
hit = true;
}
} else {
const co = Math.cos(c.rot), sn = Math.sin(c.rot);
const dx = px - c.x, dz = pz - c.z;
const lu = dx * co - dz * sn, lv = dx * sn + dz * co; // three Y-rot inverse
const cu = Math.max(-c.hw, Math.min(c.hw, lu));
const cv = Math.max(-c.hd, Math.min(c.hd, lv));
const du = lu - cu, dv = lv - cv;
const d = Math.hypot(du, dv);
if (d < R) {
let nu, nv;
if (d > 1e-4) { nu = du / d; nv = dv / d; }
else { // inside: push out shortest face
const eu = c.hw - Math.abs(lu), ev = c.hd - Math.abs(lv);
if (eu < ev) { nu = Math.sign(lu) || 1; nv = 0; }
else { nu = 0; nv = Math.sign(lv) || 1; }
}
const ou = cu + nu * R, ov = cv + nv * R;
px = c.x + ou * co + ov * sn;
pz = c.z - ou * sn + ov * co;
hit = true;
}
}
}
return hit ? [px, pz] : null;
}

View File

@ -19,7 +19,10 @@ export function downloadParkJSON(park) {
download(slug(park) + '.json', JSON.stringify(park, null, 1), 'application/json');
}
export function generateLevelJS(park) {
import { propColliders } from './colliders.js';
export function generateLevelJS(park, kitMap = {}) {
const colliders = propColliders(park, kitMap);
return `// ${park.name} — generated by SKATEMAKER PRO ${new Date().toISOString().slice(0, 10)}
// Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/)
// into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js:
@ -36,6 +39,8 @@ export const LETTERS = PARK.letters;
export const SPAWN = PARK.spawn;
export const heightAt = makeHeightAt(PARK);
export const normalAt = makeNormalAt(heightAt);
// world-space prop colliders (bins, planters, trunks) — adopt when the player is ready
export const COLLIDERS = ${JSON.stringify(colliders)};
export function buildLevel(scene) {
const { ground, letterMeshes } = buildParkScene(scene, PARK);
@ -44,6 +49,6 @@ export function buildLevel(scene) {
`;
}
export function downloadLevelJS(park) {
download('level.' + slug(park) + '.js', generateLevelJS(park), 'text/javascript');
export function downloadLevelJS(park, kitMap) {
download('level.' + slug(park) + '.js', generateLevelJS(park, kitMap), 'text/javascript');
}

58
js/kitthumbs.js Normal file
View File

@ -0,0 +1,58 @@
// SKATEMAKER PRO — kit thumbnails. Each GLB renders to a 128x96 sprite ONCE, is
// POSTed to serve.py, saved under assets/kit_thumbs/ and committed — so every
// machine gets instant thumbs and the offscreen renderer only ever runs for
// props that don't have one yet (new kit drops).
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KIT_BASE } from './parkbuild.js';
const W = 128, H = 96;
let ctx = null; // lazy shared renderer
function getCtx() {
if (ctx) return ctx;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true,
preserveDrawingBuffer: true });
renderer.setSize(W, H);
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x445544, 1.2));
const sun = new THREE.DirectionalLight(0xfff3d9, 1.6);
sun.position.set(3, 6, 4); scene.add(sun);
const cam = new THREE.PerspectiveCamera(38, W / H, 0.05, 100);
ctx = { renderer, scene, cam, loader: new GLTFLoader() };
return ctx;
}
async function renderOne(def) {
const { renderer, scene, cam, loader } = getCtx();
const glb = await new Promise((res, rej) =>
loader.load(KIT_BASE + 'props/' + def.id + '.glb', res, undefined, rej));
const obj = glb.scene;
scene.add(obj);
const box = new THREE.Box3().setFromObject(obj);
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
const rad = Math.max(s.x, s.y, s.z) * 0.62 / Math.tan(cam.fov * Math.PI / 360);
cam.position.set(c.x + rad * 0.72, c.y + rad * 0.5, c.z + rad * 0.72);
cam.lookAt(c);
renderer.render(scene, cam);
scene.remove(obj);
const blob = await new Promise(res => renderer.domElement.toBlob(res, 'image/png'));
await fetch('/api/kit-thumb?name=' + def.id + '.png', { method: 'POST', body: blob });
return true;
}
// ensure every kit prop has a thumb; returns ids rendered this pass
export async function ensureKitThumbs(kitList, onProgress) {
const missing = [];
await Promise.all(kitList.map(async d => {
const r = await fetch('assets/kit_thumbs/' + d.id + '.png', { method: 'HEAD' });
if (!r.ok) missing.push(d);
}));
const done = [];
for (const def of missing) {
try { await renderOne(def); done.push(def.id); onProgress?.(done.length, missing.length); }
catch (e) { console.warn('thumb failed', def.id, e); }
}
if (ctx && done.length) { ctx.renderer.dispose(); ctx = null; }
return done;
}

View File

@ -3,12 +3,13 @@
// park data — same heightAt, same constants, so speed and pop feel like the game.
import * as THREE from 'three';
import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js';
import { propColliders, resolveCollision } from './colliders.js';
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
export function attachTestRide(ed) {
const keys = new Set();
let rider = null, heightAt = null, normalAt = null, rails = [];
let rider = null, heightAt = null, normalAt = null, rails = [], colliders = [];
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
let grind = null; // {seg, t, dir, len}
const N = new THREE.Vector3();
@ -38,6 +39,7 @@ export function attachTestRide(ed) {
heightAt = makeHeightAt(ed.park);
normalAt = makeNormalAt(heightAt);
rails = flattenRails(ed.park);
colliders = propColliders(ed.park, ed.kit || {});
const s = ed.park.spawn;
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
heading = s.heading; speed = 0; vy = 0; state = 'roll';
@ -120,6 +122,13 @@ export function attachTestRide(ed) {
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
}
}
if (state !== 'grind') { // can't ride through furniture
const fix = resolveCollision(colliders, pos.x, pos.z, 0.28, pos.y - heightAt(pos.x, pos.z));
if (fix) {
pos.x = fix[0]; pos.z = fix[1];
if (state === 'roll') speed *= Math.pow(0.02, dt); // scrubbing along a bin
}
}
if (keys.has('KeyR')) {
const s = ed.park.spawn;
pos.set(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; state = 'roll';

View File

@ -5,6 +5,7 @@ import { checkPark } from './parkcheck.js';
import { downloadParkJSON, downloadLevelJS } from './export.js';
import * as MB from './modelbeast.js';
import { PROP_LABELS } from './props3d.js';
import { ensureKitThumbs } from './kitthumbs.js';
const $ = (sel, root = document) => root.querySelector(sel);
const el = (tag, cls, html) => {
@ -455,6 +456,10 @@ export function initUI(ed) {
list.append(el('div', 'ptitle', cat.toUpperCase()));
for (const p of items) {
const item = el('div', 'mbitem');
const im = el('img', 'kitthumb');
im.src = 'assets/kit_thumbs/' + p.id + '.png';
im.onerror = () => { im.style.visibility = 'hidden'; };
item.append(im);
const badges = [p.element ? '⛰' : '', p.grinds?.length ? '≡' : ''].join('');
item.append(el('span', 'mbname', `${p.id} ${badges} <span class="dim">${p.desc || ''}</span>`));
const b = el('button', 'pbtn', 'PLACE');
@ -471,6 +476,10 @@ export function initUI(ed) {
};
search.oninput = render;
render();
ensureKitThumbs(ed.kitList, (n, total) => {
setStatus(`rendering kit thumbnails ${n}/${total}`);
if (n === total) { setStatus('kit thumbnails ready'); render(); }
}).then(done => { if (done.length) render(); });
}
// ---------------------------------------------------------------- MODELBEAST
@ -590,7 +599,7 @@ export function initUI(ed) {
};
bar.append(loadSel);
mk('⬇ JSON', () => downloadParkJSON(ed.park));
mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park));
mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park, ed.kit));
mk('TOP', () => ed.topView());
const test = mk('▶ TEST RIDE', () => ed.toggleTest?.(), 'accent test');
ed.on('test', on => { test.textContent = on ? '■ STOP (Enter)' : '▶ TEST RIDE'; });

View File

@ -128,6 +128,14 @@ class H(SimpleHTTPRequestHandler):
os.makedirs(PARKS, exist_ok=True)
open(os.path.join(PARKS, name + '.json'), 'wb').write(body)
return self._json({'saved': name})
if path == '/api/kit-thumb': # rendered-once kit thumbnails (committed)
name = urllib.parse.unquote(q.get('name', ''))
if not re.match(r'^[\w\-]+\.png$', name):
return self._err('bad name', 400)
d = os.path.join(ROOT, 'assets', 'kit_thumbs')
os.makedirs(d, exist_ok=True)
open(os.path.join(d, name), 'wb').write(body)
return self._json({'path': 'assets/kit_thumbs/' + name})
if path == '/api/upload':
name = urllib.parse.unquote(q.get('name', ''))
if not SAFE.match(name):