diff --git a/README.md b/README.md index a232c18..645fc53 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ persistence (`parks/`, `assets/uploads/`). `+ POINT` for kinks. Round pipe (with posts) or square angle-iron profile; every node is a draggable ball. Exports flatten to straight segments so bookquoy's grind detection needs zero changes. +- **PARK KIT** — the textured object library (`../park_kit`, mounted at `/kit/`): + 52 original GLB props with photoreal MODELBEAST textures. Placing a kit obstacle + brings the whole package as one linked unit: the textured mesh, a matching + heightfield element for **real collision** (quarters anchor on their coping line, + truncated-transition math for real mini-ramp geometry), and its **grindable + edges** as linked rails — move/rotate/delete the prop and everything follows. + Kit surfaces also texture the ground: slab/grass texture selects in the PARK + panel (green concrete, tarmac, dry grass, brick...). - **PROPS** — parametric street furniture: jersey barrier, picnic table, parking block, bench, trash can, hydrant, chain-link fence, floodlight, bleachers, shade sail, fountain, drain grate, fig tree. Make any prop grindable by drawing a ledge rail diff --git a/js/editor.js b/js/editor.js index cb72149..cc8e3be 100644 --- a/js/editor.js +++ b/js/editor.js @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark, - normalizeRail } from './parkmath.js'; + normalizeRail, ELEMENT_KINDS } from './parkmath.js'; import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, GRID_PAD } from './parkbuild.js'; import { makeGrass, parkGrassAreas } from './grass.js'; @@ -274,7 +274,9 @@ export function initEditor(container) { 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; + snapshot(); + if (s.type === 'prop') dropKitLinks(arr[i]); + arr.splice(i, 1); ed.selection = null; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); }; ed.duplicateSelected = () => { @@ -286,6 +288,7 @@ export function initEditor(container) { if (copy.x !== undefined) { copy.x += 2; copy.z += 2; } if (copy.pts) for (const pt of copy.pts) { pt.x += 2; pt.z += 2; } snapshot(); ed.park[lists[s.type]].push(copy); + if (s.type === 'prop' && kitDef(copy)) makeKitLinks(copy); ed.selection = { type: s.type, id: copy.id }; rebuildAll(); autosave(); ed.emit('change'); ed.emit('select'); }; @@ -421,11 +424,64 @@ export function initEditor(container) { } 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); + if (t.src.startsWith('kit:')) makeKitLinks(pr); ed.selection = { type: 'prop', id: pr.id }; - ed.commit({ snapshot: false, props: true }); ed.setTool(null); + ed.commit({ snapshot: false, props: true, rails: true, ground: !!kitDef(pr)?.element }); + ed.setTool(null); } } + // kit props carry their own collision element + grind rails, linked by prop id. + // Local frame follows three's rotation.y: wx = x + lx*cos(th) + lz*sin(th), + // wz = z - lx*sin(th) + lz*cos(th). Elements use rot = -th (see parkmath local()). + const kitDef = pr => pr?.src?.startsWith('kit:') ? ed.kit[pr.src.slice(4)] : null; + function kitWorld(pr, lx, lz) { + const th = pr.rot || 0, c = Math.cos(th), sn = Math.sin(th), sc = pr.scale || 1; + return [pr.x + (lx * c + lz * sn) * sc, pr.z + (-lx * sn + lz * c) * sc]; + } + function syncKitLinks(pr) { + const def = kitDef(pr); if (!def) return; + const sc = pr.scale || 1; + for (const r of ed.park.rails) { + if (r.linkedTo !== pr.id) continue; + const g = def.grinds[r.linkIdx]; + const [ax, az] = kitWorld(pr, g.a[0], g.a[1]); + const [bx, bz] = kitWorld(pr, g.b[0], g.b[1]); + r.pts = [{ x: +ax.toFixed(2), z: +az.toFixed(2), y: +(g.ya * sc).toFixed(2) }, + { x: +bx.toFixed(2), z: +bz.toFixed(2), y: +(g.yb * sc).toFixed(2) }]; + } + const el = ed.park.elements.find(e => e.linkedTo === pr.id); + if (el) { + const d = def.element; + let ax = 0, az = 0; // anchor: quarters sit on their coping line + if (d.kind === 'quarter' && def.grinds?.length) { + ax = (def.grinds[0].a[0] + def.grinds[0].b[0]) / 2; + az = (def.grinds[0].a[1] + def.grinds[0].b[1]) / 2; + } + const [wx, wz] = kitWorld(pr, ax, az); + el.x = +wx.toFixed(2); el.z = +wz.toFixed(2); el.rot = -(pr.rot || 0); + } + } + function makeKitLinks(pr) { + const def = kitDef(pr); if (!def) return; + (def.grinds || []).forEach((g, i) => { + ed.park.rails.push({ id: newId(), name: def.desc || def.id, kind: g.kind || 'ledge', + profile: 'round', linkedTo: pr.id, linkIdx: i, pts: [] }); + }); + if (def.element) { + const e = { id: newId(), linkedTo: pr.id, rot: 0, x: 0, z: 0, + ...structuredClone(ELEMENT_KINDS[def.element.kind].defaults), + ...structuredClone(def.element) }; + ed.park.elements.push(e); + } + syncKitLinks(pr); + } + function dropKitLinks(pr) { + ed.park.rails = ed.park.rails.filter(r => r.linkedTo !== pr.id); + ed.park.elements = ed.park.elements.filter(e => e.linkedTo !== pr.id); + } + ed.kitDef = kitDef; + // ---------------------------------------------------------------- drag let drag = null; // {kind:'move'|'railEnd', grabDX, grabDZ, end, moved} renderer.domElement.addEventListener('pointerdown', ev => { @@ -472,7 +528,10 @@ export function initEditor(container) { } 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 === 'prop') { + rebuildProps(); + if (kitDef(obj)) { syncKitLinks(obj); requestGround(); rebuildRails(); } + } if (s.type === 'letter' || s.type === 'gap' || s.type === 'spawn') rebuildMarkers(); rebuildSelection(); ed.emit('select'); // live-update inspector numbers @@ -483,6 +542,7 @@ export function initEditor(container) { drag = null; controls.enabled = true; if (!wasMoved) { undoStack.pop(); return; } // click, no move — drop the snapshot if (s?.type === 'element') rebuildGround(); + if (s?.type === 'prop' && kitDef(getSelected())) rebuildGround(); ed.commit({ snapshot: false, rails: s?.type === 'rail' }); }; renderer.domElement.addEventListener('pointerup', endDrag); @@ -496,7 +556,10 @@ export function initEditor(container) { if (t === 'element') { requestGround(); if (obj.kind === 'bowlpoly') rebuildRails(); } if (t === 'zone' || t === 'decal') rebakeTexture(); if (t === 'rail') rebuildRails(); - if (t === 'prop') rebuildProps(); + if (t === 'prop') { + rebuildProps(); + if (kitDef(obj)) { syncKitLinks(obj); requestGround(); rebuildRails(); } + } rebuildMarkers(); rebuildSelection(); if (opts.done) { // slider released / field committed if (t === 'element') rebuildGround(); @@ -563,6 +626,11 @@ export function initEditor(container) { ed.boot = async () => { resize(); + try { + const m = await fetch('/kit/manifest.json').then(r => r.json()); + ed.kit = Object.fromEntries(m.props.map(p => [p.id, p])); + ed.kitList = m.props; + } catch { ed.kit = {}; ed.kitList = []; } let loaded = null; try { loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)); } catch {} if (!loaded) { diff --git a/js/parkbuild.js b/js/parkbuild.js index c5dc473..a4b491b 100644 --- a/js/parkbuild.js +++ b/js/parkbuild.js @@ -7,6 +7,9 @@ import { makeBuiltinProp } from './props3d.js'; import { makeGrass, parkGrassAreas } from './grass.js'; export const GRID_RES = 0.5, GRID_PAD = 14; +// where the park_kit mount lives; games can repoint it (e.g. '../park_kit/') +export let KIT_BASE = '/kit/'; +export const setKitBase = u => { KIT_BASE = u; }; // ---------------------------------------------------------------- ground texture // Zones + image decals bake into one canvas mapped planar over the grid. @@ -20,12 +23,34 @@ export function bakeGroundTexture(park, x0, x1, z0, z1) { 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 TILE_M = 3; // metres per texture tile + const paint = (fill, x, z, w, h) => { g.fillStyle = fill; g.fillRect(x, z, w, h); }; + const surfFill = (img, fallback) => { + if (!img) return fallback; + const pat = g.createPattern(img, 'repeat'); + const m = new DOMMatrix().scale(TILE_M * sx / img.width, TILE_M * sz / img.height); + pat.setTransform(m); + return pat; + }; + const drawBase = (imgs = {}) => { + paint(surfFill(imgs.grass, park.grassColor || '#4d7c3c'), 0, 0, cv.width, cv.height); + paint(surfFill(imgs.slab, park.slabColor || '#7fae6f'), + bx, bz, (B.x1 - B.x0) * sx, (B.z1 - B.z0) * sz); + }; + drawBase(); + const surf = park.surf || {}; + const surfJobs = []; + for (const key of ['slab', 'grass']) { + if (!surf[key]) continue; + const img = new Image(); + surfJobs.push(new Promise(res => { + img.onload = () => res([key, img]); + img.onerror = () => res(null); + img.src = KIT_BASE + 'textures/' + surf[key] + '.jpg'; + })); + } const drawShape = zn => { g.save(); @@ -64,7 +89,27 @@ export function bakeGroundTexture(park, x0, x1, z0, z1) { const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; - Promise.all(jobs).then(loaded => { + const drawZonesRims = () => { + for (const zn of park.zones || []) { + drawShape(zn); + g.fillStyle = zn.color || '#9aa0a2'; + g.globalAlpha = zn.opacity ?? 1; + g.fill(); g.restore(); + } + for (const e of park.elements || []) { + if (e.kind !== 'bowlpoly' || !e.tile) continue; + const rim = bowlRimWorld(e).map(([wx, wz]) => px(wx, wz)); + for (const [w, color] of [[0.7 * PX, '#7fb4c9'], [0.22 * PX, '#e8e4d8']]) { + g.beginPath(); + rim.forEach(([cx, cz], i) => i ? g.lineTo(cx, cz) : g.moveTo(cx, cz)); + g.closePath(); + g.strokeStyle = color; g.lineWidth = w; g.lineJoin = 'round'; g.stroke(); + } + } + }; + Promise.all([Promise.all(surfJobs), Promise.all(jobs)]).then(([surfs, loaded]) => { + const imgs = Object.fromEntries(surfs.filter(Boolean)); + if (imgs.slab || imgs.grass) { drawBase(imgs); drawZonesRims(); } for (const hit of loaded) { if (!hit) continue; const { d, img } = hit; @@ -189,8 +234,12 @@ export function buildProps(park, heightAt, onLoaded) { if (pr.src.startsWith('builtin:')) { const obj = makeBuiltinProp(pr.src.slice(8)); if (obj) place(obj); else console.warn('unknown builtin prop', pr.src); - } else loader.load(pr.src, g => place(g.scene), - undefined, () => console.warn('prop failed', pr.src)); + } else { + const url = pr.src.startsWith('kit:') + ? KIT_BASE + 'props/' + pr.src.slice(4) + '.glb' : pr.src; + loader.load(url, g => place(g.scene), + undefined, () => console.warn('prop failed', pr.src)); + } } return grp; } diff --git a/js/parkmath.js b/js/parkmath.js index 7a8f878..93d707c 100644 --- a/js/parkmath.js +++ b/js/parkmath.js @@ -25,7 +25,9 @@ function local(e, x, z) { // world (x,z) -> element-local (u,v) // ---------------------------------------------------------------- element kinds // Each returns a height contribution (concrete = max of all). Bowls carve (override). export const ELEMENT_KINDS = { - quarter: { // straight QP, escalating radius, lump, deck, vert ext + quarter: { // straight QP, escalating radius, lump, deck, vert ext. + // Optional h < r truncates the transition (real mini-ramp geometry: 4ft tall + // on a 6ft radius). Omitted h = full quarter, height r at the wall. label: 'Quarter Pipe', defaults: { len: 8, r0: 1.2, r1: 1.2, lump: 0, deck: 0.6, vert: 0 }, height(e, x, z) { @@ -33,9 +35,11 @@ export const ELEMENT_KINDS = { 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); + const cap = e.h && e.h < r ? e.h : r; if (v < -e.deck) return 0; - if (v <= 0) return r + (e.vert || 0); // deck; vert extension rises the wall face - return tranny(v, r); + if (v <= 0) return cap + (e.vert || 0); // deck; vert extension rises the wall face + const shift = r - Math.sqrt(cap * (2 * r - cap)); // 0 when cap === r + return tranny(v + shift, r); }, }, corner: { // radial quarter wrapped around a point — hips & corners diff --git a/js/ui.js b/js/ui.js index 36cacc8..fce2824 100644 --- a/js/ui.js +++ b/js/ui.js @@ -152,6 +152,9 @@ export function initUI(ed) { palette.append(el('div', 'ptitle', 'PANELS')); const pans = el('div', 'pcol'); palette.append(pans); + const kitBtn = el('button', 'pbtn wide accent', 'PARK KIT'); + kitBtn.onclick = () => toggleDrawer('kit'); + pans.append(kitBtn); const mbBtn = el('button', 'pbtn wide accent', 'MODELBEAST'); mbBtn.onclick = () => toggleDrawer('mb'); const ckBtn = el('button', 'pbtn wide', 'PARK CHECK'); @@ -376,6 +379,20 @@ export function initUI(ed) { c.onchange = () => { ed.park[key] = c.value; ed.commit({ tex: true }); }; inspector.append(row(label, c)); } + const SURFS = ['concrete_smooth', 'concrete_rough', 'concrete_green', 'tarmac', + 'grass_dry', 'dirt_mulch', 'brick_qld', 'wood_slats']; + ed.park.surf ||= {}; + for (const key of ['slab', 'grass']) { + const sel = el('select'); + sel.innerHTML = '' + + SURFS.map(n => ``).join(''); + sel.value = ed.park.surf[key] || ''; + sel.onchange = () => { + if (sel.value) ed.park.surf[key] = sel.value; else delete ed.park.surf[key]; + ed.commit({ tex: true }); + }; + inspector.append(row(key + ' tex', sel)); + } 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]]) @@ -393,6 +410,7 @@ export function initUI(ed) { drawer.style.display = drawerMode ? 'flex' : 'none'; if (drawerMode === 'mb') buildMB(); if (drawerMode === 'check') buildCheck(); + if (drawerMode === 'kit') buildKit(); } function buildCheck() { @@ -415,6 +433,46 @@ export function initUI(ed) { re.onclick = buildCheck; drawer.append(re); } + // ---------------------------------------------------------------- PARK KIT + function buildKit() { + drawer.innerHTML = '