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:
parent
a5c8765776
commit
188921278e
@ -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
|
||||
|
||||
78
js/editor.js
78
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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
58
js/ui.js
58
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 = '<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>';
|
||||
|
||||
9
serve.py
9
serve.py
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user