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>
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']);
|
||
|
||
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;
|
||
}
|