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