Library expansion: full obstacle set, polyline rails, street props

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>
This commit is contained in:
type-two 2026-08-03 20:19:08 +10:00
parent db40f433fe
commit 7a00c9f9a7
8 changed files with 447 additions and 95 deletions

View File

@ -17,11 +17,22 @@ persistence (`parks/`, `assets/uploads/`).
## The editor ## The editor
- **BUILD palette** — quarter, bank, funbox, pyramid, ledge, stairs, spine, mogul, bowl, - **TRANSITION** — quarter (escalating radius, lump, vert extension), hip/corner quarter,
island. Click a piece, click the ground. Drag to move, `[` `]` rotate, `D` dupe, bank, kicker (tunable concavity), spine, roller/pump bump, mogul, volcano,
`⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style). bowl (square pool or round). **STREET** — funbox, pyramid, ledge, wedge/manny pad
- **SPOTS** — rails/ledge-caps (two clicks, draggable end-balls), named score gaps, (inclined), stairs, island. Click a piece, click the ground. Drag to move, `[` `]`
B·O·O·K·Q·U·O·Y letters, spawn point. rotate, `D` dupe, `⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style).
- **STEEL** — rails are polylines: flatbar, square bar, down rail, kink, double-kink,
rainbow, A-frame, pole jam, donkey kick — or draw your own (RAIL, two clicks) and
`+ POINT` for kinks. Round pipe (with posts) or square angle-iron profile; every node
is a draggable ball. Exports flatten to straight segments so bookquoy's grind
detection needs zero changes.
- **PROPS** — parametric street furniture: jersey barrier, picnic table, parking block,
bench, trash can, hydrant, chain-link fence, floodlight, bleachers, shade sail,
fountain, drain grate, fig tree. Make any prop grindable by drawing a ledge rail
along its edge. (Overhangs — cradles, capsules, over-vert — aren't heightfield-able;
those arrive as MODELBEAST GLB props.)
- **SPOTS** — named score gaps, B·O·O·K·Q·U·O·Y letters, spawn point.
- **SURFACE** — paint zones (rect/ellipse, any color), **image decals** (drop any image on - **SURFACE** — paint zones (rect/ellipse, any color), **image decals** (drop any image on
the window, or generate one on the farm), and a **reference underlay** for tracing a real the window, or generate one on the farm), and a **reference underlay** for tracing a real
park from an aerial photo. park from an aerial photo.

View File

