// 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 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 = '
PARK CHECK — flow · tiers · steel
'; 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 = '
MODELBEAST — farm assets & gen
'; 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 = `
farm unreachable: ${e.message}
`; 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', 'SKATEMAKERPRO')); 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 = ''; loadSel.onfocus = async () => { const names = await fetch('/api/parks').then(r => r.json()); loadSel.innerHTML = '' + names.map(n => ``).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'); }