// SKATEMAKER PRO — park math. The whole religion in one file: // a park is DATA (elements list), and heightAt(x,z) evaluated from that data is the // single source of truth for the visual mesh AND the collision. Same lesson as BOOKQUOY. // // Every element lives in a rotated local frame: u along the element, v across it. // u_dir = ( cos rot, sin rot ) in (x,z) // v_dir = (-sin rot, cos rot ) — ramps descend toward +v ("facing"). const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const sstep = t => { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); }; // quarter-pipe transition: d = distance from the wall, r = radius. h(0)=r, h(r)=0. export function tranny(d, r) { if (d >= r) return 0; if (d <= 0) return r; const q = r - d; return r - Math.sqrt(Math.max(r * r - q * q, 0)); } function local(e, x, z) { // world (x,z) -> element-local (u,v) const dx = x - e.x, dz = z - e.z, c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0); return [dx * c + dz * s, -dx * s + dz * c]; } // ---------------------------------------------------------------- element kinds // Each returns a height contribution (concrete = max of all). Bowls carve (override). export const ELEMENT_KINDS = { quarter: { // straight QP, escalating radius, optional lump + deck label: 'Quarter Pipe', defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6 }, height(e, x, z) { const [u, v] = local(e, x, z); if (Math.abs(u) > e.len / 2) return 0; let r = e.r0 + (e.r1 - e.r0) * sstep(u / e.len + 0.5); if (e.lump) r += e.lump * Math.sin(u * 0.9); if (v < -e.deck) return 0; if (v <= 0) return r; // deck behind the coping return tranny(v, r); }, }, bank: { label: 'Bank', defaults: { len: 6, h: 0.9, run: 2.4, deck: 0.5 }, height(e, x, z) { const [u, v] = local(e, x, z); if (Math.abs(u) > e.len / 2 || v < -e.deck || v >= e.run) return 0; return v <= 0 ? e.h : e.h * (1 - v / e.run); }, }, funbox: { // soft-skirted box (bookquoy funbox) label: 'Funbox', defaults: { hw: 2.5, hd: 1.2, h: 0.9, skirt: 1.6 }, height(e, x, z) { const [u, v] = local(e, x, z); const d = Math.max(Math.abs(u) - e.hw, Math.abs(v) - e.hd); if (d <= 0) return e.h; if (d >= e.skirt) return 0; return e.h * (1 - d / e.skirt); }, }, ledge: { // sharp-sided plateau / manual pad / grind ledge label: 'Ledge / Pad', defaults: { hw: 1.5, hd: 0.8, h: 0.45 }, height(e, x, z) { const [u, v] = local(e, x, z); return (Math.abs(u) <= e.hw && Math.abs(v) <= e.hd) ? e.h : 0; }, }, mogul: { // gaussian bump / volcano label: 'Mogul / Volcano', defaults: { h: 0.6, s: 2.4 }, height(e, x, z) { const r2 = (x - e.x) ** 2 + (z - e.z) ** 2; return e.h * Math.exp(-r2 / (e.s * e.s)); }, }, spine: { // back-to-back quarters along u label: 'Spine', defaults: { len: 8, r: 1.2 }, height(e, x, z) { const [u, v] = local(e, x, z); if (Math.abs(u) > e.len / 2) return 0; const d = Math.abs(v); return d >= e.r ? 0 : tranny(d, e.r); }, }, pyramid: { // 4-sided bank pyramid with flat top label: 'Pyramid', defaults: { hw: 3, hd: 3, h: 1.0, run: 2.2 }, height(e, x, z) { const [u, v] = local(e, x, z); const d = Math.max(Math.abs(u) - e.hw, Math.abs(v) - e.hd); if (d <= 0) return e.h; if (d >= e.run) return 0; return e.h * (1 - d / e.run); }, }, stairs: { // platform + steps descending toward +v label: 'Stairs', defaults: { width: 4.8, h: 0.6, steps: 4, going: 0.6, platDepth: 3.2 }, height(e, x, z) { const [u, v] = local(e, x, z); if (Math.abs(u) > e.width / 2) return 0; if (v <= 0) return v >= -e.platDepth ? e.h : 0; const total = e.steps * e.going; if (v >= total) return 0; const rise = e.h / e.steps; const step = Math.floor(v / e.going); return e.h - rise * (step + 1); }, }, dome: { // elliptical pad (grass island, kerbed pad) label: 'Island Pad', defaults: { rx: 7, rz: 4.5, h: 0.14 }, height(e, x, z) { const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0); const dx = x - e.x, dz = z - e.z; const u = (dx * c + dz * s) / e.rx, v = (-dx * s + dz * c) / e.rz; return u * u + v * v < 1 ? e.h : 0; }, }, bowl: { // CARVES negative space. Override, not max. label: 'Bowl', defaults: { size: 4.2, depth: 1.5 }, carve: true, height(e, x, z) { // returns carve value, caller checks inside() const [u, v] = local(e, x, z); const dw = Math.min(e.size - Math.abs(u), e.size - Math.abs(v)); return -e.depth + tranny(Math.min(dw, e.depth), e.depth); }, inside(e, x, z) { const [u, v] = local(e, x, z); return Math.abs(u) < e.size && Math.abs(v) < e.size; }, }, }; // ---------------------------------------------------------------- evaluation export function elementHeight(e, x, z) { const k = ELEMENT_KINDS[e.kind]; return k ? k.height(e, x, z) : 0; } // makeHeightAt(park) -> heightAt(x,z). Closure over the data; rebuild-free (data is live). export function makeHeightAt(park) { return function heightAt(x, z) { const B = park.bounds; const inPark = x >= B.x0 && x <= B.x1 && z >= B.z0 && z <= B.z1; if (!inPark) { // parkland grass rising gently away const dx = Math.max(B.x0 - x, x - B.x1, 0); const dz = Math.max(B.z0 - z, z - B.z1, 0); return 0.25 + 0.09 * Math.hypot(dx, dz); } let h = 0; for (const e of park.elements) { const k = ELEMENT_KINDS[e.kind]; if (!k) continue; if (k.carve) { if (k.inside(e, x, z)) return k.height(e, x, z); // bowls own their footprint continue; } const eh = k.height(e, x, z); if (eh > h) h = eh; } return h; }; } export function makeNormalAt(heightAt) { return function normalAt(x, z, out) { const e = 0.18; const hx = heightAt(x + e, z) - heightAt(x - e, z); const hz = heightAt(x, z + e) - heightAt(x, z - e); out.set(-hx, 2 * e, -hz).normalize(); return out; }; } // footprint hit-test for the editor (generous margins so small things stay clickable) export function elementHit(e, x, z) { const M = 0.4; const k = ELEMENT_KINDS[e.kind]; if (!k) return false; switch (e.kind) { case 'quarter': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= Math.max(e.r0, e.r1) + M; } case 'bank': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.len / 2 + M && v >= -e.deck - M && v <= e.run + M; } case 'funbox': case 'pyramid': { const [u, v] = local(e, x, z); const sk = e.skirt ?? e.run; return Math.abs(u) <= e.hw + sk + M && Math.abs(v) <= e.hd + sk + M; } case 'ledge': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.hw + M && Math.abs(v) <= e.hd + M; } case 'mogul': return Math.hypot(x - e.x, z - e.z) <= e.s + M; case 'spine': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.len / 2 + M && Math.abs(v) <= e.r + M; } case 'stairs': { const [u, v] = local(e, x, z); return Math.abs(u) <= e.width / 2 + M && v >= -e.platDepth - M && v <= e.steps * e.going + M; } case 'dome': { const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0); const dx = x - e.x, dz = z - e.z; const u = (dx * c + dz * s) / (e.rx + M), v = (-dx * s + dz * c) / (e.rz + M); 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; } } return false; } // footprint outline (world-space [x,z] loop) for selection highlights export function elementOutline(e) { const rot = e.rot || 0, c = Math.cos(rot), s = Math.sin(rot); const W = (u, v) => [e.x + u * c - v * s, e.z + u * s + v * c]; const rect = (u0, u1, v0, v1) => [W(u0, v0), W(u1, v0), W(u1, v1), W(u0, v1)]; switch (e.kind) { case 'quarter': return rect(-e.len / 2, e.len / 2, -e.deck, Math.max(e.r0, e.r1)); case 'bank': return rect(-e.len / 2, e.len / 2, -e.deck, e.run); case 'funbox': return rect(-e.hw - e.skirt, e.hw + e.skirt, -e.hd - e.skirt, e.hd + e.skirt); case 'pyramid': return rect(-e.hw - e.run, e.hw + e.run, -e.hd - e.run, e.hd + e.run); case 'ledge': return rect(-e.hw, e.hw, -e.hd, e.hd); case 'mogul': { const pts = []; for (let i = 0; i < 24; i++) { const a = i / 24 * Math.PI * 2; pts.push([e.x + Math.cos(a) * e.s, e.z + Math.sin(a) * e.s]); } return pts; } case 'spine': return rect(-e.len / 2, e.len / 2, -e.r, e.r); case 'stairs': return rect(-e.width / 2, e.width / 2, -e.platDepth, e.steps * e.going); case 'dome': { const pts = []; for (let i = 0; i < 24; i++) { const a = i / 24 * Math.PI * 2; 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); } return []; } // ---------------------------------------------------------------- park scaffolding let idCounter = 1; export const newId = () => 'e' + (idCounter++) + '_' + ((Math.random() * 1e6) | 0).toString(36); export function newElement(kind, x, z) { const k = ELEMENT_KINDS[kind]; return { id: newId(), kind, x: +x.toFixed(2), z: +z.toFixed(2), rot: 0, ...structuredClone(k.defaults) }; } export function emptyPark(name = 'NEW PARK') { return { name, bounds: { x0: -30, x1: 30, z0: -20, z1: 20 }, slabColor: '#7fae6f', grassColor: '#4d7c3c', spawn: { x: -20, z: -12, heading: 0.8 }, elements: [], rails: [], gaps: [], letters: [], zones: [], decals: [], props: [], }; }