@ -3,8 +3,8 @@
// so the editor can never lie about what the game will do. // so the editor can never lie about what the game will do.
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark } import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark,
from './parkmath.js'; normalizeRail } from './parkmath.js';
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
GRID_PAD } from './parkbuild.js'; GRID_PAD } from './parkbuild.js';
@ -140,11 +140,9 @@ export function initEditor(container) {
}; };
if (sel.type === 'element') loop(elementOutline(obj)); if (sel.type === 'element') loop(elementOutline(obj));
else if (sel.type === 'rail') { else if (sel.type === 'rail') {
const a = new THREE.Vector3(obj.a[0], obj.ya, obj.a[1]); const pts = obj.pts.map(p => new THREE.Vector3(p.x, p.y, p.z));
const b = new THREE.Vector3(obj.b[0], obj.yb, obj.b[1]); selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), mat));
selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), mat)); obj.pts.forEach((p, i) => { handle(p.x, p.y, p.z, true).userData.railPt = i; });
handle(a.x, a.y, a.z, true).userData.railEnd = 'a';
handle(b.x, b.y, b.z, true).userData.railEnd = 'b';
} else if (sel.type === 'gap') { } else if (sel.type === 'gap') {
const pts = []; const pts = [];
for (let i = 0; i < 32; i++) { for (let i = 0; i < 32; i++) {
@ -222,6 +220,7 @@ export function initEditor(container) {
for (const list of [json.elements, json.rails, json.gaps, json.letters, for (const list of [json.elements, json.rails, json.gaps, json.letters,
json.zones, json.decals, json.props]) json.zones, json.decals, json.props])
for (const o of list || []) if (!o.id) o.id = newId(); for (const o of list || []) if (!o.id) o.id = newId();
for (const r of json.rails || []) normalizeRail(r);
ed.selection = null; undoStack.length = redoStack.length = 0; ed.selection = null; undoStack.length = redoStack.length = 0;
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
}; };
@ -265,7 +264,7 @@ export function initEditor(container) {
prop: 'props', zone: 'zones', decal: 'decals' }; prop: 'props', zone: 'zones', decal: 'decals' };
const copy = structuredClone(obj); copy.id = newId(); const copy = structuredClone(obj); copy.id = newId();
if (copy.x !== undefined) { copy.x += 2; copy.z += 2; } if (copy.x !== undefined) { copy.x += 2; copy.z += 2; }
if (copy.a) { copy.a = [copy.a[0] + 2, copy.a[1] + 2]; copy.b = [copy.b[0] + 2, copy.b[1] + 2]; } if (copy.pts) for (const pt of copy.pts) { pt.x += 2; pt.z += 2; }
snapshot(); ed.park[lists[s.type]].push(copy); snapshot(); ed.park[lists[s.type]].push(copy);
ed.selection = { type: s.type, id: copy.id }; ed.selection = { type: s.type, id: copy.id };
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
@ -295,8 +294,8 @@ export function initEditor(container) {
function pick(ev, p) { function pick(ev, p) {
// 1. selection handles (rail endpoints) // 1. selection handles (rail endpoints)
const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle)); const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle));
if (hs.length && hs[0].object.userData.railEnd) if (hs.length && hs[0].object.userData.railPt !== undefined)
return { type: 'railEnd', end: hs[0].object.userData.railEnd }; return { type: 'railPt', i: hs[0].object.userData.railPt };
if (!p) return null; if (!p) return null;
const P = ed.park, x = p.x, z = p.z; const P = ed.park, x = p.x, z = p.z;
// 2. props (real raycast) // 2. props (real raycast)
@ -310,9 +309,12 @@ export function initEditor(container) {
return { type: 'letter', id: L.id }; return { type: 'letter', id: L.id };
// 4. spawn // 4. spawn
if (Math.hypot(x - P.spawn.x, z - P.spawn.z) < 1.0) return { type: 'spawn' }; if (Math.hypot(x - P.spawn.x, z - P.spawn.z) < 1.0) return { type: 'spawn' };
// 5. rails // 5. rails (any segment of the polyline)
for (const r of P.rails) if (distSeg(x, z, r.a, r.b) < 0.6) for (const r of P.rails) {
return { type: 'rail', id: r.id }; for (let i = 0; i < r.pts.length - 1; i++)
if (distSeg(x, z, [r.pts[i].x, r.pts[i].z], [r.pts[i + 1].x, r.pts[i + 1].z]) < 0.6)
return { type: 'rail', id: r.id };
}
// 6. gaps (ring click — near the radius, not anywhere inside) // 6. gaps (ring click — near the radius, not anywhere inside)
for (const g of P.gaps) { const d = Math.hypot(x - g.x, z - g.z); for (const g of P.gaps) { const d = Math.hypot(x - g.x, z - g.z);
if (Math.abs(d - g.r) < 0.5) return { type: 'gap', id: g.id }; } if (Math.abs(d - g.r) < 0.5) return { type: 'gap', id: g.id }; }
@ -348,14 +350,23 @@ export function initEditor(container) {
ed.selection = { type: 'element', id: e.id }; ed.selection = { type: 'element', id: e.id };
rebuildGround(); ed.commit({ snapshot: false }); rebuildGround(); ed.commit({ snapshot: false });
} else if (t.mode === 'rail') { } else if (t.mode === 'rail') {
const y = ed.heightAt(x, z) + 0.4; const y = +(ed.heightAt(x, z) + 0.4).toFixed(2);
if (!pendingRail) { pendingRail = { x, z, y }; ed.emit('status', 'rail: click the far end'); return; } if (!pendingRail) { pendingRail = { x, z, y }; ed.emit('status', 'rail: click the far end'); return; }
const r = { id: newId(), name: 'Rail', a: [pendingRail.x, pendingRail.z], b: [x, z], const r = { id: newId(), name: 'Rail', kind: 'rail', profile: 'round',
ya: +pendingRail.y.toFixed(2), yb: +y.toFixed(2), kind: 'rail' }; pts: [{ x: pendingRail.x, z: pendingRail.z, y: pendingRail.y }, { x, z, y }] };
snapshot(); P.rails.push(r); pendingRail = null; snapshot(); P.rails.push(r); pendingRail = null;
ed.selection = { type: 'rail', id: r.id }; ed.selection = { type: 'rail', id: r.id };
ed.commit({ snapshot: false, rails: true }); ed.commit({ snapshot: false, rails: true });
ed.setTool(null); ed.setTool(null);
} else if (t.mode === 'railPreset') {
const base = ed.heightAt(x, z);
const r = { id: newId(), name: t.name, kind: t.kind || 'rail', profile: t.profile || 'round',
pts: t.pts.map(([px, pz, py]) => ({ x: +(x + px).toFixed(2), z: +(z + pz).toFixed(2),
y: +(base + py).toFixed(2) })) };
snapshot(); P.rails.push(r);
ed.selection = { type: 'rail', id: r.id };
ed.commit({ snapshot: false, rails: true });
ed.setTool(null);
} else if (t.mode === 'gap') { } else if (t.mode === 'gap') {
const g = { id: newId(), name: 'NEW GAP', x, z, r: 2.4 }; const g = { id: newId(), name: 'NEW GAP', x, z, r: 2.4 };
snapshot(); P.gaps.push(g); snapshot(); P.gaps.push(g);
@ -401,16 +412,16 @@ export function initEditor(container) {
if (ed.tool) { if (p) place(p); return; } if (ed.tool) { if (p) place(p); return; }
const hit = pick(ev, p); const hit = pick(ev, p);
if (!hit) { ed.select(null); return; } if (!hit) { ed.select(null); return; }
if (hit.type === 'railEnd') { if (hit.type === 'railPt') {
drag = { kind: 'railEnd', end: hit.end, moved: false }; drag = { kind: 'railPt', i: hit.i, moved: false };
controls.enabled = false; snapshot(); return; controls.enabled = false; snapshot(); return;
} }
ed.select({ type: hit.type, id: hit.id }); ed.select({ type: hit.type, id: hit.id });
const obj = getSelected(); const obj = getSelected();
if (obj && p) { if (obj && p) {
drag = { kind: 'move', moved: false, drag = { kind: 'move', moved: false,
grabDX: (obj.x ?? obj.a?.[0] ?? 0) - p.x, grabDX: (obj.x ?? obj.pts?.[0]?.x ?? 0) - p.x,
grabDZ: (obj.z ?? obj.a?.[1] ?? 0) - p.z }; grabDZ: (obj.z ?? obj.pts?.[0]?.z ?? 0) - p.z };
controls.enabled = false; snapshot(); controls.enabled = false; snapshot();
} }
}); });
@ -419,16 +430,15 @@ export function initEditor(container) {
const p = groundPoint(ev); if (!p) return; const p = groundPoint(ev); if (!p) return;
const s = ed.selection, obj = getSelected(); if (!obj) return; const s = ed.selection, obj = getSelected(); if (!obj) return;
drag.moved = true; drag.moved = true;
if (drag.kind === 'railEnd') { if (drag.kind === 'railPt') {
const end = drag.end; const pt = obj.pts[drag.i];
obj[end] = [snapV(p.x), snapV(p.z)]; pt.x = snapV(p.x); pt.z = snapV(p.z);
obj[end === 'a' ? 'ya' : 'yb'] = +(ed.heightAt(p.x, p.z) + 0.4).toFixed(2);
rebuildRails(); rebuildSelection(); return; rebuildRails(); rebuildSelection(); return;
} }
const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ); const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ);
if (s.type === 'rail') { if (s.type === 'rail') {
const dx = nx - obj.a[0], dz = nz - obj.a[1]; const dx = nx - obj.pts[0].x, dz = nz - obj.pts[0].z;
obj.a = [obj.a[0] + dx, obj.a[1] + dz]; obj.b = [obj.b[0] + dx, obj.b[1] + dz]; for (const pt of obj.pts) { pt.x = +(pt.x + dx).toFixed(2); pt.z = +(pt.z + dz).toFixed(2); }
rebuildRails(); rebuildRails();
} else { obj.x = nx; obj.z = nz; } } else { obj.x = nx; obj.z = nz; }
if (s.type === 'element') requestGround(); if (s.type === 'element') requestGround();
@ -444,7 +454,7 @@ export function initEditor(container) {
drag = null; controls.enabled = true; drag = null; controls.enabled = true;
if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot
if (s?.type === 'element') rebuildGround(); if (s?.type === 'element') rebuildGround();
ed.commit({ snapshot: false, rails: s?.type === 'rail' || s?.type === 'railEnd' }); ed.commit({ snapshot: false, rails: s?.type === 'rail' });
}; };
renderer.domElement.addEventListener('pointerup', endDrag); renderer.domElement.addEventListener('pointerup', endDrag);
renderer.domElement.addEventListener('pointerleave', endDrag); renderer.domElement.addEventListener('pointerleave', endDrag);

