Zum Inhalt

scripts/bake-osm-district.mjs

#!/usr/bin/env node
// bake-osm-district.mjs — DEV-ONLY. Never imported by the engine, never bundled.
//
// Fetches a small OpenStreetMap building district via Overpass, projects it to
// local metres, simplifies + quantises the footprints, and writes the compact
// committed data module `src/engine/data/osm-district.json` consumed by the
// `metro` scene (src/engine/engine/scenes/metro.ts).
//
// Why build-time, not runtime: the screensaver must run fully offline on every
// host (web / Windows .scr / macOS), and the Windows web bundle has a hard
// <5 MB budget. So the real-world geometry is baked once, here, and the tiny
// JSON travels inside the bundle like data/story-content.json.
//
// OSM data is © OpenStreetMap contributors, licensed ODbL 1.0. The derived
// geometry we ship carries that attribution in the JSON `meta` block and in
// the scene's boot header; see https://www.openstreetmap.org/copyright.
//
// Usage:
//   node scripts/bake-osm-district.mjs
//   OVERPASS_URL=https://overpass.kumi.systems/api/interpreter node scripts/bake-osm-district.mjs
//
// The district is defined by the constants below so the output is reproducible.

import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/engine/data/osm-district.json');

// ── District definition (Lower Manhattan Financial District — dense, tall,
//    well height-tagged). Change CENTER/HALF_M to re-target; the output is a
//    deterministic function of these + the OSM snapshot. ────────────────────
const CENTER_LAT = 40.7079;
const CENTER_LON = -74.0107;
const HALF_M = 230;                 // half box side in metres (~460 m square)

const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';

// Simplification knobs
const DP_EPSILON = 1.5;             // Douglas–Peucker tolerance, metres
const MAX_VERTS = 12;               // cap ring complexity
const MIN_AREA = 12;                // drop rings smaller than this (m²)
const SCALE = 0.1;                  // quantisation step, metres (decimetres)
const LEVEL_M = 3.2;                // metres per building:level
const MAX_FOOTPRINTS = 320;         // safety cap on output size

// ── Geo helpers ───────────────────────────────────────────────────────────
const R_LAT = 111320;               // metres per degree latitude
const mPerLon = R_LAT * Math.cos((CENTER_LAT * Math.PI) / 180);

function bbox() {
  const dLat = HALF_M / R_LAT;
  const dLon = HALF_M / mPerLon;
  return { s: CENTER_LAT - dLat, w: CENTER_LON - dLon, n: CENTER_LAT + dLat, e: CENTER_LON + dLon };
}

// lon/lat → local metres (equirectangular around the district centre).
// north → -z so the flythrough travels forward along -Z (engine convention).
function project(lat, lon) {
  return { x: (lon - CENTER_LON) * mPerLon, z: -((lat - CENTER_LAT) * R_LAT) };
}

// ── Polygon utilities (flat [x,z,x,z,…] rings) ──────────────────────────────
function signedArea(r) {
  let a = 0;
  for (let i = 0, n = r.length / 2; i < n; i++) {
    const j = (i + 1) % n;
    a += r[i * 2] * r[j * 2 + 1] - r[j * 2] * r[i * 2 + 1];
  }
  return a / 2;
}

function ensureCCW(r) {
  if (signedArea(r) < 0) {
    const out = [];
    for (let i = r.length / 2 - 1; i >= 0; i--) out.push(r[i * 2], r[i * 2 + 1]);
    return out;
  }
  return r;
}

// Drop the trailing duplicate vertex (OSM rings are closed) and any
// near-duplicate / collinear points.
function dedupeAndDecollinear(r) {
  let pts = [];
  for (let i = 0; i < r.length; i += 2) pts.push([r[i], r[i + 1]]);
  if (pts.length > 1) {
    const a = pts[0], b = pts[pts.length - 1];
    if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 0.05) pts.pop();
  }
  // remove near-duplicates
  pts = pts.filter((p, i) => {
    const q = pts[(i + 1) % pts.length];
    return Math.hypot(p[0] - q[0], p[1] - q[1]) > 0.05;
  });
  // remove collinear
  const out = [];
  for (let i = 0; i < pts.length; i++) {
    const a = pts[(i - 1 + pts.length) % pts.length], b = pts[i], c = pts[(i + 1) % pts.length];
    const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
    if (Math.abs(cross) > 0.5) out.push(b[0], b[1]);
  }
  return out.length >= 6 ? out : pts.flat();
}

