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
+267
View File
@@ -0,0 +1,267 @@
/**
* The scene: lights, sky, layers, camera flights, render loop.
*
* `createScene` owns a canvas and a `City` and nothing else. It knows nothing
* about React, about any API, or about what the markers mean — the caller hands
* it data and gets back a small imperative handle. That boundary is what lets
* one renderer serve a private map coloured by pipeline state and a public one
* coloured by sector without either being a fork.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts";
import { World } from "./world.ts";
export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
flights?: FlightSource;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
}
export interface SceneHandle {
world: World;
chapters: Chapter[];
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
setMarkers(markers: Marker[]): void;
dispose(): void;
}
export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle {
const { city } = options;
const world = new World(city);
const pal = paletteFor(world);
const scene = new THREE.Scene();
scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon);
scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460);
const camera = new THREE.PerspectiveCamera(
42,
canvas.clientWidth / Math.max(1, canvas.clientHeight),
0.1,
900,
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = 12;
controls.maxDistance = 340;
// Late-afternoon sun from the west, which throws the hills' shadows east
// across the flats.
const sun = new THREE.DirectionalLight(0xfff3e0, 2.1);
sun.position.set(-150, 170, 70);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 520;
const extent = 170;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = -0.0012;
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05));
scene.add(new THREE.AmbientLight(0xffffff, 0.32));
scene.add(createWater(world));
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
scene.add(createBlocks(world));
scene.add(createLandmarks(world));
scene.add(createBridges(world));
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
scene.add(markerLayer.group);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
if (options.flights) {
flightLayer = createFlightLayer(world);
scene.add(flightLayer.group);
}
// ---- Camera flights -----------------------------------------------------
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
const first = city.chapters[0];
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
const desiredTarget = new THREE.Vector3();
const desiredPosition = new THREE.Vector3();
const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() };
let flying = false;
let flightT = 0;
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
function chapterPose(ch: Chapter) {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
return {
target: new THREE.Vector3(x, groundY, z),
pos: new THREE.Vector3(
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
groundY + ch.focus.height,
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
),
};
}
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
const pose = chapterPose(ch);
flightFrom.pos.copy(camera.position);
flightFrom.target.copy(controls.target);
desiredPosition.copy(pose.pos);
desiredTarget.copy(pose.target);
flightT = 0;
flying = true;
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
}
}
{
const pose = chapterPose(first);
camera.position.copy(pose.pos);
controls.target.copy(pose.target);
controls.update();
}
// ---- Picking ------------------------------------------------------------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered: Marker | null = null;
function onPointerMove(event: PointerEvent) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0];
const marker = (hit?.object.userData.marker as Marker | undefined) ?? null;
if (marker !== hovered) {
hovered = marker;
canvas.style.cursor = marker ? "pointer" : "";
options.onMarkerPick?.(marker);
}
}
canvas.addEventListener("pointermove", onPointerMove);
// ---- Loop ---------------------------------------------------------------
const clock = new THREE.Clock();
let raf = 0;
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (w === 0 || h === 0) return;
if (canvas.width !== w || canvas.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
}
function tick() {
raf = requestAnimationFrame(tick);
const dt = Math.min(clock.getDelta(), 0.05);
resize();
if (flying) {
flightT = Math.min(1, flightT + dt * 0.65);
// easeInOutCubic — a flight that starts and lands gently
const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
camera.position.lerpVectors(flightFrom.pos, desiredPosition, e);
controls.target.lerpVectors(flightFrom.target, desiredTarget, e);
if (flightT >= 1) flying = false;
}
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
}
}
controls.update();
renderer.render(scene, camera);
}
tick();
const onWindowResize = () => resize();
window.addEventListener("resize", onWindowResize);
return {
world,
chapters: city.chapters,
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
chapterListeners.push(fn);
},
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onWindowResize);
canvas.removeEventListener("pointermove", onPointerMove);
options.flights?.dispose?.();
flightLayer?.dispose();
markerLayer.dispose();
controls.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else if (mat) (mat as THREE.Material).dispose();
});
renderer.dispose();
},
};
}
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = 2;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 2, 256);
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.LinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}