View File

@ -24,13 +24,13 @@ export function generateLevelJS(park) {
// Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/) // Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/)
// into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js: // into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js:
// heightAt(x,z) is the single source of truth for visuals AND collision. // heightAt(x,z) is the single source of truth for visuals AND collision.
import { makeHeightAt, makeNormalAt } from './parkmath.js'; import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js';
import { buildParkScene } from './parkbuild.js'; import { buildParkScene } from './parkbuild.js';
export const PARK = ${JSON.stringify(park, null, 1)}; export const PARK = ${JSON.stringify(park, null, 1)};
export const BOUNDS = PARK.bounds; export const BOUNDS = PARK.bounds;
export const RAILS = PARK.rails; export const RAILS = flattenRails(PARK);
export const GAPS = PARK.gaps; export const GAPS = PARK.gaps;
export const LETTERS = PARK.letters; export const LETTERS = PARK.letters;
export const SPAWN = PARK.spawn; export const SPAWN = PARK.spawn;

View File

@ -2,7 +2,8 @@
// so what you see in the editor is byte-for-byte what the game builds. // so what you see in the editor is byte-for-byte what the game builds.
import * as THREE from 'three'; import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { makeHeightAt } from './parkmath.js'; import { makeHeightAt, normalizeRail } from './parkmath.js';
import { makeBuiltinProp } from './props3d.js';
export const GRID_RES = 0.5, GRID_PAD = 14; export const GRID_RES = 0.5, GRID_PAD = 14;
@ -107,22 +108,32 @@ export function buildGround(park) {
} }
// ---------------------------------------------------------------- steel // ---------------------------------------------------------------- steel
// Rails are polylines (kinks, rainbows, A-frames); each segment is one bar. Round
// profile = pipe + posts; square profile = angle-iron look, no posts.
export function buildRails(park) { export function buildRails(park) {
const grp = new THREE.Group(); grp.name = 'rails'; const grp = new THREE.Group(); grp.name = 'rails';
const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }); const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 });
const UP = new THREE.Vector3(0, 1, 0);
for (const r of park.rails || []) { for (const r of park.rails || []) {
const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]); normalizeRail(r);
const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]); for (let i = 0; i < r.pts.length - 1; i++) {
const len = Math.max(a.distanceTo(b), 0.01); const pa = r.pts[i], pb = r.pts[i + 1];
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel); const a = new THREE.Vector3(pa.x, pa.y, pa.z);
bar.position.copy(a).add(b).multiplyScalar(0.5); const b = new THREE.Vector3(pb.x, pb.y, pb.z);
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); const len = Math.max(a.distanceTo(b), 0.01);
bar.userData.railId = r.id; const geo = r.profile === 'square'
grp.add(bar); ? new THREE.BoxGeometry(0.09, len, 0.07)
if (r.kind === 'rail') { : new THREE.CylinderGeometry(0.035, 0.035, len, 8);
for (const t of [0.12, 0.88]) { const bar = new THREE.Mesh(geo, steel);
const p = a.clone().lerp(b, t); bar.position.copy(a).add(b).multiplyScalar(0.5);
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, Math.max(p.y, 0.05), 6), steel); bar.quaternion.setFromUnitVectors(UP, b.clone().sub(a).normalize());
bar.userData.railId = r.id;
grp.add(bar);
}
if (r.kind === 'rail' && r.profile !== 'square') {
for (const p of r.pts) { // a post under every node
if (p.y < 0.12) continue;
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, p.y, 6), steel);
post.position.set(p.x, p.y / 2, p.z); post.position.set(p.x, p.y / 2, p.z);
post.userData.railId = r.id; post.userData.railId = r.id;
grp.add(post); grp.add(post);
@ -133,35 +144,23 @@ export function buildRails(park) {
} }
// ---------------------------------------------------------------- props // ---------------------------------------------------------------- props
const fig = (s, trunkM, leafM) => { // builtin Moreton Bay fig
const grp = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM);
trunk.position.y = 1.3 * s; grp.add(trunk);
for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) {
const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM);
m.position.set(ox * s, oy * s, oz * s); grp.add(m);
}
return grp;
};
export function buildProps(park, heightAt, onLoaded) { export function buildProps(park, heightAt, onLoaded) {
const grp = new THREE.Group(); grp.name = 'props'; const grp = new THREE.Group(); grp.name = 'props';
const loader = new GLTFLoader(); const loader = new GLTFLoader();
const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 });
const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a });
for (const pr of park.props || []) { for (const pr of park.props || []) {
const place = obj => { const place = obj => {
obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z); obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z);
obj.rotation.y = pr.rot || 0; obj.rotation.y = pr.rot || 0;
const s = pr.scale || 1; obj.scale.setScalar(pr.scale || 1);
if (pr.src !== 'builtin:fig') obj.scale.setScalar(s);
obj.userData.propId = pr.id; obj.userData.propId = pr.id;
obj.traverse(o => { o.userData.propId = pr.id; }); obj.traverse(o => { o.userData.propId = pr.id; });
grp.add(obj); grp.add(obj);
if (onLoaded) onLoaded(pr, obj); if (onLoaded) onLoaded(pr, obj);
}; };
if (pr.src === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM)); if (pr.src.startsWith('builtin:')) {
else loader.load(pr.src, g => place(g.scene), const obj = makeBuiltinProp(pr.src.slice(8));
if (obj) place(obj); else console.warn('unknown builtin prop', pr.src);
} else loader.load(pr.src, g => place(g.scene),
undefined, () => console.warn('prop failed', pr.src)); undefined, () => console.warn('prop failed', pr.src));
} }
return grp; return grp;

