skatemakerpro/serve.py
type-two d68fa84f92 Kit thumbnails + prop colliders
js/kitthumbs.js renders each kit GLB to a 128x96 sprite once (offscreen
renderer, framed by bounding sphere), POSTs to /api/kit-thumb, and all 52
are committed under assets/kit_thumbs/ so every checkout gets instant thumbs;
the generator only runs for props without one (future kit drops). KIT drawer
shows them with category groups and filter.

js/colliders.js derives world-space colliders from kit manifest hints +
a table for builtin props (bins, planters, trunks, fences...). Test ride
resolves circle-vs-box/circle push-out — you scrub and stop instead of
riding through furniture. Exports gain an additive COLLIDERS array for
bookquoy to adopt when its player is ready; existing contract untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:13:36 +10:00

175 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""SKATEMAKER PRO dev server.
Static files + three jobs the browser can't do alone:
/api/parks GET list | /api/parks/<name> GET load, POST save (JSON body)
/api/upload?name= POST raw bytes -> assets/uploads/<name>, 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/<id>/file GET -> file bytes (same-origin for GLTFLoader)
/mb/jobs POST -> submit {operator, asset_id?, params}
/mb/jobs/<id> 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')
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.\- ]+$')
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.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)
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/kit-thumb': # rendered-once kit thumbnails (committed)
name = urllib.parse.unquote(q.get('name', ''))
if not re.match(r'^[\w\-]+\.png$', name):
return self._err('bad name', 400)
d = os.path.join(ROOT, 'assets', 'kit_thumbs')
os.makedirs(d, exist_ok=True)
open(os.path.join(d, name), 'wb').write(body)
return self._json({'path': 'assets/kit_thumbs/' + 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()