1
0

Lumbridge Simulate Engine — the city, and the licence it can actually ship under

LSE is the third of the three, beside lumbridge-compute and lumbridge-bench: a
3D engine for walkable places. This first commit is the outside of the world —
San Francisco — plus the seams the inside will attach to.

The engine renders a City and a list of Markers and knows nothing else. It does
not know markers are usually companies and it will never learn that "rejected"
is red; that mapping lives in an adapter. Which is what lets one renderer serve
a private map, a public one, and a self-hoster with no Lumbridge account, none
of them a fork of the others.

Three things were designed around the licence rather than discovered after it,
because each one is a promise Apache 2.0 makes that is easy to break by
accident. No trademarks in the repo — logos are fetched at runtime, and
public/logos/ is gitignored. No OpenStreetMap-derived coordinates, which is why
every coastline in cities/sf.ts was traced by hand: Nominatim output is ODbL,
share-alike, and would attach to the whole pack. And no FlightRadar24 client —
their terms forbid scraping and redistribution, so flights are an interface
with a simulator and open community ADS-B behind it.

The privacy constraint and the licence constraint turned out to want the same
thing. Geocoded company positions and pipeline status both stay behind Workie's
API; the open repo holds the city and the renderer. The tempting shortcut —
commit an sf-companies.json — breaks both at once.

Ported out of Workie, where a 3D city engine had no business living. Workie's
/live is deleted rather than deprecated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-04 21:49:08 -07:00
commit 67f8df8d53
20 changed files with 4789 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
/**
* The built city, plus the handful of landmarks placed by hand.
*
* Two things make this read as a city rather than as noise:
*
* - **Buildings step along a block lattice** in each district's own grid
* bearing. An earlier version rejection-sampled uniformly inside each
* district and it looked like rubble, because a city is not a Poisson
* process. In San Francisco the lattice also reproduces the 46° between
* the grid north of Market and the grid south of it, and Market Street
* falls out as a seam rather than having to be drawn.
* - **Buildings stand on the terrain.** Every base is sampled from
* `world.groundAt`, so Nob Hill's low-rises tower over taller blocks in the
* flats below — which is true of that city, and which a flat map gets
* exactly backwards.
*
* Everything is instanced: one draw call for the whole city.
*/
import * as THREE from "three";
import type { District } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** Lot size in scene units, and how many lots sit between cross-streets. */
const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
const PALETTES = {
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;
interface Box {
x: number;
z: number;
y: number;
w: number;
d: number;
h: number;
rot: number;
color: THREE.Color;
}
function polygonBounds(poly: [number, number][]) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const [lat, lng] of poly) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
return { minLat, maxLat, minLng, maxLng };
}
export function createBlocks(world: World): THREE.InstancedMesh {
const boxes: Box[] = [];
let seedBase = 1337;
for (const district of world.city.districts) {
const rand = seededRandom(seedBase);
seedBase += 7919;
const palette = PALETTES[district.palette];
const angle = district.gridAngle;
const coverage = district.coverage ?? 0.88;
// The district's extent in scene space, padded so the rotated lattice
// still covers the corners once it is turned.
const b = polygonBounds(district.polygon);
const corners = [
world.project(b.minLat, b.minLng),
world.project(b.minLat, b.maxLng),
world.project(b.maxLat, b.minLng),
world.project(b.maxLat, b.maxLng),
];
const xs = corners.map((c) => c[0]);
const zs = corners.map((c) => c[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const steps = Math.ceil(reach / LOT);
for (let iu = -steps; iu <= steps; iu++) {
if (((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
for (let iv = -steps; iv <= steps; iv++) {
if (((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
const x = cx + u * cos - v * sin;
const z = cz + u * sin + v * cos;
const [lat, lng] = world.unproject(x, z);
if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
if (!world.isLand(lat, lng)) continue;
if (world.pointInAny(lat, lng, world.city.parks)) continue;
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
// Cubed, so tall buildings stay rare and the skyline keeps a
// silhouette instead of turning into a plateau.
const roll = rand();
const isTower = rand() < district.towerChance;
const t = isTower ? 0.55 + roll * 0.45 : roll ** 3;
const heightM = district.minHeight + t * (district.maxHeight - district.minHeight);
// Towers take several lots. A 260 m tower on one 40 m lot is a 25:1
// needle, and downtown came out looking like a bed of nails; real
// towers assemble their sites, and Salesforce Tower is about 5:1.
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
boxes.push({
x,
z,
y: world.groundAt(lat, lng),
w: LOT * fill,
d: LOT * fill * (0.85 + rand() * 0.3),
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
});
}
}
}
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
mesh.castShadow = true;
mesh.receiveShadow = true;
const matrix = new THREE.Matrix4();
const quat = new THREE.Quaternion();
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
boxes.forEach((b, i) => {
pos.set(b.x, b.y, b.z);
quat.setFromAxisAngle(up, b.rot);
scl.set(b.w, b.h, b.d);
matrix.compose(pos, quat, scl);
mesh.setMatrixAt(i, matrix);
mesh.setColorAt(i, b.color);
});
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return mesh;
}
/**
* The named buildings. Separate meshes because the eye goes looking for these
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
* Hill, the red tripod on the ridge — and a box would not do.
*/
export function createLandmarks(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "landmarks";
for (const lm of world.city.landmarks) {
const [x, z] = world.project(lm.lat, lm.lng);
const base = world.groundAt(lm.lat, lm.lng);
const h = world.metres(lm.height);
const w = lm.footprint * world.lngScale * 2;
let geo: THREE.BufferGeometry;
switch (lm.shape) {
case "pyramid":
geo = new THREE.ConeGeometry(w * 0.72, h, 4);
geo.translate(0, h / 2, 0);
geo.rotateY(Math.PI / 4);
break;
case "cylinder":
geo = new THREE.CylinderGeometry(w * 0.6, w * 0.68, h, 20);
geo.translate(0, h / 2, 0);
break;
case "tower":
geo = new THREE.CylinderGeometry(w * 0.42, w * 0.62, h, 4);
geo.rotateY(Math.PI / 4);
geo.translate(0, h / 2, 0);
break;
default:
geo = new THREE.BoxGeometry(w, h, w);
geo.translate(0, h / 2, 0);
}
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: lm.color ?? 0xaebac6 }),
);
mesh.position.set(x, base, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.landmark = lm;
group.add(mesh);
}
return group;
}