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