skatemakerpro/js/ui.js
type-two d68fa84f92 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>
2026-08-03 21:13:36 +10:00

646 lines
31 KiB
JavaScript
Raw 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.

// SKATEMAKER PRO — DOM panels. Everything talks to the editor through its tiny API:
// setTool / select / updateSelected / commit / loadPark. No three.js in here.
import { ELEMENT_KINDS } from './parkmath.js';
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) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (html !== undefined) n.innerHTML = html;
return n;
};
// numeric param rows: [key, label, min, max, step]
const DEG = Math.PI / 180;
const COMMON_XZ = [['x', 'x', -60, 60, 0.1], ['z', 'z', -40, 40, 0.1]];
const PARAMS = {
quarter: [['len', 'length', 1, 80, 0.5], ['r0', 'radius a', 0.3, 4, 0.05],
['r1', 'radius b', 0.3, 4, 0.05], ['lump', 'lumpiness', 0, 0.3, 0.01],
['deck', 'deck', 0, 4, 0.1], ['vert', 'vert ext', 0, 2, 0.05]],
corner: [['r', 'radius', 0.4, 4, 0.05], ['sweep', 'sweep°', 0.26, 6.28, 0.02]],
kicker: [['len', 'length', 0.6, 12, 0.2], ['h', 'height', 0.2, 2.4, 0.05],
['run', 'run', 0.5, 5, 0.1], ['curve', 'concavity', 0, 1, 0.05],
['deck', 'deck', 0, 2, 0.05]],
roller: [['len', 'length', 0.5, 20, 0.5], ['h', 'height', 0.1, 1.2, 0.05],
['s', 'spread', 0.4, 4, 0.1]],
wedgepad: [['hw', 'half width', 0.4, 8, 0.1], ['hd', 'half depth', 0.3, 6, 0.1],
['h0', 'height a', 0, 1.5, 0.05], ['h1', 'height b', 0, 1.5, 0.05]],
volcano: [['r0', 'top radius', 0.2, 5, 0.1], ['run', 'skirt', 0.5, 6, 0.1],
['h', 'height', 0.2, 2.5, 0.05]],
bank: [['len', 'length', 1, 80, 0.5], ['h', 'height', 0.1, 3, 0.05],
['run', 'run', 0.5, 8, 0.1], ['deck', 'deck', 0, 4, 0.1]],
funbox: [['hw', 'half width', 0.3, 10, 0.1], ['hd', 'half depth', 0.3, 10, 0.1],
['h', 'height', 0.1, 2.5, 0.05], ['skirt', 'skirt', 0.2, 4, 0.1]],
ledge: [['hw', 'half width', 0.2, 14, 0.1], ['hd', 'half depth', 0.2, 14, 0.1],
['h', 'height', 0.05, 1.5, 0.01]],
mogul: [['h', 'height', 0.1, 2, 0.05], ['s', 'spread', 0.5, 6, 0.1]],
spine: [['len', 'length', 1, 30, 0.5], ['r', 'radius', 0.4, 3, 0.05]],
pyramid: [['hw', 'half width', 0.5, 10, 0.1], ['hd', 'half depth', 0.5, 10, 0.1],
['h', 'height', 0.2, 3, 0.05], ['run', 'run', 0.5, 6, 0.1]],
stairs: [['width', 'width', 1, 12, 0.2], ['h', 'drop', 0.2, 2.4, 0.05],
['steps', 'steps', 2, 12, 1], ['going', 'going', 0.3, 1.2, 0.05],
['platDepth', 'platform', 0.5, 8, 0.1]],
dome: [['rx', 'radius x', 0.5, 14, 0.1], ['rz', 'radius z', 0.5, 14, 0.1],
['h', 'height', 0.05, 1, 0.01]],
bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]],
};
const PALETTE_GROUPS = [
['TRANSITION', [['quarter', 'QUARTER'], ['corner', 'HIP/CORNER'], ['bank', 'BANK'],
['kicker', 'KICKER'], ['spine', 'SPINE'], ['roller', 'ROLLER'],
['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL'],
['bowlpoly', 'CUSTOM BOWL']]],
['STREET', [['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], ['ledge', 'LEDGE'],
['wedgepad', 'WEDGE PAD'], ['stairs', 'STAIRS'], ['dome', 'ISLAND']]],
];
// rail presets: pts are [dx, dz, dy-above-ground] from the click point
const RAIL_PRESETS = [
['FLATBAR', { name: 'Flatbar', pts: [[0, 0, 0.45], [3, 0, 0.45]] }],
['SQUARE BAR', { name: 'Square Bar', profile: 'square', kind: 'ledge',
pts: [[0, 0, 0.4], [3, 0, 0.4]] }],
['DOWN RAIL', { name: 'Down Rail', pts: [[0, 0, 1.0], [3.2, 0, 0.3]] }],
['KINK RAIL', { name: 'Kink Rail', pts: [[0, 0, 0.95], [2, 0, 0.95], [4.2, 0, 0.3]] }],
['DBL KINK', { name: 'Double Kink', pts: [[0, 0, 1.1], [1.5, 0, 1.1], [3, 0, 0.7],
[4.5, 0, 0.7], [6, 0, 0.25]] }],
['RAINBOW', { name: 'Rainbow Rail',
pts: Array.from({ length: 7 }, (_, i) => [i * 0.667, 0,
0.25 + 1.05 * Math.sin(i / 6 * Math.PI)]) }],
['A-FRAME', { name: 'A-Frame Rail', pts: [[0, 0, 0.35], [2, 0, 1.0], [4, 0, 0.35]] }],
['POLE JAM', { name: 'Pole Jam', pts: [[0, 0, 0.06], [1.7, 0, 0.85]] }],
['DONKEY', { name: 'Donkey Kick', pts: [[0, 0, 0.5], [2.4, 0, 0.5], [3.2, 0, 0.95]] }],
];
const SPOTS = [
['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }],
['letter', 'LETTER', { mode: 'letter' }], ['spawn', 'SPAWN', { mode: 'spawn' }],
['zone', 'PAINT', { mode: 'zone' }],
];
export function initUI(ed) {
const palette = $('#palette'), inspector = $('#inspector'),
status = $('#status'), drawer = $('#drawer');
const setStatus = t => { status.textContent = t; };
ed.on('status', setStatus);
// ---------------------------------------------------------------- palette
function buildPalette() {
palette.innerHTML = '';
for (const [title, kinds] of PALETTE_GROUPS) {
palette.append(el('div', 'ptitle', title));
const grid = el('div', 'pgrid'); palette.append(grid);
for (const [kind, label] of kinds) {
const b = el('button', 'pbtn', label);
b.dataset.tool = 'place:' + kind;
b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind });
grid.append(b);
}
}
palette.append(el('div', 'ptitle', 'STEEL'));
const railGrid = el('div', 'pgrid'); palette.append(railGrid);
for (const [label, preset] of RAIL_PRESETS) {
const b = el('button', 'pbtn', label);
b.dataset.tool = 'preset:' + label;
b.onclick = () => ed.setTool(ed.tool?.presetLabel === label ? null :
{ mode: 'railPreset', presetLabel: label, ...preset });
railGrid.append(b);
}
palette.append(el('div', 'ptitle', 'PROPS'));
const propGrid = el('div', 'pgrid'); palette.append(propGrid);
for (const [name, label] of PROP_LABELS) {
const b = el('button', 'pbtn', label);
b.dataset.tool = 'prop:' + name;
b.onclick = () => ed.setTool(ed.tool?.propName === name ? null :
{ mode: 'prop', propName: name, src: 'builtin:' + name, scale: 1 });
propGrid.append(b);
}
palette.append(el('div', 'ptitle', 'SPOTS'));
const grid2 = el('div', 'pgrid'); palette.append(grid2);
for (const [key, label, tool] of SPOTS) {
const b = el('button', 'pbtn', label);
b.dataset.tool = tool.mode;
b.onclick = () => ed.setTool(ed.tool?.mode === tool.mode ? null : { ...tool });
grid2.append(b);
}
palette.append(el('div', 'ptitle', 'SURFACE'));
const surf = el('div', 'pcol'); palette.append(surf);
const decalBtn = el('button', 'pbtn wide', 'IMAGE DECAL…');
decalBtn.onclick = () => pickFile('image/*', async f => {
const path = await uploadFile(f);
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('decal loaded — click the ground to place it');
};
img.src = path;
});
surf.append(decalBtn);
const underBtn = el('button', 'pbtn wide', 'REFERENCE UNDERLAY…');
underBtn.onclick = () => pickFile('image/*', async f => {
const path = await uploadFile(f);
ed.park.underlay = { img: path, visible: true, opacity: 0.45, w: 90, x: 0, z: 0, rot: 0, y: 6 };
ed.commit({ underlay: true });
ed.select({ type: 'underlay' });
});
surf.append(underBtn);
const snapRow = el('label', 'snaprow',
'<input type="checkbox" id="snapbox"> grid snap 0.5m');
surf.append(snapRow);
$('#snapbox', snapRow).onchange = e => { ed.snapStep = e.target.checked ? 0.5 : 0; };
palette.append(el('div', 'ptitle', 'PANELS'));
const pans = el('div', 'pcol'); palette.append(pans);
const kitBtn = el('button', 'pbtn wide accent', 'PARK KIT');
kitBtn.onclick = () => toggleDrawer('kit');
pans.append(kitBtn);
const mbBtn = el('button', 'pbtn wide accent', 'MODELBEAST');
mbBtn.onclick = () => toggleDrawer('mb');
const ckBtn = el('button', 'pbtn wide', 'PARK CHECK');
ckBtn.onclick = () => toggleDrawer('check');
pans.append(mbBtn, ckBtn);
}
ed.on('tool', () => {
for (const b of palette.querySelectorAll('.pbtn[data-tool]')) {
const t = b.dataset.tool;
const active = ed.tool &&
(t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode ||
t === 'preset:' + (ed.tool.presetLabel || '') || t === 'prop:' + (ed.tool.propName || ''));
b.classList.toggle('active', !!active);
}
setStatus(ed.tool ? 'click the ground to place — ESC to cancel'
: 'click to select · drag to move · rightdrag orbit · [ ] rotate · D dupe · T top');
});
// ---------------------------------------------------------------- inspector
function row(label, input) {
const r = el('div', 'irow');
r.append(el('label', null, label), input);
return r;
}
function numRow(obj, key, label, min, max, step, onInput) {
const wrap = el('div', 'numwrap');
const slider = el('input'); slider.type = 'range';
slider.min = min; slider.max = max; slider.step = step; slider.value = obj[key] ?? 0;
const num = el('input', 'num'); num.type = 'number';
num.step = step; num.value = obj[key] ?? 0;
const set = (v, done) => {
v = +v; if (Number.isNaN(v)) return;
slider.value = v; num.value = v;
onInput(v, done);
};
slider.oninput = () => set(slider.value, false);
slider.onchange = () => set(slider.value, true);
num.onchange = () => set(num.value, true);
wrap.append(slider, num);
return row(label, wrap);
}
function buildInspector() {
inspector.innerHTML = '';
const s = ed.selection, obj = ed.getSelected?.();
if (s?.type === 'underlay') return buildUnderlayInspector();
if (!s || !obj) {
inspector.append(el('div', 'ihint',
'nothing selected<br><br>· click a piece to edit it<br>· palette → click ground to add' +
'<br>· ENTER to test ride<br>· ctrl-Z undo'));
return buildParkInspector();
}
const title = s.type === 'element' ? ELEMENT_KINDS[obj.kind].label : s.type.toUpperCase();
inspector.append(el('div', 'ititle', title));
const upd = key => (v, done) => ed.updateSelected(key, v, { done });
if (s.type === 'element') {
for (const [key, label, min, max, step] of COMMON_XZ)
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
for (const [key, label, min, max, step] of PARAMS[obj.kind] || [])
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
if (obj.kind === 'bowlpoly') {
inspector.append(el('div', 'ihint',
'drag the rim balls to shape it — inside corners, hips, whatever'));
obj.depths.forEach((d, i) => {
inspector.append(numRow({ d }, 'd', 'depth pt ' + (i + 1), 0.4, 3.4, 0.05,
(v, done) => { obj.depths[i] = v; ed.updateSelected('depths', obj.depths, { done }); }));
});
const vbtns = el('div', 'ibtns');
const addV = el('button', 'pbtn', '+ VERTEX');
addV.onclick = () => {
const a = obj.pts[obj.pts.length - 1], b = obj.pts[0];
obj.pts.push({ u: +((a.u + b.u) / 2).toFixed(2), v: +((a.v + b.v) / 2).toFixed(2) });
obj.depths.push(obj.depths[obj.depths.length - 1]);
ed.updateSelected('pts', obj.pts, { done: true });
};
const delV = el('button', 'pbtn', ' VERTEX');
delV.onclick = () => {
if (obj.pts.length <= 3) return;
obj.pts.pop(); obj.depths.pop();
ed.updateSelected('pts', obj.pts, { done: true });
};
vbtns.append(addV, delV); inspector.append(vbtns);
for (const [key, label] of [['coping', 'pool coping (grindable)'], ['tile', 'tile band']]) {
const cb = el('label', 'snaprow');
const inp = el('input'); inp.type = 'checkbox'; inp.checked = !!obj[key];
inp.onchange = () => ed.updateSelected(key, inp.checked ? 1 : 0, { done: true });
cb.append(inp, ' ' + label); inspector.append(cb);
}
}
if (obj.kind === 'bowl') {
const shape = el('select');
shape.innerHTML = '<option value="0">square pool</option><option value="1">round bowl</option>';
shape.value = String(obj.round ? 1 : 0);
shape.onchange = () => ed.updateSelected('round', +shape.value, { done: true });
inspector.append(row('shape', shape));
}
} else if (s.type === 'rail') {
const name = el('input', 'text'); name.value = obj.name || '';
name.onchange = () => ed.updateSelected('name', name.value, { done: true });
inspector.append(row('name', name));
const kind = el('select');
kind.innerHTML = '<option value="rail">rail (posts)</option><option value="ledge">ledge cap</option>';
kind.value = obj.kind;
kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true });
inspector.append(row('type', kind));
const prof = el('select');
prof.innerHTML = '<option value="round">round pipe</option><option value="square">square bar</option>';
prof.value = obj.profile || 'round';
prof.onchange = () => ed.updateSelected('profile', prof.value, { done: true });
inspector.append(row('profile', prof));
obj.pts.forEach((pt, i) => {
inspector.append(numRow(pt, 'y', 'pt ' + (i + 1) + ' height', 0, 4.5, 0.01,
(v, done) => { pt.y = v; ed.updateSelected('pts', obj.pts, { done }); }));
});
const pbtns = el('div', 'ibtns');
const addPt = el('button', 'pbtn', '+ POINT');
addPt.onclick = () => {
const a = obj.pts[obj.pts.length - 2], b = obj.pts[obj.pts.length - 1];
obj.pts.push({ x: +(b.x + (b.x - a.x)).toFixed(2), z: +(b.z + (b.z - a.z)).toFixed(2), y: b.y });
ed.updateSelected('pts', obj.pts, { done: true });
};
const delPt = el('button', 'pbtn', ' POINT');
delPt.onclick = () => {
if (obj.pts.length <= 2) return;
obj.pts.pop();
ed.updateSelected('pts', obj.pts, { done: true });
};
pbtns.append(addPt, delPt); inspector.append(pbtns);
inspector.append(el('div', 'ihint', 'drag the yellow balls to move points; kinks are just points'));
} else if (s.type === 'gap') {
const name = el('input', 'text'); name.value = obj.name;
name.onchange = () => ed.updateSelected('name', name.value, { done: true });
inspector.append(row('name', name));
for (const [key, label, min, max, step] of COMMON_XZ)
inspector.append(numRow(obj, key, label, min, max, step, upd(key)));
inspector.append(numRow(obj, 'r', 'radius', 0.5, 8, 0.1, upd('r')));
} else if (s.type === 'letter') {
const ch = el('input', 'text'); ch.maxLength = 1; ch.value = obj.ch;
ch.onchange = () => ed.updateSelected('ch', (ch.value || 'O').toUpperCase(), { done: true });
inspector.append(row('letter', ch));
inspector.append(numRow(obj, 'y', 'float height', 0.5, 6, 0.1, upd('y')));
} else if (s.type === 'spawn') {
inspector.append(numRow({ heading: (obj.heading || 0) / DEG }, 'heading', 'heading°', -180, 180, 5,
(v, done) => ed.updateSelected('heading', +(v * DEG).toFixed(3), { done })));
} else if (s.type === 'prop') {
inspector.append(el('div', 'ihint', obj.src));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow(obj, 'scale', 'scale', 0.05, 8, 0.05, upd('scale')));
inspector.append(numRow({ y: obj.y || 0 }, 'y', 'lift', -2, 6, 0.05, upd('y')));
} else if (s.type === 'zone') {
const shape = el('select');
shape.innerHTML = '<option value="rect">rect</option><option value="ellipse">ellipse</option>';
shape.value = obj.shape;
shape.onchange = () => ed.updateSelected('shape', shape.value, { done: true });
inspector.append(row('shape', shape));
const color = el('input'); color.type = 'color'; color.value = obj.color;
color.oninput = () => ed.updateSelected('color', color.value, {});
color.onchange = () => ed.updateSelected('color', color.value, { done: true });
inspector.append(row('color', color));
inspector.append(numRow(obj, 'hw', 'half width', 0.3, 40, 0.1, upd('hw')));
inspector.append(numRow(obj, 'hd', 'half depth', 0.3, 40, 0.1, upd('hd')));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity')));
} else if (s.type === 'decal') {
inspector.append(numRow(obj, 'w', 'width', 0.5, 60, 0.1, (v, done) => {
const asp = obj.h / obj.w; ed.updateSelected('w', v, {});
ed.updateSelected('h', +(v * asp).toFixed(2), { done });
}));
inspector.append(numRow(obj, 'h', 'height', 0.5, 60, 0.1, upd('h')));
inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5,
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity')));
}
if (s.type !== 'spawn') {
const btns = el('div', 'ibtns');
const dup = el('button', 'pbtn', 'DUPE (D)'); dup.onclick = () => ed.duplicateSelected();
const del = el('button', 'pbtn danger', 'DELETE'); del.onclick = () => ed.deleteSelected();
btns.append(dup, del); inspector.append(btns);
}
}
function buildUnderlayInspector() {
const u = ed.park.underlay;
inspector.append(el('div', 'ititle', 'REFERENCE UNDERLAY'));
if (!u) { inspector.append(el('div', 'ihint', 'none loaded')); return; }
const updU = key => (v, done) => { u[key] = v; if (done !== false) ed.commit({ underlay: true, snapshot: done }); };
inspector.append(numRow(u, 'opacity', 'opacity', 0, 1, 0.05, updU('opacity')));
inspector.append(numRow(u, 'w', 'width (m)', 10, 300, 1, updU('w')));
inspector.append(numRow(u, 'x', 'x', -80, 80, 0.5, updU('x')));
inspector.append(numRow(u, 'z', 'z', -60, 60, 0.5, updU('z')));
inspector.append(numRow(u, 'y', 'hover', 0.1, 20, 0.1, updU('y')));
inspector.append(numRow({ rot: (u.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 1,
(v, done) => { u.rot = v * DEG; ed.commit({ underlay: true, snapshot: done }); }));
const vis = el('button', 'pbtn wide', u.visible ? 'HIDE' : 'SHOW');
vis.onclick = () => { u.visible = !u.visible; ed.commit({ underlay: true }); buildInspector(); };
const kill = el('button', 'pbtn danger wide', 'REMOVE');
kill.onclick = () => { delete ed.park.underlay; ed.commit({ underlay: true }); ed.select(null); };
inspector.append(vis, kill);
}
function buildParkInspector() {
inspector.append(el('div', 'ititle', 'PARK'));
const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 });
const gsel = el('select');
gsel.innerHTML = '<option value="lush">lush grass</option><option value="dry">dry grass</option>' +
'<option value="pixel">pixel grass</option><option value="off">no grass</option>';
gsel.value = g.style;
gsel.onchange = () => { g.style = gsel.value; ed.rebuildGrass(); ed.commit({}); };
inspector.append(row('grass', gsel));
inspector.append(numRow(g, 'density', 'blades/m²', 1, 20, 1, (v, done) => {
g.density = v; if (done) { ed.rebuildGrass(); ed.commit({}); }
}));
const cols = [['slabColor', 'slab'], ['grassColor', 'grass']];
for (const [key, label] of cols) {
const c = el('input'); c.type = 'color'; c.value = ed.park[key];
c.onchange = () => { ed.park[key] = c.value; ed.commit({ tex: true }); };
inspector.append(row(label, c));
}
const SURFS = ['concrete_smooth', 'concrete_rough', 'concrete_green', 'tarmac',
'grass_dry', 'dirt_mulch', 'brick_qld', 'wood_slats'];
ed.park.surf ||= {};
for (const key of ['slab', 'grass']) {
const sel = el('select');
sel.innerHTML = '<option value="">flat color</option>' +
SURFS.map(n => `<option>${n}</option>`).join('');
sel.value = ed.park.surf[key] || '';
sel.onchange = () => {
if (sel.value) ed.park.surf[key] = sel.value; else delete ed.park.surf[key];
ed.commit({ tex: true });
};
inspector.append(row(key + ' tex', sel));
}
const B = ed.park.bounds;
for (const [key, label, min, max] of [['x0', 'west edge', -120, -5], ['x1', 'east edge', 5, 120],
['z0', 'north edge', -120, -5], ['z1', 'south edge', 5, 120]])
inspector.append(numRow(B, key, label, min, max, 1, (v, done) => {
B[key] = v; if (done) ed.commit({ ground: true });
}));
}
ed.on('select', buildInspector);
ed.on('change', () => { if (!ed.selection) buildInspector(); });
// ---------------------------------------------------------------- drawer (MB / check)
let drawerMode = null;
function toggleDrawer(mode) {
drawerMode = drawerMode === mode ? null : mode;
drawer.style.display = drawerMode ? 'flex' : 'none';
if (drawerMode === 'mb') buildMB();
if (drawerMode === 'check') buildCheck();
if (drawerMode === 'kit') buildKit();
}
function buildCheck() {
drawer.innerHTML = '<div class="ptitle">PARK CHECK <span class="dim">— flow · tiers · steel</span></div>';
const list = el('div', 'checklist'); drawer.append(list);
const results = checkPark(ed.park);
if (!results.length) {
list.append(el('div', 'checkok', '✓ park reads clean — flow, tiers and steel all check out'));
return;
}
for (const r of results) {
const item = el('div', 'checkitem', '⚠ ' + r.msg);
if (r.sel) {
item.classList.add('link');
item.onclick = () => ed.select(r.sel);
}
list.append(item);
}
const re = el('button', 'pbtn wide', 'RE-CHECK');
re.onclick = buildCheck; drawer.append(re);
}
// ---------------------------------------------------------------- PARK KIT
function buildKit() {
drawer.innerHTML = '<div class="ptitle">PARK KIT <span class="dim">— 52 textured originals</span></div>';
if (!ed.kitList?.length) {
drawer.append(el('div', 'checkitem',
'park_kit not mounted — serve.py expects it at ../park_kit (or set PARK_KIT env)'));
return;
}
const search = el('input', 'text'); search.placeholder = 'filter… (qp, rail, tree)';
drawer.append(search);
const list = el('div', 'mblist'); drawer.append(list);
const CATS = ['obstacle', 'furniture', 'vegetation', 'dressing'];
const render = () => {
const q = search.value.trim().toLowerCase();
list.innerHTML = '';
for (const cat of CATS) {
const items = ed.kitList.filter(p => p.category === cat &&
(!q || p.id.includes(q) || (p.desc || '').toLowerCase().includes(q)));
if (!items.length) continue;
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');
b.onclick = () => {
ed.setTool({ mode: 'prop', propName: 'kit_' + p.id, src: 'kit:' + p.id, scale: 1 });
setStatus(p.id + ' — click the ground to place it' +
(p.element ? ' (brings real collision)' : '') +
(p.grinds?.length ? ' (grindable)' : ''));
};
item.append(b);
list.append(item);
}
}
};
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
async function buildMB() {
drawer.innerHTML = '<div class="ptitle">MODELBEAST <span class="dim">— farm assets & gen</span></div>';
const gen = el('div', 'mbgen');
const prompt = el('input', 'text'); prompt.placeholder = 'prompt… e.g. "graffiti tag, spray paint, transparent bg"';
const genImg = el('button', 'pbtn accent', 'GEN IMAGE');
const genStatus = el('div', 'mbstatus');
gen.append(prompt, genImg, genStatus); drawer.append(gen);
genImg.onclick = async () => {
if (!prompt.value.trim()) return;
genImg.disabled = true;
try {
await MB.generateImage(prompt.value.trim(), s => { genStatus.textContent = s; });
genStatus.textContent = 'done — refreshing list';
await refresh();
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
genImg.disabled = false;
};
const list = el('div', 'mblist', 'loading farm assets…'); drawer.append(list);
async function refresh() {
let assets;
try { assets = await MB.listAssets(); }
catch (e) { list.innerHTML = `<div class="checkitem">farm unreachable: ${e.message}</div>`; return; }
list.innerHTML = '';
for (const a of assets.slice(0, 40)) {
const name = a.name || a.filename || a.id;
const isGlb = /\.glb$/i.test(name), isImg = /\.(png|jpe?g|webp)$/i.test(name);
if (!isGlb && !isImg) continue;
const item = el('div', 'mbitem');
item.append(el('span', 'mbname', `${isGlb ? '◆' : '▣'} ${name}`));
if (isGlb) {
const b = el('button', 'pbtn', 'PLACE');
b.onclick = async () => {
b.textContent = '…';
const { path } = await MB.importAsset(a.id, name);
ed.setTool({ mode: 'prop', src: path, scale: 1 });
setStatus('model fetched — click the ground to place it');
b.textContent = 'PLACE';
};
item.append(b);
}
if (isImg) {
const d = el('button', 'pbtn', 'DECAL');
d.onclick = async () => {
d.textContent = '…';
const { path } = await MB.importAsset(a.id, name);
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('image fetched — click the ground to place the decal');
};
img.src = path;
d.textContent = 'DECAL';
};
const m3 = el('button', 'pbtn', 'MAKE 3D');
m3.onclick = async () => {
m3.disabled = true;
try {
await MB.imageTo3D(a.id, s => { genStatus.textContent = s; });
genStatus.textContent = 'mesh done — hit REFRESH and PLACE it';
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
m3.disabled = false;
};
const bg = el('button', 'pbtn', 'CUT BG');
bg.onclick = async () => {
bg.disabled = true;
try {
await MB.removeBg(a.id, s => { genStatus.textContent = s; });
genStatus.textContent = 'bg cut — hit REFRESH for the clean version';
} catch (e) { genStatus.textContent = 'error: ' + e.message; }
bg.disabled = false;
};
item.append(d, bg, m3);
}
list.append(item);
}
if (!list.children.length) list.append(el('div', 'ihint', 'no glb/img assets on the farm yet'));
}
const rf = el('button', 'pbtn wide', 'REFRESH');
rf.onclick = refresh; drawer.append(rf);
refresh();
}
// ---------------------------------------------------------------- topbar
function buildTopbar() {
const bar = $('#topbar');
bar.innerHTML = '';
bar.append(el('span', 'logo', 'SKATEMAKER<b>PRO</b>'));
const name = el('input', 'text parkname');
name.value = ed.park?.name || 'NEW PARK';
name.onchange = () => { ed.park.name = name.value; ed.commit({}); };
bar.append(name);
ed.on('change', () => { if (document.activeElement !== name) name.value = ed.park.name; });
const mk = (label, fn, cls = '') => {
const b = el('button', 'pbtn ' + cls, label); b.onclick = fn; bar.append(b); return b;
};
mk('NEW', () => { if (confirm('start a fresh park? (autosave is overwritten)')) ed.newPark('NEW PARK'); });
mk('SAVE', async () => {
const n = ed.park.name.replace(/[^\w\- ]+/g, '').trim() || 'park';
await fetch('/api/parks/' + encodeURIComponent(n), { method: 'POST', body: JSON.stringify(ed.park, null, 1) });
setStatus(`saved parks/${n}.json`);
});
const loadSel = el('select'); loadSel.innerHTML = '<option value="">LOAD…</option>';
loadSel.onfocus = async () => {
const names = await fetch('/api/parks').then(r => r.json());
loadSel.innerHTML = '<option value="">LOAD…</option>' +
names.map(n => `<option>${n}</option>`).join('');
};
loadSel.onchange = async () => {
if (!loadSel.value) return;
ed.loadPark(await fetch('/api/parks/' + encodeURIComponent(loadSel.value)).then(r => r.json()));
loadSel.value = '';
};
bar.append(loadSel);
mk('⬇ JSON', () => downloadParkJSON(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'; });
}
// ---------------------------------------------------------------- file helpers
function pickFile(accept, cb) {
const inp = el('input'); inp.type = 'file'; inp.accept = accept;
inp.onchange = () => { if (inp.files[0]) cb(inp.files[0]); };
inp.click();
}
async function uploadFile(f) {
const name = Date.now().toString(36) + '_' + f.name.replace(/[^\w.\-]+/g, '_');
const r = await fetch('/api/upload?name=' + encodeURIComponent(name),
{ method: 'POST', body: await f.arrayBuffer() });
const j = await r.json();
if (j.error) throw new Error(j.error);
return j.path;
}
// drag & drop anywhere: .glb -> prop, image -> decal
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', async e => {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (!f) return;
const path = await uploadFile(f);
if (/\.glb$/i.test(f.name)) {
ed.setTool({ mode: 'prop', src: path, scale: 1 });
setStatus('model uploaded — click the ground to place it');
} else if (/^image\//.test(f.type)) {
const img = new Image();
img.onload = () => {
ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width });
setStatus('image uploaded — click the ground to place the decal');
};
img.src = path;
}
});
buildPalette(); buildTopbar(); buildInspector();
ed.emit('tool');
}