View File

@ -3,8 +3,8 @@
// metres: street trannies want r 1.8-2.4, bowls 2.7-3.4, run-outs 3-4.5m of flat. // metres: street trannies want r 1.8-2.4, bowls 2.7-3.4, run-outs 3-4.5m of flat.
import { makeHeightAt, elementOutline } from './parkmath.js'; import { makeHeightAt, elementOutline } from './parkmath.js';
const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid']); const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid', 'wedgepad']);
const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul']); const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano']);
export function checkPark(park) { export function checkPark(park) {
const out = []; const out = [];
@ -34,10 +34,11 @@ export function checkPark(park) {
return heightAt(x, z) > 0.16; return heightAt(x, z) > 0.16;
}; };
for (const e of els) { for (const e of els) {
if (!['quarter', 'bank', 'stairs'].includes(e.kind)) continue; if (!['quarter', 'bank', 'stairs', 'kicker'].includes(e.kind)) continue;
const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot); const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot);
const start = e.kind === 'quarter' ? Math.max(e.r0, e.r1) const start = e.kind === 'quarter' ? Math.max(e.r0, e.r1)
: e.kind === 'bank' ? e.run : e.steps * e.going; : e.kind === 'bank' || e.kind === 'kicker' ? e.run
: e.steps * e.going;
let clear = 0; let clear = 0;
for (let d = 0.5; d <= 4.5; d += 0.5) { for (let d = 0.5; d <= 4.5; d += 0.5) {
const v = start + d; const v = start + d;
@ -80,13 +81,19 @@ export function checkPark(park) {
if (tranny === 0) warn(null, 'all street, no transition — add quarters/banks/spines'); if (tranny === 0) warn(null, 'all street, no transition — add quarters/banks/spines');
} }
// --- steel: rails floating or buried // --- steel: rails floating or buried (any node of the polyline)
for (const r of park.rails || []) { for (const r of park.rails || []) {
const ha = heightAt(r.a[0], r.a[1]), hb = heightAt(r.b[0], r.b[1]); for (const pt of r.pts || []) {
if (r.ya < ha - 0.05 || r.yb < hb - 0.05) const h = heightAt(pt.x, pt.z);
warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise ya/yb`); if (pt.y < h - 0.05) {
if (r.ya - ha > 1.6 || r.yb - hb > 1.6) warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise its points`);
warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`); break;
}
if (pt.y - h > 1.8) {
warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`);
break;
}
}
} }
// --- spawn sanity // --- spawn sanity

View File

