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,141 @@
|
||||
/**
|
||||
* Bridges and roads — the lines that tie the landmasses together and give the
|
||||
* grid something to hang off.
|
||||
*
|
||||
* Roads follow the terrain: each path is resampled far more finely than it is
|
||||
* written in the city pack, and every sample takes its height from the ground,
|
||||
* so a street climbs out of the flats instead of burrowing through the hill.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { Bridge, LatLng } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
|
||||
const out: THREE.Vector3[] = [];
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const from = path[i];
|
||||
const to = path[i + 1];
|
||||
if (!from || !to) continue;
|
||||
const [lat0, lng0] = from;
|
||||
const [lat1, lng1] = to;
|
||||
const steps = i === path.length - 2 ? samplesPerLeg : samplesPerLeg - 1;
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / samplesPerLeg;
|
||||
const lat = lat0 + (lat1 - lat0) * t;
|
||||
const lng = lng0 + (lng1 - lng0) * t;
|
||||
const [x, z] = world.project(lat, lng);
|
||||
out.push(new THREE.Vector3(x, world.groundAt(lat, lng) + lift, z));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
|
||||
const curve = new THREE.CatmullRomCurve3(points);
|
||||
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
|
||||
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
export function createRoads(world: World): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "roads";
|
||||
for (const road of world.city.roads) {
|
||||
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||
group.add(ribbon(drapePath(world, road.path), road.width, color));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* A suspension bridge: deck, towers, and a main cable sagging between them.
|
||||
*
|
||||
* The cable is the detail worth the code. Two orange towers with a straight
|
||||
* line between them read as a trestle; the catenary is what makes the shape at
|
||||
* the mouth of the bay unmistakably the Golden Gate.
|
||||
*/
|
||||
export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = bridge.name;
|
||||
|
||||
const deckY = world.metres(bridge.deckHeight);
|
||||
const towerY = world.metres(bridge.towerHeight);
|
||||
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
|
||||
|
||||
const deckPoints = bridge.path.map(([lat, lng]) => {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
return new THREE.Vector3(x, deckY, z);
|
||||
});
|
||||
|
||||
const deck = ribbon(deckPoints, 0.5, bridge.color);
|
||||
deck.castShadow = true;
|
||||
group.add(deck);
|
||||
|
||||
const towerTops: THREE.Vector3[] = [];
|
||||
for (const [lat, lng] of bridge.towers) {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
|
||||
geo.translate(0, towerY / 2, 0);
|
||||
const tower = new THREE.Mesh(geo, material());
|
||||
tower.position.set(x, 0, z);
|
||||
tower.castShadow = true;
|
||||
group.add(tower);
|
||||
|
||||
// Cross-braces, which is most of what you see of a tower at distance.
|
||||
for (const frac of [0.55, 0.82]) {
|
||||
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
|
||||
brace.position.set(x, towerY * frac, z);
|
||||
group.add(brace);
|
||||
}
|
||||
towerTops.push(new THREE.Vector3(x, towerY, z));
|
||||
}
|
||||
|
||||
const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]];
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const a = anchors[i];
|
||||
const b = anchors[i + 1];
|
||||
if (!a || !b) continue;
|
||||
const isMainSpan = i > 0 && i < anchors.length - 2;
|
||||
const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42);
|
||||
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let s = 0; s <= 18; s++) {
|
||||
const t = s / 18;
|
||||
const p = a.clone().lerp(b, t);
|
||||
p.y -= Math.sin(t * Math.PI) * sag;
|
||||
pts.push(p);
|
||||
}
|
||||
group.add(
|
||||
new THREE.Mesh(
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
|
||||
material(),
|
||||
),
|
||||
);
|
||||
|
||||
// Vertical hangers down to the deck.
|
||||
for (let s = 2; s < 18; s += 2) {
|
||||
const t = s / 18;
|
||||
const p = a.clone().lerp(b, t);
|
||||
const top = p.y - Math.sin(t * Math.PI) * sag;
|
||||
if (top <= deckY + 0.2) continue;
|
||||
const h = top - deckY;
|
||||
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
|
||||
geo.translate(0, h / 2, 0);
|
||||
const hanger = new THREE.Mesh(geo, material());
|
||||
hanger.position.set(p.x, deckY, p.z);
|
||||
group.add(hanger);
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createBridges(world: World): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "bridges";
|
||||
for (const b of world.city.bridges) group.add(createBridge(world, b));
|
||||
return group;
|
||||
}
|
||||
Reference in New Issue
Block a user