// Douglas–Peucker on a closed ring: split at the two farthest-apart points,
// simplify each half, recombine.
function perpDist(p, a, b) {
  const dx = b[0] - a[0], dy = b[1] - a[1];
  const len = Math.hypot(dx, dy) || 1e-9;
  return Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / len;
}
function dpOpen(pts, eps) {
  if (pts.length < 3) return pts;
  let maxD = -1, idx = -1;
  const a = pts[0], b = pts[pts.length - 1];
  for (let i = 1; i < pts.length - 1; i++) {
    const d = perpDist(pts[i], a, b);
    if (d > maxD) { maxD = d; idx = i; }
  }
  if (maxD <= eps) return [a, b];
  const left = dpOpen(pts.slice(0, idx + 1), eps);
  const right = dpOpen(pts.slice(idx), eps);
  return left.slice(0, -1).concat(right);
}
function simplifyRing(r, eps) {
  let pts = [];
  for (let i = 0; i < r.length; i += 2) pts.push([r[i], r[i + 1]]);
  if (pts.length < 4) return r;
  // farthest pair as DP anchors
  let iA = 0, iB = 0, best = -1;
  for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) {
    const d = Math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1]);
    if (d > best) { best = d; iA = i; iB = j; }
  }
  const rot = pts.slice(iA).concat(pts.slice(0, iA));
  const k = (iB - iA + pts.length) % pts.length;
  const first = rot.slice(0, k + 1), second = rot.slice(k).concat([rot[0]]);
  const s1 = dpOpen(first, eps), s2 = dpOpen(second, eps);
  const merged = s1.slice(0, -1).concat(s2.slice(0, -1));
  return merged.flat();
}

// Keep the MAX_VERTS highest-deviation vertices.
function capVerts(r, maxV) {
  let pts = [];
  for (let i = 0; i < r.length; i += 2) pts.push([r[i], r[i + 1]]);
  while (pts.length > maxV) {
    let minD = Infinity, minI = -1;
    for (let i = 0; i < pts.length; i++) {
      const a = pts[(i - 1 + pts.length) % pts.length], b = pts[i], c = pts[(i + 1) % pts.length];
      const d = perpDist(b, a, c);
      if (d < minD) { minD = d; minI = i; }
    }
    pts.splice(minI, 1);
  }
  return pts.flat();
}

function segIntersect(p1, p2, p3, p4) {
  const d = (b, a) => [b[0] - a[0], b[1] - a[1]];
  const cross = (u, v) => u[0] * v[1] - u[1] * v[0];
  const r = d(p2, p1), s = d(p4, p3);
  const rxs = cross(r, s);
  if (Math.abs(rxs) < 1e-9) return false;
  const qp = d(p3, p1);
  const t = cross(qp, s) / rxs, u = cross(qp, r) / rxs;
  return t > 1e-6 && t < 1 - 1e-6 && u > 1e-6 && u < 1 - 1e-6;
}
function isSimple(r) {
  const pts = [];
  for (let i = 0; i < r.length; i += 2) pts.push([r[i], r[i + 1]]);
  const n = pts.length;
  for (let i = 0; i < n; i++) {
    const a1 = pts[i], a2 = pts[(i + 1) % n];
    for (let j = i + 1; j < n; j++) {
      if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
      if (segIntersect(a1, a2, pts[j], pts[(j + 1) % n])) return false;
    }
  }
  return true;
}

// ── PCA: principal axis of a point cloud → yaw that aligns it with Z ────────
function principalYaw(allPts) {
  let mx = 0, mz = 0;
  for (const [x, z] of allPts) { mx += x; mz += z; }
  mx /= allPts.length; mz /= allPts.length;
  let sxx = 0, szz = 0, sxz = 0;
  for (const [x, z] of allPts) { const dx = x - mx, dz = z - mz; sxx += dx * dx; szz += dz * dz; sxz += dx * dz; }
  // largest-eigenvector angle of the 2×2 covariance
  const theta = 0.5 * Math.atan2(2 * sxz, sxx - szz);
  // theta is the angle of the principal axis; rotate so it lines up with +Z.
  return { yaw: Math.PI / 2 - theta, cx: mx, cz: mz };
}
function rotate(x, z, yaw) {
  const c = Math.cos(yaw), s = Math.sin(yaw);
  return [x * c - z * s, x * s + z * c];
}

// ── Height from tags ────────────────────────────────────────────────────────
function heightOf(tags, id) {
  if (tags?.height) {
    const h = parseFloat(String(tags.height).replace(/[^\d.]/g, ''));
    if (h > 0) return h;
  }
  if (tags?.['building:levels']) {
    const l = parseFloat(tags['building:levels']);
    if (l > 0) return l * LEVEL_M;
  }
  // deterministic default from the way id → 12..72 m
  let h = 0; const s = String(id);
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  return 12 + (Math.abs(h) % 60);
}

// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
  const b = bbox();
  const query =
    `[out:json][timeout:60];way["building"](${b.s},${b.w},${b.n},${b.e});(._;>;);out body;`;

  console.log(`[bake-osm] querying ${OVERPASS_URL}`);
  console.log(`[bake-osm] bbox ${b.s.toFixed(5)},${b.w.toFixed(5)},${b.n.toFixed(5)},${b.e.toFixed(5)}`);

  const res = await fetch(OVERPASS_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      // Overpass CDNs reject the default undici UA with 406; identify politely.
      'User-Agent': 'kuro-screensaver bake-osm (github.com/johannes-kaindl/kuro-screensaver)',
    },
    body: 'data=' + encodeURIComponent(query),
  });
  if (!res.ok) throw new Error(`Overpass HTTP ${res.status} ${res.statusText}`);
  const json = await res.json();

  const nodes = new Map();
  const ways = [];
  for (const el of json.elements) {
    if (el.type === 'node') nodes.set(el.id, [el.lat, el.lon]);
    else if (el.type === 'way' && el.tags?.building) ways.push(el);
  }
  console.log(`[bake-osm] ${ways.length} building ways, ${nodes.size} nodes`);

  // First pass: project + collect all vertices for the PCA alignment.
  const raw = [];
  const cloud = [];
  for (const w of ways) {
    const ring = [];
    for (const nid of w.nodes) {
      const nd = nodes.get(nid);
      if (!nd) continue;
      const p = project(nd[0], nd[1]);
      ring.push(p.x, p.z);
      cloud.push([p.x, p.z]);
    }
    if (ring.length >= 8) raw.push({ ring, h: heightOf(w.tags, w.id) });
  }
  if (!cloud.length) throw new Error('no projectable geometry returned');

  const { yaw, cx, cz } = principalYaw(cloud);

  // Second pass: rotate to align dominant axis with Z, recentre, simplify.
  const footprints = [];
  let spanZ = 0, spanX = 0;
  for (const f of raw) {
    let r = [];
    for (let i = 0; i < f.ring.length; i += 2) {
      const [rx, rz] = rotate(f.ring[i] - cx, f.ring[i + 1] - cz, yaw);
      r.push(rx, rz);
    }
    r = dedupeAndDecollinear(r);
    if (r.length < 6) continue;
    r = simplifyRing(r, DP_EPSILON);
    r = dedupeAndDecollinear(r);
    if (r.length / 2 > MAX_VERTS) r = capVerts(r, MAX_VERTS);
    if (r.length < 6) continue;
    if (Math.abs(signedArea(r)) < MIN_AREA) continue;
    if (!isSimple(r)) continue;
    r = ensureCCW(r);
    for (let i = 0; i < r.length; i += 2) {
      spanX = Math.max(spanX, Math.abs(r[i]));
      spanZ = Math.max(spanZ, Math.abs(r[i + 1]));
    }
    footprints.push({ h: Math.round(f.h), ringM: r });
  }

  // Sort by |z| so the densest core sits near the corridor centre, cap count.
  footprints.sort((a, b2) => Math.abs(a.ringM[1]) - Math.abs(b2.ringM[1]));
  const kept = footprints.slice(0, MAX_FOOTPRINTS);

  // Quantise to decimetres + delta-encode each ring.
  const q = (v) => Math.round(v / SCALE);
  const encoded = kept.map((f) => {
    const r = f.ringM;
    const out = [q(r[0]), q(r[1])];
    let px = q(r[0]), pz = q(r[1]);
    for (let i = 2; i < r.length; i += 2) {
      const cxq = q(r[i]), czq = q(r[i + 1]);
      out.push(cxq - px, czq - pz);
      px = cxq; pz = czq;
    }
    return { h: f.h, r: out };
  });

  const data = {
    meta: {
      source: '© OpenStreetMap contributors',
      license: 'ODbL 1.0',
      licenseUrl: 'https://www.openstreetmap.org/copyright',
      attribution: 'Map data © OpenStreetMap contributors, ODbL 1.0',
      note: 'Baked at build time by scripts/bake-osm-district.mjs — geometry only, no imagery.',
      bbox: [+b.s.toFixed(6), +b.w.toFixed(6), +b.n.toFixed(6), +b.e.toFixed(6)],
      origin: [CENTER_LAT, CENTER_LON],
      scale: SCALE,
      axisYawRad: +yaw.toFixed(5),
      spanX: Math.ceil(spanX),
      spanZ: Math.ceil(spanZ),
      count: encoded.length,
      query,
    },
    footprints: encoded,
  };

  writeFileSync(OUT, JSON.stringify(data));
  const bytes = Buffer.byteLength(JSON.stringify(data));
  console.log(`[bake-osm] wrote ${OUT}`);
  console.log(`[bake-osm] ${encoded.length} footprints, ${(bytes / 1024).toFixed(1)} KB minified`);
  console.log(`[bake-osm] ${data.meta.attribution}`);
}

main().catch((err) => {
  console.error('[bake-osm] FAILED:', err.message);
  process.exit(1);
});