park_kit integration: textured props with linked collision + grinds

serve.py mounts ../park_kit at /kit/. New PARK KIT drawer browses all 52
manifest props (filter + category groups); placing one creates a linked unit:
textured GLB + matching heightfield element (anchored on the coping/grind
line; quarter kind gained an optional h cap for truncated real-mini-ramp
transitions) + grindable edge rails. Move/rotate/scale/delete cascades through
the links. Kit textures also drive the ground: park.surf {slab,grass} pattern-
fills the bake (concrete_green, tarmac, grass_dry...), PARK panel selects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 21:04:31 +10:00
parent a5c8765776
commit 188921278e
6 changed files with 211 additions and 15 deletions

View File

@ -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

View File

@ -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) {

View File

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

View File

@ -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

View File

@ -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 = '<option value="">flat color</option>' +
SURFS.map(n => `<option>${n}</option>`).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 = '<div class="ptitle">PARK KIT <span class="dim">— 52 textured originals</span></div>';
if (!ed.kitList?.length) {
drawer.append(el('div', 'checkitem',
'park_kit not mounted — serve.py expects it at ../park_kit (or set PARK_KIT env)'));
return;
}
const search = el('input', 'text'); search.placeholder = 'filter… (qp, rail, tree)';
drawer.append(search);
const list = el('div', 'mblist'); drawer.append(list);
const CATS = ['obstacle', 'furniture', 'vegetation', 'dressing'];
const render = () => {
const q = search.value.trim().toLowerCase();
list.innerHTML = '';
for (const cat of CATS) {
const items = ed.kitList.filter(p => p.category === cat &&
(!q || p.id.includes(q) || (p.desc || '').toLowerCase().includes(q)));
if (!items.length) continue;
list.append(el('div', 'ptitle', cat.toUpperCase()));
for (const p of items) {
const item = el('div', 'mbitem');
const badges = [p.element ? '⛰' : '', p.grinds?.length ? '≡' : ''].join('');
item.append(el('span', 'mbname', `${p.id} ${badges} <span class="dim">${p.desc || ''}</span>`));
const b = el('button', 'pbtn', 'PLACE');
b.onclick = () => {
ed.setTool({ mode: 'prop', propName: 'kit_' + p.id, src: 'kit:' + p.id, scale: 1 });
setStatus(p.id + ' — click the ground to place it' +
(p.element ? ' (brings real collision)' : '') +
(p.grinds?.length ? ' (grindable)' : ''));
};
item.append(b);
list.append(item);
}
}
};
search.oninput = render;
render();
}
// ---------------------------------------------------------------- MODELBEAST
async function buildMB() {
drawer.innerHTML = '<div class="ptitle">MODELBEAST <span class="dim">— farm assets & gen</span></div>';

View File

@ -20,6 +20,7 @@ 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')
KIT = os.environ.get('PARK_KIT', os.path.join(os.path.dirname(ROOT), 'park_kit'))
MB_HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
SAFE = re.compile(r'^[\w.\- ]+$')
@ -83,6 +84,14 @@ class H(SimpleHTTPRequestHandler):
if not os.path.exists(f):
return self._err('not found', 404)
return self._send(200, open(f, 'rb').read())
if path.startswith('/kit/'): # park_kit mount (props+textures+manifest)
rel = os.path.normpath(path[5:]).lstrip('/')
f = os.path.join(KIT, rel)
if not f.startswith(KIT + os.sep) or not os.path.isfile(f):
return self._err('not found', 404)
import mimetypes
ctype = mimetypes.guess_type(f)[0] or 'application/octet-stream'
return self._send(200, open(f, 'rb').read(), ctype)
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)