// 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',
' 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
· click a piece to edit it
· palette → click ground to add' +
'
· ENTER to test ride
· 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 = '';
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 = '';
kind.value = obj.kind;
kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true });
inspector.append(row('type', kind));
const prof = el('select');
prof.innerHTML = '';
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 = '';
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 = '' +
'';
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 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 = '