diff --git a/README.md b/README.md
index 7615d66..18d0911 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,12 @@ persistence (`parks/`, `assets/uploads/`).
- **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
+ bowl (square pool or round), and the **CUSTOM BOWL**: a polygon pool where every rim
+ vertex is a draggable ball with its own depth slider. Inside corners = drag a vertex
+ in, hips = out, shallow-to-deep escalator = ramp the depths, waterfall = two close
+ vertices with different depths — one continuous carve, no seams by construction.
+ Auto **pool coping** (bullnose tube, exported as grindable ledge segments) and a
+ **tile band** baked into the ground texture. **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,
@@ -47,9 +52,10 @@ persistence (`parks/`, `assets/uploads/`).
## Park format
`parks/*.json` — bounds, spawn, `elements` (parametric primitives evaluated by
-`js/parkmath.js`), `rails`, `gaps`, `letters`, `zones`, `decals`, `props`, optional
-`underlay`. `parks/paddo.json` is Paddington Skatepark ported from bookquoy — load it,
-ride it, remix it.
+`js/parkmath.js`), `rails` (polylines), `gaps`, `letters`, `zones`, `decals`, `props`,
+optional `underlay`. Premade parks: `parks/paddo.json` (Paddington Skatepark ported from
+bookquoy) and `parks/pool_demo.json` (THE POOLROOM — custom-bowl showcase, passes PARK
+CHECK clean). Load them, ride them, remix them.
## Export → BOOKQUOY
diff --git a/js/editor.js b/js/editor.js
index 6fcc187..6c37482 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -138,7 +138,16 @@ export function initEditor(container) {
new THREE.MeshBasicMaterial({ color: 0xffd93b }));
m.position.set(x, y, z); m.userData.handle = true; selGrp.add(m); return m;
};
- if (sel.type === 'element') loop(elementOutline(obj));
+ if (sel.type === 'element') {
+ loop(elementOutline(obj));
+ if (obj.kind === 'bowlpoly') { // rim vertices are draggable
+ const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0);
+ obj.pts.forEach((p, i) => {
+ const wx = obj.x + p.u * c - p.v * sn, wz = obj.z + p.u * sn + p.v * c;
+ handle(wx, 0.18, wz, true).userData.bowlPt = i;
+ });
+ }
+ }
else if (sel.type === 'rail') {
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));
@@ -296,6 +305,8 @@ export function initEditor(container) {
const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle));
if (hs.length && hs[0].object.userData.railPt !== undefined)
return { type: 'railPt', i: hs[0].object.userData.railPt };
+ if (hs.length && hs[0].object.userData.bowlPt !== undefined)
+ return { type: 'bowlPt', i: hs[0].object.userData.bowlPt };
if (!p) return null;
const P = ed.park, x = p.x, z = p.z;
// 2. props (real raycast)
@@ -412,8 +423,8 @@ 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 === 'railPt') {
- drag = { kind: 'railPt', i: hit.i, moved: false };
+ if (hit.type === 'railPt' || hit.type === 'bowlPt') {
+ drag = { kind: hit.type, i: hit.i, moved: false };
controls.enabled = false; snapshot(); return;
}
ed.select({ type: hit.type, id: hit.id });
@@ -435,6 +446,13 @@ export function initEditor(container) {
pt.x = snapV(p.x); pt.z = snapV(p.z);
rebuildRails(); rebuildSelection(); return;
}
+ if (drag.kind === 'bowlPt') { // world -> bowl-local vertex
+ const c = Math.cos(obj.rot || 0), sn = Math.sin(obj.rot || 0);
+ const du = snapV(p.x) - obj.x, dz = snapV(p.z) - obj.z;
+ obj.pts[drag.i].u = +(du * c + dz * sn).toFixed(2);
+ obj.pts[drag.i].v = +(-du * sn + dz * c).toFixed(2);
+ requestGround(); rebuildRails(); rebuildSelection(); return;
+ }
const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ);
if (s.type === 'rail') {
const dx = nx - obj.pts[0].x, dz = nz - obj.pts[0].z;
@@ -464,7 +482,7 @@ export function initEditor(container) {
const obj = getSelected(); if (!obj) return;
obj[key] = value;
const t = ed.selection.type;
- if (t === 'element') requestGround();
+ if (t === 'element') { requestGround(); if (obj.kind === 'bowlpoly') rebuildRails(); }
if (t === 'zone' || t === 'decal') rebakeTexture();
if (t === 'rail') rebuildRails();
if (t === 'prop') rebuildProps();
diff --git a/js/parkbuild.js b/js/parkbuild.js
index 3bb1f9a..ad8cd44 100644
--- a/js/parkbuild.js
+++ b/js/parkbuild.js
@@ -2,7 +2,7 @@
// 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, normalizeRail } from './parkmath.js';
+import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js';
import { makeBuiltinProp } from './props3d.js';
export const GRID_RES = 0.5, GRID_PAD = 14;
@@ -40,6 +40,17 @@ export function bakeGroundTexture(park, x0, x1, z0, z1) {
g.globalAlpha = zn.opacity ?? 1;
g.fill(); g.restore();
}
+ // pool detail: tile band + coping line traced around every custom-bowl rim
+ for (const e of park.elements || []) {
+ if (e.kind !== 'bowlpoly' || !e.tile) continue;
+ const rim = bowlRimWorld(e).map(([wx, wz]) => px(wx, wz));
+ for (const [w, color] of [[0.7 * PX, '#7fb4c9'], [0.22 * PX, '#e8e4d8']]) {
+ g.beginPath();
+ rim.forEach(([cx, cz], i) => i ? g.lineTo(cx, cz) : g.moveTo(cx, cz));
+ g.closePath();
+ g.strokeStyle = color; g.lineWidth = w; g.lineJoin = 'round'; g.stroke();
+ }
+ }
const jobs = []; // decals load async, re-bake per image
for (const d of park.decals || []) {
const img = new Image();
@@ -140,6 +151,23 @@ export function buildRails(park) {
}
}
}
+ // bullnose pool coping around custom-bowl rims
+ const stone = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.7 });
+ const UP2 = new THREE.Vector3(0, 1, 0);
+ for (const e of park.elements || []) {
+ if (e.kind !== 'bowlpoly' || !e.coping) continue;
+ const rim = bowlRimWorld(e);
+ for (let i = 0; i < rim.length; i++) {
+ const [ax, az] = rim[i], [bx, bz] = rim[(i + 1) % rim.length];
+ const a = new THREE.Vector3(ax, 0.03, az), b = new THREE.Vector3(bx, 0.03, bz);
+ const len = Math.max(a.distanceTo(b), 0.01);
+ const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, len, 8), stone);
+ bar.position.copy(a).add(b).multiplyScalar(0.5);
+ bar.quaternion.setFromUnitVectors(UP2, b.clone().sub(a).normalize());
+ bar.userData.elId = e.id;
+ grp.add(bar);
+ }
+ }
return grp;
}
diff --git a/js/parkcheck.js b/js/parkcheck.js
index 16f8b7a..0243371 100644
--- a/js/parkcheck.js
+++ b/js/parkcheck.js
@@ -4,7 +4,7 @@
import { makeHeightAt, elementOutline } from './parkmath.js';
const STREET_KINDS = new Set(['ledge', 'funbox', 'stairs', 'pyramid', 'wedgepad']);
-const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano']);
+const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano', 'bowlpoly']);
export function checkPark(park) {
const out = [];
diff --git a/js/parkmath.js b/js/parkmath.js
index af51e2f..7a8f878 100644
--- a/js/parkmath.js
+++ b/js/parkmath.js
@@ -189,8 +189,62 @@ export const ELEMENT_KINDS = {
: Math.abs(u) < e.size && Math.abs(v) < e.size;
},
},
+ bowlpoly: { // modular pool: polygon rim, per-vertex depths.
+ // Inside corner = concave vertex, outside = convex, escalator = ramp the depths,
+ // waterfall = two close vertices with different depths. One continuous carve.
+ label: 'Custom Bowl',
+ defaults: {
+ pts: [{ u: 3.5, v: 0 }, { u: 2.2, v: 2.8 }, { u: -1.5, v: 3.2 },
+ { u: -3.8, v: 0.8 }, { u: -3.0, v: -2.4 }, { u: 0.8, v: -3.2 }],
+ depths: [1.2, 1.2, 1.5, 1.8, 1.8, 1.5],
+ coping: 1, tile: 1,
+ },
+ carve: true,
+ inside(e, x, z) {
+ const [u, v] = local(e, x, z);
+ return pointInPoly(u, v, e.pts);
+ },
+ height(e, x, z) {
+ const [u, v] = local(e, x, z);
+ const dB = distToPoly(u, v, e.pts);
+ // smooth floor: inverse-distance-weighted vertex depths (shallow end slopes to deep)
+ let sw = 0, swd = 0;
+ for (let i = 0; i < e.pts.length; i++) {
+ const du = u - e.pts[i].u, dv = v - e.pts[i].v;
+ const w = 1 / (du * du + dv * dv + 0.25);
+ sw += w; swd += w * (e.depths[i] ?? 1.5);
+ }
+ const D = swd / sw;
+ return -D + tranny(Math.min(dB, D), D);
+ },
+ },
};
+function pointInPoly(u, v, pts) {
+ let inside = false;
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
+ const a = pts[i], b = pts[j];
+ if ((a.v > v) !== (b.v > v) &&
+ u < (b.u - a.u) * (v - a.v) / (b.v - a.v) + a.u) inside = !inside;
+ }
+ return inside;
+}
+function distToPoly(u, v, pts) {
+ let best = Infinity;
+ for (let i = 0; i < pts.length; i++) {
+ const a = pts[i], b = pts[(i + 1) % pts.length];
+ const du = b.u - a.u, dv = b.v - a.v;
+ const t = clamp(((u - a.u) * du + (v - a.v) * dv) / (du * du + dv * dv || 1), 0, 1);
+ best = Math.min(best, Math.hypot(u - (a.u + t * du), v - (a.v + t * dv)));
+ }
+ return best;
+}
+// world-space rim polygon (editor handles, coping tube, tile bake)
+export function bowlRimWorld(e) {
+ const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
+ return e.pts.map(p => [e.x + p.u * c - p.v * s, e.z + p.u * s + p.v * c]);
+}
+
// ---------------------------------------------------------------- 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
@@ -212,6 +266,15 @@ export function flattenRails(park) {
out.push({ name: r.name, a: [a.x, a.z], b: [b.x, b.z], ya: a.y, yb: b.y, kind: r.kind });
}
}
+ // pool coping rims are grindable steel: one ledge segment per rim edge
+ for (const e of park.elements || []) {
+ if (e.kind !== 'bowlpoly' || !e.coping) continue;
+ const rim = bowlRimWorld(e);
+ for (let i = 0; i < rim.length; i++) {
+ const a = rim[i], b = rim[(i + 1) % rim.length];
+ out.push({ name: 'Pool Coping', a, b, ya: 0.02, yb: 0.02, kind: 'ledge' });
+ }
+ }
return out;
}
@@ -290,6 +353,8 @@ export function elementHit(e, x, z) {
return u * u + v * v < 1; }
case 'bowl': { const [u, v] = local(e, x, z);
return Math.abs(u) <= e.size + M && Math.abs(v) <= e.size + M; }
+ case 'bowlpoly': { const [u, v] = local(e, x, z);
+ return pointInPoly(u, v, e.pts) || distToPoly(u, v, e.pts) <= M; }
}
return false;
}
@@ -325,6 +390,7 @@ export function elementOutline(e) {
pts.push(W(Math.cos(a) * e.rx, Math.sin(a) * e.rz)); }
return pts; }
case 'bowl': return rect(-e.size, e.size, -e.size, e.size);
+ case 'bowlpoly': return bowlRimWorld(e);
}
return [];
}
diff --git a/js/ui.js b/js/ui.js
index 368262e..575ce08 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -51,7 +51,8 @@ const PARAMS = {
const PALETTE_GROUPS = [
['TRANSITION', [['quarter', 'QUARTER'], ['corner', 'HIP/CORNER'], ['bank', 'BANK'],
['kicker', 'KICKER'], ['spine', 'SPINE'], ['roller', 'ROLLER'],
- ['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL']]],
+ ['mogul', 'MOGUL'], ['volcano', 'VOLCANO'], ['bowl', 'BOWL'],
+ ['bowlpoly', 'CUSTOM BOWL']]],
['STREET', [['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], ['ledge', 'LEDGE'],
['wedgepad', 'WEDGE PAD'], ['stairs', 'STAIRS'], ['dome', 'ISLAND']]],
];
@@ -215,6 +216,35 @@ 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 === 'bowlpoly') {
+ inspector.append(el('div', 'ihint',
+ 'drag the rim balls to shape it — inside corners, hips, whatever'));
+ obj.depths.forEach((d, i) => {
+ inspector.append(numRow({ d }, 'd', 'depth pt ' + (i + 1), 0.4, 3.4, 0.05,
+ (v, done) => { obj.depths[i] = v; ed.updateSelected('depths', obj.depths, { done }); }));
+ });
+ const vbtns = el('div', 'ibtns');
+ const addV = el('button', 'pbtn', '+ VERTEX');
+ addV.onclick = () => {
+ const a = obj.pts[obj.pts.length - 1], b = obj.pts[0];
+ obj.pts.push({ u: +((a.u + b.u) / 2).toFixed(2), v: +((a.v + b.v) / 2).toFixed(2) });
+ obj.depths.push(obj.depths[obj.depths.length - 1]);
+ ed.updateSelected('pts', obj.pts, { done: true });
+ };
+ const delV = el('button', 'pbtn', '− VERTEX');
+ delV.onclick = () => {
+ if (obj.pts.length <= 3) return;
+ obj.pts.pop(); obj.depths.pop();
+ ed.updateSelected('pts', obj.pts, { done: true });
+ };
+ vbtns.append(addV, delV); inspector.append(vbtns);
+ for (const [key, label] of [['coping', 'pool coping (grindable)'], ['tile', 'tile band']]) {
+ const cb = el('label', 'snaprow');
+ const inp = el('input'); inp.type = 'checkbox'; inp.checked = !!obj[key];
+ inp.onchange = () => ed.updateSelected(key, inp.checked ? 1 : 0, { done: true });
+ cb.append(inp, ' ' + label); inspector.append(cb);
+ }
+ }
if (obj.kind === 'bowl') {
const shape = el('select');
shape.innerHTML = '';
diff --git a/parks/pool_demo.json b/parks/pool_demo.json
new file mode 100644
index 0000000..aa3a26b
--- /dev/null
+++ b/parks/pool_demo.json
@@ -0,0 +1,215 @@
+{
+ "name": "THE POOLROOM",
+ "bounds": {
+ "x0": -24,
+ "x1": 24,
+ "z0": -16,
+ "z1": 16
+ },
+ "slabColor": "#8f958f",
+ "grassColor": "#4d7c3c",
+ "spawn": {
+ "x": -18,
+ "z": 10,
+ "heading": 1.1
+ },
+ "elements": [
+ {
+ "id": "e2_bqff",
+ "kind": "bowlpoly",
+ "x": -8,
+ "z": 0,
+ "rot": 0,
+ "pts": [
+ {
+ "u": 5,
+ "v": 0
+ },
+ {
+ "u": 3.4,
+ "v": 4
+ },
+ {
+ "u": -2,
+ "v": 4.6
+ },
+ {
+ "u": -5.4,
+ "v": 1.2
+ },
+ {
+ "u": -4.4,
+ "v": -3.4
+ },
+ {
+ "u": 1.2,
+ "v": -4.6
+ }
+ ],
+ "depths": [
+ 0.9,
+ 0.9,
+ 1.4,
+ 2.2,
+ 2.2,
+ 1.4
+ ],
+ "coping": 1,
+ "tile": 1
+ },
+ {
+ "id": "e3_iu5u",
+ "kind": "quarter",
+ "x": 12,
+ "z": -16,
+ "rot": 0,
+ "len": 20,
+ "r0": 1.8,
+ "r1": 1.8,
+ "lump": 0,
+ "deck": 0.6,
+ "vert": 0
+ },
+ {
+ "id": "e4_fhid",
+ "kind": "kicker",
+ "x": 10,
+ "z": 6,
+ "rot": 3.141592653589793,
+ "len": 2.4,
+ "h": 0.7,
+ "run": 1.8,
+ "curve": 0.6,
+ "deck": 0.25
+ },
+ {
+ "id": "e5_g65t",
+ "kind": "roller",
+ "x": 16,
+ "z": 0,
+ "rot": 1.5708,
+ "len": 8,
+ "h": 0.45,
+ "s": 1.1
+ },
+ {
+ "id": "e6_dthi",
+ "kind": "ledge",
+ "x": 6,
+ "z": 12,
+ "rot": 0,
+ "hw": 1.5,
+ "hd": 0.8,
+ "h": 0.22
+ }
+ ],
+ "rails": [
+ {
+ "id": "rb",
+ "name": "Rainbow Rail",
+ "kind": "rail",
+ "profile": "round",
+ "pts": [
+ {
+ "x": 2,
+ "z": -10,
+ "y": 0.25
+ },
+ {
+ "x": 2.667,
+ "z": -10,
+ "y": 0.7749999999999999
+ },
+ {
+ "x": 3.334,
+ "z": -10,
+ "y": 1.1593266739736605
+ },
+ {
+ "x": 4.001,
+ "z": -10,
+ "y": 1.3
+ },
+ {
+ "x": 4.668,
+ "z": -10,
+ "y": 1.1593266739736605
+ },
+ {
+ "x": 5.335,
+ "z": -10,
+ "y": 0.7749999999999999
+ },
+ {
+ "x": 6.002000000000001,
+ "z": -10,
+ "y": 0.2500000000000001
+ }
+ ]
+ },
+ {
+ "id": "sq",
+ "name": "Square Bar",
+ "kind": "ledge",
+ "profile": "square",
+ "pts": [
+ {
+ "x": -16,
+ "z": -6,
+ "y": 0.4
+ },
+ {
+ "x": -13,
+ "z": -6,
+ "y": 0.4
+ }
+ ]
+ }
+ ],
+ "gaps": [
+ {
+ "id": "g1",
+ "name": "OVER THE DEEP END",
+ "x": -11,
+ "z": 0,
+ "r": 2.4
+ }
+ ],
+ "letters": [],
+ "zones": [],
+ "decals": [],
+ "props": [
+ {
+ "id": "w1",
+ "src": "builtin:picnic",
+ "x": -18,
+ "z": -12,
+ "rot": 0.4,
+ "scale": 1
+ },
+ {
+ "id": "w2",
+ "src": "builtin:floodlight",
+ "x": 20,
+ "z": 12,
+ "rot": 0,
+ "scale": 1
+ },
+ {
+ "id": "w3",
+ "src": "builtin:fence",
+ "x": 0,
+ "z": -15.5,
+ "rot": 0,
+ "scale": 1
+ },
+ {
+ "id": "w4",
+ "src": "builtin:bleachers",
+ "x": -20,
+ "z": 4,
+ "rot": 1.5708,
+ "scale": 1
+ }
+ ]
+}
\ No newline at end of file