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