@ -25,19 +25,73 @@ function local(e, x, z) { // world (x,z) -> element-local (u,v)
// ---------------------------------------------------------------- element kinds // ---------------------------------------------------------------- element kinds
// Each returns a height contribution (concrete = max of all). Bowls carve (override). // Each returns a height contribution (concrete = max of all). Bowls carve (override).
export const ELEMENT_KINDS = { export const ELEMENT_KINDS = {
quarter: { // straight QP, escalating radius, optional lump + deck quarter: { // straight QP, escalating radius, lump, deck, vert ext
label: 'Quarter Pipe', label: 'Quarter Pipe',
defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6 }, defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6, vert: 0 },
height(e, x, z) { height(e, x, z) {
const [u, v] = local(e, x, z); const [u, v] = local(e, x, z);
if (Math.abs(u) > e.len / 2) return 0; if (Math.abs(u) > e.len / 2) return 0;
let r = e.r0 + (e.r1 - e.r0) * sstep(u / e.len + 0.5); let r = e.r0 + (e.r1 - e.r0) * sstep(u / e.len + 0.5);
if (e.lump) r += e.lump * Math.sin(u * 0.9); if (e.lump) r += e.lump * Math.sin(u * 0.9);
if (v < -e.deck) return 0; if (v < -e.deck) return 0;
if (v <= 0) return r; // deck behind the coping if (v <= 0) return r + (e.vert || 0); // deck; vert extension rises the wall face
return tranny(v, r); return tranny(v, r);
}, },
}, },
corner: { // radial quarter wrapped around a point — hips & corners
label: 'Corner / Hip QP',
defaults: { r: 1.4, sweep: Math.PI / 2 },
height(e, x, z) {
const dx = x - e.x, dz = z - e.z;
const rho = Math.hypot(dx, dz);
if (rho >= e.r) return 0;
let ang = Math.atan2(dz, dx) - (e.rot || 0);
ang = ((ang % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
if (ang > e.sweep) return 0;
return tranny(rho, e.r);
},
},
kicker: { // launch wedge, blend linear->circular-concave
label: 'Kicker',
defaults: { len: 2.4, h: 0.7, run: 1.8, curve: 0.6, deck: 0.25 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.len / 2 || v < -e.deck || v >= e.run) return 0;
if (v <= 0) return e.h;
const s = v / e.run; // 0 at lip, 1 at base
const lin = 1 - s;
const con = 1 - Math.sqrt(Math.max(0, 1 - (1 - s) ** 2));
return e.h * ((1 - (e.curve ?? 0)) * lin + (e.curve ?? 0) * con);
},
},
roller: { // pump bump / speed roller — gaussian ridge
label: 'Roller',
defaults: { len: 4, h: 0.45, s: 1.1 },
height(e, x, z) {
const [u, v] = local(e, x, z);
const over = Math.max(0, Math.abs(u) - e.len / 2); // rounded ends
return e.h * Math.exp(-(v * v + over * over) / (e.s * e.s));
},
},
wedgepad: { // inclined / step-up manny pad (sharp sides)
label: 'Wedge Pad',
defaults: { hw: 1.8, hd: 0.9, h0: 0.2, h1: 0.5 },
height(e, x, z) {
const [u, v] = local(e, x, z);
if (Math.abs(u) > e.hw || Math.abs(v) > e.hd) return 0;
return e.h0 + (e.h1 - e.h0) * (u / e.hw + 1) / 2;
},
},
volcano: { // truncated cone with flat skateable top
label: 'Volcano',
defaults: { r0: 0.7, run: 1.8, h: 0.9 },
height(e, x, z) {
const rho = Math.hypot(x - e.x, z - e.z);
if (rho <= e.r0) return e.h;
if (rho >= e.r0 + e.run) return 0;
return e.h * (1 - (rho - e.r0) / e.run);
},
},
bank: { bank: {
label: 'Bank', label: 'Bank',
defaults: { len: 6, h: 0.9, run: 2.4, deck: 0.5 }, defaults: { len: 6, h: 0.9, run: 2.4, deck: 0.5 },
@ -121,20 +175,46 @@ export const ELEMENT_KINDS = {
}, },
bowl: { // CARVES negative space. Override, not max. bowl: { // CARVES negative space. Override, not max.
label: 'Bowl', label: 'Bowl',
defaults: { size: 4.2, depth: 1.5 }, defaults: { size: 4.2, depth: 1.5, round: 0 },
carve: true, carve: true,
height(e, x, z) { // returns carve value, caller checks inside() height(e, x, z) { // returns carve value, caller checks inside()
const [u, v] = local(e, x, z); const [u, v] = local(e, x, z);
const dw = Math.min(e.size - Math.abs(u), e.size - Math.abs(v)); const dw = e.round ? e.size - Math.hypot(u, v)
: Math.min(e.size - Math.abs(u), e.size - Math.abs(v));
return -e.depth + tranny(Math.min(dw, e.depth), e.depth); return -e.depth + tranny(Math.min(dw, e.depth), e.depth);
}, },
inside(e, x, z) { inside(e, x, z) {
const [u, v] = local(e, x, z); const [u, v] = local(e, x, z);
return Math.abs(u) < e.size && Math.abs(v) < e.size; return e.round ? Math.hypot(u, v) < e.size
: Math.abs(u) < e.size && Math.abs(v) < e.size;
}, },
}, },
}; };
// ---------------------------------------------------------------- rails
// v2 rails are polylines: { id, name, kind:'rail'|'ledge', profile:'round'|'square',
// pts:[{x,z,y},...] }. v1 {a,b,ya,yb} files normalize on sight. The GAME contract
// stays straight segments — flattenRails() chops polylines for bookquoy's player.
export function normalizeRail(r) {
if (!r.pts) {
r.pts = [{ x: r.a[0], z: r.a[1], y: r.ya }, { x: r.b[0], z: r.b[1], y: r.yb }];
delete r.a; delete r.b; delete r.ya; delete r.yb;
}
r.profile ||= 'round';
return r;
}
export function flattenRails(park) {
const out = [];
for (const r of park.rails || []) {
normalizeRail(r);
for (let i = 0; i < r.pts.length - 1; i++) {
const a = r.pts[i], b = r.pts[i + 1];
out.push({ name: r.name, a: [a.x, a.z], b: [b.x, b.z], ya: a.y, yb: b.y, kind: r.kind });
}
}
return out;
}
// ---------------------------------------------------------------- evaluation // ---------------------------------------------------------------- evaluation
export function elementHeight(e, x, z) { export function elementHeight(e, x, z) {
const k = ELEMENT_KINDS[e.kind]; const k = ELEMENT_KINDS[e.kind];
@ -192,6 +272,14 @@ export function elementHit(e, x, z) {
case 'ledge': { const [u, v] = local(e, x, z); case 'ledge': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; } return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; }
case 'mogul': return Math.hypot(x - e.x, z - e.z) <= e.s + M; case 'mogul': return Math.hypot(x - e.x, z - e.z) <= e.s + M;
case 'kicker': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= e.run + M; }
case 'roller': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + e.s + M && Math.abs(v) <= e.s + M; }
case 'wedgepad': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; }
case 'volcano': return Math.hypot(x - e.x, z - e.z) <= e.r0 + e.run + M;
case 'corner': return Math.hypot(x - e.x, z - e.z) <= e.r + M;
case 'spine': { const [u, v] = local(e, x, z); case 'spine': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; } return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; }
case 'stairs': { const [u, v] = local(e, x, z); case 'stairs': { const [u, v] = local(e, x, z);
@ -220,6 +308,16 @@ export function elementOutline(e) {
case 'mogul': { const pts = []; for (let i = 0; i < 24; i++) { case 'mogul': { const pts = []; for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * e.s, e.z + Math.sin(a) * e.s]); } const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * e.s, e.z + Math.sin(a) * e.s]); }
return pts; } return pts; }
case 'kicker': return rect(-e.len / 2, e.len / 2, -e.deck, e.run);
case 'roller': return rect(-e.len / 2 - e.s, e.len / 2 + e.s, -e.s, e.s);
case 'wedgepad': return rect(-e.hw, e.hw, -e.hd, e.hd);
case 'volcano': { const pts = [], R = e.r0 + e.run; for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * R, e.z + Math.sin(a) * R]); }
return pts; }
case 'corner': { const pts = [[e.x, e.z]]; for (let i = 0; i <= 12; i++) {
const a = rot + i / 12 * e.sweep;
pts.push([e.x + Math.cos(a) * e.r, e.z + Math.sin(a) * e.r]); }
return pts; }
case 'spine': return rect(-e.len / 2, e.len / 2, -e.r, e.r); case 'spine': return rect(-e.len / 2, e.len / 2, -e.r, e.r);
case 'stairs': return rect(-e.width / 2, e.width / 2, -e.platDepth, e.steps * e.going); case 'stairs': return rect(-e.width / 2, e.width / 2, -e.platDepth, e.steps * e.going);
case 'dome': { const pts = []; for (let i = 0; i < 24; i++) { case 'dome': { const pts = []; for (let i = 0; i < 24; i++) {

148
js/props3d.js Normal file
View File

@ -0,0 +1,148 @@
// SKATEMAKER PRO — builtin street props & park infrastructure. Parametric three.js
// assemblies (instant, tiny, no downloads); MODELBEAST GLBs cover anything fancier.
// Every factory returns a Group with its origin at ground center. To make a prop
// grindable, draw a ledge-cap rail along its edge — steel is data, props are dressing.
import * as THREE from 'three';
const M = {
concrete: new THREE.MeshLambertMaterial({ color: 0xa8a49c }),
concreteDark: new THREE.MeshLambertMaterial({ color: 0x8b8880 }),
steel: new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }),
steelDark: new THREE.MeshStandardMaterial({ color: 0x5f6670, metalness: 0.7, roughness: 0.45 }),
wood: new THREE.MeshLambertMaterial({ color: 0x9c7a4f }),
woodDark: new THREE.MeshLambertMaterial({ color: 0x74583a }),
red: new THREE.MeshLambertMaterial({ color: 0xc0392b }),
green: new THREE.MeshLambertMaterial({ color: 0x2c6e49 }),
yellow: new THREE.MeshLambertMaterial({ color: 0xd9b23b }),
fabric: new THREE.MeshLambertMaterial({ color: 0x3a7ca5, side: THREE.DoubleSide }),
trunk: new THREE.MeshLambertMaterial({ color: 0x5b4632 }),
leaf: new THREE.MeshLambertMaterial({ color: 0x2e5b2a }),
};
const box = (w, h, d, mat, x = 0, y = 0, z = 0, ry = 0) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
m.position.set(x, y, z); m.rotation.y = ry; return m;
};
const cyl = (r0, r1, h, mat, x = 0, y = 0, z = 0, seg = 10) => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(r0, r1, h, seg), mat);
m.position.set(x, y, z); return m;
};
export const PROP_FACTORIES = {
fig() { // Moreton Bay fig (the Paddo original)
const g = new THREE.Group();
g.add(cyl(0.28, 0.42, 2.6, M.trunk, 0, 1.3, 0, 7));
for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) {
const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 8), M.leaf);
s.position.set(ox, oy, oz); g.add(s);
}
return g;
},
jersey() { // concrete K-rail, 2m section
const g = new THREE.Group();
g.add(box(0.62, 0.24, 2.0, M.concreteDark, 0, 0.12));
const mid = box(0.4, 0.3, 2.0, M.concrete, 0, 0.39); g.add(mid);
const top = box(0.22, 0.4, 2.0, M.concrete, 0, 0.74); g.add(top);
return g;
},
picnic() { // timber picnic table
const g = new THREE.Group();
g.add(box(1.6, 0.05, 0.8, M.wood, 0, 0.74));
g.add(box(1.6, 0.05, 0.3, M.wood, 0, 0.45, 0.62));
g.add(box(1.6, 0.05, 0.3, M.wood, 0, 0.45, -0.62));
for (const sx of [-0.65, 0.65]) {
g.add(box(0.08, 0.74, 0.08, M.woodDark, sx, 0.37, 0.25));
g.add(box(0.08, 0.74, 0.08, M.woodDark, sx, 0.37, -0.25));
g.add(box(0.08, 0.45, 1.5, M.woodDark, sx, 0.22));
}
return g;
},
parkingblock() { // car stop / slappy target
const g = new THREE.Group();
g.add(box(1.8, 0.14, 0.32, M.yellow, 0, 0.07));
return g;
},
trashcan() {
const g = new THREE.Group();
g.add(cyl(0.34, 0.3, 0.92, M.green, 0, 0.46, 0, 12));
g.add(cyl(0.36, 0.36, 0.05, M.steelDark, 0, 0.94, 0, 12));
return g;
},
hydrant() {
const g = new THREE.Group();
g.add(cyl(0.14, 0.17, 0.62, M.red, 0, 0.31));
const dome = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 8), M.red);
dome.position.y = 0.64; g.add(dome);
g.add(box(0.44, 0.09, 0.09, M.red, 0, 0.42));
return g;
},
bench() { // park bench
const g = new THREE.Group();
g.add(box(1.6, 0.05, 0.42, M.wood, 0, 0.45));
g.add(box(1.6, 0.42, 0.05, M.wood, 0, 0.72, -0.2));
for (const sx of [-0.7, 0.7]) g.add(box(0.07, 0.45, 0.4, M.steelDark, sx, 0.22));
return g;
},
fence() { // chain-link section, 2.4m
const g = new THREE.Group();
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 1.8, 14, 10),
new THREE.MeshBasicMaterial({ color: 0x9aa2ab, wireframe: true, transparent: true,
opacity: 0.5, side: THREE.DoubleSide }));
mesh.position.y = 0.95; g.add(mesh);
for (const sx of [-1.2, 1.2]) g.add(cyl(0.035, 0.035, 1.9, M.steelDark, sx, 0.95, 0, 8));
g.add(box(2.4, 0.05, 0.05, M.steelDark, 0, 1.84));
return g;
},
floodlight() {
const g = new THREE.Group();
g.add(cyl(0.09, 0.12, 6.5, M.steelDark, 0, 3.25, 0, 8));
for (const sx of [-0.3, 0.3])
g.add(box(0.5, 0.3, 0.25, new THREE.MeshBasicMaterial({ color: 0xfff3c8 }), sx, 6.4));
return g;
},
bleachers() { // 3-row spectator bleachers
const g = new THREE.Group();
for (let i = 0; i < 3; i++) {
g.add(box(3.0, 0.06, 0.4, M.steel, 0, 0.34 + i * 0.32, -i * 0.42));
g.add(box(3.0, 0.34 + i * 0.32, 0.06, M.steelDark, 0, (0.34 + i * 0.32) / 2, -i * 0.42 + 0.2));
}
return g;
},
shadesail() {
const g = new THREE.Group();
for (const [sx, sz] of [[-2, -2], [2, -2], [0, 2.2]])
g.add(cyl(0.05, 0.05, 3.2, M.steelDark, sx, 1.6, sz, 8));
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(
[-2, 3.1, -2, 2, 3.1, -2, 0, 2.7, 2.2], 3));
geo.computeVertexNormals();
g.add(new THREE.Mesh(geo, M.fabric));
return g;
},
fountain() { // water refill station
const g = new THREE.Group();
g.add(cyl(0.16, 0.2, 0.85, M.steelDark, 0, 0.42, 0, 8));
g.add(cyl(0.22, 0.22, 0.07, M.steel, 0, 0.88, 0, 10));
return g;
},
grate() { // flush drainage grate (flat detail)
const g = new THREE.Group();
const p = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9, 6, 6),
new THREE.MeshBasicMaterial({ color: 0x3c4148, wireframe: true }));
p.rotation.x = -Math.PI / 2; p.position.y = 0.015; g.add(p);
return g;
},
};
export const PROP_LABELS = [
['jersey', 'JERSEY BARRIER'], ['picnic', 'PICNIC TABLE'], ['parkingblock', 'PARKING BLOCK'],
['bench', 'BENCH'], ['trashcan', 'TRASH CAN'], ['hydrant', 'HYDRANT'],
['fence', 'FENCE 2.4M'], ['floodlight', 'FLOODLIGHT'], ['bleachers', 'BLEACHERS'],
['shadesail', 'SHADE SAIL'], ['fountain', 'FOUNTAIN'], ['grate', 'DRAIN GRATE'],
['fig', 'FIG TREE'],
];
export function makeBuiltinProp(name) {
const f = PROP_FACTORIES[name];
return f ? f() : null;
}

