New heightfield kinds: hip/corner quarter, kicker (tunable concavity), roller, wedge/manny pad, volcano, round-bowl option, quarter vert extension. Rails are now polylines (kink/dbl-kink/rainbow/A-frame/pole-jam/donkey presets, round or square profile, per-node drag handles) that flatten to straight segments on export so bookquoy grind detection is untouched. 13 parametric street props (jersey barrier, picnic table, parking block, fence, floodlight, bleachers...). Palette regrouped TRANSITION/STREET/STEEL/PROPS; park check covers new kinds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
539 lines
26 KiB
JavaScript
539 lines
26 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']]],
|
||
['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 === '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');
|
||
}
|