skatemakerpro/js/ui.js
type-two a962ac41d9 Modular custom bowls: polygon pools with per-vertex depths
New bowlpoly element: the rim is a polygon, every vertex a draggable handle
with its own depth slider. Inside/outside corners, hips, shallow-to-deep
escalators and waterfall drops all come from the same continuous carve (IDW
floor blend, quarter-pipe wall transition) — no seams by construction. Auto
pool coping (bullnose tube, exported as grindable ledge segments per rim
edge) and tile band baked into the ground texture. Ships parks/pool_demo.json
(THE POOLROOM) as a premade showcase; ridden end-to-end in test mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:26:47 +10:00

569 lines
28 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';
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 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 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 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();
}
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);
}
// ---------------------------------------------------------------- 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));
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');
}