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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user