109
js/ui.js
View File

@ -4,6 +4,7 @@ import { ELEMENT_KINDS } from './parkmath.js';
import { checkPark } from './parkcheck.js'; import { checkPark } from './parkcheck.js';
import { downloadParkJSON, downloadLevelJS } from './export.js'; import { downloadParkJSON, downloadLevelJS } from './export.js';
import * as MB from './modelbeast.js'; import * as MB from './modelbeast.js';
import { PROP_LABELS } from './props3d.js';
const $ = (sel, root = document) => root.querySelector(sel); const $ = (sel, root = document) => root.querySelector(sel);
const el = (tag, cls, html) => { const el = (tag, cls, html) => {
@ -19,7 +20,17 @@ const COMMON_XZ = [['x', 'x', -60, 60, 0.1], ['z', 'z', -40, 40, 0.1]];
const PARAMS = { const PARAMS = {
quarter: [['len', 'length', 1, 80, 0.5], ['r0', 'radius a', 0.3, 4, 0.05], 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], ['r1', 'radius b', 0.3, 4, 0.05], ['lump', 'lumpiness', 0, 0.3, 0.01],
['deck', 'deck', 0, 4, 0.1]], ['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], 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]], ['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], funbox: [['hw', 'half width', 0.3, 10, 0.1], ['hd', 'half depth', 0.3, 10, 0.1],
@ -37,10 +48,28 @@ const PARAMS = {
['h', 'height', 0.05, 1, 0.01]], ['h', 'height', 0.05, 1, 0.01]],
bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]], bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]],
}; };
const PALETTE = [ const PALETTE_GROUPS = [
['quarter', 'QUARTER'], ['bank', 'BANK'], ['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], ['TRANSITION', [['quarter', 'QUARTER'], ['corner', 'HIP/CORNER'], ['bank', 'BANK'],
['ledge', 'LEDGE'], ['stairs', 'STAIRS'], ['spine', 'SPINE'], ['mogul', 'MOGUL'], ['kicker', 'KICKER'], ['spine', 'SPINE'], ['roller', 'ROLLER'],
['bowl', 'BOWL'], ['dome', 'ISLAND'], ['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 = [ const SPOTS = [
['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }], ['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }],
@ -57,13 +86,34 @@ export function initUI(ed) {
// ---------------------------------------------------------------- palette // ---------------------------------------------------------------- palette
function buildPalette() { function buildPalette() {
palette.innerHTML = '<div class="ptitle">BUILD</div>'; palette.innerHTML = '';
const grid = el('div', 'pgrid'); palette.append(grid); for (const [title, kinds] of PALETTE_GROUPS) {
for (const [kind, label] of PALETTE) { 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); const b = el('button', 'pbtn', label);
b.dataset.tool = 'place:' + kind; b.dataset.tool = 'preset:' + label;
b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind }); b.onclick = () => ed.setTool(ed.tool?.presetLabel === label ? null :
grid.append(b); { 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')); palette.append(el('div', 'ptitle', 'SPOTS'));
const grid2 = el('div', 'pgrid'); palette.append(grid2); const grid2 = el('div', 'pgrid'); palette.append(grid2);
@ -112,7 +162,8 @@ export function initUI(ed) {
for (const b of palette.querySelectorAll('.pbtn[data-tool]')) { for (const b of palette.querySelectorAll('.pbtn[data-tool]')) {
const t = b.dataset.tool; const t = b.dataset.tool;
const active = ed.tool && const active = ed.tool &&
(t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode); (t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode ||
t === 'preset:' + (ed.tool.presetLabel || '') || t === 'prop:' + (ed.tool.propName || ''));
b.classList.toggle('active', !!active); b.classList.toggle('active', !!active);
} }
setStatus(ed.tool ? 'click the ground to place — ESC to cancel' setStatus(ed.tool ? 'click the ground to place — ESC to cancel'
@ -164,6 +215,13 @@ export function initUI(ed) {
(v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done }))); (v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done })));
for (const [key, label, min, max, step] of PARAMS[obj.kind] || []) for (const [key, label, min, max, step] of PARAMS[obj.kind] || [])
inspector.append(numRow(obj, key, label, min, max, step, upd(key))); 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') { } else if (s.type === 'rail') {
const name = el('input', 'text'); name.value = obj.name || ''; const name = el('input', 'text'); name.value = obj.name || '';
name.onchange = () => ed.updateSelected('name', name.value, { done: true }); name.onchange = () => ed.updateSelected('name', name.value, { done: true });
@ -173,9 +231,30 @@ export function initUI(ed) {
kind.value = obj.kind; kind.value = obj.kind;
kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true }); kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true });
inspector.append(row('type', kind)); inspector.append(row('type', kind));
inspector.append(numRow(obj, 'ya', 'height a', 0, 4, 0.01, upd('ya'))); const prof = el('select');
inspector.append(numRow(obj, 'yb', 'height b', 0, 4, 0.01, upd('yb'))); prof.innerHTML = '<option value="round">round pipe</option><option value="square">square bar</option>';
inspector.append(el('div', 'ihint', 'drag the yellow end-balls to move the ends')); 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') { } else if (s.type === 'gap') {
const name = el('input', 'text'); name.value = obj.name; const name = el('input', 'text'); name.value = obj.name;
name.onchange = () => ed.updateSelected('name', name.value, { done: true }); name.onchange = () => ed.updateSelected('name', name.value, { done: true });