From db40f433fe8d54fba55327148b23d937f39287a1 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 3 Aug 2026 19:34:45 +1000 Subject: [PATCH] =?UTF-8?q?SKATEMAKER=20PRO=20v1=20=E2=80=94=20stand-alone?= =?UTF-8?q?=20park=20builder/editor=20for=20BOOKQUOY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-driven parks: one heightAt(x,z) evaluated from element JSON drives the editor viewport, collision, test ride, and exported game levels. Ships with Paddo ported from bookquoy, a MODELBEAST panel (gen images, cut bg, image->3D, place farm GLBs as props), image decals + reference underlay tracing, a park design linter (docs/DESIGN_PRINCIPLES.md), THPS-style instant test ride, and a level.js codegen that drops straight into bookquoy. Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 + .gitignore | 3 + README.md | 59 +++++ docs/DESIGN_PRINCIPLES.md | 36 +++ index.html | 104 ++++++++ js/editor.js | 538 ++++++++++++++++++++++++++++++++++++++ js/export.js | 49 ++++ js/modelbeast.js | 69 +++++ js/parkbuild.js | 204 +++++++++++++++ js/parkcheck.js | 98 +++++++ js/parkmath.js | 253 ++++++++++++++++++ js/testride.js | 120 +++++++++ js/ui.js | 459 ++++++++++++++++++++++++++++++++ parks/paddo.json | 75 ++++++ serve.py | 157 +++++++++++ 15 files changed, 2235 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/DESIGN_PRINCIPLES.md create mode 100644 index.html create mode 100644 js/editor.js create mode 100644 js/export.js create mode 100644 js/modelbeast.js create mode 100644 js/parkbuild.js create mode 100644 js/parkcheck.js create mode 100644 js/parkmath.js create mode 100644 js/testride.js create mode 100644 js/ui.js create mode 100644 parks/paddo.json create mode 100644 serve.py diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..32ad58d --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "skatemakerpro", + "runtimeExecutable": "python3", + "runtimeArgs": ["serve.py", "8093"], + "port": 8093 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a17f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +assets/uploads/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1603a1 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# SKATEMAKER PRO — stand-alone park builder + +The park editor for **BOOKQUOY** (and whatever comes after it). Born from bookquoy's one +rule: **`heightAt(x,z)` is the single source of truth** — the same element data drives the +editor viewport, the collision, the test ride, and the exported game level. Shape it in the +editor and it *is* the real thing. + +## Run it + +``` +python3 serve.py # http://localhost:8093 +``` + +`serve.py` is static hosting **plus** the MODELBEAST proxy (token read from +`~/Documents/backnforth/.env` server-side — never shipped to the browser) and park/asset +persistence (`parks/`, `assets/uploads/`). + +## The editor + +- **BUILD palette** — quarter, bank, funbox, pyramid, ledge, stairs, spine, mogul, bowl, + island. Click a piece, click the ground. Drag to move, `[` `]` rotate, `D` dupe, + `⌫` delete, `⌘Z` undo, optional 0.5m grid snap (THPS style). +- **SPOTS** — rails/ledge-caps (two clicks, draggable end-balls), named score gaps, + B·O·O·K·Q·U·O·Y letters, spawn point. +- **SURFACE** — paint zones (rect/ellipse, any color), **image decals** (drop any image on + the window, or generate one on the farm), and a **reference underlay** for tracing a real + park from an aerial photo. +- **MODELBEAST panel** — browse farm assets, one-click **PLACE** any GLB as a park prop, + use any farm image as a decal, **GEN IMAGE** (flux_local, ~9s), **MAKE 3D** + (hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue. +- **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping + geometry, run-out clearance, crossing lines, skill tiering, buried/floating steel. +- **▶ TEST RIDE** (`Enter`) — instant Create-A-Park-style build↔skate toggle with + bookquoy's physics constants over the live data. W push, A/D steer, Space ollie. + +## 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. + +## Export → BOOKQUOY + +**⬇ LEVEL.JS** generates `level..js` exporting bookquoy's exact `level.js` contract +(`heightAt, normalAt, BOUNDS, RAILS, GAPS, LETTERS, SPAWN, buildLevel`). To ship a park: + +1. copy `js/parkmath.js` + `js/parkbuild.js` + the generated `level..js` into + `bookquoy/js/`, and any `assets/uploads/` files it references +2. point the two imports (`main.js`, `player.js`) at the new level file + +**⬇ JSON** downloads the raw park data; **SAVE/LOAD…** persist to `parks/` on disk. + +## Lineage + +- bookquoy (`../bookquoy`) — the game this feeds; level 01 PADDO +- `~/Documents/thwk/THPS_STUDY.md` — Neversoft architecture study (technique only, + no extracted assets ever ship) +- THPS Create-A-Park — inspiration for the palette/test-loop/themes/premade-parks shape diff --git a/docs/DESIGN_PRINCIPLES.md b/docs/DESIGN_PRINCIPLES.md new file mode 100644 index 0000000..7c87a48 --- /dev/null +++ b/docs/DESIGN_PRINCIPLES.md @@ -0,0 +1,36 @@ +# What makes a great skatepark + +The design brief behind PARK CHECK (`js/parkcheck.js`). World units are metres; +bookquoy scale maps 1:1 (a 1.8 radius quarter = a 6ft quarter). + +## Flow & line architecture +- **Collision-free traffic paths** — no intersecting lines; nothing drops in across + another feature's run. +- **Adequate flatland & run-outs** — 10–15ft (3–4.5m) of clear flat for setup and landing + recovery. Tight squeezes kill speed and create bottleneck bail zones. +- **Pumping geometry** — transition radii that feel natural: 6–8ft (1.8–2.4m) for + street-flow sections, 9–11ft+ (2.7–3.4m) for deep bowls. + +## Obstacle variety & progression +- **Skill tiering** — 8–10in (0.2–0.25m) micro-ledges and mellow banks alongside the + 14–18in (0.35–0.45m) standard ledges, handrails and hubbas, so beginners progress + without occupying main lines. +- **Terrain balance** — street (stairs, manual pads, flatbars, euro-gaps) paired with + transition (spines, hips, speed bumps, open bowls). +- **Coping mechanics** — consistent reveal (~3/8in proud above and in front of the lip) + for predictable grinds; pool coping and tile in advanced bowls. *(Game mapping: rails + hug the deck height — PARK CHECK flags buried or floating steel.)* + +## Surface & build quality +- Smooth high-PSI steel-trowelled concrete; prefab metal/wood deteriorates and seams at + the transition base. *(Game mapping: one continuous heightfield — no seams by + construction.)* +- Expansion joints routed away from landings and slide paths. + +## Drainage & sightlines +- Drains and micro-slopes strictly outside trick lines; no puddle pockets. +- Open sightlines across the footprint to spot traffic and clear landings. + +## Amenities & community +- Lighting for evenings, shaded perimeter seating clear of run-ups, water, bins, transit + access. *(Game mapping: props/set dressing live outside the bounds slab.)* diff --git a/index.html b/index.html new file mode 100644 index 0000000..bae60aa --- /dev/null +++ b/index.html @@ -0,0 +1,104 @@ + + + + +SKATEMAKER PRO — park builder + + + +
+
+
+
+
+
booting…
+
+
+ + + + + diff --git a/js/editor.js b/js/editor.js new file mode 100644 index 0000000..d770680 --- /dev/null +++ b/js/editor.js @@ -0,0 +1,538 @@ +// SKATEMAKER PRO — editor core. Scene, picking, dragging, undo, rebuilds. +// The park is DATA; every edit mutates park JSON and the world rebuilds from it — +// so the editor can never lie about what the game will do. +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark } + from './parkmath.js'; +import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, + GRID_PAD } from './parkbuild.js'; + +const AUTOSAVE_KEY = 'smp_autosave_v1'; + +export function initEditor(container) { + // ---------------------------------------------------------------- three + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x1a2028); + scene.fog = new THREE.Fog(0x1a2028, 90, 220); + const cam = new THREE.PerspectiveCamera(55, 1, 0.1, 500); + cam.position.set(0, 42, 46); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + scene.add(new THREE.HemisphereLight(0xffffff, 0x3a4436, 1.05)); + const sun = new THREE.DirectionalLight(0xfff3d9, 1.5); + sun.position.set(30, 46, 18); scene.add(sun); + + const controls = new OrbitControls(cam, renderer.domElement); + controls.enableDamping = true; controls.dampingFactor = 0.12; + controls.maxPolarAngle = Math.PI * 0.49; + controls.mouseButtons = { LEFT: -1, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; + controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }; + controls.keyPanSpeed = 20; + + const resize = () => { + const w = container.clientWidth, h = container.clientHeight; + renderer.setSize(w, h); cam.aspect = w / h; cam.updateProjectionMatrix(); + }; + new ResizeObserver(resize).observe(container); + + // ---------------------------------------------------------------- state + const ed = { + scene, cam, renderer, controls, + park: null, heightAt: null, + selection: null, // {type:'element'|'rail'|'gap'|'letter'|'spawn'|'prop'|'zone'|'decal', id} + tool: null, // {mode:'place', kind} | {mode:'rail'} | {mode:'gap'}... + testing: false, + snapStep: 0, // 0 = free, 0.5 = THPS-style grid snap + listeners: {}, + on(ev, fn) { (this.listeners[ev] ||= []).push(fn); }, + emit(ev, ...a) { for (const f of this.listeners[ev] || []) f(...a); }, + }; + + let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [], + markersGrp = new THREE.Group(), selGrp = new THREE.Group(), + underlayMesh = null; + scene.add(markersGrp, selGrp); + + // ---------------------------------------------------------------- rebuilds + function rebuildGround() { + if (ground) { + scene.remove(ground); + ground.geometry.dispose(); ground.material.map?.dispose(); ground.material.dispose(); + } + ground = buildGround(ed.park); + scene.add(ground); + } + function rebakeTexture() { + if (!ground) return; + const B = ed.park.bounds; + ground.material.map?.dispose(); + ground.material.map = bakeGroundTexture(ed.park, + B.x0 - GRID_PAD, B.x1 + GRID_PAD, B.z0 - GRID_PAD, B.z1 + GRID_PAD); + ground.material.needsUpdate = true; + } + function rebuildRails() { + if (railsGrp) scene.remove(railsGrp); + railsGrp = buildRails(ed.park); scene.add(railsGrp); + } + function rebuildProps() { + if (propsGrp) scene.remove(propsGrp); + propsGrp = buildProps(ed.park, ed.heightAt); scene.add(propsGrp); + } + function rebuildMarkers() { + markersGrp.clear(); + for (const m of letterMeshes) scene.remove(m); + letterMeshes = buildLetters(ed.park, ed.heightAt); + for (const m of letterMeshes) scene.add(m); + const ringMat = new THREE.LineBasicMaterial({ color: 0xffd93b, transparent: true, opacity: 0.75 }); + for (const g of ed.park.gaps) { + const pts = []; + for (let i = 0; i <= 32; i++) { + const a = i / 32 * Math.PI * 2, x = g.x + Math.cos(a) * g.r, z = g.z + Math.sin(a) * g.r; + pts.push(new THREE.Vector3(x, ed.heightAt(x, z) + 0.12, z)); + } + markersGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), ringMat)); + } + const s = ed.park.spawn; + const cone = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.1, 8), + new THREE.MeshBasicMaterial({ color: 0xff8c3b })); + cone.rotation.x = Math.PI / 2; cone.rotation.z = -s.heading; + const holder = new THREE.Group(); holder.add(cone); + cone.position.z = 0.4; + holder.rotation.y = s.heading; + holder.position.set(s.x, ed.heightAt(s.x, s.z) + 0.5, s.z); + markersGrp.add(holder); + } + function rebuildUnderlay() { + if (underlayMesh) { scene.remove(underlayMesh); underlayMesh.material.map?.dispose(); underlayMesh = null; } + const u = ed.park.underlay; + if (!u || !u.img || !u.visible) return; + const tex = new THREE.TextureLoader().load(u.img, t => { + t.colorSpace = THREE.SRGBColorSpace; + const asp = t.image.height / t.image.width; + underlayMesh.scale.set(u.w, u.w * asp, 1); + }); + underlayMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: u.opacity ?? 0.5, + depthWrite: false })); + underlayMesh.rotation.x = -Math.PI / 2; + underlayMesh.rotation.z = u.rot || 0; + underlayMesh.position.set(u.x || 0, (u.y ?? 6), u.z || 0); + underlayMesh.renderOrder = 5; + scene.add(underlayMesh); + } + + // selection highlight + function rebuildSelection() { + selGrp.clear(); + const sel = ed.selection, obj = getSelected(); + if (!sel || !obj) return; + const mat = new THREE.LineBasicMaterial({ color: 0xffd93b }); + const loop = pts2 => { + const pts = pts2.map(([x, z]) => new THREE.Vector3(x, ed.heightAt(x, z) + 0.1, z)); + selGrp.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), mat)); + }; + const handle = (x, y, z, big) => { + const m = new THREE.Mesh(new THREE.SphereGeometry(big ? 0.28 : 0.18, 12, 8), + 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)); + else if (sel.type === 'rail') { + const a = new THREE.Vector3(obj.a[0], obj.ya, obj.a[1]); + const b = new THREE.Vector3(obj.b[0], obj.yb, obj.b[1]); + selGrp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), mat)); + handle(a.x, a.y, a.z, true).userData.railEnd = 'a'; + handle(b.x, b.y, b.z, true).userData.railEnd = 'b'; + } else if (sel.type === 'gap') { + const pts = []; + for (let i = 0; i < 32; i++) { + const a = i / 32 * Math.PI * 2; + pts.push([obj.x + Math.cos(a) * obj.r, obj.z + Math.sin(a) * obj.r]); + } + loop(pts); + } else if (sel.type === 'letter') { + handle(obj.x, ed.heightAt(obj.x, obj.z) + obj.y, obj.z, true); + } else if (sel.type === 'spawn') { + handle(obj.x, ed.heightAt(obj.x, obj.z) + 0.5, obj.z, true); + } else if (sel.type === 'prop') { + const o = propsGrp?.children.find(c => c.userData.propId === obj.id); + if (o) selGrp.add(new THREE.BoxHelper(o, 0xffd93b)); + } else if (sel.type === 'zone' || sel.type === 'decal') { + const hw = sel.type === 'zone' ? obj.hw : obj.w / 2, + hd = sel.type === 'zone' ? obj.hd : obj.h / 2; + const c = Math.cos(obj.rot || 0), s = Math.sin(obj.rot || 0); + const W = (u, v) => [obj.x + u * c - v * s, obj.z + u * s + v * c]; + loop([W(-hw, -hd), W(hw, -hd), W(hw, hd), W(-hw, hd)]); + } + } + + let groundTimer = 0, groundDirty = false; + function requestGround() { + groundDirty = true; + const now = performance.now(); + if (now - groundTimer > 90) { groundTimer = now; groundDirty = false; rebuildGround(); } + } + + function rebuildAll() { + ed.heightAt = makeHeightAt(ed.park); + rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers(); + rebuildUnderlay(); rebuildSelection(); + } + + // ---------------------------------------------------------------- undo / persistence + const undoStack = [], redoStack = []; + function snapshot() { + undoStack.push(JSON.stringify(ed.park)); + if (undoStack.length > 120) undoStack.shift(); + redoStack.length = 0; + } + function autosave() { + try { localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(ed.park)); } catch {} + } + // parts: {ground, rails, props, markers, tex, underlay} — default everything cheap + ed.commit = (parts = {}) => { + if (parts.snapshot !== false) snapshot(); + if (parts.ground) rebuildGround(); + if (parts.tex) rebakeTexture(); + if (parts.rails) rebuildRails(); + if (parts.props) rebuildProps(); + if (parts.markers !== false) rebuildMarkers(); + if (parts.underlay) rebuildUnderlay(); + rebuildSelection(); + autosave(); + ed.emit('change'); + }; + ed.undo = () => { + if (!undoStack.length) return; + redoStack.push(JSON.stringify(ed.park)); + ed.park = JSON.parse(undoStack.pop()); + ed.selection = null; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); + }; + ed.redo = () => { + if (!redoStack.length) return; + undoStack.push(JSON.stringify(ed.park)); + ed.park = JSON.parse(redoStack.pop()); + ed.selection = null; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); + }; + + ed.loadPark = json => { + ed.park = json; + for (const list of [json.elements, json.rails, json.gaps, json.letters, + json.zones, json.decals, json.props]) + for (const o of list || []) if (!o.id) o.id = newId(); + ed.selection = null; undoStack.length = redoStack.length = 0; + rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); + }; + ed.newPark = name => ed.loadPark(emptyPark(name)); + + // ---------------------------------------------------------------- selection helpers + function getSelected() { + const s = ed.selection; + if (!s || !ed.park) return null; + const P = ed.park; + switch (s.type) { + case 'element': return P.elements.find(e => e.id === s.id); + case 'rail': return P.rails.find(e => e.id === s.id); + case 'gap': return P.gaps.find(e => e.id === s.id); + case 'letter': return P.letters.find(e => e.id === s.id); + case 'prop': return P.props.find(e => e.id === s.id); + case 'zone': return P.zones.find(e => e.id === s.id); + case 'decal': return P.decals.find(e => e.id === s.id); + case 'spawn': return P.spawn; + } + return null; + } + ed.getSelected = getSelected; + ed.select = sel => { ed.selection = sel; rebuildSelection(); ed.emit('select'); }; + + ed.deleteSelected = () => { + const s = ed.selection; + if (!s || s.type === 'spawn') return; + const lists = { element: 'elements', rail: 'rails', gap: 'gaps', letter: 'letters', + prop: 'props', zone: 'zones', decal: 'decals' }; + const arr = ed.park[lists[s.type]]; + const i = arr.findIndex(e => e.id === s.id); + if (i < 0) return; + snapshot(); arr.splice(i, 1); ed.selection = null; + rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); + }; + ed.duplicateSelected = () => { + const s = ed.selection, obj = getSelected(); + if (!obj || s.type === 'spawn') return; + const lists = { element: 'elements', rail: 'rails', gap: 'gaps', letter: 'letters', + prop: 'props', zone: 'zones', decal: 'decals' }; + const copy = structuredClone(obj); copy.id = newId(); + if (copy.x !== undefined) { copy.x += 2; copy.z += 2; } + if (copy.a) { copy.a = [copy.a[0] + 2, copy.a[1] + 2]; copy.b = [copy.b[0] + 2, copy.b[1] + 2]; } + snapshot(); ed.park[lists[s.type]].push(copy); + ed.selection = { type: s.type, id: copy.id }; + rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); + }; + + // ---------------------------------------------------------------- picking + const ray = new THREE.Raycaster(); + const ndc = new THREE.Vector2(); + const planeY0 = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + + function groundPoint(ev) { + const r = renderer.domElement.getBoundingClientRect(); + ndc.set((ev.clientX - r.left) / r.width * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1); + ray.setFromCamera(ndc, cam); + const hit = ground ? ray.intersectObject(ground)[0] : null; + if (hit) return hit.point; + const p = new THREE.Vector3(); + return ray.ray.intersectPlane(planeY0, p) ? p : null; + } + + const distSeg = (px, pz, a, b) => { + const dx = b[0] - a[0], dz = b[1] - a[1]; + const t = Math.max(0, Math.min(1, ((px - a[0]) * dx + (pz - a[1]) * dz) / (dx * dx + dz * dz || 1))); + return Math.hypot(px - (a[0] + t * dx), pz - (a[1] + t * dz)); + }; + + function pick(ev, p) { + // 1. selection handles (rail endpoints) + const hs = ray.intersectObjects(selGrp.children.filter(o => o.userData.handle)); + if (hs.length && hs[0].object.userData.railEnd) + return { type: 'railEnd', end: hs[0].object.userData.railEnd }; + if (!p) return null; + const P = ed.park, x = p.x, z = p.z; + // 2. props (real raycast) + if (propsGrp) { + const ph = ray.intersectObjects(propsGrp.children, true)[0]; + if (ph) { const id = ph.object.userData.propId; + if (id) return { type: 'prop', id }; } + } + // 3. letters + for (const L of P.letters) if (Math.hypot(x - L.x, z - L.z) < 1.0) + return { type: 'letter', id: L.id }; + // 4. spawn + if (Math.hypot(x - P.spawn.x, z - P.spawn.z) < 1.0) return { type: 'spawn' }; + // 5. rails + for (const r of P.rails) if (distSeg(x, z, r.a, r.b) < 0.6) + return { type: 'rail', id: r.id }; + // 6. gaps (ring click — near the radius, not anywhere inside) + for (const g of P.gaps) { const d = Math.hypot(x - g.x, z - g.z); + if (Math.abs(d - g.r) < 0.5) return { type: 'gap', id: g.id }; } + // 7. elements, topmost drawn last + for (let i = P.elements.length - 1; i >= 0; i--) + if (elementHit(P.elements[i], x, z)) return { type: 'element', id: P.elements[i].id }; + // 8. zones / decals + const inRect = (o, hw, hd) => { + const c = Math.cos(o.rot || 0), s = Math.sin(o.rot || 0); + const dx = x - o.x, dz = z - o.z; + return Math.abs(dx * c + dz * s) <= hw && Math.abs(-dx * s + dz * c) <= hd; + }; + for (let i = (P.decals || []).length - 1; i >= 0; i--) + if (inRect(P.decals[i], P.decals[i].w / 2, P.decals[i].h / 2)) + return { type: 'decal', id: P.decals[i].id }; + for (let i = (P.zones || []).length - 1; i >= 0; i--) + if (inRect(P.zones[i], P.zones[i].hw, P.zones[i].hd)) + return { type: 'zone', id: P.zones[i].id }; + return null; + } + + // ---------------------------------------------------------------- tools & placement + ed.setTool = tool => { ed.tool = tool; pendingRail = null; ed.emit('tool'); }; + let pendingRail = null; // first click of a rail placement + + const snapV = v => ed.snapStep ? Math.round(v / ed.snapStep) * ed.snapStep : +v.toFixed(2); + function place(p) { + const t = ed.tool, P = ed.park; + const x = snapV(p.x), z = snapV(p.z); + if (t.mode === 'place') { + const e = newElement(t.kind, x, z); + snapshot(); P.elements.push(e); + ed.selection = { type: 'element', id: e.id }; + rebuildGround(); ed.commit({ snapshot: false }); + } else if (t.mode === 'rail') { + const y = ed.heightAt(x, z) + 0.4; + if (!pendingRail) { pendingRail = { x, z, y }; ed.emit('status', 'rail: click the far end'); return; } + const r = { id: newId(), name: 'Rail', a: [pendingRail.x, pendingRail.z], b: [x, z], + ya: +pendingRail.y.toFixed(2), yb: +y.toFixed(2), kind: 'rail' }; + snapshot(); P.rails.push(r); pendingRail = null; + ed.selection = { type: 'rail', id: r.id }; + ed.commit({ snapshot: false, rails: true }); + ed.setTool(null); + } else if (t.mode === 'gap') { + const g = { id: newId(), name: 'NEW GAP', x, z, r: 2.4 }; + snapshot(); P.gaps.push(g); + ed.selection = { type: 'gap', id: g.id }; + ed.commit({ snapshot: false }); ed.setTool(null); + } else if (t.mode === 'letter') { + const used = P.letters.map(l => l.ch).join(''); + const next = 'BOOKQUOY'.split('').find((c, i) => 'BOOKQUOY'.slice(0, i + 1).split(c).length - 1 > + used.split(c).length - 1) || 'O'; + const L = { id: newId(), ch: next, x, y: 1.8, z }; + snapshot(); P.letters.push(L); + ed.selection = { type: 'letter', id: L.id }; + ed.commit({ snapshot: false }); ed.setTool(null); + } else if (t.mode === 'spawn') { + snapshot(); P.spawn.x = x; P.spawn.z = z; + ed.selection = { type: 'spawn' }; + ed.commit({ snapshot: false }); ed.setTool(null); + } else if (t.mode === 'zone') { + const zn = { id: newId(), shape: 'rect', x, z, rot: 0, hw: 3, hd: 3, + color: '#9aa0a2', opacity: 1 }; + snapshot(); P.zones.push(zn); + ed.selection = { type: 'zone', id: zn.id }; + ed.commit({ snapshot: false, tex: true }); ed.setTool(null); + } else if (t.mode === 'decal') { + const d = { id: newId(), img: t.img, x, z, rot: 0, w: t.w || 6, + h: (t.w || 6) * (t.aspect || 1), opacity: 1 }; + snapshot(); P.decals.push(d); + ed.selection = { type: 'decal', id: d.id }; + ed.commit({ snapshot: false, tex: true }); ed.setTool(null); + } else if (t.mode === 'prop') { + const pr = { id: newId(), src: t.src, x, z, rot: 0, scale: t.scale || 1 }; + snapshot(); P.props.push(pr); + ed.selection = { type: 'prop', id: pr.id }; + ed.commit({ snapshot: false, props: true }); ed.setTool(null); + } + } + + // ---------------------------------------------------------------- drag + let drag = null; // {kind:'move'|'railEnd', grabDX, grabDZ, end, moved} + renderer.domElement.addEventListener('pointerdown', ev => { + if (ev.button !== 0 || ed.testing) return; + const p = groundPoint(ev); + if (ed.tool) { if (p) place(p); return; } + const hit = pick(ev, p); + if (!hit) { ed.select(null); return; } + if (hit.type === 'railEnd') { + drag = { kind: 'railEnd', end: hit.end, moved: false }; + controls.enabled = false; snapshot(); return; + } + ed.select({ type: hit.type, id: hit.id }); + const obj = getSelected(); + if (obj && p) { + drag = { kind: 'move', moved: false, + grabDX: (obj.x ?? obj.a?.[0] ?? 0) - p.x, + grabDZ: (obj.z ?? obj.a?.[1] ?? 0) - p.z }; + controls.enabled = false; snapshot(); + } + }); + renderer.domElement.addEventListener('pointermove', ev => { + if (!drag || ed.testing) return; + const p = groundPoint(ev); if (!p) return; + const s = ed.selection, obj = getSelected(); if (!obj) return; + drag.moved = true; + if (drag.kind === 'railEnd') { + const end = drag.end; + obj[end] = [snapV(p.x), snapV(p.z)]; + obj[end === 'a' ? 'ya' : 'yb'] = +(ed.heightAt(p.x, p.z) + 0.4).toFixed(2); + rebuildRails(); rebuildSelection(); return; + } + const nx = snapV(p.x + drag.grabDX), nz = snapV(p.z + drag.grabDZ); + if (s.type === 'rail') { + const dx = nx - obj.a[0], dz = nz - obj.a[1]; + obj.a = [obj.a[0] + dx, obj.a[1] + dz]; obj.b = [obj.b[0] + dx, obj.b[1] + dz]; + rebuildRails(); + } else { obj.x = nx; obj.z = nz; } + if (s.type === 'element') requestGround(); + if (s.type === 'zone' || s.type === 'decal') rebakeTexture(); + if (s.type === 'prop') rebuildProps(); + if (s.type === 'letter' || s.type === 'gap' || s.type === 'spawn') rebuildMarkers(); + rebuildSelection(); + ed.emit('select'); // live-update inspector numbers + }); + const endDrag = () => { + if (!drag) return; + const wasMoved = drag.moved, s = ed.selection; + drag = null; controls.enabled = true; + if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot + if (s?.type === 'element') rebuildGround(); + ed.commit({ snapshot: false, rails: s?.type === 'rail' || s?.type === 'railEnd' }); + }; + renderer.domElement.addEventListener('pointerup', endDrag); + renderer.domElement.addEventListener('pointerleave', endDrag); + + // ---------------------------------------------------------------- param edits from UI + ed.updateSelected = (key, value, opts = {}) => { + const obj = getSelected(); if (!obj) return; + obj[key] = value; + const t = ed.selection.type; + if (t === 'element') requestGround(); + if (t === 'zone' || t === 'decal') rebakeTexture(); + if (t === 'rail') rebuildRails(); + if (t === 'prop') rebuildProps(); + rebuildMarkers(); rebuildSelection(); + if (opts.done) { // slider released / field committed + if (t === 'element') rebuildGround(); + ed.commit({ snapshot: true, rails: t === 'rail', props: t === 'prop' }); + } + }; + + // ---------------------------------------------------------------- keys + addEventListener('keydown', ev => { + if (ed.testing) return; + if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'SELECT' || + ev.target.tagName === 'TEXTAREA') return; + const obj = getSelected(); + if ((ev.metaKey || ev.ctrlKey) && ev.code === 'KeyZ') { + ev.preventDefault(); ev.shiftKey ? ed.redo() : ed.undo(); return; + } + if ((ev.metaKey || ev.ctrlKey) && ev.code === 'KeyY') { ev.preventDefault(); ed.redo(); return; } + switch (ev.code) { + case 'Escape': ed.setTool(null); ed.select(null); break; + case 'Backspace': case 'Delete': ed.deleteSelected(); break; + case 'KeyD': if (!ev.metaKey) ed.duplicateSelected(); break; + case 'BracketLeft': case 'BracketRight': { + if (!obj || obj.rot === undefined) break; + const step = (ev.code === 'BracketLeft' ? -1 : 1) * (ev.shiftKey ? 5 : 15) * Math.PI / 180; + ed.updateSelected('rot', +(obj.rot + step).toFixed(3), { done: true }); + ed.emit('select'); + break; + } + case 'KeyT': ed.topView(); break; + } + }); + + ed.topView = () => { + const B = ed.park.bounds; + const cx = (B.x0 + B.x1) / 2, cz = (B.z0 + B.z1) / 2; + controls.target.set(cx, 0, cz); + cam.position.set(cx, Math.max(B.x1 - B.x0, B.z1 - B.z0) * 1.05, cz + 0.01); + }; + + // ---------------------------------------------------------------- boot + loop + let extraUpdate = null; + ed.setExtraUpdate = fn => { extraUpdate = fn; }; + let last = performance.now(); + function frame(now) { + requestAnimationFrame(frame); + const dt = Math.min((now - last) / 1000, 0.05); last = now; + if (groundDirty && now - groundTimer > 90) { groundTimer = now; groundDirty = false; rebuildGround(); } + for (const m of letterMeshes) { m.rotation.y += dt * 2.2; } + if (extraUpdate) extraUpdate(dt); + else controls.update(); + renderer.render(scene, cam); + } + + // deterministic stepper for headless testing (browser pane throttles rAF when + // hidden — same lesson, and same convention, as bookquoy's window.__step) + ed.step = (seconds = 1) => { + const n = Math.round(seconds * 60); + for (let i = 0; i < n; i++) { + const dt = 1 / 60; + if (extraUpdate) extraUpdate(dt); else controls.update(); + } + renderer.render(scene, cam); + }; + + ed.boot = async () => { + resize(); + let loaded = null; + try { loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)); } catch {} + if (!loaded) { + try { loaded = await fetch('parks/paddo.json').then(r => r.json()); } catch {} + } + ed.loadPark(loaded || emptyPark('NEW PARK')); + ed.topView(); + requestAnimationFrame(frame); + }; + + return ed; +} diff --git a/js/export.js b/js/export.js new file mode 100644 index 0000000..6dad687 --- /dev/null +++ b/js/export.js @@ -0,0 +1,49 @@ +// SKATEMAKER PRO — exports. A park leaves here two ways: +// 1. park.json — the native data format +// 2. level..js — a drop-in for BOOKQUOY: exports the exact interface +// bookquoy's js/level.js has (heightAt, normalAt, BOUNDS, +// RAILS, GAPS, LETTERS, SPAWN, buildLevel). Ship it with +// parkmath.js + parkbuild.js + assets/uploads/. + +function download(name, text, type = 'application/octet-stream') { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([text], { type })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 5000); +} + +const slug = park => (park.name || 'park').toLowerCase().replace(/[^\w]+/g, '_'); + +export function downloadParkJSON(park) { + download(slug(park) + '.json', JSON.stringify(park, null, 1), 'application/json'); +} + +export function generateLevelJS(park) { + return `// ${park.name} — generated by SKATEMAKER PRO ${new Date().toISOString().slice(0, 10)} +// Drop-in for BOOKQUOY: copy this file + parkmath.js + parkbuild.js (+ assets/uploads/) +// into bookquoy/js/ and point main.js/player.js imports at it. Same contract as level.js: +// heightAt(x,z) is the single source of truth for visuals AND collision. +import { makeHeightAt, makeNormalAt } from './parkmath.js'; +import { buildParkScene } from './parkbuild.js'; + +export const PARK = ${JSON.stringify(park, null, 1)}; + +export const BOUNDS = PARK.bounds; +export const RAILS = PARK.rails; +export const GAPS = PARK.gaps; +export const LETTERS = PARK.letters; +export const SPAWN = PARK.spawn; +export const heightAt = makeHeightAt(PARK); +export const normalAt = makeNormalAt(heightAt); + +export function buildLevel(scene) { + const { ground, letterMeshes } = buildParkScene(scene, PARK); + return { ground, letterMeshes }; +} +`; +} + +export function downloadLevelJS(park) { + download('level.' + slug(park) + '.js', generateLevelJS(park), 'text/javascript'); +} diff --git a/js/modelbeast.js b/js/modelbeast.js new file mode 100644 index 0000000..1bb593b --- /dev/null +++ b/js/modelbeast.js @@ -0,0 +1,69 @@ +// SKATEMAKER PRO — MODELBEAST client. Talks only to serve.py's /mb/ proxy; +// the farm token never reaches the browser. Guest tier: max 4 active jobs. +// Contract (mb_recon.py, verified 2026-07-16): submit {operator, asset_id?, params}, +// poll status, then find your output by parent_job in the assets list. + +async function j(url, opts) { + const r = await fetch(url, opts); + const body = await r.json(); + if (body.error) throw new Error(body.error); + return body; +} + +export async function listAssets() { + const a = await j('/mb/assets'); + return Array.isArray(a) ? a : a.items || []; +} + +export const importAsset = (id, name) => + j(`/mb/import?asset=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`); + +export const submitJob = (operator, params = {}, assetId = null) => + j('/mb/jobs', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(assetId ? { operator, asset_id: assetId, params } + : { operator, params }), + }); + +export async function pollJob(id, onStatus, timeoutS = 600) { + const t0 = Date.now(); + for (;;) { + await new Promise(res => setTimeout(res, 5000)); + const st = (await j(`/mb/jobs/${id}`)).status; + onStatus?.(`job ${id}: ${st} (${((Date.now() - t0) / 1000) | 0}s)`); + if (st === 'done') return; + if (st === 'error' || st === 'cancelled') throw new Error('job ended ' + st); + if (Date.now() - t0 > timeoutS * 1000) throw new Error('job timed out'); + } +} + +async function outputOf(jobId, extRe) { + const assets = await listAssets(); + const mine = assets.filter(a => a.parent_job === jobId && + extRe.test(a.name || a.filename || '')); + return mine[0] || null; +} + +// prompt -> image on the farm (flux_local, ~9s on the m3ultra) +export async function generateImage(prompt, onStatus) { + onStatus?.('submitting flux_local…'); + const job = await submitJob('flux_local', { prompt }); + await pollJob(job.id, onStatus); + return outputOf(job.id, /\.(png|jpe?g|webp)$/i); +} + +// MB image asset -> same image with background removed (for clean decals) +export async function removeBg(assetId, onStatus) { + onStatus?.('submitting bg_remove_local…'); + const job = await submitJob('bg_remove_local', {}, assetId); + await pollJob(job.id, onStatus); + return outputOf(job.id, /\.(png|webp)$/i); +} + +// MB image asset -> GLB (hunyuan3d_mlx). Slow — minutes, not seconds. +export async function imageTo3D(assetId, onStatus) { + onStatus?.('submitting hunyuan3d_mlx…'); + const job = await submitJob('hunyuan3d_mlx', {}, assetId); + await pollJob(job.id, onStatus, 1800); + return outputOf(job.id, /\.glb$/i); +} diff --git a/js/parkbuild.js b/js/parkbuild.js new file mode 100644 index 0000000..d2e6cbc --- /dev/null +++ b/js/parkbuild.js @@ -0,0 +1,204 @@ +// SKATEMAKER PRO — park -> three.js scene. Shared by the editor AND exported levels, +// 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 } from './parkmath.js'; + +export const GRID_RES = 0.5, GRID_PAD = 14; + +// ---------------------------------------------------------------- ground texture +// Zones + image decals bake into one canvas mapped planar over the grid. +// Vertex colors carry height shading; three multiplies map * vertexColors. +export function bakeGroundTexture(park, x0, x1, z0, z1) { + const PX = 16; // texels per metre + const cv = document.createElement('canvas'); + cv.width = Math.min(4096, Math.round((x1 - x0) * PX)); + cv.height = Math.min(4096, Math.round((z1 - z0) * PX)); + const sx = cv.width / (x1 - x0), sz = cv.height / (z1 - z0); + const g = cv.getContext('2d'); + const px = (x, z) => [(x - x0) * sx, (z - z0) * sz]; + + g.fillStyle = park.grassColor || '#4d7c3c'; // parkland + g.fillRect(0, 0, cv.width, cv.height); + const B = park.bounds; + const [bx, bz] = px(B.x0, B.z0); + g.fillStyle = park.slabColor || '#7fae6f'; // the slab + g.fillRect(bx, bz, (B.x1 - B.x0) * sx, (B.z1 - B.z0) * sz); + + const drawShape = zn => { + g.save(); + const [cx, cz] = px(zn.x, zn.z); + g.translate(cx, cz); g.rotate(zn.rot || 0); + g.beginPath(); + if (zn.shape === 'ellipse') g.ellipse(0, 0, zn.hw * sx, zn.hd * sz, 0, 0, Math.PI * 2); + else g.rect(-zn.hw * sx, -zn.hd * sz, zn.hw * 2 * sx, zn.hd * 2 * sz); + }; + for (const zn of park.zones || []) { + drawShape(zn); + g.fillStyle = zn.color || '#9aa0a2'; + g.globalAlpha = zn.opacity ?? 1; + g.fill(); g.restore(); + } + const jobs = []; // decals load async, re-bake per image + for (const d of park.decals || []) { + const img = new Image(); + jobs.push(new Promise(res => { + img.onload = () => res({ d, img }); + img.onerror = () => res(null); + img.src = d.img; + })); + } + const tex = new THREE.CanvasTexture(cv); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 4; + Promise.all(jobs).then(loaded => { + for (const hit of loaded) { + if (!hit) continue; + const { d, img } = hit; + g.save(); + const [cx, cz] = px(d.x, d.z); + g.translate(cx, cz); g.rotate(d.rot || 0); + g.globalAlpha = d.opacity ?? 1; + g.drawImage(img, -d.w / 2 * sx, -d.h / 2 * sz, d.w * sx, d.h * sz); + g.restore(); + } + tex.needsUpdate = true; + }); + return tex; +} + +// height shading: darker in carves, subtle lift with elevation — cheap depth cueing +function shade(h) { + const s = h < -0.02 ? 0.72 + 0.1 * h : Math.min(1, 0.9 + h * 0.06); + return Math.max(0.55, s); +} + +export function buildGround(park) { + const heightAt = makeHeightAt(park); + const B = park.bounds; + const x0 = B.x0 - GRID_PAD, x1 = B.x1 + GRID_PAD, z0 = B.z0 - GRID_PAD, z1 = B.z1 + GRID_PAD; + const nx = Math.round((x1 - x0) / GRID_RES) + 1, nz = Math.round((z1 - z0) / GRID_RES) + 1; + const pos = new Float32Array(nx * nz * 3), col = new Float32Array(nx * nz * 3), + uv = new Float32Array(nx * nz * 2), idx = []; + let p = 0, q = 0; + for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) { + const x = x0 + i * GRID_RES, z = z0 + j * GRID_RES, h = heightAt(x, z); + pos[p] = x; pos[p + 1] = h; pos[p + 2] = z; + const s = shade(h); + col[p] = s; col[p + 1] = s; col[p + 2] = s; + p += 3; + uv[q++] = (x - x0) / (x1 - x0); uv[q++] = 1 - (z - z0) / (z1 - z0); + } + for (let j = 0; j < nz - 1; j++) for (let i = 0; i < nx - 1; i++) { + const a = j * nx + i, b = a + 1, c = a + nx, d = c + 1; + idx.push(a, c, b, b, c, d); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); + geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + geo.setIndex(idx); geo.computeVertexNormals(); + const tex = bakeGroundTexture(park, x0, x1, z0, z1); + const mesh = new THREE.Mesh(geo, + new THREE.MeshLambertMaterial({ map: tex, vertexColors: true })); + mesh.receiveShadow = true; + mesh.name = 'ground'; + return mesh; +} + +// ---------------------------------------------------------------- steel +export function buildRails(park) { + const grp = new THREE.Group(); grp.name = 'rails'; + const steel = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 0.8, roughness: 0.35 }); + for (const r of park.rails || []) { + const a = new THREE.Vector3(r.a[0], r.ya, r.a[1]); + const b = new THREE.Vector3(r.b[0], r.yb, r.b[1]); + const len = Math.max(a.distanceTo(b), 0.01); + const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 8), steel); + bar.position.copy(a).add(b).multiplyScalar(0.5); + bar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); + bar.userData.railId = r.id; + grp.add(bar); + if (r.kind === 'rail') { + for (const t of [0.12, 0.88]) { + const p = a.clone().lerp(b, t); + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, Math.max(p.y, 0.05), 6), steel); + post.position.set(p.x, p.y / 2, p.z); + post.userData.railId = r.id; + grp.add(post); + } + } + } + return grp; +} + +// ---------------------------------------------------------------- props +const fig = (s, trunkM, leafM) => { // builtin Moreton Bay fig + const grp = new THREE.Group(); + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.28 * s, 0.42 * s, 2.6 * s, 7), trunkM); + trunk.position.y = 1.3 * s; grp.add(trunk); + for (const [ox, oy, oz, r] of [[0, 3.1, 0, 2.4], [1.4, 2.6, 0.7, 1.7], [-1.3, 2.7, -0.6, 1.6]]) { + const m = new THREE.Mesh(new THREE.SphereGeometry(r * s, 10, 8), leafM); + m.position.set(ox * s, oy * s, oz * s); grp.add(m); + } + return grp; +}; + +export function buildProps(park, heightAt, onLoaded) { + const grp = new THREE.Group(); grp.name = 'props'; + const loader = new GLTFLoader(); + const trunkM = new THREE.MeshLambertMaterial({ color: 0x5b4632 }); + const leafM = new THREE.MeshLambertMaterial({ color: 0x2e5b2a }); + for (const pr of park.props || []) { + const place = obj => { + obj.position.set(pr.x, heightAt(pr.x, pr.z) + (pr.y || 0), pr.z); + obj.rotation.y = pr.rot || 0; + const s = pr.scale || 1; + if (pr.src !== 'builtin:fig') obj.scale.setScalar(s); + obj.userData.propId = pr.id; + obj.traverse(o => { o.userData.propId = pr.id; }); + grp.add(obj); + if (onLoaded) onLoaded(pr, obj); + }; + if (pr.src === 'builtin:fig') place(fig(pr.scale || 1, trunkM, leafM)); + else loader.load(pr.src, g => place(g.scene), + undefined, () => console.warn('prop failed', pr.src)); + } + return grp; +} + +// ---------------------------------------------------------------- letters +export function buildLetters(park, heightAt) { + return (park.letters || []).map(L => { + const cv = document.createElement('canvas'); cv.width = cv.height = 128; + const cx = cv.getContext('2d'); + cx.fillStyle = '#ffd93b'; cx.strokeStyle = '#2b2b2b'; cx.lineWidth = 10; + cx.font = 'bold 104px monospace'; cx.textAlign = 'center'; cx.textBaseline = 'middle'; + cx.strokeText(L.ch, 64, 70); cx.fillText(L.ch, 64, 70); + const tex = new THREE.CanvasTexture(cv); + const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.9), + new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide })); + const base = heightAt(L.x, L.z); + spr.position.set(L.x, Math.max(base, 0) + L.y, L.z); + spr.userData = { ch: L.ch, taken: false, baseY: spr.position.y, letterId: L.id }; + return spr; + }); +} + +// ---------------------------------------------------------------- whole scene +// Returns the same shape bookquoy's buildLevel returns, so exports are drop-ins. +export function buildParkScene(scene, park, opts = {}) { + const heightAt = makeHeightAt(park); + const ground = buildGround(park); + scene.add(ground); + const rails = buildRails(park); + scene.add(rails); + const props = buildProps(park, heightAt, opts.onPropLoaded); + scene.add(props); + let letterMeshes = []; + if (!opts.skipLetters) { + letterMeshes = buildLetters(park, heightAt); + for (const m of letterMeshes) scene.add(m); + } + return { ground, rails, props, letterMeshes }; +} diff --git a/js/parkcheck.js b/js/parkcheck.js new file mode 100644 index 0000000..6b4930d --- /dev/null +++ b/js/parkcheck.js @@ -0,0 +1,98 @@ +// 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']); +const TRANNY_KINDS = new Set(['quarter', 'spine', 'bowl', 'bank', 'mogul']); + +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'].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.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 + for (const r of park.rails || []) { + const ha = heightAt(r.a[0], r.a[1]), hb = heightAt(r.b[0], r.b[1]); + if (r.ya < ha - 0.05 || r.yb < hb - 0.05) + warn({ type: 'rail', id: r.id }, `"${r.name}" is buried in concrete — raise ya/yb`); + if (r.ya - ha > 1.6 || r.yb - hb > 1.6) + warn({ type: 'rail', id: r.id }, `"${r.name}" floats way above the deck — grinds will feel wrong`); + } + + // --- 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; +} diff --git a/js/parkmath.js b/js/parkmath.js new file mode 100644 index 0000000..1ecfca5 --- /dev/null +++ b/js/parkmath.js @@ -0,0 +1,253 @@ +// 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: [], + }; +} diff --git a/js/testride.js b/js/testride.js new file mode 100644 index 0000000..f9268fd --- /dev/null +++ b/js/testride.js @@ -0,0 +1,120 @@ +// SKATEMAKER PRO — test ride. The Create-A-Park lesson: the instant build↔skate +// toggle is what makes an editor sing. Simplified bookquoy physics over the live +// park data — same heightAt, same constants, so speed and pop feel like the game. +import * as THREE from 'three'; +import { makeHeightAt, makeNormalAt } from './parkmath.js'; + +const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6; + +export function attachTestRide(ed) { + const keys = new Set(); + let rider = null, heightAt = null, normalAt = null; + let pos, heading = 0, speed = 0, vy = 0, state = 'roll'; + const N = new THREE.Vector3(); + const camTarget = new THREE.Vector3(); + let savedCam = null; + + function makeRider() { + const g = new THREE.Group(); + const deck = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.03, 0.82), + new THREE.MeshStandardMaterial({ color: 0xd8a13a, roughness: 0.6 })); + deck.position.y = 0.1; g.add(deck); + const wheelM = new THREE.MeshStandardMaterial({ color: 0xf5f0e6, roughness: 0.4 }); + for (const [dx, dz] of [[-0.08, 0.3], [0.08, 0.3], [-0.08, -0.3], [0.08, -0.3]]) { + const w = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.04, 10), wheelM); + w.rotation.z = Math.PI / 2; w.position.set(dx, 0.05, dz); g.add(w); + } + const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.75, 4, 10), + new THREE.MeshStandardMaterial({ color: 0x3a86ff, roughness: 0.7 })); + body.position.y = 0.75; g.add(body); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10), + new THREE.MeshStandardMaterial({ color: 0xe8b98a, roughness: 0.8 })); + head.position.y = 1.42; g.add(head); + return g; + } + + function start() { + heightAt = makeHeightAt(ed.park); + normalAt = makeNormalAt(heightAt); + const s = ed.park.spawn; + pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z); + heading = s.heading; speed = 0; vy = 0; state = 'roll'; + rider = makeRider(); + ed.scene.add(rider); + savedCam = { p: ed.cam.position.clone(), t: ed.controls.target.clone() }; + ed.controls.enabled = false; + ed.testing = true; + ed.emit('test', true); + ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · R respawn · ENTER back to editing'); + } + function stop() { + if (rider) ed.scene.remove(rider); + rider = null; + ed.testing = false; + ed.controls.enabled = true; + if (savedCam) { ed.cam.position.copy(savedCam.p); ed.controls.target.copy(savedCam.t); } + ed.setExtraUpdate(null); + ed.emit('test', false); + ed.emit('status', 'back to building'); + } + + function update(dt) { + const fwd = new THREE.Vector3(Math.sin(heading), 0, Math.cos(heading)); + if (state === 'roll') { + if (keys.has('KeyA') || keys.has('ArrowLeft')) heading += STEER * dt; + if (keys.has('KeyD') || keys.has('ArrowRight')) heading -= STEER * dt; + if (keys.has('KeyW') || keys.has('ArrowUp')) speed = Math.min(speed + PUSH_ACC * dt, MAX_SPEED); + if (keys.has('KeyS') || keys.has('ArrowDown')) speed = Math.max(speed - 14 * dt, 0); + normalAt(pos.x, pos.z, N); + speed += -(N.x * fwd.x + N.z * fwd.z) * GRAV * dt * 1.4; // slope pulls + speed -= FRICTION * dt * speed * 0.4; + speed = Math.max(0, Math.min(speed, 16)); + pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; + const h = heightAt(pos.x, pos.z); + const dh = h - pos.y; + if (dh < -0.12) { // rolled off something + state = 'air'; vy = Math.max(-2, dh / dt * 0.2); + } else { + pos.y = h; + if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; } + } + } else { + vy -= GRAV * dt; + pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt; + const h = heightAt(pos.x, pos.z); + if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; } + } + if (keys.has('KeyR')) { + const s = ed.park.spawn; + pos.set(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; state = 'roll'; + } + rider.position.copy(pos); + rider.rotation.y = heading; + if (state === 'roll') { + normalAt(pos.x, pos.z, N); + const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N); + rider.quaternion.slerp(tilt.multiply(new THREE.Quaternion() + .setFromAxisAngle(new THREE.Vector3(0, 1, 0), heading)), 0.25); + } + // bookquoy chase cam + const back = 5.2, up = 2.4; + const cx = pos.x - Math.sin(heading) * back, cz = pos.z - Math.cos(heading) * back; + const cy = Math.max(pos.y + up, heightAt(cx, cz) + 1.2); + ed.cam.position.lerp(new THREE.Vector3(cx, cy, cz), 1 - Math.pow(0.0015, dt)); + camTarget.lerp(new THREE.Vector3(pos.x, pos.y + 1.1, pos.z), 1 - Math.pow(0.0005, dt)); + ed.cam.lookAt(camTarget); + } + + ed.toggleTest = () => { + if (ed.testing) stop(); + else { start(); ed.setExtraUpdate(update); } + }; + + addEventListener('keydown', e => { + if (e.code === 'Enter' && !['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) { + ed.toggleTest(); return; + } + if (ed.testing) { keys.add(e.code); if (e.code === 'Space') e.preventDefault(); } + }); + addEventListener('keyup', e => keys.delete(e.code)); +} diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..302de97 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,459 @@ +// SKATEMAKER PRO — DOM panels. Everything talks to the editor through its tiny API: +// setTool / select / updateSelected / commit / loadPark. No three.js in here. +import { ELEMENT_KINDS } from './parkmath.js'; +import { checkPark } from './parkcheck.js'; +import { downloadParkJSON, downloadLevelJS } from './export.js'; +import * as MB from './modelbeast.js'; + +const $ = (sel, root = document) => root.querySelector(sel); +const el = (tag, cls, html) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (html !== undefined) n.innerHTML = html; + return n; +}; + +// numeric param rows: [key, label, min, max, step] +const DEG = Math.PI / 180; +const COMMON_XZ = [['x', 'x', -60, 60, 0.1], ['z', 'z', -40, 40, 0.1]]; +const PARAMS = { + quarter: [['len', 'length', 1, 80, 0.5], ['r0', 'radius a', 0.3, 4, 0.05], + ['r1', 'radius b', 0.3, 4, 0.05], ['lump', 'lumpiness', 0, 0.3, 0.01], + ['deck', 'deck', 0, 4, 0.1]], + bank: [['len', 'length', 1, 80, 0.5], ['h', 'height', 0.1, 3, 0.05], + ['run', 'run', 0.5, 8, 0.1], ['deck', 'deck', 0, 4, 0.1]], + funbox: [['hw', 'half width', 0.3, 10, 0.1], ['hd', 'half depth', 0.3, 10, 0.1], + ['h', 'height', 0.1, 2.5, 0.05], ['skirt', 'skirt', 0.2, 4, 0.1]], + ledge: [['hw', 'half width', 0.2, 14, 0.1], ['hd', 'half depth', 0.2, 14, 0.1], + ['h', 'height', 0.05, 1.5, 0.01]], + mogul: [['h', 'height', 0.1, 2, 0.05], ['s', 'spread', 0.5, 6, 0.1]], + spine: [['len', 'length', 1, 30, 0.5], ['r', 'radius', 0.4, 3, 0.05]], + pyramid: [['hw', 'half width', 0.5, 10, 0.1], ['hd', 'half depth', 0.5, 10, 0.1], + ['h', 'height', 0.2, 3, 0.05], ['run', 'run', 0.5, 6, 0.1]], + stairs: [['width', 'width', 1, 12, 0.2], ['h', 'drop', 0.2, 2.4, 0.05], + ['steps', 'steps', 2, 12, 1], ['going', 'going', 0.3, 1.2, 0.05], + ['platDepth', 'platform', 0.5, 8, 0.1]], + dome: [['rx', 'radius x', 0.5, 14, 0.1], ['rz', 'radius z', 0.5, 14, 0.1], + ['h', 'height', 0.05, 1, 0.01]], + bowl: [['size', 'half size', 1.5, 10, 0.1], ['depth', 'depth', 0.5, 3.4, 0.05]], +}; +const PALETTE = [ + ['quarter', 'QUARTER'], ['bank', 'BANK'], ['funbox', 'FUNBOX'], ['pyramid', 'PYRAMID'], + ['ledge', 'LEDGE'], ['stairs', 'STAIRS'], ['spine', 'SPINE'], ['mogul', 'MOGUL'], + ['bowl', 'BOWL'], ['dome', 'ISLAND'], +]; +const SPOTS = [ + ['rail', 'RAIL', { mode: 'rail' }], ['gap', 'GAP', { mode: 'gap' }], + ['letter', 'LETTER', { mode: 'letter' }], ['spawn', 'SPAWN', { mode: 'spawn' }], + ['zone', 'PAINT', { mode: 'zone' }], +]; + +export function initUI(ed) { + const palette = $('#palette'), inspector = $('#inspector'), + status = $('#status'), drawer = $('#drawer'); + + const setStatus = t => { status.textContent = t; }; + ed.on('status', setStatus); + + // ---------------------------------------------------------------- palette + function buildPalette() { + palette.innerHTML = '
BUILD
'; + const grid = el('div', 'pgrid'); palette.append(grid); + for (const [kind, label] of PALETTE) { + const b = el('button', 'pbtn', label); + b.dataset.tool = 'place:' + kind; + b.onclick = () => ed.setTool(ed.tool?.kind === kind ? null : { mode: 'place', kind }); + grid.append(b); + } + palette.append(el('div', 'ptitle', 'SPOTS')); + const grid2 = el('div', 'pgrid'); palette.append(grid2); + for (const [key, label, tool] of SPOTS) { + const b = el('button', 'pbtn', label); + b.dataset.tool = tool.mode; + b.onclick = () => ed.setTool(ed.tool?.mode === tool.mode ? null : { ...tool }); + grid2.append(b); + } + palette.append(el('div', 'ptitle', 'SURFACE')); + const surf = el('div', 'pcol'); palette.append(surf); + const decalBtn = el('button', 'pbtn wide', 'IMAGE DECAL…'); + decalBtn.onclick = () => pickFile('image/*', async f => { + const path = await uploadFile(f); + const img = new Image(); + img.onload = () => { + ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width }); + setStatus('decal loaded — click the ground to place it'); + }; + img.src = path; + }); + surf.append(decalBtn); + const underBtn = el('button', 'pbtn wide', 'REFERENCE UNDERLAY…'); + underBtn.onclick = () => pickFile('image/*', async f => { + const path = await uploadFile(f); + ed.park.underlay = { img: path, visible: true, opacity: 0.45, w: 90, x: 0, z: 0, rot: 0, y: 6 }; + ed.commit({ underlay: true }); + ed.select({ type: 'underlay' }); + }); + surf.append(underBtn); + const snapRow = el('label', 'snaprow', + ' grid snap 0.5m'); + surf.append(snapRow); + $('#snapbox', snapRow).onchange = e => { ed.snapStep = e.target.checked ? 0.5 : 0; }; + + palette.append(el('div', 'ptitle', 'PANELS')); + const pans = el('div', 'pcol'); palette.append(pans); + const mbBtn = el('button', 'pbtn wide accent', 'MODELBEAST'); + mbBtn.onclick = () => toggleDrawer('mb'); + const ckBtn = el('button', 'pbtn wide', 'PARK CHECK'); + ckBtn.onclick = () => toggleDrawer('check'); + pans.append(mbBtn, ckBtn); + } + + ed.on('tool', () => { + for (const b of palette.querySelectorAll('.pbtn[data-tool]')) { + const t = b.dataset.tool; + const active = ed.tool && + (t === 'place:' + (ed.tool.kind || '') || t === ed.tool.mode); + b.classList.toggle('active', !!active); + } + setStatus(ed.tool ? 'click the ground to place — ESC to cancel' + : 'click to select · drag to move · rightdrag orbit · [ ] rotate · D dupe · T top'); + }); + + // ---------------------------------------------------------------- inspector + function row(label, input) { + const r = el('div', 'irow'); + r.append(el('label', null, label), input); + return r; + } + function numRow(obj, key, label, min, max, step, onInput) { + const wrap = el('div', 'numwrap'); + const slider = el('input'); slider.type = 'range'; + slider.min = min; slider.max = max; slider.step = step; slider.value = obj[key] ?? 0; + const num = el('input', 'num'); num.type = 'number'; + num.step = step; num.value = obj[key] ?? 0; + const set = (v, done) => { + v = +v; if (Number.isNaN(v)) return; + slider.value = v; num.value = v; + onInput(v, done); + }; + slider.oninput = () => set(slider.value, false); + slider.onchange = () => set(slider.value, true); + num.onchange = () => set(num.value, true); + wrap.append(slider, num); + return row(label, wrap); + } + + function buildInspector() { + inspector.innerHTML = ''; + const s = ed.selection, obj = ed.getSelected?.(); + if (s?.type === 'underlay') return buildUnderlayInspector(); + if (!s || !obj) { + inspector.append(el('div', 'ihint', + 'nothing selected

· click a piece to edit it
· palette → click ground to add' + + '
· ENTER to test ride
· ctrl-Z undo')); + return buildParkInspector(); + } + const title = s.type === 'element' ? ELEMENT_KINDS[obj.kind].label : s.type.toUpperCase(); + inspector.append(el('div', 'ititle', title)); + const upd = key => (v, done) => ed.updateSelected(key, v, { done }); + + if (s.type === 'element') { + for (const [key, label, min, max, step] of COMMON_XZ) + inspector.append(numRow(obj, key, label, min, max, step, upd(key))); + inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5, + (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))); + } else if (s.type === 'rail') { + const name = el('input', 'text'); name.value = obj.name || ''; + name.onchange = () => ed.updateSelected('name', name.value, { done: true }); + inspector.append(row('name', name)); + const kind = el('select'); + kind.innerHTML = ''; + kind.value = obj.kind; + kind.onchange = () => ed.updateSelected('kind', kind.value, { done: true }); + inspector.append(row('type', kind)); + inspector.append(numRow(obj, 'ya', 'height a', 0, 4, 0.01, upd('ya'))); + inspector.append(numRow(obj, 'yb', 'height b', 0, 4, 0.01, upd('yb'))); + inspector.append(el('div', 'ihint', 'drag the yellow end-balls to move the ends')); + } else if (s.type === 'gap') { + const name = el('input', 'text'); name.value = obj.name; + name.onchange = () => ed.updateSelected('name', name.value, { done: true }); + inspector.append(row('name', name)); + for (const [key, label, min, max, step] of COMMON_XZ) + inspector.append(numRow(obj, key, label, min, max, step, upd(key))); + inspector.append(numRow(obj, 'r', 'radius', 0.5, 8, 0.1, upd('r'))); + } else if (s.type === 'letter') { + const ch = el('input', 'text'); ch.maxLength = 1; ch.value = obj.ch; + ch.onchange = () => ed.updateSelected('ch', (ch.value || 'O').toUpperCase(), { done: true }); + inspector.append(row('letter', ch)); + inspector.append(numRow(obj, 'y', 'float height', 0.5, 6, 0.1, upd('y'))); + } else if (s.type === 'spawn') { + inspector.append(numRow({ heading: (obj.heading || 0) / DEG }, 'heading', 'heading°', -180, 180, 5, + (v, done) => ed.updateSelected('heading', +(v * DEG).toFixed(3), { done }))); + } else if (s.type === 'prop') { + inspector.append(el('div', 'ihint', obj.src)); + inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5, + (v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done }))); + inspector.append(numRow(obj, 'scale', 'scale', 0.05, 8, 0.05, upd('scale'))); + inspector.append(numRow({ y: obj.y || 0 }, 'y', 'lift', -2, 6, 0.05, upd('y'))); + } else if (s.type === 'zone') { + const shape = el('select'); + shape.innerHTML = ''; + shape.value = obj.shape; + shape.onchange = () => ed.updateSelected('shape', shape.value, { done: true }); + inspector.append(row('shape', shape)); + const color = el('input'); color.type = 'color'; color.value = obj.color; + color.oninput = () => ed.updateSelected('color', color.value, {}); + color.onchange = () => ed.updateSelected('color', color.value, { done: true }); + inspector.append(row('color', color)); + inspector.append(numRow(obj, 'hw', 'half width', 0.3, 40, 0.1, upd('hw'))); + inspector.append(numRow(obj, 'hd', 'half depth', 0.3, 40, 0.1, upd('hd'))); + inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5, + (v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done }))); + inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity'))); + } else if (s.type === 'decal') { + inspector.append(numRow(obj, 'w', 'width', 0.5, 60, 0.1, (v, done) => { + const asp = obj.h / obj.w; ed.updateSelected('w', v, {}); + ed.updateSelected('h', +(v * asp).toFixed(2), { done }); + })); + inspector.append(numRow(obj, 'h', 'height', 0.5, 60, 0.1, upd('h'))); + inspector.append(numRow({ rot: (obj.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 5, + (v, done) => ed.updateSelected('rot', +(v * DEG).toFixed(4), { done }))); + inspector.append(numRow({ opacity: obj.opacity ?? 1 }, 'opacity', 'opacity', 0, 1, 0.05, upd('opacity'))); + } + if (s.type !== 'spawn') { + const btns = el('div', 'ibtns'); + const dup = el('button', 'pbtn', 'DUPE (D)'); dup.onclick = () => ed.duplicateSelected(); + const del = el('button', 'pbtn danger', 'DELETE'); del.onclick = () => ed.deleteSelected(); + btns.append(dup, del); inspector.append(btns); + } + } + + function buildUnderlayInspector() { + const u = ed.park.underlay; + inspector.append(el('div', 'ititle', 'REFERENCE UNDERLAY')); + if (!u) { inspector.append(el('div', 'ihint', 'none loaded')); return; } + const updU = key => (v, done) => { u[key] = v; if (done !== false) ed.commit({ underlay: true, snapshot: done }); }; + inspector.append(numRow(u, 'opacity', 'opacity', 0, 1, 0.05, updU('opacity'))); + inspector.append(numRow(u, 'w', 'width (m)', 10, 300, 1, updU('w'))); + inspector.append(numRow(u, 'x', 'x', -80, 80, 0.5, updU('x'))); + inspector.append(numRow(u, 'z', 'z', -60, 60, 0.5, updU('z'))); + inspector.append(numRow(u, 'y', 'hover', 0.1, 20, 0.1, updU('y'))); + inspector.append(numRow({ rot: (u.rot || 0) / DEG }, 'rot', 'rotation°', -180, 180, 1, + (v, done) => { u.rot = v * DEG; ed.commit({ underlay: true, snapshot: done }); })); + const vis = el('button', 'pbtn wide', u.visible ? 'HIDE' : 'SHOW'); + vis.onclick = () => { u.visible = !u.visible; ed.commit({ underlay: true }); buildInspector(); }; + const kill = el('button', 'pbtn danger wide', 'REMOVE'); + kill.onclick = () => { delete ed.park.underlay; ed.commit({ underlay: true }); ed.select(null); }; + inspector.append(vis, kill); + } + + function buildParkInspector() { + inspector.append(el('div', 'ititle', 'PARK')); + const cols = [['slabColor', 'slab'], ['grassColor', 'grass']]; + for (const [key, label] of cols) { + const c = el('input'); c.type = 'color'; c.value = ed.park[key]; + c.onchange = () => { ed.park[key] = c.value; ed.commit({ tex: true }); }; + inspector.append(row(label, c)); + } + const B = ed.park.bounds; + for (const [key, label, min, max] of [['x0', 'west edge', -120, -5], ['x1', 'east edge', 5, 120], + ['z0', 'north edge', -120, -5], ['z1', 'south edge', 5, 120]]) + inspector.append(numRow(B, key, label, min, max, 1, (v, done) => { + B[key] = v; if (done) ed.commit({ ground: true }); + })); + } + ed.on('select', buildInspector); + ed.on('change', () => { if (!ed.selection) buildInspector(); }); + + // ---------------------------------------------------------------- drawer (MB / check) + let drawerMode = null; + function toggleDrawer(mode) { + drawerMode = drawerMode === mode ? null : mode; + drawer.style.display = drawerMode ? 'flex' : 'none'; + if (drawerMode === 'mb') buildMB(); + if (drawerMode === 'check') buildCheck(); + } + + function buildCheck() { + drawer.innerHTML = '
PARK CHECK — flow · tiers · steel
'; + const list = el('div', 'checklist'); drawer.append(list); + const results = checkPark(ed.park); + if (!results.length) { + list.append(el('div', 'checkok', '✓ park reads clean — flow, tiers and steel all check out')); + return; + } + for (const r of results) { + const item = el('div', 'checkitem', '⚠ ' + r.msg); + if (r.sel) { + item.classList.add('link'); + item.onclick = () => ed.select(r.sel); + } + list.append(item); + } + const re = el('button', 'pbtn wide', 'RE-CHECK'); + re.onclick = buildCheck; drawer.append(re); + } + + // ---------------------------------------------------------------- MODELBEAST + async function buildMB() { + drawer.innerHTML = '
MODELBEAST — farm assets & gen
'; + const gen = el('div', 'mbgen'); + const prompt = el('input', 'text'); prompt.placeholder = 'prompt… e.g. "graffiti tag, spray paint, transparent bg"'; + const genImg = el('button', 'pbtn accent', 'GEN IMAGE'); + const genStatus = el('div', 'mbstatus'); + gen.append(prompt, genImg, genStatus); drawer.append(gen); + genImg.onclick = async () => { + if (!prompt.value.trim()) return; + genImg.disabled = true; + try { + await MB.generateImage(prompt.value.trim(), s => { genStatus.textContent = s; }); + genStatus.textContent = 'done — refreshing list'; + await refresh(); + } catch (e) { genStatus.textContent = 'error: ' + e.message; } + genImg.disabled = false; + }; + + const list = el('div', 'mblist', 'loading farm assets…'); drawer.append(list); + async function refresh() { + let assets; + try { assets = await MB.listAssets(); } + catch (e) { list.innerHTML = `
farm unreachable: ${e.message}
`; return; } + list.innerHTML = ''; + for (const a of assets.slice(0, 40)) { + const name = a.name || a.filename || a.id; + const isGlb = /\.glb$/i.test(name), isImg = /\.(png|jpe?g|webp)$/i.test(name); + if (!isGlb && !isImg) continue; + const item = el('div', 'mbitem'); + item.append(el('span', 'mbname', `${isGlb ? '◆' : '▣'} ${name}`)); + if (isGlb) { + const b = el('button', 'pbtn', 'PLACE'); + b.onclick = async () => { + b.textContent = '…'; + const { path } = await MB.importAsset(a.id, name); + ed.setTool({ mode: 'prop', src: path, scale: 1 }); + setStatus('model fetched — click the ground to place it'); + b.textContent = 'PLACE'; + }; + item.append(b); + } + if (isImg) { + const d = el('button', 'pbtn', 'DECAL'); + d.onclick = async () => { + d.textContent = '…'; + const { path } = await MB.importAsset(a.id, name); + const img = new Image(); + img.onload = () => { + ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width }); + setStatus('image fetched — click the ground to place the decal'); + }; + img.src = path; + d.textContent = 'DECAL'; + }; + const m3 = el('button', 'pbtn', 'MAKE 3D'); + m3.onclick = async () => { + m3.disabled = true; + try { + await MB.imageTo3D(a.id, s => { genStatus.textContent = s; }); + genStatus.textContent = 'mesh done — hit REFRESH and PLACE it'; + } catch (e) { genStatus.textContent = 'error: ' + e.message; } + m3.disabled = false; + }; + const bg = el('button', 'pbtn', 'CUT BG'); + bg.onclick = async () => { + bg.disabled = true; + try { + await MB.removeBg(a.id, s => { genStatus.textContent = s; }); + genStatus.textContent = 'bg cut — hit REFRESH for the clean version'; + } catch (e) { genStatus.textContent = 'error: ' + e.message; } + bg.disabled = false; + }; + item.append(d, bg, m3); + } + list.append(item); + } + if (!list.children.length) list.append(el('div', 'ihint', 'no glb/img assets on the farm yet')); + } + const rf = el('button', 'pbtn wide', 'REFRESH'); + rf.onclick = refresh; drawer.append(rf); + refresh(); + } + + // ---------------------------------------------------------------- topbar + function buildTopbar() { + const bar = $('#topbar'); + bar.innerHTML = ''; + bar.append(el('span', 'logo', 'SKATEMAKERPRO')); + const name = el('input', 'text parkname'); + name.value = ed.park?.name || 'NEW PARK'; + name.onchange = () => { ed.park.name = name.value; ed.commit({}); }; + bar.append(name); + ed.on('change', () => { if (document.activeElement !== name) name.value = ed.park.name; }); + + const mk = (label, fn, cls = '') => { + const b = el('button', 'pbtn ' + cls, label); b.onclick = fn; bar.append(b); return b; + }; + mk('NEW', () => { if (confirm('start a fresh park? (autosave is overwritten)')) ed.newPark('NEW PARK'); }); + mk('SAVE', async () => { + const n = ed.park.name.replace(/[^\w\- ]+/g, '').trim() || 'park'; + await fetch('/api/parks/' + encodeURIComponent(n), { method: 'POST', body: JSON.stringify(ed.park, null, 1) }); + setStatus(`saved parks/${n}.json`); + }); + const loadSel = el('select'); loadSel.innerHTML = ''; + loadSel.onfocus = async () => { + const names = await fetch('/api/parks').then(r => r.json()); + loadSel.innerHTML = '' + + names.map(n => ``).join(''); + }; + loadSel.onchange = async () => { + if (!loadSel.value) return; + ed.loadPark(await fetch('/api/parks/' + encodeURIComponent(loadSel.value)).then(r => r.json())); + loadSel.value = ''; + }; + bar.append(loadSel); + mk('⬇ JSON', () => downloadParkJSON(ed.park)); + mk('⬇ LEVEL.JS', () => downloadLevelJS(ed.park)); + mk('TOP', () => ed.topView()); + const test = mk('▶ TEST RIDE', () => ed.toggleTest?.(), 'accent test'); + ed.on('test', on => { test.textContent = on ? '■ STOP (Enter)' : '▶ TEST RIDE'; }); + } + + // ---------------------------------------------------------------- file helpers + function pickFile(accept, cb) { + const inp = el('input'); inp.type = 'file'; inp.accept = accept; + inp.onchange = () => { if (inp.files[0]) cb(inp.files[0]); }; + inp.click(); + } + async function uploadFile(f) { + const name = Date.now().toString(36) + '_' + f.name.replace(/[^\w.\-]+/g, '_'); + const r = await fetch('/api/upload?name=' + encodeURIComponent(name), + { method: 'POST', body: await f.arrayBuffer() }); + const j = await r.json(); + if (j.error) throw new Error(j.error); + return j.path; + } + + // drag & drop anywhere: .glb -> prop, image -> decal + addEventListener('dragover', e => e.preventDefault()); + addEventListener('drop', async e => { + e.preventDefault(); + const f = e.dataTransfer.files?.[0]; + if (!f) return; + const path = await uploadFile(f); + if (/\.glb$/i.test(f.name)) { + ed.setTool({ mode: 'prop', src: path, scale: 1 }); + setStatus('model uploaded — click the ground to place it'); + } else if (/^image\//.test(f.type)) { + const img = new Image(); + img.onload = () => { + ed.setTool({ mode: 'decal', img: path, w: 6, aspect: img.height / img.width }); + setStatus('image uploaded — click the ground to place the decal'); + }; + img.src = path; + } + }); + + buildPalette(); buildTopbar(); buildInspector(); + ed.emit('tool'); +} diff --git a/parks/paddo.json b/parks/paddo.json new file mode 100644 index 0000000..52ea48a --- /dev/null +++ b/parks/paddo.json @@ -0,0 +1,75 @@ +{ + "name": "PADDO", + "bounds": { "x0": -40, "x1": 40, "z0": -26, "z1": 26 }, + "slabColor": "#7fae6f", + "grassColor": "#4d7c3c", + "spawn": { "x": -32, "z": -18, "heading": 0.85 }, + "elements": [ + { "id": "wall_w", "kind": "quarter", "x": -40, "z": 0, "rot": -1.5708, "len": 52, "r0": 0.9, "r1": 1.8, "lump": 0, "deck": 0 }, + { "id": "wall_n", "kind": "quarter", "x": 0, "z": -26, "rot": 0, "len": 80, "r0": 0.5, "r1": 1.1, "lump": 0, "deck": 0 }, + { "id": "lumpy_s", "kind": "quarter", "x": 1, "z": 26, "rot": 3.1416, "len": 34, "r0": 1.8, "r1": 1.8, "lump": 0.12, "deck": 0 }, + { "id": "bank_900", "kind": "bank", "x": -21, "z": 26, "rot": 3.1416, "len": 10, "h": 0.9, "run": 2.4, "deck": 0 }, + { "id": "bank_ne", "kind": "bank", "x": 27, "z": -26, "rot": 0, "len": 14, "h": 0.8, "run": 2.2, "deck": 0 }, + { "id": "bowl_w", "kind": "bowl", "x": -27, "z": -6, "rot": 0, "size": 4.2, "depth": 1.5 }, + { "id": "volcano", "kind": "mogul", "x": -18, "z": 4, "h": 0.7, "s": 2.6 }, + { "id": "mogul_1", "kind": "mogul", "x": -2, "z": 6, "h": 0.45, "s": 2.2 }, + { "id": "mogul_rb", "kind": "mogul", "x": 2, "z": 9, "h": 0.8, "s": 2.5 }, + { "id": "spine_1", "kind": "spine", "x": -8, "z": 12, "rot": 0, "len": 8, "r": 1.2 }, + { "id": "island", "kind": "dome", "x": 2, "z": -2, "rot": 0, "rx": 7, "rz": 4.5, "h": 0.14 }, + { "id": "funbox_c", "kind": "funbox", "x": 16, "z": -2, "rot": 0, "hw": 2.5, "hd": 1.2, "h": 0.9, "skirt": 1.6 }, + { "id": "ledge_a", "kind": "ledge", "x": 8.5, "z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.6 }, + { "id": "ledge_b", "kind": "ledge", "x": 11.5,"z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.45 }, + { "id": "ledge_c", "kind": "ledge", "x": 14.5,"z": -11, "rot": 0, "hw": 1.5, "hd": 0.8, "h": 0.3 }, + { "id": "manny", "kind": "funbox", "x": 26, "z": -14, "rot": 0, "hw": 2.2, "hd": 1.2, "h": 0.35, "skirt": 1.1 }, + { "id": "long_ldg", "kind": "ledge", "x": 33.5,"z": -2, "rot": 0, "hw": 0.5, "hd": 12, "h": 0.42 }, + { "id": "stairs_1", "kind": "stairs", "x": 6, "z": 12.2,"rot": 0, "width": 4.8, "h": 0.6, "steps": 4, "going": 0.6, "platDepth": 3.2 } + ], + "rails": [ + { "id": "r1", "name": "Funbox Flat Rail", "a": [13.5, -2.9], "b": [18.5, -2.9], "ya": 1.25, "yb": 1.25, "kind": "rail" }, + { "id": "r2", "name": "Funbox Down Rail", "a": [18.5, -1.0], "b": [21.8, -1.0], "ya": 1.2, "yb": 0.35, "kind": "rail" }, + { "id": "r3", "name": "P-Rail", "a": [22.0, 8.0], "b": [26.0, 8.0], "ya": 0.35, "yb": 0.35, "kind": "rail" }, + { "id": "r4", "name": "Rainbow Rail", "a": [-0.4, 9.0], "b": [2.0, 9.0], "ya": 0.45, "yb": 1.35, "kind": "rail" }, + { "id": "r5", "name": "Rainbow Rail", "a": [2.0, 9.0], "b": [4.4, 9.0], "ya": 1.35, "yb": 0.45, "kind": "rail" }, + { "id": "r6", "name": "Long Metal Ledge", "a": [33.0, -13.5], "b": [33.0, 9.5], "ya": 0.42, "yb": 0.42, "kind": "ledge" }, + { "id": "r7", "name": "Down Ledge", "a": [7.0, -11.9], "b": [10.0, -11.9],"ya": 0.6, "yb": 0.6, "kind": "ledge" }, + { "id": "r8", "name": "Down Ledge", "a": [10.0, -11.9], "b": [13.0, -11.9],"ya": 0.45, "yb": 0.45, "kind": "ledge" }, + { "id": "r9", "name": "Down Ledge", "a": [13.0, -11.9], "b": [16.0, -11.9],"ya": 0.3, "yb": 0.3, "kind": "ledge" }, + { "id": "r10", "name": "Stair Hubba", "a": [3.4, 12.2], "b": [3.4, 15.0], "ya": 0.62, "yb": 0.06, "kind": "ledge" }, + { "id": "r11", "name": "Stair Hubba", "a": [8.6, 12.2], "b": [8.6, 15.0], "ya": 0.62, "yb": 0.06, "kind": "ledge" } + ], + "gaps": [ + { "id": "g1", "name": "THE STAIR GAP", "x": 6, "z": 13.6, "r": 2.4 }, + { "id": "g2", "name": "FUNBOX FLY", "x": 16, "z": -2, "r": 2.8 }, + { "id": "g3", "name": "SPINE TRANSFER", "x": -8, "z": 12, "r": 2.4 }, + { "id": "g4", "name": "BOWL HIP", "x": -22.5, "z": -2, "r": 2.2 }, + { "id": "g5", "name": "RAINBOW ARC", "x": 2, "z": 9, "r": 2.2 }, + { "id": "g6", "name": "LEDGE LEAP", "x": 11.5, "z": -11, "r": 2.6 }, + { "id": "g7", "name": "KEYHOLE GAP", "x": -36, "z": -14, "r": 2.6 }, + { "id": "g8", "name": "CAXTON CARVE", "x": 10, "z": -22.5, "r": 2.6 }, + { "id": "g9", "name": "ISLAND HOP", "x": 2, "z": 2.6, "r": 2.2 } + ], + "letters": [ + { "id": "L1", "ch": "B", "x": -27, "y": 0.9, "z": -6 }, + { "id": "L2", "ch": "O", "x": -8, "y": 2.3, "z": 12 }, + { "id": "L3", "ch": "O", "x": 16, "y": 2.4, "z": -2 }, + { "id": "L4", "ch": "K", "x": 6, "y": 1.6, "z": 14.2 }, + { "id": "L5", "ch": "Q", "x": -35, "y": 2.2, "z": -13 }, + { "id": "L6", "ch": "U", "x": 2, "y": 2.5, "z": 9 }, + { "id": "L7", "ch": "O", "x": 33, "y": 1.6, "z": -2 }, + { "id": "L8", "ch": "Y", "x": 24, "y": 1.5, "z": 8 } + ], + "zones": [ + { "id": "z1", "shape": "rect", "x": 23, "z": 0, "rot": 0, "hw": 17, "hd": 26, "color": "#9aa0a2" }, + { "id": "z2", "shape": "ellipse", "x": 2, "z": -2, "rot": 0, "hw": 7, "hd": 4.5, "color": "#557f3f" }, + { "id": "z3", "shape": "rect", "x": -27, "z": -6, "rot": 0, "hw": 4.2, "hd": 4.2, "color": "#6a7d70" } + ], + "decals": [], + "props": [ + { "id": "p1", "src": "builtin:fig", "x": 0, "z": -3, "rot": 0, "scale": 1.25 }, + { "id": "p2", "src": "builtin:fig", "x": 4.5, "z": -1, "rot": 0, "scale": 1.0 }, + { "id": "p3", "src": "builtin:fig", "x": -1, "z": 1, "rot": 0, "scale": 0.9 }, + { "id": "p4", "src": "builtin:fig", "x": -34, "z": 16, "rot": 0, "scale": 1.1 }, + { "id": "p5", "src": "builtin:fig", "x": 30, "z": 20, "rot": 0, "scale": 1.3 }, + { "id": "p6", "src": "builtin:fig", "x": -14, "z": -30, "rot": 0, "scale": 1.2 } + ] +} diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..e7481f3 --- /dev/null +++ b/serve.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""SKATEMAKER PRO dev server. + +Static files + three jobs the browser can't do alone: + /api/parks GET list | /api/parks/ GET load, POST save (JSON body) + /api/upload?name= POST raw bytes -> assets/uploads/, returns {"path": ...} + /mb/... MODELBEAST proxy (queue on the m3ultra, token from + ~/Documents/backnforth/.env — stays server-side, never in JS) + /mb/assets GET -> asset list + /mb/assets//file GET -> file bytes (same-origin for GLTFLoader) + /mb/jobs POST -> submit {operator, asset_id?, params} + /mb/jobs/ GET -> poll (control-char-safe) + /mb/import?asset=&name= GET -> download MB asset into assets/uploads/, return path + +Run: python3 serve.py [port] (default 8093) +""" +import json, os, re, sys, urllib.parse, urllib.request +from http.server import HTTPServer, SimpleHTTPRequestHandler + +ROOT = os.path.dirname(os.path.abspath(__file__)) +PARKS = os.path.join(ROOT, 'parks') +UPLOADS = os.path.join(ROOT, 'assets', 'uploads') +MB_HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777') +SAFE = re.compile(r'^[\w.\- ]+$') + + +def mb_token(): + t = os.environ.get('MB_TOKEN') + if t: + return t + try: + for line in open(os.path.expanduser('~/Documents/backnforth/.env')): + if line.startswith('MB_TOKEN='): + return line.split('=', 1)[1].strip() + except OSError: + pass + return None + + +def mb_req(path, data=None, ctype=None, timeout=180): + tok = mb_token() + if not tok: + raise RuntimeError('MB_TOKEN not found') + h = {'Authorization': 'Bearer ' + tok} + if ctype: + h['Content-Type'] = ctype + r = urllib.request.Request(MB_HOST + path, data=data, headers=h) + return urllib.request.urlopen(r, timeout=timeout).read() + + +def clean_json(b): # MB job logs carry raw control chars + s = b.decode('utf-8', 'replace') + return ''.join(c if c >= ' ' or c in '\t\n' else ' ' for c in s).encode() + + +class H(SimpleHTTPRequestHandler): + def _send(self, code, body, ctype='application/json'): + self.send_response(code) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store') + self.end_headers() + self.wfile.write(body) + + def _json(self, obj, code=200): + self._send(code, json.dumps(obj).encode()) + + def _err(self, msg, code=500): + self._json({'error': str(msg)}, code) + + def do_GET(self): + path, _, query = self.path.partition('?') + q = dict(p.split('=', 1) for p in query.split('&') if '=' in p) + try: + if path == '/api/parks': + names = sorted(f[:-5] for f in os.listdir(PARKS) if f.endswith('.json')) + return self._json(names) + if path.startswith('/api/parks/'): + name = urllib.parse.unquote(path.split('/', 3)[3]) + if not SAFE.match(name): + return self._err('bad name', 400) + f = os.path.join(PARKS, name + '.json') + if not os.path.exists(f): + return self._err('not found', 404) + return self._send(200, open(f, 'rb').read()) + if path == '/mb/assets': + return self._send(200, clean_json(mb_req('/api/assets?limit=100'))) + m = re.match(r'^/mb/assets/([\w-]+)/file$', path) + if m: + return self._send(200, mb_req(f'/api/assets/{m.group(1)}/file'), + 'application/octet-stream') + m = re.match(r'^/mb/jobs/([\w-]+)$', path) + if m: + return self._send(200, clean_json(mb_req(f'/api/jobs/{m.group(1)}'))) + if path == '/mb/import': + aid, name = q.get('asset', ''), urllib.parse.unquote(q.get('name', 'asset.bin')) + if not re.match(r'^[\w-]+$', aid) or not SAFE.match(name): + return self._err('bad args', 400) + os.makedirs(UPLOADS, exist_ok=True) + data = mb_req(f'/api/assets/{aid}/file') + out = os.path.join(UPLOADS, name) + open(out, 'wb').write(data) + return self._json({'path': 'assets/uploads/' + name, 'bytes': len(data)}) + except Exception as e: + return self._err(e) + return super().do_GET() + + def do_POST(self): + path, _, query = self.path.partition('?') + q = dict(p.split('=', 1) for p in query.split('&') if '=' in p) + n = int(self.headers.get('Content-Length') or 0) + body = self.rfile.read(n) if n else b'' + try: + if path.startswith('/api/parks/'): + name = urllib.parse.unquote(path.split('/', 3)[3]) + if not SAFE.match(name): + return self._err('bad name', 400) + json.loads(body) # must be valid JSON + os.makedirs(PARKS, exist_ok=True) + open(os.path.join(PARKS, name + '.json'), 'wb').write(body) + return self._json({'saved': name}) + if path == '/api/upload': + name = urllib.parse.unquote(q.get('name', '')) + if not SAFE.match(name): + return self._err('bad name', 400) + os.makedirs(UPLOADS, exist_ok=True) + open(os.path.join(UPLOADS, name), 'wb').write(body) + return self._json({'path': 'assets/uploads/' + name, 'bytes': len(body)}) + if path == '/mb/jobs': + return self._send(200, clean_json( + mb_req('/api/jobs', data=body, ctype='application/json'))) + if path == '/mb/upload': # raw bytes -> MB multipart asset + name = urllib.parse.unquote(q.get('name', 'upload.png')) + if not SAFE.match(name): + return self._err('bad name', 400) + bound = 'smpb0undary' + ctype = q.get('type', 'application/octet-stream') + mp = (f'--{bound}\r\nContent-Disposition: form-data; name="file"; ' + f'filename="{name}"\r\nContent-Type: {ctype}\r\n\r\n').encode() \ + + body + f'\r\n--{bound}--\r\n'.encode() + return self._send(200, clean_json(mb_req( + '/api/assets', data=mp, + ctype=f'multipart/form-data; boundary={bound}'))) + except Exception as e: + return self._err(e) + return self._err('unknown endpoint', 404) + + def log_message(self, fmt, *a): + if '/mb/jobs/' not in str(a[0] if a else ''): # quiet the poll spam + super().log_message(fmt, *a) + + +if __name__ == '__main__': + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8093 + os.chdir(ROOT) + print(f'SKATEMAKER PRO on http://localhost:{port} (MB proxy -> {MB_HOST})') + HTTPServer(('0.0.0.0', port), H).serve_forever()