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:
parent
db40f433fe
commit
7a00c9f9a7
21
README.md
21
README.md
@ -17,11 +17,22 @@ persistence (`parks/`, `assets/uploads/`).
|
||||
|
||||
## The editor
|
||||
|
||||
- **BUILD palette** — quarter, bank, funbox, pyramid, ledge, stairs, spine, mogul, bowl,
|
||||
island. Click a piece, click the ground. Drag to move, `[` `]` rotate, `D` dupe,
|
||||
`⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style).
|
||||
- **SPOTS** — rails/ledge-caps (two clicks, draggable end-balls), named score gaps,
|
||||
B·O·O·K·Q·U·O·Y letters, spawn point.
|
||||
- **TRANSITION** — quarter (escalating radius, lump, vert extension), hip/corner quarter,
|
||||
bank, kicker (tunable concavity), spine, roller/pump bump, mogul, volcano,
|
||||
bowl (square pool or round). **STREET** — funbox, pyramid, ledge, wedge/manny pad
|
||||
(inclined), stairs, island. Click a piece, click the ground. Drag to move, `[` `]`
|
||||
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
|
||||
the window, or generate one on the farm), and a **reference underlay** for tracing a real
|
||||
park from an aerial photo.
|
||||
|
||||
64
js/editor.js
64
js/editor.js
@ -3,8 +3,8 @@
|
||||
// so the editor can never lie about what the game will do.
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark }
|
||||
from './parkmath.js';
|
||||
import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark,
|
||||
normalizeRail } from './parkmath.js';
|
||||
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
|
||||
GRID_PAD } from './parkbuild.js';
|
||||
|
||||
@ -140,11 +140,9 @@ export function initEditor(container) {
|
||||
};
|
||||
if (sel.type === 'element') loop(elementOutline(obj));
|
||||
else if (sel.type === 'rail') {
|
||||
const a = new THREE.Vector3(obj.a[0], obj.ya, obj.a[1]);
|
||||
const b = new THREE.Vector3(obj.b[0], obj.yb, obj.b[1]);
|
||||
selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), mat));
|
||||
handle(a.x, a.y, a.z, true).userData.railEnd = 'a';
|
||||
handle(b.x, b.y, b.z, true).userData.railEnd = 'b';
|
||||
const pts = obj.pts.map(p => new THREE.Vector3(p.x, p.y, p.z));
|
||||
selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), mat));
|
||||
obj.pts.forEach((p, i) => { handle(p.x, p.y, p.z, true).userData.railPt = i; });
|
||||
} else if (sel.type === 'gap') {
|
||||
const pts = [];
|
||||
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,
|
||||
json.zones, json.decals, json.props])
|
||||
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;
|
||||
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
|
||||
};
|
||||
@ -265,7 +264,7 @@ export function initEditor(container) {
|
||||
prop: 'props', zone: 'zones', decal: 'decals' };
|
||||
const copy = structuredClone(obj); copy.id = newId();
|
||||
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);
|
||||
ed.selection = { type: s.type, id: copy.id };
|
||||
rebuildAll(); autosave(); ed.emit('change'); ed.emit('select');
|
||||
@ -295,8 +294,8 @@ export function initEditor(container) {
|
||||
function pick(ev, p) {
|
||||
// 1. selection handles (rail endpoints)
|
||||
const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle));
|
||||
if (hs.length && hs[0].object.userData.railEnd)
|
||||
return { type: 'railEnd', end: hs[0].object.userData.railEnd };
|
||||
if (hs.length && hs[0].object.userData.railPt !== undefined)
|
||||
return { type: 'railPt', i: hs[0].object.userData.railPt };
|
||||
if (!p) return null;
|
||||
const P = ed.park, x = p.x, z = p.z;
|
||||
// 2. props (real raycast)
|
||||
@ -310,9 +309,12 @@ export function initEditor(container) {
|
||||
return { type: 'letter', id: L.id };
|
||||
// 4. spawn
|
||||
if (Math.hypot(x - P.spawn.x, z - P.spawn.z) < 1.0) return { type: 'spawn' };
|
||||
// 5. rails
|
||||
for (const r of P.rails) if (distSeg(x, z, r.a, r.b) < 0.6)
|
||||
return { type: 'rail', id: r.id };
|
||||
// 5. rails (any segment of the polyline)
|
||||
for (const r of P.rails) {
|
||||
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)
|
||||
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 }; }
|
||||
@ -348,14 +350,23 @@ export function initEditor(container) {
|
||||
ed.selection = { type: 'element', id: e.id };
|
||||
rebuildGround(); ed.commit({ snapshot: false });
|
||||
} 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; }
|
||||
const r = { id: newId(), name: 'Rail', a: [pendingRail.x, pendingRail.z], b: [x, z],
|
||||
ya: +pendingRail.y.toFixed(2), yb: +y.toFixed(2), kind: 'rail' };
|
||||
const r = { id: newId(), name: 'Rail', kind: 'rail', profile: 'round',
|
||||
pts: [{ x: pendingRail.x, z: pendingRail.z, y: pendingRail.y }, { x, z, y }] };
|
||||
snapshot(); P.rails.push(r); pendingRail = null;
|
||||
ed.selection = { type: 'rail', id: r.id };
|
||||
ed.commit({ snapshot: false, rails: true });
|
||||
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') {
|
||||
const g = { id: newId(), name: 'NEW GAP', x, z, r: 2.4 };
|
||||
snapshot(); P.gaps.push(g);
|
||||
@ -401,16 +412,16 @@ export function initEditor(container) {
|
||||
if (ed.tool) { if (p) place(p); return; }
|
||||
const hit = pick(ev, p);
|
||||
if (!hit) { ed.select(null); return; }
|
||||
if (hit.type === 'railEnd') {
|
||||
drag = { kind: 'railEnd', end: hit.end, moved: false };
|
||||
if (hit.type === 'railPt') {
|
||||
drag = { kind: 'railPt', i: hit.i, moved: false };
|
||||
controls.enabled = false; snapshot(); return;
|
||||
}
|
||||
ed.select({ type: hit.type, id: hit.id });
|
||||
const obj = getSelected();
|
||||
if (obj && p) {
|
||||
drag = { kind: 'move', moved: false,
|
||||
grabDX: (obj.x ?? obj.a?.[0] ?? 0) - p.x,
|
||||
grabDZ: (obj.z ?? obj.a?.[1] ?? 0) - p.z };
|
||||
grabDX: (obj.x ?? obj.pts?.[0]?.x ?? 0) - p.x,
|
||||
grabDZ: (obj.z ?? obj.pts?.[0]?.z ?? 0) - p.z };
|
||||
controls.enabled = false; snapshot();
|
||||
}
|
||||
});
|
||||
@ -419,16 +430,15 @@ export function initEditor(container) {
|
||||
const p = groundPoint(ev); if (!p) return;
|
||||
const s = ed.selection, obj = getSelected(); if (!obj) return;
|
||||
drag.moved = true;
|
||||
if (drag.kind === 'railEnd') {
|
||||
const end = drag.end;
|
||||
obj[end] = [snapV(p.x), snapV(p.z)];
|
||||
obj[end === 'a' ? 'ya' : 'yb'] = +(ed.heightAt(p.x, p.z) + 0.4).toFixed(2);
|
||||
if (drag.kind === 'railPt') {
|
||||
const pt = obj.pts[drag.i];
|
||||
pt.x = snapV(p.x); pt.z = snapV(p.z);
|
||||
rebuildRails(); rebuildSelection(); return;
|
||||
}
|
||||
const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ);
|
||||
if (s.type === 'rail') {
|
||||
const dx = nx - obj.a[0], dz = nz - obj.a[1];
|
||||
obj.a = [obj.a[0] + dx, obj.a[1] + dz]; obj.b = [obj.b[0] + dx, obj.b[1] + dz];
|
||||
const dx = nx - obj.pts[0].x, dz = nz - obj.pts[0].z;
|
||||
for (const pt of obj.pts) { pt.x = +(pt.x + dx).toFixed(2); pt.z = +(pt.z + dz).toFixed(2); }
|
||||
rebuildRails();
|
||||
} else { obj.x = nx; obj.z = nz; }
|
||||
if (s.type === 'element') requestGround();
|
||||
@ -444,7 +454,7 @@ export function initEditor(container) {
|
||||
drag = null; controls.enabled = true;
|
||||
if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot
|
||||
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('pointerleave', endDrag);
|
||||
|
||||
@ -24,13 +24,13 @@ export function generateLevelJS(park) {
|
||||
// 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:
|
||||
// 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';
|
||||
|
||||
export const PARK = ${JSON.stringify(park, null, 1)};
|
||||
|
||||
export const BOUNDS = PARK.bounds;
|
||||
export const RAILS = PARK.rails;
|
||||
export const RAILS = flattenRails(PARK);
|
||||
export const GAPS = PARK.gaps;
|
||||
export const LETTERS = PARK.letters;
|
||||
export const SPAWN = PARK.spawn;
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
// so what you see in the editor is byte-for-byte what the game builds.
|
||||
import * as THREE from 'three';
|
||||
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;
|
||||
|
||||
@ -107,22 +108,32 @@ export function buildGround(park) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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) {
|
||||
const grp = new THREE.Group(); grp.name = 'rails';
|
||||
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 || []) {
|
||||
const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]);
|
||||
const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]);
|
||||
const len = Math.max(a.distanceTo(b), 0.01);
|
||||
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel);
|
||||
bar.position.copy(a).add(b).multiplyScalar(0.5);
|
||||
bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize());
|
||||
bar.userData.railId = r.id;
|
||||
grp.add(bar);
|
||||
if (r.kind === 'rail') {
|
||||
for (const t of [0.12, 0.88]) {
|
||||
const p = a.clone().lerp(b, t);
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, Math.max(p.y, 0.05), 6), steel);
|
||||
normalizeRail(r);
|
||||
for (let i = 0; i < r.pts.length - 1; i++) {
|
||||
const pa = r.pts[i], pb = r.pts[i + 1];
|
||||
const a = new THREE.Vector3(pa.x, pa.y, pa.z);
|
||||
const b = new THREE.Vector3(pb.x, pb.y, pb.z);
|
||||
const len = Math.max(a.distanceTo(b), 0.01);
|
||||
const geo = r.profile === 'square'
|
||||
? new THREE.BoxGeometry(0.09, len, 0.07)
|
||||
: new THREE.CylinderGeometry(0.035, 0.035, len, 8);
|
||||
const bar = new THREE.Mesh(geo, steel);
|
||||
bar.position.copy(a).add(b).multiplyScalar(0.5);
|
||||
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.userData.railId = r.id;
|
||||
grp.add(post);
|
||||
@ -133,35 +144,23 @@ export function buildRails(park) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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) {
|
||||
const grp = new THREE.Group(); grp.name = 'props';
|
||||
const loader = new GLTFLoader();
|
||||
const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 });
|
||||
const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a });
|
||||
for (const pr of park.props || []) {
|
||||
const place = obj => {
|
||||
obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z);
|
||||
obj.rotation.y = pr.rot || 0;
|
||||
const s = pr.scale || 1;
|
||||
if (pr.src !== 'builtin:fig') obj.scale.setScalar(s);
|
||||
obj.scale.setScalar(pr.scale || 1);
|
||||
obj.userData.propId = pr.id;
|
||||
obj.traverse(o => { o.userData.propId = pr.id; });
|
||||
grp.add(obj);
|
||||
if (onLoaded) onLoaded(pr, obj);
|
||||
};
|
||||
if (pr.src === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM));
|
||||
else loader.load(pr.src, g => place(g.scene),
|
||||
if (pr.src.startsWith('builtin:')) {
|
||||
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));
|
||||
}
|
||||
return grp;
|
||||
|
||||
@ -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.
|
||||
import { makeHeightAt, elementOutline } from './parkmath.js';
|
||||
|
||||
const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid']);
|
||||
const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul']);
|
||||
const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid', 'wedgepad']);
|
||||
const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano']);
|
||||
|
||||
export function checkPark(park) {
|
||||
const out = [];
|
||||
@ -34,10 +34,11 @@ export function checkPark(park) {
|
||||
return heightAt(x, z) > 0.16;
|
||||
};
|
||||
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 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;
|
||||
for (let d = 0.5; d <= 4.5; d += 0.5) {
|
||||
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');
|
||||
}
|
||||
|
||||
// --- steel: rails floating or buried
|
||||
// --- steel: rails floating or buried (any node of the polyline)
|
||||
for (const r of park.rails || []) {
|
||||
const ha = heightAt(r.a[0], r.a[1]), hb = heightAt(r.b[0], r.b[1]);
|
||||
if (r.ya < ha - 0.05 || r.yb < hb - 0.05)
|
||||
warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise ya/yb`);
|
||||
if (r.ya - ha > 1.6 || r.yb - hb > 1.6)
|
||||
warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`);
|
||||
for (const pt of r.pts || []) {
|
||||
const h = heightAt(pt.x, pt.z);
|
||||
if (pt.y < h - 0.05) {
|
||||
warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise its points`);
|
||||
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
|
||||
|
||||
110
js/parkmath.js
110
js/parkmath.js
@ -25,19 +25,73 @@ function local(e, x, z) { // world (x,z) -> element-local (u,v)
|
||||
// ---------------------------------------------------------------- element kinds
|
||||
// Each returns a height contribution (concrete = max of all). Bowls carve (override).
|
||||
export const ELEMENT_KINDS = {
|
||||
quarter: { // straight QP, escalating radius, optional lump + deck
|
||||
quarter: { // straight QP, escalating radius, lump, deck, vert ext
|
||||
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) {
|
||||
const [u, v] = local(e, x, z);
|
||||
if (Math.abs(u) > e.len / 2) return 0;
|
||||
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 (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);
|
||||
},
|
||||
},
|
||||
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: {
|
||||
label: 'Bank',
|
||||
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.
|
||||
label: 'Bowl',
|
||||
defaults: { size: 4.2, depth: 1.5 },
|
||||
defaults: { size: 4.2, depth: 1.5, round: 0 },
|
||||
carve: true,
|
||||
height(e, x, z) { // returns carve value, caller checks inside()
|
||||
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);
|
||||
},
|
||||
inside(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
|
||||
export function elementHeight(e, x, z) {
|
||||
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);
|
||||
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 '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);
|
||||
return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; }
|
||||
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++) {
|
||||
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; }
|
||||
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 '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++) {
|
||||
|
||||
148
js/props3d.js
Normal file
148
js/props3d.js
Normal 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
109
js/ui.js
@ -4,6 +4,7 @@ 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) => {
|
||||
@ -19,7 +20,17 @@ 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]],
|
||||
['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],
|
||||
@ -37,10 +48,28 @@ const PARAMS = {
|
||||
['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 = [
|
||||
['quarter', 'QUARTER'], ['bank', 'BANK'], ['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'],
|
||||
['ledge', 'LEDGE'], ['stairs', 'STAIRS'], ['spine', 'SPINE'], ['mogul', 'MOGUL'],
|
||||
['bowl', 'BOWL'], ['dome', 'ISLAND'],
|
||||
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' }],
|
||||
@ -57,13 +86,34 @@ export function initUI(ed) {
|
||||
|
||||
// ---------------------------------------------------------------- palette
|
||||
function buildPalette() {
|
||||
palette.innerHTML = '<div class="ptitle">BUILD</div>';
|
||||
const grid = el('div', 'pgrid'); palette.append(grid);
|
||||
for (const [kind, label] of PALETTE) {
|
||||
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 = 'place:' + kind;
|
||||
b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind });
|
||||
grid.append(b);
|
||||
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);
|
||||
@ -112,7 +162,8 @@ export function initUI(ed) {
|
||||
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 === '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'
|
||||
@ -164,6 +215,13 @@ export function initUI(ed) {
|
||||
(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 });
|
||||
@ -173,9 +231,30 @@ export function initUI(ed) {
|
||||
kind.value = obj.kind;
|
||||
kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true });
|
||||
inspector.append(row('type', kind));
|
||||
inspector.append(numRow(obj, 'ya', 'height a', 0, 4, 0.01, upd('ya')));
|
||||
inspector.append(numRow(obj, 'yb', 'height b', 0, 4, 0.01, upd('yb')));
|
||||
inspector.append(el('div', 'ihint', 'drag the yellow end-balls to move the ends'));
|
||||
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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user