8fb85cd2e5
The wide shot, which is what an anonymous visitor actually lands on. **The sea was `MeshLambertMaterial`** — a material with no specular term at all, by construction — on a board where water is half the frame. It is now a low-roughness dielectric that reads `scene.environment`, with a runtime-generated tiling swell normal map sampled twice per fragment at two scales and two headings, so the sun breaks into a moving glitter path instead of a mirror point. An `onBeforeCompile` patch takes the body colour toward the deep value looking straight down and leaves it to the reflection at grazing, and walks roughness up past 1.6 board spans so the far water cannot shimmer. The swell spectrum is 1/k^2 and not 1/k because the first attempt was photographed: at 1/k every component carries the same slope, the shortest wave wins, and the sea renders as hard diagonal corduroy. A test holds it now. **The board no longer ends in a diamond.** The sea plane went from 1.8 board spans to 18, past the fog's far plane from anywhere the orbit reaches, and the sky is a world-space dome rather than a screen-space gradient. That gradient was wrong in a way dusk made obvious: the sunset band was painted along the *bottom* of the picture, under the board, while the true horizon at the top of frame stayed zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the seam was a symptom of it. **Terrain casts shadows.** Left off before because double-sided terrain against a ~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is a stride-2 decimation appended to the same index buffer and swapped in by `onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of the depth cost, and indistinguishable from the full-resolution caster in a side-by-side crop. Stride 1 was measured at +65,566 triangles and would have missed the budget by ~47,000, so it was not shipped. **California reads as California.** It was a beige kite: the eastern edge one ruled line for five degrees of latitude, the south closing in a diagonal V, the whole south-east a featureless tan wedge. Now the coast runs to the Mexican border with San Diego on it, the eastern edge follows the Colorado and the Nevada diagonal, and the south-east is the Basin and Range — forty parallel desert ridges throwing shadows east, Death Valley as a white pan between the Panamints and the Black Mountains, the Salton Sea the one cool value for two hundred kilometres. The opening pose is retuned to the bigger board; the old 452/392 stand-off left a slab of empty ocean where the state should be. **The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to find one at all — indistinguishable from a dead pixel, on a board whose entire claim is that the sky is live. They are airliners now, with planform and trail, and clicking one raises its card for a signed-out visitor. **The Model X is off the wall.** It stood at floor level outside a studio 188 m up a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen from `site.elevation`, which the pack already carries — not from an office id, which is the bug class this repo already hit once when a door marker gated on `id === "sf"` and would have pinned the Los Angeles building to San Francisco. Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground on the Bay Area and SoCal boards. And one test asserted an exact source line for the water material, so the better multi-line implementation failed it — it now asserts the property (dielectric, metalness 0, low roughness) rather than the author's first guess at formatting. Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000 triangles — 2.8% of triangle headroom left, which is the number the next person should check first. No budget was raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
||
* The contract between the engine and everything else.
|
||
*
|
||
* The engine renders a `City` and a list of `Marker`s. It does not know what a
|
||
* marker *is* — not that markers are companies, not that a red one means a
|
||
* rejection. That mapping lives in an adapter, outside this package, which is
|
||
* what lets one renderer serve a private career map, a public sector map, and
|
||
* whatever anyone else builds, without any of them being a fork.
|
||
*
|
||
* See ARCHITECTURE.md §3.3.
|
||
*/
|
||
|
||
/** `[latitude, longitude]`, always in that order. */
|
||
export type LatLng = [number, number];
|
||
|
||
// ---- Geography ------------------------------------------------------------
|
||
|
||
/**
|
||
* A hill, as a radial peak summed into the heightfield.
|
||
*
|
||
* `elevation` is metres above sea level at the summit. `radius` is roughly
|
||
* where the hill meets the flats, in degrees of latitude.
|
||
*/
|
||
export interface Hill {
|
||
name: string;
|
||
lat: number;
|
||
lng: number;
|
||
elevation: number;
|
||
radius: number;
|
||
}
|
||
|
||
/**
|
||
* Where buildings go, how tall, and on what street grid.
|
||
*
|
||
* `gridAngle` is the district's street bearing in radians. It is per-district
|
||
* rather than per-city because that is the fact on the ground in San Francisco:
|
||
* the grid north of Market and the grid south of it are 46° out of true, and
|
||
* reproducing that is most of what makes the city recognisable from above.
|
||
*/
|
||
export interface District {
|
||
id: string;
|
||
name: string;
|
||
polygon: LatLng[];
|
||
/** Street bearing, radians clockwise from true north. */
|
||
gridAngle: number;
|
||
minHeight: number;
|
||
maxHeight: number;
|
||
/** Chance a given lot gets a tower rather than a low-rise. */
|
||
towerChance: number;
|
||
/** Facade palette key; see `blocks.ts`. */
|
||
palette: "downtown" | "residential" | "industrial";
|
||
/** Fraction of lots that get built on at all. Defaults to 0.88. */
|
||
coverage?: number;
|
||
}
|
||
|
||
/** A building placed by hand because the eye goes looking for it. */
|
||
export interface Landmark {
|
||
name: string;
|
||
lat: number;
|
||
lng: number;
|
||
/** Roof height in metres. */
|
||
height: number;
|
||
/** Half-width in degrees of longitude. */
|
||
footprint: number;
|
||
shape: "box" | "pyramid" | "tower" | "cylinder";
|
||
color?: number;
|
||
label?: boolean;
|
||
}
|
||
|
||
export interface Bridge {
|
||
name: string;
|
||
/** Deck centreline. Both ends should run onto land. */
|
||
path: LatLng[];
|
||
towers: LatLng[];
|
||
towerHeight: number;
|
||
deckHeight: number;
|
||
/** Suspension sag as a fraction of tower height. */
|
||
sag: number;
|
||
color: number;
|
||
}
|
||
|
||
export interface Road {
|
||
path: LatLng[];
|
||
width: number;
|
||
kind: "street" | "freeway";
|
||
}
|
||
|
||
/**
|
||
* A named destination, as the interface knows it: what the legend prints and
|
||
* what `flyTo` is keyed on.
|
||
*
|
||
* Split out of `Chapter` because an office has exactly the same idea — a short
|
||
* list of places you can jump to — but positions them in metres, not in
|
||
* latitude and longitude. Only this half of a chapter is shared; the pose is
|
||
* not. `number` and `description` are optional here and required on `Chapter`,
|
||
* because a city's chapters are a numbered tour with a sentence each and an
|
||
* office's views are usually just "Reception" and "The desk bay".
|
||
*/
|
||
export interface View {
|
||
id: string;
|
||
label: string;
|
||
shortLabel: string;
|
||
number?: string;
|
||
description?: string;
|
||
}
|
||
|
||
/** A camera destination, and a sentence about why it is on the map. */
|
||
export interface Chapter extends View {
|
||
number: string;
|
||
description: string;
|
||
focus: {
|
||
lat: number;
|
||
lng: number;
|
||
distance: number;
|
||
height: number;
|
||
rotation: number;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A rectangle rendered at fine terrain resolution.
|
||
*
|
||
* SF declares one covering the whole city and behaves as if this did not exist.
|
||
* LA needs six — DTLA, Santa Monica, Culver, Irvine, Pasadena, downtown
|
||
* Riverside — with the basin between them coarse, because LA/OC/Riverside is
|
||
* roughly fourteen times SF's area and a uniform 45 m lattice over it would be
|
||
* 4.6M points. See ARCHITECTURE.md §5.
|
||
*/
|
||
export interface FocusRegion {
|
||
minLat: number;
|
||
maxLat: number;
|
||
minLng: number;
|
||
maxLng: number;
|
||
}
|
||
|
||
/**
|
||
* Everything the engine needs to draw a place. Pure data — a city pack must
|
||
* contain no code, so that adding one is a contribution anybody can review.
|
||
*/
|
||
export interface City {
|
||
id: string;
|
||
name: string;
|
||
|
||
/** Map centre, and the origin of scene space. */
|
||
center: { lat: number; lng: number };
|
||
/** Scene bounds. Everything outside this is open water or off-frame. */
|
||
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
|
||
|
||
/**
|
||
* Degrees to scene units, for latitude. Longitude is derived as
|
||
* `latScale * cos(center.lat)` so the place keeps its true proportions.
|
||
*/
|
||
latScale: number;
|
||
|
||
/**
|
||
* How much taller than life the vertical is. Terrain and buildings share it,
|
||
* so they stay honest relative to each other.
|
||
*/
|
||
verticalExaggeration: number;
|
||
|
||
/** Ground-cell size inside a focus region, in degrees. */
|
||
cellLat: number;
|
||
cellLng: number;
|
||
/** Multiplier applied to cell size outside every focus region. 1 = uniform. */
|
||
coarseFactor?: number;
|
||
focusRegions?: FocusRegion[];
|
||
|
||
/** Distance from open water, in degrees, over which relief ramps to zero. */
|
||
coastFalloff: number;
|
||
|
||
landmasses: LatLng[][];
|
||
parks: LatLng[][];
|
||
inlandWater: LatLng[][];
|
||
hills: Hill[];
|
||
districts: District[];
|
||
landmarks: Landmark[];
|
||
bridges: Bridge[];
|
||
roads: Road[];
|
||
chapters: Chapter[];
|
||
|
||
/** Palette overrides; every field is optional. */
|
||
palette?: Partial<ScenePalette>;
|
||
}
|
||
|
||
export interface ScenePalette {
|
||
skyTop: number;
|
||
skyHorizon: number;
|
||
sea: number;
|
||
lake: number;
|
||
shore: number;
|
||
sand: number;
|
||
flats: number;
|
||
upland: number;
|
||
park: number;
|
||
parkHigh: number;
|
||
/**
|
||
* Bare high ground, and **optional on purpose**.
|
||
*
|
||
* The unpainted ramp is `flats → upland` over the first 150 m and then flat
|
||
* forever, which is right for a city — a hill in San Francisco is built to its
|
||
* summit and the buildings do the talking. On a board measured in hundreds of
|
||
* kilometres it is not: the Sierra crest at 4,000 m and the Mojave floor at
|
||
* 600 come out the same number, so a granite skyline and a creosote flat are
|
||
* one colour and the only thing separating them is the shading.
|
||
*
|
||
* A pack that declares this gets a second stop above `upland` — see
|
||
* `groundColor` in `terrain.ts` for the two elevations it ramps between. A
|
||
* pack that does not is rendered exactly as before, which is why this is
|
||
* optional rather than a tenth required colour that every existing pack would
|
||
* have to answer for.
|
||
*/
|
||
alpine?: number;
|
||
}
|
||
|
||
// ---- Lighting -------------------------------------------------------------
|
||
|
||
/**
|
||
* Everything the light rig needs, as plain numbers.
|
||
*
|
||
* This is a *state*, not an observation: `Environment` — `{ time, sun, weather }`
|
||
* — is what the world is doing, and a `LightingState` is what that means for the
|
||
* rig. `Atmosphere` owns the conversion and is the only thing allowed to make
|
||
* one; a scene applies it and never writes back. Two modules both constructing
|
||
* and mutating the same three lights is the failure this shape exists to
|
||
* prevent. See CONTRACT.md §4.
|
||
*
|
||
* Colours are `0xrrggbb`, matching `ScenePalette` and three.js.
|
||
*/
|
||
export interface LightingState {
|
||
sun: {
|
||
/**
|
||
* Unit vector from the scene toward the sun. Distance is deliberately
|
||
* absent: how far away to place the light is a fact about the scale of the
|
||
* scene, and the sun does not know whether it is shining on 94 m per unit
|
||
* or on 1 m per unit.
|
||
*/
|
||
direction: [number, number, number];
|
||
color: number;
|
||
intensity: number;
|
||
};
|
||
hemisphere: { sky: number; ground: number; intensity: number };
|
||
ambient: { color: number; intensity: number };
|
||
/**
|
||
* Background gradient, or `null` to leave the background alone — which is
|
||
* what an interior wants, since it has walls and no horizon.
|
||
*/
|
||
sky: { top: number; horizon: number } | null;
|
||
/** `null` for no fog at all. An office gets none. */
|
||
fog: { color: number; near: number; far: number } | null;
|
||
}
|
||
|
||
// ---- Markers --------------------------------------------------------------
|
||
|
||
/**
|
||
* A thing worth pointing at, minus where it is.
|
||
*
|
||
* `colorKey` is deliberately opaque to the engine — it indexes into a palette
|
||
* the caller supplies. The engine will not learn what "rejected" means.
|
||
*
|
||
* This is the half that survives a change of coordinate system: a pin on a city
|
||
* at 37.79 N, -122.40 E and a pin on a desk 4.2 m along the east wall are the
|
||
* same kind of thing to everything downstream of the geometry, so an office can
|
||
* carry its own positions and still hand a `Pin` to the same detail card.
|
||
*/
|
||
export interface Pin {
|
||
id: string;
|
||
label: string;
|
||
colorKey: string;
|
||
/** Optional href for the detail card. */
|
||
url?: string;
|
||
/** Optional one-liner for the detail card. */
|
||
blurb?: string;
|
||
}
|
||
|
||
/**
|
||
* A small, map-scale building drawn in place of a pin.
|
||
*
|
||
* This is deliberately a glyph rather than an architectural model. A city is
|
||
* normally viewed from kilometres away, so loading a façade kit with one mesh
|
||
* per window buys triangles nobody can see and gives up the one-draw-call city
|
||
* that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar
|
||
* — a ground floor, repeated bays, a roof line and a deterministic silhouette
|
||
* — and expresses it in a handful of procedural meshes.
|
||
*
|
||
* Metres are used here because these values describe a real building even
|
||
* though the city scene does not: `World` converts horizontal metres with
|
||
* `metresPerUnit` and vertical metres with the city's exaggeration.
|
||
*/
|
||
export interface BuildingGlyph {
|
||
kind: "building";
|
||
width: number;
|
||
depth: number;
|
||
height: number;
|
||
/** Approximate occupied floors; used to choose the façade rhythm. */
|
||
storeys: number;
|
||
/** Compass bearing of local −Z, degrees clockwise from true north. */
|
||
heading: number;
|
||
/** The silhouette family, not a tenant or product category. */
|
||
profile: "tower" | "hangar" | "courtyard" | "block";
|
||
/** Stable variation for bay widths and lit panes. */
|
||
seed?: number;
|
||
/** Neutral shell colour. The marker palette still supplies the door/accent. */
|
||
bodyColor?: number;
|
||
}
|
||
|
||
/** A `Pin` placed on a city, in degrees. */
|
||
export interface Marker extends Pin {
|
||
lat: number;
|
||
lng: number;
|
||
/**
|
||
* False when the position is a placeholder rather than a real address.
|
||
* Rendered distinctly, because inventing a location on a map whose premise
|
||
* is that it is real is worse than admitting the gap.
|
||
*/
|
||
located?: boolean;
|
||
/** Optional map-scale representation. Omit it for the ordinary pin. */
|
||
glyph?: BuildingGlyph;
|
||
}
|
||
|
||
/** Caller-supplied `colorKey` -> colour. */
|
||
export type MarkerPalette = Record<string, number>;
|
||
|
||
// ---- Flights --------------------------------------------------------------
|
||
|
||
export interface Aircraft {
|
||
id: string;
|
||
lat: number;
|
||
lng: number;
|
||
/** Barometric altitude in metres. */
|
||
altitude: number;
|
||
/** Degrees clockwise from true north. */
|
||
heading: number;
|
||
callsign?: string;
|
||
}
|
||
|
||
/**
|
||
* Where aircraft come from.
|
||
*
|
||
* An interface rather than a client, because this repo must not ship one for the
|
||
* obvious source: FlightRadar24's terms do not permit scraping and do not permit
|
||
* redistributing the data, so a client for it in an Apache-2.0 repo would be
|
||
* publishing instructions for violating a ToS. This package ships a simulator
|
||
* and open community sources; anything commercial is an adapter in a private
|
||
* deployment. See ARCHITECTURE.md §4.
|
||
*/
|
||
export interface FlightSource {
|
||
/** Current traffic. Called on a timer; must be cheap and must not throw. */
|
||
poll(): Promise<Aircraft[]> | Aircraft[];
|
||
/** Seconds between polls. */
|
||
interval: number;
|
||
dispose?(): void;
|
||
}
|
||
|
||
// ---- Satellites -----------------------------------------------------------
|
||
|
||
/**
|
||
* Which constellation a satellite belongs to, as far as anyone looking up cares.
|
||
*
|
||
* A **display bucket and not a taxonomy**: no orbital regime, no operator, no
|
||
* launch date. `engine/satellites.ts` picks a colour from it and nothing else
|
||
* reads it, and a field that carried more would be a field that got wrong.
|
||
*
|
||
* `starlink` is broken out from `comms` because it is the reason the layer
|
||
* exists — it is the constellation people can see with their eyes, in a train,
|
||
* forty minutes after sunset. `other` is not a failure; most of the catalogue is
|
||
* other.
|
||
*
|
||
* This lives here rather than in `server/wire.ts` for the same reason `Marker`
|
||
* does: the renderer owns the vocabulary and the wire carries it, so a body off
|
||
* the network is handed to the engine as-is with no adapter in between.
|
||
*/
|
||
export type SatelliteGroup =
|
||
| "starlink"
|
||
| "comms"
|
||
| "navigation"
|
||
| "station"
|
||
| "weather"
|
||
| "other";
|