Modular custom bowls: polygon pools with per-vertex depths

New bowlpoly element: the rim is a polygon, every vertex a draggable handle
with its own depth slider. Inside/outside corners, hips, shallow-to-deep
escalators and waterfall drops all come from the same continuous carve (IDW
floor blend, quarter-pipe wall transition) — no seams by construction. Auto
pool coping (bullnose tube, exported as grindable ledge segments per rim
edge) and tile band baked into the ground texture. Ships parks/pool_demo.json
(THE POOLROOM) as a premade showcase; ridden end-to-end in test mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 20:26:47 +10:00
parent 7a00c9f9a7
commit a962ac41d9
7 changed files with 374 additions and 11 deletions

View File

@ -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

View File

@ -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();

View File

@ -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;
}

View File

@ -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 = [];

View File

@ -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 [];
}

View File

@ -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 = '<option value="0">square pool</option><option value="1">round bowl</option>';

215
parks/pool_demo.json Normal file
View File

@ -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
}
]
}