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>
106 lines
4.7 KiB
JavaScript
106 lines
4.7 KiB
JavaScript
// SKATEMAKER PRO — park design linter. Encodes the real-park design brief
|
||
// (docs/DESIGN_PRINCIPLES.md) as live checks over the park data. World units are
|
||
// 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', 'wedgepad']);
|
||
const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul', 'kicker', 'roller', 'corner', 'volcano', 'bowlpoly']);
|
||
|
||
export function checkPark(park) {
|
||
const out = [];
|
||
const warn = (sel, msg) => out.push({ sel, msg });
|
||
const heightAt = makeHeightAt(park);
|
||
const els = park.elements || [];
|
||
|
||
// --- pumping geometry: transition radii in the natural-feel band
|
||
for (const e of els) {
|
||
if (e.kind === 'quarter') {
|
||
const r = Math.min(e.r0, e.r1);
|
||
if (r < 0.9) warn({ type: 'element', id: e.id },
|
||
`tight quarter (r ${r}) — pumping geometry wants ~1.8–2.4m for street flow`);
|
||
}
|
||
if (e.kind === 'spine' && e.r < 0.9)
|
||
warn({ type: 'element', id: e.id },
|
||
`tight spine (r ${e.r}) — hard to pump; 1.2+ feels natural`);
|
||
if (e.kind === 'bowl' && e.depth > 1.2 && e.depth > e.size * 0.45)
|
||
warn({ type: 'element', id: e.id },
|
||
`bowl walls near-vertical (depth ${e.depth} vs size ${e.size}) — deep bowls want 2.7–3.4m radii`);
|
||
}
|
||
|
||
// --- run-outs: flat clear concrete along the facing direction of ramps
|
||
const isObstacleAt = (x, z, ignore) => {
|
||
const B = park.bounds;
|
||
if (x < B.x0 || x > B.x1 || z < B.z0 || z > B.z1) return true; // grass = no run-out
|
||
return heightAt(x, z) > 0.16;
|
||
};
|
||
for (const e of els) {
|
||
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.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;
|
||
const x = e.x - v * s, z = e.z + v * c; // +v dir = (-sin, cos)
|
||
if (isObstacleAt(x, z)) break;
|
||
clear = d;
|
||
}
|
||
if (clear < 3) warn({ type: 'element', id: e.id },
|
||
`short run-out (${clear.toFixed(1)}m clear) — landings want 3–4.5m of flat before the next thing`);
|
||
}
|
||
|
||
// --- collision-free traffic: heavy footprint overlaps between big elements
|
||
const boxes = els.filter(e => e.kind !== 'mogul' && e.kind !== 'dome').map(e => {
|
||
const pts = elementOutline(e);
|
||
const xs = pts.map(p => p[0]), zs = pts.map(p => p[1]);
|
||
return { e, x0: Math.min(...xs), x1: Math.max(...xs), z0: Math.min(...zs), z1: Math.max(...zs) };
|
||
});
|
||
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
||
const a = boxes[i], b = boxes[j];
|
||
const ox = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
|
||
const oz = Math.min(a.z1, b.z1) - Math.max(a.z0, b.z0);
|
||
if (ox > 1.2 && oz > 1.2)
|
||
warn({ type: 'element', id: a.e.id },
|
||
`${a.e.kind} overlaps ${b.e.kind} — crossing lines create bail zones (intentional combos are fine)`);
|
||
}
|
||
|
||
// --- skill tiering: someone has to be able to learn here
|
||
const ledges = els.filter(e => e.kind === 'ledge');
|
||
if (ledges.length >= 2 && !ledges.some(e => e.h <= 0.28))
|
||
warn(null, 'no micro-ledge (≤0.25m) — beginners need low tier features off the main lines');
|
||
const qs = els.filter(e => e.kind === 'quarter');
|
||
if (qs.length >= 2 && !qs.some(e => Math.min(e.r0, e.r1) <= 1.0))
|
||
warn(null, 'all quarters are big — add a mellow one so beginners can drop in');
|
||
|
||
// --- terrain balance: street vs transition
|
||
const street = els.filter(e => STREET_KINDS.has(e.kind)).length;
|
||
const tranny = els.filter(e => TRANNY_KINDS.has(e.kind)).length;
|
||
if (street + tranny >= 6) {
|
||
if (street === 0) warn(null, 'all transition, no street — add ledges/stairs/flat rails');
|
||
if (tranny === 0) warn(null, 'all street, no transition — add quarters/banks/spines');
|
||
}
|
||
|
||
// --- steel: rails floating or buried (any node of the polyline)
|
||
for (const r of park.rails || []) {
|
||
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
|
||
const sp = park.spawn;
|
||
if (heightAt(sp.x, sp.z) > 0.5)
|
||
warn({ type: 'spawn' }, 'spawn is on top of an obstacle — riders drop in blind');
|
||
|
||
return out;
|
||
}
|