1
0

feat: SFO, LAX, both bridges, a road that reads as a road, and aeroplanes that move

**The aeroplanes were stuck because the wire could not describe motion.**
`WireAircraft` carried position, altitude and heading and nothing else, so the
client could only interpolate between the last two observations: every aircraft
replayed a segment it had already flown, arrived at the newest known point, and
sat still until the next poll landed five to fifteen seconds later. The feed had
the missing numbers the whole time and the server threw them away. Sampled live
from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`,
`baro_rate`, plus `r` registration and `t` type designator. They are on the wire
now in SI, aircraft dead-reckon along their own track and correct toward the
truth when a fix lands, and the click card an anonymous visitor gets says
"B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained
from an ODbL feed we may actually republish.

**SFO and LAX exist.** A new `engine/airports.ts` composes an airport from
runways, taxiways, aprons and terminal masses, with markings drawn on a canvas
rather than modelled; the pattern of the runways is what the eye recognises from
altitude, long before any building does. SFO is the two crossing pairs on the bay
fill; LAX is the four parallels either side of the terminal horseshoe, plus the
Southland fields under the traffic that actually flies there.

**The Golden Gate and the Bay Bridge are those bridges.** One kit in
`engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary
main cable, a series of hangers and a deck — so both are configurations rather
than two private implementations. The Bay Bridge carries the real 2013 topology:
two suspension towers west of Yerba Buena, one east, then the piered causeway.
The freeway stopped being a wireframe overlay and became a road, with shoulders,
a median, and lane markings as texture.

**And the board got faster while all of that landed.** California went from
728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to
47.8%. The Bay Area board is 506,550 triangles lighter than before this work.
Two things paid for it:

- `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full
  transmission backdrop pass whenever any rendered material has transmission
  above zero, re-drawing the entire opaque scene into a second target every
  frame — so the city was rendering terrain, every block and every freeway piece
  TWICE. Measured by patching only that number in a copy of the built bundle:
  703,267 tris / 562 draws with it, 398,608 / 371 without. The material was
  already `transparent: true, opacity: 0.86`, so it was buying nothing.
- Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever
  the height and colour agree with the quad replacing them. The coastline is
  provably untouched — a patch collapses only when every point is on land and
  agrees about `park` — and a test asserts the drawn footprint matches the
  cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops
  emitting cost more than the flatness scan costs to run.

**The budget now watches the boards this was built on.** There was no `bay-area`
or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing
measured, which is how a cap you do not have looks from the inside. Both are in
the matrix now with caps set from measurement, and the rationale lives in the
harness because JSON cannot hold a comment.

Two known defects ship with this, both recorded in TODO.md rather than hidden:

- `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is
  desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a
  comparable pixel count and holds 16.7 flat — which points at the 2048 shadow
  map desktop uses against handheld's 1024. Measured at the commit before this
  work with the same harness: identical p95 33.3. Pre-existing, and invisible
  until the cell existed.
- The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter
  zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far
  end of the California orbit is 51.0 at a 60-degree field of view, and 26 —
  tried first — put the glyph at 0.0123 of the frame against the 0.012 where the
  wings stop resolving. The real fix is to clamp against the camera's focus
  distance rather than the aircraft's, which is a signature change.

Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries,
provenance, zero-config boot, dependency licences, arena source hashes and the
UI smoke across two viewports and two access tiers all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:06:12 -07:00
parent d9f8baf171
commit f81d5218d4
29 changed files with 7056 additions and 288 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* One material property that costs 43% of the California board.
*
* `MeshPhysicalMaterial.transmission` is not a per-pixel cost. three.js runs a
* **transmission backdrop pass** whenever any rendered material has a non-zero
* one: the entire opaque scene is drawn a second time, into a render target, so
* that the transparent surface has something to refract. It is charged per
* *scene*, not per material and not per pixel, so a single small mesh switches
* it on for everything.
*
* The electric aircraft's canopy carried `transmission: 0.08` and it made the
* board draw its terrain, its blocks and every freeway piece twice per frame.
* Measured with `scripts/performance-budget.mjs`, California desktop:
*
* with it 695,828 triangles 562 draw calls
* without it 391,169 triangles 371 draw calls
*
* 304,659 triangles and 191 draw calls, for a canopy that is a few dozen pixels
* of dark glass on a glyph-scale aeroplane and which reads identically without
* it — the material is already `transparent` at `opacity: 0.86`, and the chase
* camera shots before and after are indistinguishable.
*
* This test exists because that is a **one-word regression**: somebody adding
* realism to a canopy would be adding it to a material that looks like it is
* about the aeroplane, and the cost would land on the terrain, silently, on
* every phone. The performance budget would catch it, eventually, and would say
* "the California board got slower" rather than "this line did it".
*
* The office glazing in `src/assets/materials.ts` keeps `transmission: 0.92`
* and should: that is a wall of windows a metre from the camera at 1 unit = 1 m,
* and the office cell has four hundred thousand triangles of headroom to pay the
* pass with. `materialRoles.test.ts` asserts it is still there. The rule is not
* "no transmission"; it is "not in the city scene".
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import * as THREE from "three";
import { createElectricAircraftMaterials } from "../../aircraft/asset.ts";
test("the electric aircraft's canopy stays out of the transmission pass", () => {
const materials = createElectricAircraftMaterials();
const glass = materials.glass as THREE.MeshPhysicalMaterial;
assert.ok(glass instanceof THREE.MeshPhysicalMaterial, "the canopy is no longer physical");
assert.equal(
glass.transmission,
0,
"the canopy is refracting again — that is a second full pass over the whole city scene",
);
// What carries the see-through instead, so a future reader can tell that the
// glass was not simply turned into a painted panel.
assert.equal(glass.transparent, true, "the canopy stopped being see-through altogether");
assert.ok(glass.opacity < 1, "the canopy is opaque; it needs the blend to read as glass");
});
test("no material the aircraft ships turns the pass on by another door", () => {
const materials = createElectricAircraftMaterials();
for (const [role, material] of Object.entries(materials)) {
const transmission = (material as THREE.MeshPhysicalMaterial).transmission;
assert.ok(
transmission === undefined || transmission === 0,
`${role} has transmission ${transmission}; the whole scene is now drawn twice`,
);
}
});
+564
View File
@@ -0,0 +1,564 @@
/**
* The sky moving between snapshots, which is the difference between a live map
* and a photograph of one.
*
* ## The defect
*
* `WireAircraft` carried a position and nothing else: no ground speed, no
* vertical rate. So `createFlightLayer` could only interpolate between the last
* two observations it had been handed — every aircraft replayed a leg it had
* already flown, arrived at the newest known point, and then **sat perfectly
* still** for the five to fifteen seconds until the next one landed. Nobody
* could catch it, because the layer rendered correctly the whole time: a still
* frame of a stuck sky and a still frame of a flying one are the same picture,
* and every existing assertion in the suite passed on the broken build.
*
* It was found by taking two screenshots eight seconds apart with a **frozen**
* `/api/v1/flights` body — the shape of every live deployment between server
* cache refreshes — and measuring the aircraft: pixel-identical. The first test
* below is that experiment, with the pixels replaced by `mesh.position`.
*
* ## What is asserted, and what is deliberately not
*
* Everything here is observed through the scene graph or through an exported
* pure function, the same rule `flights.test.ts` set. `reckonForward` is the
* arithmetic — heading conventions, the cosine on longitude, the clamps — and is
* checked in degrees, where a sign error is legible. The layer is checked
* through `mesh.position`, because "does the aeroplane move" is a question about
* where it is drawn.
*
* The clock is `performance.now`, replaced with a counter for the file exactly
* as `flights.test.ts` does it: the intervals under test are tens of seconds
* long and the layer reads the clock fresh on every call.
*/
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import * as THREE from "three";
import { readDump1090 } from "../../../server/src/flights/adsb.ts";
import {
aircraftDetail,
createFlightLayer,
reckonForward,
sampleRoute,
type FlightLayer,
} from "../../engine/flights.ts";
import type { Aircraft } from "../../engine/types.ts";
// ---- The clock -------------------------------------------------------------
let clockMs = 0;
const realNow = performance.now;
before(() => {
performance.now = () => clockMs;
});
after(() => {
performance.now = realNow;
});
function at(seconds: number): void {
clockMs = seconds * 1000;
}
// ---- The board -------------------------------------------------------------
/** Metres in a degree of latitude, restated rather than imported. See the header. */
const METRES_PER_DEGREE_LAT = 111_320;
/**
* A flat stand-in for `World` — the layer's whole contact with one is `project`
* and `metres`, and a real heightfield is half a million samples of nothing to
* do with any of it. `skyTraffic.test.ts` uses the same trick.
*
* **Unlike that one, this projection is geodetically honest**, and it has to be:
* every assertion below is a distance, and the dead-reckoner converts metres to
* degrees with the real 111,320 m per degree. A stand-in that put a round 1,000
* units on a degree of latitude would make one scene unit 111.32 m rather than
* the 100 it claimed, and every expected distance here would be 10% out — which
* looks exactly like a broken reckoner and is not one. So the scales are
* derived from the same constant the code uses, and the longitude axis is
* squashed by the cosine of the reference latitude the way `World` does it.
*
* One scene unit is therefore 100 m, and every distance below reads as metres
* divided by a hundred.
*/
const METRES_PER_UNIT = 100;
const REFERENCE_LAT = 37.6;
const UNITS_PER_DEGREE_LAT = METRES_PER_DEGREE_LAT / METRES_PER_UNIT;
const UNITS_PER_DEGREE_LNG = UNITS_PER_DEGREE_LAT * Math.cos((REFERENCE_LAT * Math.PI) / 180);
const flatWorld = {
project: (lat: number, lng: number) => [lng * UNITS_PER_DEGREE_LNG, -lat * UNITS_PER_DEGREE_LAT],
metres: (m: number) => m / METRES_PER_UNIT,
metresPerUnit: METRES_PER_UNIT,
} as unknown as Parameters<typeof createFlightLayer>[0];
function meshOf(layer: FlightLayer): THREE.Mesh {
const mesh = layer.group.children.find((c): c is THREE.Mesh => c.type === "Mesh");
assert.ok(mesh, "the layer has no aircraft mesh");
return mesh;
}
/** Ground distance between two scene positions, in scene units. */
function apart(a: THREE.Vector3, b: THREE.Vector3): number {
return Math.hypot(a.x - b.x, a.z - b.z);
}
/** An airliner heading due north at 250 m/s, level, over the flat board. */
function jet(over: Partial<Aircraft> = {}): Aircraft {
return {
id: "ual505",
callsign: "UAL505",
lat: 37.6,
lng: -122.4,
altitude: 9000,
heading: 0,
groundSpeed: 250,
verticalRate: 0,
...over,
};
}
// ---- The arithmetic --------------------------------------------------------
describe("reckonForward", () => {
const state = { lat: 37.6, lng: -122.4, altitude: 9000, heading: 0, speed: 250, climb: 0 };
it("flies north on heading 000 and changes no longitude", () => {
const after10 = reckonForward(state, 10);
assert.equal(after10.lng, state.lng, "a northbound aircraft drifted east or west");
const metres = (after10.lat - state.lat) * METRES_PER_DEGREE_LAT;
assert.ok(Math.abs(metres - 2500) < 1, `flew ${metres.toFixed(1)} m in ten seconds, not 2500`);
});
/**
* The cosine on longitude, which is the one term that can be silently
* omitted: leave it out and every eastbound aircraft over California flies at
* 79% of its reported speed, uniformly, in a direction nobody is measuring.
*/
it("covers more degrees of longitude than of latitude for the same speed", () => {
const east = reckonForward({ ...state, heading: 90 }, 10);
assert.ok(Math.abs(east.lat - state.lat) < 1e-9, "an eastbound aircraft drifted north");
const degrees = east.lng - state.lng;
const expected = 2500 / (METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180));
assert.ok(
Math.abs(degrees - expected) / expected < 1e-6,
`${degrees} degrees of longitude, expected ${expected}`,
);
// And the ground distance is the same as the northbound leg's, which is the
// property the cosine exists to preserve.
const north = reckonForward(state, 10);
const northM = (north.lat - state.lat) * METRES_PER_DEGREE_LAT;
const eastM = degrees * METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180);
assert.ok(Math.abs(northM - eastM) < 0.01, "north and east legs are not the same length");
});
it("turns clockwise: 090 is east and 270 is west", () => {
assert.ok(reckonForward({ ...state, heading: 90 }, 10).lng > state.lng, "090 went west");
assert.ok(reckonForward({ ...state, heading: 270 }, 10).lng < state.lng, "270 went east");
assert.ok(reckonForward({ ...state, heading: 180 }, 10).lat < state.lat, "180 went north");
});
it("climbs and descends at the stated rate, and never through the ground", () => {
assert.equal(reckonForward({ ...state, climb: 5 }, 10).altitude, 9050);
const dived = reckonForward({ ...state, altitude: 300, climb: -20 }, 60);
assert.equal(dived.altitude, 0, "an aircraft was reckoned below the terrain");
});
/**
* The propagation is clamped at a minute, because a position and a velocity
* describe the next few seconds well and the next ten minutes not at all — an
* airliner turns, descends and lands, and none of that is in the two numbers
* this had to work from. Past the clamp it holds station rather than flying
* off the board on data the rest of the file has already stopped believing.
*/
it("stops reckoning after a minute rather than flying forever", () => {
const minute = reckonForward(state, 60);
const hour = reckonForward(state, 3600);
assert.deepEqual(hour, minute, "an aircraft kept flying on a fix an hour old");
});
it("does not run backwards when a clock does", () => {
assert.deepEqual(reckonForward(state, -30), {
lat: state.lat,
lng: state.lng,
altitude: state.altitude,
});
});
});
// ---- The regression itself -------------------------------------------------
/**
* A frozen live body, polled at 1 Hz, which is what every deployment serves
* between cache refreshes. The layer skips a repeated position without
* recording it — see `flights.test.ts` — so this is precisely the state in
* which the old code had nothing left to interpolate and stood still.
*/
describe("a frozen snapshot of a moving aircraft", () => {
function hold(layer: FlightLayer, a: Aircraft, from: number, until: number) {
for (let t = from; t < until; t += 1) {
at(t);
layer.update([a]);
}
}
it("keeps flying between refreshes instead of standing still", () => {
const layer = createFlightLayer(flatWorld);
at(0);
layer.update([jet()]);
const mesh = meshOf(layer);
const start = mesh.position.clone();
hold(layer, jet(), 1, 10);
at(10);
layer.tick();
// Ten seconds at 250 m/s is 2,500 m, which on this board is 25 units.
const flown = apart(mesh.position, start);
assert.ok(
Math.abs(flown - 25) < 0.5,
`the aircraft covered ${flown.toFixed(2)} units in ten seconds, not 25`,
);
// North is -z on every board in this repo.
assert.ok(mesh.position.z < start.z, "a northbound aircraft flew south");
});
/**
* The other half of the claim, and the reason the fields are optional: a
* source that does not say how fast something is going gets the old
* behaviour, exactly. Guessing a speed for a ground vehicle or a
* position-only TIS-B target would be inventing motion, which is a worse lie
* than showing none — and it is the behaviour `flights.test.ts` pins.
*/
it("still stands still when the source reported no speed", () => {
const layer = createFlightLayer(flatWorld);
const parked: Aircraft = { ...jet(), groundSpeed: undefined, verticalRate: undefined };
at(0);
layer.update([parked]);
const mesh = meshOf(layer);
const start = mesh.position.clone();
hold(layer, parked, 1, 10);
at(10);
layer.tick();
assert.equal(apart(mesh.position, start), 0, "an aircraft with no reported speed moved");
});
/** A ground vehicle reports `gs: 0.0`, and zero is a fact, not a measurement gap. */
it("holds a target whose reported speed is zero", () => {
const layer = createFlightLayer(flatWorld);
const still = jet({ groundSpeed: 0, altitude: 0 });
at(0);
layer.update([still]);
const mesh = meshOf(layer);
const start = mesh.position.clone();
at(20);
layer.tick();
assert.equal(apart(mesh.position, start), 0, "a parked aircraft taxied on its own");
});
it("climbs between refreshes as well as advancing", () => {
const layer = createFlightLayer(flatWorld);
const climbing = jet({ altitude: 1000, verticalRate: 10 });
at(0);
layer.update([climbing]);
const mesh = meshOf(layer);
const start = mesh.position.y;
at(10);
layer.tick();
// 10 m/s for 10 s is 100 m, which `flatWorld.metres` puts at one unit.
assert.ok(
Math.abs(mesh.position.y - start - 1) < 0.02,
`climbed ${(mesh.position.y - start).toFixed(3)} units, not 1`,
);
});
});
/**
* The trail buffer is preallocated for `MAX_TRACKS × TRAIL_POINTS` segments,
* and a reckoned track draws **one more segment than an interpolated one** —
* its newest observation is history rather than a destination, so the spine
* runs through it and on to the head. That is one extra vertex pair per track,
* which a sky at the ceiling would run off the end of.
*
* `rebuildTrails` already clamps rather than overflowing — a crowded sky draws
* shorter trails, each still joined to its dart — but nothing asserted it for
* the longer spine, and running off a `Float32Array` is silent: the writes go
* nowhere and the draw range says everything is fine.
*/
it("keeps a crowded reckoned sky inside the preallocated trail buffer", () => {
const layer = createFlightLayer(flatWorld);
const line = layer.group.getObjectByName("flight-trails") as THREE.LineSegments;
const capacity = (line.geometry.attributes.position as THREE.BufferAttribute).count;
const flock = (step: number) =>
Array.from({ length: 220 }, (_, i) =>
jet({
id: `ac${i}`,
lat: 37.4 + (i % 20) * 0.01 + step,
lng: -122.6 + Math.floor(i / 20) * 0.01,
}),
);
for (let poll = 0; poll < 90; poll += 1) {
at(poll * 2);
layer.update(flock(poll * 0.002));
}
assert.ok(
line.geometry.drawRange.count <= capacity,
`${line.geometry.drawRange.count} vertices drawn from a buffer of ${capacity}`,
);
assert.ok(line.geometry.drawRange.count > 0, "a full sky drew no trails at all");
});
// ---- Landing on the truth --------------------------------------------------
describe("a fresh observation arriving", () => {
/**
* The correction must be a slide and not a jump, and this is the assertion
* that says which. The reckoner is always a little wrong — the aircraft
* banked, the wind changed, the fix was already stale — so an observation
* lands with the drawn aeroplane a few hundred metres from where the feed
* says it is. Snapping is a visible twitch on every aircraft on every
* refresh; over a live feed that is one flinch every five to fifteen seconds,
* forever.
*/
it("eases onto a position that disagrees with the reckoning", () => {
const layer = createFlightLayer(flatWorld);
at(0);
layer.update([jet()]);
const mesh = meshOf(layer);
// Ten seconds of flying, and then a fix 500 m east of the reckoned track —
// the aircraft was drifting, or the receiver was.
at(10);
const drifted = jet({
lat: 37.6 + 2500 / METRES_PER_DEGREE_LAT,
lng: -122.4 + 500 / (METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180)),
});
layer.update([drifted]);
const truthX = flatWorld.project(drifted.lat, drifted.lng)[0];
const missBefore = Math.abs(mesh.position.x - truthX);
assert.ok(missBefore > 1, `the aircraft snapped onto the fix (${missBefore.toFixed(2)} units)`);
// …and closes on it without another observation, which is what "slide"
// means. Three seconds is one time constant, so most of it is gone.
at(13);
layer.tick();
const missAfter = Math.abs(mesh.position.x - truthX);
assert.ok(missAfter < missBefore * 0.5, "the error was carried rather than corrected");
at(25);
layer.tick();
assert.ok(
Math.abs(mesh.position.x - truthX) < 0.05,
"the correction never finished; the track is permanently offset",
);
});
/**
* A fix is already stale when it arrives — the receiver's last message, plus
* the server's cache TTL, plus the browser's hold. Adopting one as though it
* described this instant draws the whole sky that far behind, uniformly,
* which is the sort of error nobody notices because everything is wrong
* together.
*/
it("advances a stale fix to the present before adopting it", () => {
const fresh = createFlightLayer(flatWorld);
const stale = createFlightLayer(flatWorld);
at(0);
fresh.update([jet()]);
stale.update([jet({ ageSeconds: 8 })]);
at(0);
fresh.tick();
stale.tick();
const ahead = meshOf(fresh).position.z - meshOf(stale).position.z;
// Eight seconds at 250 m/s is 2,000 m: 20 units, northbound, so -z.
assert.ok(
Math.abs(ahead - 20) < 0.2,
`the stale fix was placed ${ahead.toFixed(2)} units ahead, not 20`,
);
});
/**
* The teleport guard has to survive all of this. A simulated route reaching
* the end of its leg reappears at the start, several hundred units away, and
* `flights.ts` documents at length what happens when that is mistaken for
* flying. It must not be eased onto either: a three-second slide across the
* board with a trail attached is worse than the jump it replaced.
*/
it("still jumps rather than sliding when a route wraps", () => {
const layer = createFlightLayer(flatWorld);
at(0);
layer.update([jet()]);
at(10);
layer.update([jet({ lat: 37.6 + 2500 / METRES_PER_DEGREE_LAT })]);
at(20);
// Half a degree of latitude in one refresh: 556 km, i.e. a wrap.
const wrapped = jet({ lat: 37.1 });
layer.update([wrapped]);
at(20);
layer.tick();
const mesh = meshOf(layer);
const [x, z] = flatWorld.project(wrapped.lat, wrapped.lng);
assert.ok(
apart(mesh.position, new THREE.Vector3(x, mesh.position.y, z)) < 0.01,
"a wrapped route was eased across the board instead of restarting",
);
});
});
// ---- The simulator behaves the same way ------------------------------------
describe("the bundled simulator", () => {
/**
* A keyless clone and a live deployment must move through the same code, or
* only one of them is ever looked at — which is the arrangement that let the
* live sky sit still for as long as it did. `sampleRoute` therefore reports a
* velocity, derived from the leg rather than invented.
*/
it("reports a ground speed consistent with its own leg", () => {
const route = {
callsign: "NIMBUS 4",
from: [37.95, -122.36] as [number, number],
to: [37.66, -122.4] as [number, number],
fromAlt: 2400,
toAlt: 500,
duration: 190,
};
const a = sampleRoute(route, 0.25);
assert.ok(a.groundSpeed !== undefined, "a simulated aircraft has no speed");
const dLat = (route.to[0] - route.from[0]) * METRES_PER_DEGREE_LAT;
const dLng =
(route.to[1] - route.from[1]) * METRES_PER_DEGREE_LAT * Math.cos((a.lat * Math.PI) / 180);
const expected = Math.hypot(dLat, dLng) / route.duration;
assert.ok(
Math.abs((a.groundSpeed ?? 0) - expected) / expected < 0.01,
`${a.groundSpeed} m/s does not match the leg's ${expected} m/s`,
);
// Descending, so the rate is negative, and it eases off toward the end.
assert.ok((a.verticalRate ?? 0) < 0, "an arrival was reported as climbing");
assert.ok(
Math.abs(a.verticalRate ?? 0) > Math.abs(sampleRoute(route, 0.9).verticalRate ?? 0),
"the eased descent does not shallow out",
);
});
});
// ---- The card --------------------------------------------------------------
describe("the detail card", () => {
/**
* The registration and the type were the stated reason to want a commercial
* feed. Both community feeds have carried them on every row all along, under
* the same ODbL as the position, and the server was dropping them on the
* floor — so an anonymous visitor clicking a dart now reads what the aircraft
* is rather than a hex address.
*/
it("carries the registration and the ICAO type", () => {
const card = aircraftDetail(jet(), {
icao24: "a923cd",
registration: " N68834 ",
type: "B739",
observed: true,
});
assert.equal(card.registration, "N68834");
assert.equal(card.type, "B739");
assert.equal(card.icao24, "a923cd");
// Knots, because that is the unit a speed over the ground is read in.
assert.equal(card.groundSpeedKt, Math.round(250 / 0.514_444));
});
it("says nothing rather than something empty", () => {
const card = aircraftDetail(jet({ groundSpeed: undefined, verticalRate: undefined }), {
registration: " ",
});
assert.equal(card.registration, null);
assert.equal(card.type, null);
assert.equal(card.groundSpeedKt, null, "a card claimed a speed nobody reported");
assert.equal(card.verticalRateFpm, null);
});
});
// ---- The server's half of the wire -----------------------------------------
/**
* The unit conversions, through a real entry point.
*
* `readDump1090` parses exactly the envelope both hosted feeds serve, so a
* file on disk exercises the same `normalise` the network path does. The
* conversions are the part worth pinning: knots and feet per minute are what
* the feed publishes and metres per second is what the wire carries, and a
* factor that is wrong by 1.9 produces a sky that renders perfectly and is
* wrong everywhere.
*
* The row is a real one, copied from `api.adsb.lol/v2/point` while this was
* being written.
*/
describe("the ADS-B adapter", () => {
const dir = mkdtempSync(join(tmpdir(), "tera-adsb-"));
async function parse(rows: unknown[]) {
const path = join(dir, `${Math.random().toString(36).slice(2)}.json`);
writeFileSync(path, JSON.stringify({ now: 1_787_388_360, ac: rows }));
const snapshot = await readDump1090(path);
assert.ok(snapshot, "the adapter refused a well-formed envelope");
return snapshot;
}
it("converts knots and feet per minute to SI, and keeps the identifiers", async () => {
const snapshot = await parse([
{
hex: "a923cd",
flight: "UAL505 ",
r: "N68834",
t: "B739",
gs: 249.2,
track: 357.7,
baro_rate: 1344,
alt_baro: 4950,
lat: 37.62,
lon: -122.38,
seen_pos: 0.183,
},
]);
const [a] = snapshot.aircraft;
assert.ok(a);
assert.ok(Math.abs((a.groundSpeed ?? 0) - 249.2 * 0.514_444) < 1e-6, "ground speed in knots");
assert.ok(Math.abs((a.verticalRate ?? 0) - 1344 * 0.00508) < 1e-6, "climb in feet per minute");
assert.equal(a.registration, "N68834");
assert.equal(a.type, "B739");
assert.equal(a.ageSeconds, 0.183);
assert.equal(a.callsign, "UAL505");
});
/**
* The gate that matters most. A ground vehicle reports `gs: 0.0` with a null
* track, and `heading` falls back to `0` for a row with no track — harmless
* for something that is not moving, and a claim that a baggage tug is
* taxiing due north the moment anything advances it. No track, no speed.
*/
it("carries no speed for a row with no track, whatever the speed said", async () => {
const snapshot = await parse([
{ hex: "a0b820", gs: 0, alt_baro: "ground", lat: 37.61, lon: -122.39 },
{ hex: "abcdef", gs: 180, alt_baro: 3000, lat: 37.61, lon: -122.39 },
{ hex: "beef00", gs: 0, track: 90, alt_baro: 3000, lat: 37.61, lon: -122.39 },
]);
for (const a of snapshot.aircraft) {
assert.equal(a.groundSpeed, undefined, `${a.id} was given a speed it did not report`);
}
});
it("falls back to the geometric climb rate when there is no barometric one", async () => {
const snapshot = await parse([
{ hex: "a1f5ff", geom_rate: -640, track: 180, gs: 300, alt_baro: 8000, lat: 37.6, lon: -122.4 },
]);
const [a] = snapshot.aircraft;
assert.ok(a);
assert.ok((a.verticalRate ?? 0) < 0, "a descent was reported as a climb");
assert.ok(Math.abs((a.verticalRate ?? 0) - -640 * 0.00508) < 1e-6);
});
});
@@ -0,0 +1,267 @@
/**
* Two seams that were each finished by a different workstream and could only be
* closed here, and both of which fail *silently* — the build is green, the
* types check, every unit test passes, and the visitor sees less than they
* should.
*
* **The airports.** `engine/airports.ts` is a complete, tested kit, and
* `cities/sf.ts` and `cities/socal.ts` author nine real fields between them. In
* between sits one call. Until it existed the two detailed boards had *no*
* airports at all — worse than before the kit landed, because the packs had
* already dropped the runways-as-roads they used to fake them with. There is no
* half-wired state that looks right, which is exactly why it is worth a test:
* the failure is a missing thing, and a missing thing is what a screenshot is
* worst at.
*
* **The card.** `flights.ts` resolves a registration and an ICAO type for every
* live track, and `AircraftDetailInput` had nowhere to put them, so the two
* facts that turn a dart into an aeroplane were parsed and thrown away one line
* before a visitor could read them.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import SAN_FRANCISCO from "../../cities/sf.ts";
import SOCAL from "../../cities/socal.ts";
import { formatAircraftDetail, type AircraftDetailInput } from "../../ui/hud.ts";
import { runwayThresholds } from "../../engine/airports.ts";
import type { Airport, City, LatLng } from "../../engine/types.ts";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8");
const BOARDS: readonly (readonly [string, City])[] = [
["sf", SAN_FRANCISCO],
["socal", SOCAL],
];
// ---- The airport wiring ---------------------------------------------------
test("both detailed boards ship the airports their packs author", () => {
for (const [id, city] of BOARDS) {
const airports = city.airports ?? [];
assert.ok(
airports.length > 0,
`${id} declares no airports. The kit is built and the fields are authored; ` +
`this is the one line in the pack literal that publishes them.`,
);
for (const airport of airports) {
assert.ok(airport.runways.length > 0, `${id}/${airport.id} has no runways`);
}
}
});
test("the scene builds them, from the field the packs fill in", () => {
const scene = read("src/engine/scene.ts");
assert.ok(
scene.includes('import { createAirports } from "./airports.ts";'),
"scene.ts no longer imports the airport kit",
);
assert.ok(
/scene\.add\(createAirports\(world, city\.airports \?\? \[\]\)\);/.test(scene),
"scene.ts must add the airports for the city it was handed. `?? []` and not a " +
"guard, because a board with no airfields is the normal case and must cost " +
"nothing to express.",
);
});
test("the airport contract stays plain data, and off the package surface", () => {
const types = read("src/engine/types.ts");
assert.ok(
!/from "\.\/airports\.ts"/.test(types),
"types.ts must not reach for airports.ts even for a type. `src/index.ts` " +
"reaches City, and barrel.test.ts reads the import graph as source — it " +
"cannot tell an erased edge from a real one, and airports.ts imports three.js.",
);
// Structured-cloneable, because a City is posted to the terrain worker.
for (const [id, city] of BOARDS) {
assert.doesNotThrow(
() => structuredClone(city.airports ?? []),
`${id}'s airports are not sendable to the terrain worker`,
);
}
});
/**
* The hazard the bridges workstream named: a runway drawn as a `Road` drapes
* about thirteen metres above the terrain at board scale, while the kit lays a
* runway flush on it. A board carrying both floats a dark stripe over every
* runway, and nothing in the type system objects.
*/
test("no pack still draws a runway as a road", () => {
const metresBetween = (a: LatLng, b: LatLng) => {
const dLat = (a[0] - b[0]) * 111_320;
const dLng = (a[1] - b[1]) * 111_320 * Math.cos((a[0] * Math.PI) / 180);
return Math.hypot(dLat, dLng);
};
for (const [id, city] of BOARDS) {
for (const airport of city.airports ?? []) {
for (const runway of airport.runways) {
const { low, high } = runwayThresholds(runway);
for (const road of city.roads) {
if (road.path.length !== 2) continue;
const [a, b] = road.path as [LatLng, LatLng];
const same =
(metresBetween(a, low) < 400 && metresBetween(b, high) < 400) ||
(metresBetween(a, high) < 400 && metresBetween(b, low) < 400);
assert.ok(
!same,
`${id} draws ${airport.id}/${runway.id} as a Road as well as a runway — ` +
`the ribbon will float over the pavement`,
);
}
}
}
}
});
/**
* `FIELD_LIFT` in airports.ts has to clear `LOD_HEIGHT_TOLERANCE` in terrain.ts,
* because the terrain LOD collapses a flat patch to a quad that may sit that far
* *above* the lattice it replaced. It cannot be a second hand-typed number: the
* first time these two drifted apart, Van Nuys grew a wedge of bare ground
* through the middle of the field and nothing failed.
*/
test("the field lift is derived from the terrain tolerance, not typed beside it", () => {
assert.ok(
/export const LOD_HEIGHT_TOLERANCE/.test(read("src/engine/terrain.ts")),
"terrain.ts must export the tolerance the airport plate is measured against",
);
assert.ok(
/const FIELD_LIFT = LOD_HEIGHT_TOLERANCE \+ /.test(read("src/engine/airports.ts")),
"airports.ts must derive FIELD_LIFT from LOD_HEIGHT_TOLERANCE",
);
});
// ---- The aircraft card ----------------------------------------------------
const LIVE: AircraftDetailInput = {
id: "a4d8f2",
callsign: "UAL1234",
lat: 37.6213,
lng: -122.379,
altitude: 1_524,
heading: 298.6,
type: "B739",
registration: "N68834",
groundSpeedKt: 212,
verticalRateFpm: -1_408,
synthetic: false,
attribution: "ADS-B data © adsb.lol contributors, ODbL",
};
const rowsOf = (input: AircraftDetailInput) =>
Object.fromEntries(formatAircraftDetail(input).rows.map((r) => [r.label, r.value]));
test("an anonymous visitor reads what the aeroplane IS, first", () => {
const view = formatAircraftDetail(LIVE);
assert.equal(view.rows[0]?.label, "Aircraft");
assert.equal(view.rows[0]?.value, "B739 · N68834");
// And the identity a receiver actually heard is still on the card.
assert.equal(view.title, "UAL1234");
assert.equal(view.subtitle, "Mode S A4D8F2");
});
test("the card carries the whole of what the feed said", () => {
const rows = rowsOf(LIVE);
assert.equal(rows["Altitude"], "5,000 ft · 1,524 m");
assert.equal(rows["Heading"], "299° WNW");
assert.equal(rows["Ground speed"], "212 kt");
assert.equal(rows["Climb"], "1,408 ft/min");
assert.equal(rows["Position"], "37.621° N · 122.379° W");
});
test("a rate inside the noise band is level flight, not a manoeuvre", () => {
assert.equal(rowsOf({ ...LIVE, verticalRateFpm: 64 })["Climb"], "Level");
assert.equal(rowsOf({ ...LIVE, verticalRateFpm: 0 })["Climb"], "Level");
assert.equal(rowsOf({ ...LIVE, verticalRateFpm: 2_240 })["Climb"], "+2,240 ft/min");
});
test("a zero is a fact and an absence is not", () => {
// Parked, and saying so.
assert.equal(rowsOf({ ...LIVE, groundSpeedKt: 0 })["Ground speed"], "0 kt");
// The simulator, and every server one version behind: the rows vanish rather
// than printing "unknown", which is what makes this safe to ship un-gated.
const simulated = rowsOf({
...LIVE,
type: null,
registration: null,
groundSpeedKt: null,
verticalRateFpm: null,
synthetic: true,
});
assert.equal(simulated["Aircraft"], undefined);
assert.equal(simulated["Ground speed"], undefined);
assert.equal(simulated["Climb"], undefined);
assert.equal(simulated["Altitude"], "5,000 ft · 1,524 m");
});
test("one half of an airframe is still worth printing", () => {
assert.equal(rowsOf({ ...LIVE, registration: null })["Aircraft"], "B739");
assert.equal(rowsOf({ ...LIVE, type: null })["Aircraft"], "N68834");
assert.equal(rowsOf({ ...LIVE, type: " ", registration: "" })["Aircraft"], undefined);
});
test("main.ts hands the card all four, ungated", () => {
const main = read("src/main.ts");
const from = main.indexOf("function showAircraftDetail");
assert.ok(from > 0, "showAircraftDetail has been renamed");
const body = main.slice(from, main.indexOf("\n}", from));
for (const field of ["registration", "type", "groundSpeedKt", "verticalRateFpm"]) {
assert.ok(
new RegExp(`${field}: resolved\\.${field},`).test(body),
`showAircraftDetail drops ${field} on the floor`,
);
}
assert.ok(
!/\baccess\./.test(body),
"owner decision 2: the flight card is not gated on an account",
);
});
// A type-level check, in the only place that can make one: every field the card
// reads must exist on the record `flights.ts` resolves.
test("the card's inputs are the detail record's outputs", async () => {
const { aircraftDetail } = await import("../../engine/flights.ts");
const detail = aircraftDetail(
{ id: "a4d8f2", lat: 37.62, lng: -122.38, altitude: 1_524, heading: 298.6 },
{ observed: true, icao24: "a4d8f2", type: "B739", registration: "N68834" },
);
const card: AircraftDetailInput = {
id: detail.icao24 ?? detail.id,
callsign: detail.callsign,
lat: detail.lat,
lng: detail.lng,
altitude: detail.altitudeM,
heading: detail.headingDeg,
type: detail.type,
registration: detail.registration,
groundSpeedKt: detail.groundSpeedKt,
verticalRateFpm: detail.verticalRateFpm,
synthetic: !detail.observed,
attribution: detail.attribution.join(" · "),
};
assert.equal(formatAircraftDetail(card).rows[0]?.value, "B739 · N68834");
});
// ---- One thing the packs must agree about ---------------------------------
test("every authored field sits inside the board that draws it", () => {
const inside = (city: City, [lat, lng]: LatLng) =>
lat >= city.bounds.minLat &&
lat <= city.bounds.maxLat &&
lng >= city.bounds.minLng &&
lng <= city.bounds.maxLng;
for (const [id, city] of BOARDS) {
for (const airport of (city.airports ?? []) as Airport[]) {
assert.ok(inside(city, [airport.lat, airport.lng]), `${id}/${airport.id} is off the board`);
for (const point of airport.field ?? []) {
assert.ok(inside(city, point), `${id}/${airport.id}'s fence leaves the board`);
}
}
}
});
+481
View File
@@ -0,0 +1,481 @@
/**
* SFO, and the airport kit under it.
*
* Every defect this file guards against typechecked, threw nothing, and cost
* nothing in the performance budget. Each one was found by rendering the board
* and looking at it, and each assertion below is the cheapest arithmetic
* statement of what the picture showed:
*
* 1. **A field that was not there.** `terrain.ts` builds its relief mesh at
* `world.metres(e) + 0.012` — a bias of its own — so an airport plate laid
* a hundredth of a unit above `groundAt` is laid *under the ground*. SFO
* rendered as four runways floating on bare terrain with a magenta sliver
* of fill visible only where it overhung the bay. Nothing warned.
* 2. **Taxiways wound face-down.** A strip whose two rails are emitted
* right-before-left has reversed winding, `computeVertexNormals` points
* every normal at the ground, and a one-sided material draws nothing. Six
* taxiways were simply absent.
* 3. **Every building mirrored.** `rotateY` takes the angle whose sine and
* cosine are the *scene-space* along-vector, so the bearing is
* `atan2(x, z)` and not `atan2(x, z)`. With the negation SFO's terminal
* horseshoe was built on 153.5° instead of 26.5° — not rotated, reflected —
* and the aircraft on stand, which never went through that function, parked
* in a tidy row beside nothing.
* 4. **Two control towers.** The pack already carried a labelled `SFO Control
* Tower` landmark, which is what puts SFO on the minimap, and the airport
* declared one as well. They stood four hundred metres apart.
*
* The geography assertions are a different kind. A runway on the wrong bearing
* is not a bug in any code — it is a number somebody typed — and the only thing
* that catches it is stating the published figure next to the authored one.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import SF_CITY, { AIRPORTS, SFO, SJC, PENINSULA } from "../../cities/sf.ts";
import {
createAirports,
parallelTaxiway,
runwayThresholds,
type Airport,
type Runway,
} from "../../engine/airports.ts";
import type { LatLng } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";
const METRES_PER_DEGREE_LAT = 111_320;
const DEG = Math.PI / 180;
/** Metres between two coordinates, flat-earth, which is right at this scale. */
function metresBetween(a: LatLng, b: LatLng): number {
const north = (a[0] - b[0]) * METRES_PER_DEGREE_LAT;
const east = (a[1] - b[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
return Math.hypot(north, east);
}
/** True bearing from `a` to `b`, degrees clockwise from north. */
function bearing(a: LatLng, b: LatLng): number {
const north = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
const east = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
return ((Math.atan2(east, north) / DEG) + 360) % 360;
}
/** Perpendicular distance in metres from a runway's centreline to a point. */
function offsetFromCentreline(runway: Runway, point: LatLng): number {
const north = (point[0] - runway.lat) * METRES_PER_DEGREE_LAT;
const east = (point[1] - runway.lng) * METRES_PER_DEGREE_LAT * Math.cos(runway.lat * DEG);
// The runway's right-hand normal: its heading turned a quarter clockwise.
const rightEast = Math.cos(runway.heading * DEG);
const rightNorth = -Math.sin(runway.heading * DEG);
return east * rightEast + north * rightNorth;
}
function runwayById(airport: Airport, id: string): Runway {
const runway = airport.runways.find((candidate) => candidate.id === id);
assert.ok(runway, `${airport.id} has no runway ${id}`);
return runway;
}
/**
* San Francisco's real projection with flat ground, which is what SFO stands
* on: the field is bay fill and `elevationAt` returns exactly zero across all
* of it. A tidy 1:1 world would hide every scale mistake in the kit.
*/
function bayWorld(): World {
const world = new World(SF_CITY);
return Object.assign(Object.create(Object.getPrototypeOf(world) as object), world, {
groundAt: () => 0,
elevationSampled: () => 0,
}) as World;
}
describe("SFO — the numbers a picture cannot check", () => {
it("lays both crossing pairs on their true bearings, not their painted ones", () => {
// A designator is magnetic and rounded to ten degrees; San Francisco's
// declination is ~13.5° east. Building from "28" or "01" puts the whole
// airport thirteen degrees out.
assert.equal(runwayById(SFO, "10L/28R").heading, 118.6);
assert.equal(runwayById(SFO, "10R/28L").heading, 118.6);
assert.equal(runwayById(SFO, "1L/19R").heading, 26.5);
assert.equal(runwayById(SFO, "1R/19L").heading, 26.5);
});
it("crosses the two pairs at very close to a right angle", () => {
const crossing = Math.abs(
runwayById(SFO, "10L/28R").heading - runwayById(SFO, "1L/19R").heading,
);
// 92.1°. An airfield whose runways cross at seventy degrees is a different
// airport, and reads as a generic one.
assert.ok(
Math.abs(crossing - 90) <= 4,
`SFO's pairs cross at ${crossing.toFixed(1)}°, which is not SFO`,
);
});
it("holds each runway to its published length", () => {
// Threshold to threshold, metres, from the published feet.
for (const [id, feet] of [
["10L/28R", 11_870],
["10R/28L", 11_381],
["1L/19R", 7_650],
["1R/19L", 8_650],
] as const) {
const runway = runwayById(SFO, id);
const metres = feet * 0.3048;
assert.ok(
Math.abs(runway.length - metres) < 12,
`${id} is ${runway.length} m against ${metres.toFixed(0)} m`,
);
// And the derived thresholds agree with the declared length, which is what
// makes `runwayThresholds` safe for anything downstream to build on.
const { low, high } = runwayThresholds(runway);
assert.ok(Math.abs(metresBetween(low, high) - runway.length) < 2);
assert.ok(Math.abs(bearing(low, high) - runway.heading) < 0.1);
}
});
it("keeps the two famous parallel separations", () => {
// 750 ft between the 28s: the closest parallel pair in the United States
// used for simultaneous approaches, and the reason every SFO arrival in low
// cloud is a single-file arrival. 700 ft between the 01s.
const tenRight = offsetFromCentreline(runwayById(SFO, "10L/28R"), [
runwayById(SFO, "10R/28L").lat,
runwayById(SFO, "10R/28L").lng,
]);
assert.ok(
Math.abs(tenRight - 750 * 0.3048) < 8,
`10L/28R to 10R/28L is ${tenRight.toFixed(0)} m, not 229 m`,
);
const oneRight = offsetFromCentreline(runwayById(SFO, "1L/19R"), [
runwayById(SFO, "1R/19L").lat,
runwayById(SFO, "1R/19L").lng,
]);
assert.ok(
Math.abs(oneRight - 700 * 0.3048) < 8,
`1L/19R to 1R/19L is ${oneRight.toFixed(0)} m, not 213 m`,
);
// Sign matters as much as magnitude: 10R is south-south-west of 10L and 1R
// is east-south-east of 1L. A mirrored pair is a plausible airport in the
// wrong place.
assert.ok(tenRight > 0 && oneRight > 0, "both right-hand runways are on the right");
});
it("builds the 28 thresholds out onto the mud, and not past it", () => {
const world = new World(SF_CITY);
for (const id of ["10L/28R", "10R/28L"] as const) {
const { high } = runwayThresholds(runwayById(SFO, id));
assert.equal(world.isLand(high[0], high[1]), true, `${id}'s east threshold is in the bay`);
// Within 250 m of the traced bay edge. `PENINSULA` was drawn with "SFO's
// bay edge — the runways are built out onto the mud" on the vertex, and
// this is the assertion that the runways now honour it rather than
// stopping half a mile short.
let nearest = Infinity;
for (const vertex of PENINSULA) nearest = Math.min(nearest, metresBetween(high, vertex));
assert.ok(nearest < 900, `${id}'s east threshold is ${nearest.toFixed(0)} m from any shore vertex`);
}
});
});
describe("every airport on the Bay Area board sits somewhere legal", () => {
const world = new World(SF_CITY);
/** Every authored coordinate an airport puts on the ground. */
function coordinates(airport: Airport): LatLng[] {
const out: LatLng[] = [[airport.lat, airport.lng]];
for (const runway of airport.runways) {
const { low, high } = runwayThresholds(runway);
out.push(low, high, [runway.lat, runway.lng]);
}
for (const point of airport.field ?? []) out.push(point);
for (const apron of airport.aprons ?? []) out.push(...apron.polygon);
for (const taxiway of airport.taxiways ?? []) out.push(...taxiway.path);
for (const terminal of airport.terminals ?? []) out.push([terminal.lat, terminal.lng]);
return out;
}
for (const airport of AIRPORTS) {
it(`${airport.id} is entirely on land`, () => {
for (const [lat, lng] of coordinates(airport)) {
assert.equal(world.isLand(lat, lng), true, `${airport.id}: ${lat},${lng} is water`);
}
});
it(`${airport.id} has no district or park scattered over it`, () => {
// `blocks.ts` skips no lot for an airport — it has never heard of one — so
// the only thing keeping houses off a runway is the district polygon
// stopping short of it. `millbrae-burlingame` says so in a comment; this
// is the assertion that makes the comment true, and it covers the whole
// graded plate rather than only its corners.
const field = airport.field;
assert.ok(field, `${airport.id} declares no field outline`);
for (let lat = 37.34; lat < 37.65; lat += 0.0006) {
for (let lng = -122.4; lng < -121.9; lng += 0.0006) {
if (!world.pointInPolygon(lat, lng, field)) continue;
for (const district of SF_CITY.districts) {
assert.equal(
world.pointInPolygon(lat, lng, district.polygon),
false,
`${airport.id}: district ${district.id} reaches ${lat.toFixed(4)},${lng.toFixed(4)}`,
);
}
assert.equal(world.inPark(lat, lng), false, `${airport.id}: park over ${lat},${lng}`);
}
}
});
}
it("draws exactly one control tower at SFO", () => {
// The pack's landmark is the tower, because `label: true` is what puts SFO
// on the minimap and `minimap.ts` reads `city.landmarks`, not the airport.
const towers = SF_CITY.landmarks.filter((landmark) => /SFO/.test(landmark.name));
assert.equal(towers.length, 1);
assert.equal(SFO.tower, undefined, "SFO declares a second tower on top of its landmark");
// And it stands with the terminals rather than out on the field.
const tower = towers[0]!;
let nearest = Infinity;
for (const terminal of SFO.terminals ?? []) {
nearest = Math.min(nearest, metresBetween([tower.lat, tower.lng], [terminal.lat, terminal.lng]));
}
assert.ok(nearest < 400, `the tower is ${nearest.toFixed(0)} m from the nearest terminal`);
});
});
describe("the pack stays data, and stays derivable", () => {
it("keeps SFO's parallel taxiways aligned with the runways they parallel", () => {
// The coordinates are typed rather than computed, because a city pack must
// not import three.js. This is what stops them drifting: each one still has
// to be what `parallelTaxiway` would produce for the runway it belongs to.
const spec: Array<[string, string, 1 | -1, number, number]> = [
["A", "10L/28R", -1, 165, 120],
["B", "10R/28L", 1, 165, 120],
["F", "1L/19R", -1, 110, 100],
["Z", "1R/19L", 1, 165, 100],
];
for (const [id, runwayId, side, offset, trim] of spec) {
const authored = (SFO.taxiways ?? []).find((taxiway) => taxiway.id === id);
assert.ok(authored, `SFO has no taxiway ${id}`);
const derived = parallelTaxiway(runwayById(SFO, runwayId), side, offset, trim).path;
assert.equal(authored.path.length, derived.length);
authored.path.forEach((point, index) => {
const want = derived[index]!;
// Five decimal places of latitude is about a metre, which is the
// rounding in the pack and nothing else.
assert.ok(
metresBetween(point, want) < 2,
`taxiway ${id} point ${index} is ${metresBetween(point, want).toFixed(1)} m off`,
);
});
}
});
it("survives the JSON round trip a served pack would take", () => {
// CONTRACT.md §2: a pack hand-written as a module and one arriving over HTTP
// have to be literally the same thing. An `undefined`-valued key is the way
// that quietly stops being true, and `City` is posted to the terrain worker
// as a structured clone besides.
for (const airport of AIRPORTS) {
assert.deepEqual(JSON.parse(JSON.stringify(airport)), airport);
assert.doesNotThrow(() => structuredClone(airport));
}
});
});
describe("the airport kit's geometry", () => {
const world = bayWorld();
const group = createAirports(world, AIRPORTS);
/** Every mesh in the group, with its triangle count. */
function meshes(): Array<{ name: string; triangles: number; mesh: THREE.Mesh }> {
const out: Array<{ name: string; triangles: number; mesh: THREE.Mesh }> = [];
group.traverse((object) => {
const mesh = object as THREE.Mesh & { isMesh?: boolean; isInstancedMesh?: boolean; count?: number };
if (!mesh.isMesh) return;
const geometry = mesh.geometry;
const indices = geometry.index?.count ?? geometry.getAttribute("position").count;
const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
out.push({ name: mesh.name, triangles: (indices / 3) * instances, mesh });
});
return out;
}
it("costs a handful of draw calls no matter how many airports a board has", () => {
// Buckets are shared **across** airports rather than per airport, so the
// count is a function of how many kinds of surface an airport has and not of
// how many airports there are. Two fields here; SoCal will have six.
assert.ok(meshes().length <= 12, `${meshes().length} draw calls for two airports`);
});
it("stays far inside its triangle allowance", () => {
const total = meshes().reduce((sum, entry) => sum + entry.triangles, 0);
// The allowance for this round was 35,000 on the Bay Area cell. Runways are
// quads and the only thing here that costs anything is the aircraft on
// stand, which are worth it.
assert.ok(total < 6_000, `the Bay Area's airports are ${total} triangles`);
const runways = meshes().find((entry) => entry.name === "airports:runway");
assert.ok(runways);
assert.equal(runways.triangles, AIRPORTS.reduce((n, a) => n + a.runways.length, 0) * 2);
});
it("faces every paved surface at the sky", () => {
// The taxiways were built right-rail-first and every triangle's normal
// pointed at the ground, so a one-sided material drew nothing at all: no
// warning, no black stripe, just no taxiways. This is that regression.
for (const { name, mesh } of meshes()) {
if (!/field|apron|taxiway|runway|markings/.test(name)) continue;
const normals = mesh.geometry.getAttribute("normal");
assert.ok(normals, `${name} has no normals and cannot merge`);
for (let i = 0; i < normals.count; i += 1) {
assert.ok(normals.getY(i) > 0.9, `${name} vertex ${i} faces ${normals.getY(i).toFixed(2)}`);
}
}
});
it("lays the field above the bias terrain.ts gives its own mesh", () => {
// `terrain.ts` pushes its relief to `world.metres(e) + 0.012`. An airport at
// `groundAt` plus a hundredth is an airport under the ground.
const field = meshes().find((entry) => entry.name === "airports:field");
assert.ok(field);
field.mesh.geometry.computeBoundingBox();
const y = field.mesh.geometry.boundingBox!.min.y;
assert.ok(y > 0.012, `the field plate sits at ${y}, under terrain's own 0.012 bias`);
// And the paint is above the concrete, which is above the field.
const order = ["airports:field", "airports:apron", "airports:taxiway", "airports:runway", "airports:markings"];
let previous = -Infinity;
for (const name of order) {
const entry = meshes().find((candidate) => candidate.name === name);
assert.ok(entry, `${name} was not built`);
entry.mesh.geometry.computeBoundingBox();
const min = entry.mesh.geometry.boundingBox!.min.y;
assert.ok(min > previous, `${name} is not above the layer below it`);
previous = min;
}
});
it("turns a terminal to its heading rather than mirroring it", () => {
// `rotateY` wants `atan2(x, z)` of the scene-space along-vector. With the
// sign of z flipped a building is not rotated but *reflected*, and SFO's
// horseshoe came out on 153.5° instead of 26.5°. The test builds one
// terminal on a known bearing and measures the box that comes back.
const single: Airport = {
id: "TEST",
name: "one shed",
lat: 37.6189,
lng: -122.375,
elevation: 0,
runways: [],
terminals: [
// Long and thin on purpose: the two furthest vertices of a box are its
// diagonal corners, so a stubby shed measures a couple of degrees off
// its own axis for reasons that have nothing to do with the bug.
{ id: "shed", lat: 37.6189, lng: -122.375, length: 2400, width: 18, height: 4, heading: 26.5 },
],
};
const built = createAirports(world, [single]);
const shed = [...built.children].find((child) => child.name === "airports:terminal") as THREE.Mesh;
assert.ok(shed, "no terminal was built");
const positions = shed.geometry.getAttribute("position");
// The two vertices furthest apart lie on the long axis, so the bearing
// between them is the building's.
let best = -1;
let a = new THREE.Vector3();
let b = new THREE.Vector3();
const p = new THREE.Vector3();
const q = new THREE.Vector3();
for (let i = 0; i < positions.count; i += 1) {
p.fromBufferAttribute(positions, i);
for (let j = i + 1; j < positions.count; j += 1) {
q.fromBufferAttribute(positions, j);
const d = p.distanceTo(q);
if (d > best) {
best = d;
a = p.clone();
b = q.clone();
}
}
}
// Scene space runs x east and z south, so north is z.
const along = b.clone().sub(a);
const measured = ((Math.atan2(along.x, -along.z) / DEG) + 360) % 360;
// The long axis is a line, not a ray, so either end is correct.
const error = Math.min(Math.abs(measured - 26.5), Math.abs(measured - 206.5));
// The mirror this catches is 127° wrong, so a degree of slack for the
// diagonal costs the test nothing.
assert.ok(error < 1.5, `the shed was built on ${measured.toFixed(1)}° rather than 26.5°`);
});
it("parks aircraft against the terminals that declared gates", () => {
const stands = meshes().find((entry) => entry.name === "airports:stands");
assert.ok(stands, "nothing is parked at either airport");
const expected = AIRPORTS.flatMap((airport) => airport.terminals ?? []).reduce(
(sum, terminal) => sum + (terminal.gates?.count ?? 0),
0,
);
const instanced = stands.mesh as THREE.InstancedMesh;
assert.equal(instanced.count, expected);
// Each one within a wingspan or two of the building it belongs to, which is
// the check that caught the mirrored terminals: the stands were exactly
// where they should be and the buildings were not.
const position = new THREE.Vector3();
const matrix = new THREE.Matrix4();
for (let i = 0; i < instanced.count; i += 1) {
instanced.getMatrixAt(i, matrix);
position.setFromMatrixPosition(matrix);
let nearest = Infinity;
for (const airport of AIRPORTS) {
for (const terminal of airport.terminals ?? []) {
if (!terminal.gates) continue;
const [x, z] = world.project(terminal.lat, terminal.lng);
nearest = Math.min(nearest, Math.hypot(position.x - x, position.z - z));
}
}
// Half the longest terminal plus the stand depth, in scene units.
assert.ok(nearest < 4.2, `stand ${i} is ${nearest.toFixed(2)} units from any terminal`);
}
});
it("draws nothing at all for a board with no airports", () => {
const empty = createAirports(world, []);
assert.equal(empty.children.length, 0);
assert.equal(empty.name, "airports");
});
it("is unbothered by a Node run with no canvas", () => {
// `markingsAtlas` needs a 2D context and there is none here. The paint has
// to fall back to a flat colour rather than throwing, because these tests
// and the office's server-side pack checks both run without a DOM.
const markings = meshes().find((entry) => entry.name === "airports:markings");
assert.ok(markings);
const material = markings.mesh.material as THREE.MeshBasicMaterial;
assert.equal(material.map, null);
assert.equal(material.toneMapped, false);
});
});
describe("SJC", () => {
it("puts both parallels on one bearing 700 ft apart", () => {
assert.equal(SJC.runways.length, 2);
for (const runway of SJC.runways) assert.equal(runway.heading, 131.5);
const separation = offsetFromCentreline(SJC.runways[0]!, [
SJC.runways[1]!.lat,
SJC.runways[1]!.lng,
]);
assert.ok(Math.abs(separation - 700 * 0.3048) < 8, `SJC's parallels are ${separation.toFixed(0)} m apart`);
});
it("keeps its plate off the freeway this pack draws beside it", () => {
// `BAYSHORE_101` passes about 240 m north-east of runway 12L here, so the
// graded plate runs further to the south-west than to the north-east. A
// symmetric one had US-101 drawn across the middle of the airport.
const world = new World(SF_CITY);
for (const [lat, lng] of SF_CITY.roads.flatMap((road) => road.path)) {
if (lat < 37.34 || lat > 37.38) continue;
assert.equal(
world.pointInPolygon(lat, lng, SJC.field!),
false,
`a road vertex at ${lat},${lng} is inside SJC's plate`,
);
}
});
});
+427
View File
@@ -0,0 +1,427 @@
/**
* The Southland fields, and the terrain they stand on.
*
* Every defect this file guards typechecked, threw nothing and cost nothing in
* the performance budget. Each was found by rendering the board and looking at
* it, and each assertion is the cheapest arithmetic statement of what the
* picture showed:
*
* 1. **Burbank on a hillside.** `airports.ts` grades a field to the highest
* ground it covers, which is what grading is. The pack's `Mount Thom` sat
* at 34.205/118.33 with a 4.4 km falloff — half a kilometre north of
* runway 08/26 — and put 478 m of mountain on the 26 threshold against
* 191 m on the 08 threshold. The plate graded to the high end and Hollywood
* Burbank rendered as a green table floating over its own city, with a
* shadow under the south fence. Photographed before and after.
* 2. **Long Beach on Signal Hill.** Same failure, smaller: a field rectangle
* reaching 118.169 caught the flank of a real 111 m hill 1.9 km away and
* lifted the whole plate a hundred metres.
* 3. **Houses on every runway.** `blocks.ts` has never heard of an airport.
* All six of these sit inside a district polygon, and the only thing
* keeping tract housing off a runway is the polygon stopping short — a
* notch for four of them, a hole reached by a corridor for Long Beach and
* Ontario. This sweeps the whole of every field rather than its corners,
* because checking the corners is exactly what lets a subdivision land in
* the middle of one.
* 4. **Runways on the painted numbers.** The board used to carry LAX as two
* hand-typed roads lying due eastwest. LAX's runways are on 82.9° true;
* due eastwest is seven degrees and four hundred metres out, and it is the
* kind of wrong that anybody who has flown into LAX sees at once.
*
* The geography assertions are a different kind. A runway on the wrong bearing
* is not a bug in any code — it is a number somebody typed — and the only thing
* that catches it is stating the published figure next to the authored one.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import SOCAL_CITY, { AIRPORTS, LAX, BUR, VNY, LGB, SNA, ONT, ROADS } from "../../cities/socal.ts";
import {
createAirports,
parallelTaxiway,
runwayThresholds,
type Airport,
type Runway,
} from "../../engine/airports.ts";
import type { LatLng } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";
const METRES_PER_DEGREE_LAT = 111_320;
const DEG = Math.PI / 180;
/**
* Magnetic declination over the Los Angeles basin, degrees east.
*
* The number that turns a painted designator into a true bearing, and the
* reason none of the headings below is a multiple of ten.
*/
const DECLINATION = 11.8;
function metresBetween(a: LatLng, b: LatLng): number {
const north = (a[0] - b[0]) * METRES_PER_DEGREE_LAT;
const east = (a[1] - b[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
return Math.hypot(north, east);
}
function bearing(a: LatLng, b: LatLng): number {
const north = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
const east = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
return ((Math.atan2(east, north) / DEG) + 360) % 360;
}
/** Perpendicular distance in metres from a runway's centreline to a point. */
function offsetFromCentreline(runway: Runway, point: LatLng): number {
const north = (point[0] - runway.lat) * METRES_PER_DEGREE_LAT;
const east = (point[1] - runway.lng) * METRES_PER_DEGREE_LAT * Math.cos(runway.lat * DEG);
const rightEast = Math.cos(runway.heading * DEG);
const rightNorth = -Math.sin(runway.heading * DEG);
return east * rightEast + north * rightNorth;
}
function runwayById(airport: Airport, id: string): Runway {
const runway = airport.runways.find((candidate) => candidate.id === id);
assert.ok(runway, `${airport.id} has no runway ${id}`);
return runway;
}
describe("LAX — the numbers a picture cannot check", () => {
it("lays all four parallels on one true bearing, not on their painted ones", () => {
// A designator is magnetic and rounded to ten degrees. 82.9 true minus the
// basin's 11.8° east declination is 71.1 magnetic, which rounds to "07" —
// and the south pair is 07L/25R and 07R/25L. The FAA does not allow four
// parallels to share a number, so the north pair takes the next one down and
// is called 06/24 while lying on exactly the same bearing. Any reading that
// makes the 24s a different heading from the 25s is wrong about LAX.
for (const runway of LAX.runways) assert.equal(runway.heading, 82.9);
const magnetic = 82.9 - DECLINATION;
assert.ok(Math.abs(magnetic - 70) < 5, `82.9 true is ${magnetic.toFixed(1)} magnetic`);
});
it("holds each runway to its published length", () => {
for (const [id, feet] of [
["06L/24R", 8_926],
["06R/24L", 10_285],
["07L/25R", 12_091],
["07R/25L", 11_095],
] as const) {
const runway = runwayById(LAX, id);
const metres = feet * 0.3048;
assert.ok(
Math.abs(runway.length - metres) < 12,
`${id} is ${runway.length} m against ${metres.toFixed(0)} m`,
);
}
});
it("keeps the two complexes their real distance apart, and on the right sides", () => {
// 700 ft inside the north complex, 800 ft inside the south, and about
// 1,250 m of airport between 24L and 25R — which is what the horseshoe fills.
const north = offsetFromCentreline(runwayById(LAX, "06L/24R"), [
runwayById(LAX, "06R/24L").lat,
runwayById(LAX, "06R/24L").lng,
]);
assert.ok(Math.abs(north - 700 * 0.3048) < 8, `06L to 06R is ${north.toFixed(0)} m`);
const south = offsetFromCentreline(runwayById(LAX, "07L/25R"), [
runwayById(LAX, "07R/25L").lat,
runwayById(LAX, "07R/25L").lng,
]);
assert.ok(Math.abs(south - 800 * 0.3048) < 8, `07L to 07R is ${south.toFixed(0)} m`);
// Sign matters as much as magnitude: right of the 07 direction is south, and
// every "R" runway at LAX is the southern one of its pair. A mirrored
// complex is a plausible airport in the wrong place.
assert.ok(north > 0 && south > 0, "both right-hand runways are on the right");
const between = offsetFromCentreline(runwayById(LAX, "06R/24L"), [
runwayById(LAX, "07L/25R").lat,
runwayById(LAX, "07L/25R").lng,
]);
assert.ok(
Math.abs(between - 1250) < 60,
`the terminal gap is ${between.toFixed(0)} m, not ~1,250`,
);
});
it("puts the horseshoe between the complexes with its stands on the outside", () => {
const arms = ["north-arm", "south-arm"] as const;
for (const id of arms) {
const terminal = (LAX.terminals ?? []).find((t) => t.id === id);
assert.ok(terminal, `LAX has no ${id}`);
assert.ok(terminal.gates, `${id} has no stands, which is what makes it an airport`);
const across = offsetFromCentreline(runwayById(LAX, "06R/24L"), [terminal.lat, terminal.lng]);
assert.ok(across > 0 && across < 1250, `${id} is not between the complexes`);
}
// North arm's stands face north, south arm's face south: away from the
// court, which holds roadway and cars and nothing that needs a wingspan.
assert.equal((LAX.terminals ?? []).find((t) => t.id === "north-arm")?.gates?.side, -1);
assert.equal((LAX.terminals ?? []).find((t) => t.id === "south-arm")?.gates?.side, 1);
});
});
describe("every field's bearings agree with its designators", () => {
for (const airport of AIRPORTS) {
it(`${airport.id} is on true bearings, not painted ones`, () => {
for (const runway of airport.runways) {
const designator = runway.designators?.[0];
assert.ok(designator, `${airport.id} ${runway.id} has no designators`);
const painted = Number.parseInt(designator, 10) * 10;
// LAX's north complex is the documented exception, and it is a rule
// rather than an error: the FAA does not let four parallel runways share
// a number, so the pair that is not on the magnetic figure takes the next
// one down. 06L/24R and 06R/24L lie on exactly the 07s' bearing.
const renumbered = airport === LAX && designator.startsWith("06") ? 10 : 0;
const magnetic = runway.heading - DECLINATION;
// Five degrees is the rounding a designator already carries; anything
// outside it is a runway pointing somewhere else.
assert.ok(
Math.abs(((magnetic - painted - renumbered + 540) % 360) - 180) < 5,
`${airport.id} ${runway.id}: ${runway.heading}° true is ${magnetic.toFixed(1)}° magnetic, ` +
`which is not "${designator}"`,
);
// And it is never a round number, which is the mistake this catches:
// building from the painted figure lays the field a declination out.
assert.notEqual(runway.heading % 10, 0, `${airport.id} ${runway.id} is on a magnetic heading`);
}
});
it(`${airport.id}'s thresholds agree with its declared lengths`, () => {
for (const runway of airport.runways) {
const { low, high } = runwayThresholds(runway);
assert.ok(Math.abs(metresBetween(low, high) - runway.length) < 2);
assert.ok(Math.abs(bearing(low, high) - runway.heading) < 0.1);
}
});
}
});
describe("the pack stays data, and stays derivable", () => {
it("keeps every parallel taxiway aligned with the runway it parallels", () => {
// The coordinates are typed rather than computed, because a city pack must
// not import three.js. This is what stops them drifting: each one still has
// to be what `parallelTaxiway` would produce.
const spec: Array<[Airport, string, string, 1 | -1, number, number]> = [
[LAX, "B", "06L/24R", -1, 150, 90],
[LAX, "C", "06R/24L", 1, 140, 90],
[LAX, "D", "07L/25R", -1, 140, 90],
[LAX, "E", "07R/25L", 1, 150, 90],
[BUR, "A", "15/33", -1, 140, 90],
[BUR, "C", "08/26", 1, 130, 80],
[VNY, "A", "16R/34L", -1, 140, 80],
[LGB, "D", "12/30", 1, 140, 100],
[LGB, "B", "08L/26R", -1, 130, 80],
[SNA, "A", "02L/20R", 1, 140, 80],
[ONT, "A", "08L/26R", 1, 140, 100],
[ONT, "B", "08R/26L", -1, 140, 100],
];
for (const [airport, id, runwayId, side, offset, trim] of spec) {
const authored = (airport.taxiways ?? []).find((taxiway) => taxiway.id === id);
assert.ok(authored, `${airport.id} has no taxiway ${id}`);
const derived = parallelTaxiway(runwayById(airport, runwayId), side, offset, trim).path;
assert.equal(authored.path.length, derived.length);
authored.path.forEach((point, index) => {
const want = derived[index]!;
assert.ok(
metresBetween(point, want) < 2,
`${airport.id} taxiway ${id} point ${index} is ${metresBetween(point, want).toFixed(1)} m off`,
);
});
}
});
it("survives the JSON round trip a served pack would take", () => {
// CONTRACT.md §2: a pack hand-written as a module and one arriving over HTTP
// have to be literally the same thing.
for (const airport of AIRPORTS) {
assert.deepEqual(JSON.parse(JSON.stringify(airport)), airport);
assert.doesNotThrow(() => structuredClone(airport));
}
});
it("no longer draws a runway as a road, and no road crosses a field", () => {
// Two hand-typed strips on 33.9535 and 33.9405 due east-west used to stand in
// for LAX. A road ribbon drapes 0.14 units above the terrain and a runway
// quad lies flush on it, so a board carrying both floats a dark stripe over
// every runway. The sweep is every hundred metres along every road rather
// than every vertex, because a freeway with one vertex either side of an
// airport still has tarmac drawn across it — which is how I-405 was found
// running over the 25R touchdown zone.
const world = new World(SOCAL_CITY);
for (const road of ROADS) {
for (let index = 1; index < road.path.length; index += 1) {
const from = road.path[index - 1]!;
const to = road.path[index]!;
const steps = Math.max(1, Math.ceil(metresBetween(from, to) / 100));
for (let step = 0; step <= steps; step += 1) {
const lat = from[0] + (to[0] - from[0]) * (step / steps);
const lng = from[1] + (to[1] - from[1]) * (step / steps);
for (const airport of AIRPORTS) {
assert.equal(
world.pointInPolygon(lat, lng, airport.field!), false,
`a road runs across ${airport.id} at ${lat.toFixed(4)},${lng.toFixed(4)}`,
);
}
}
}
}
});
});
describe("every airport on the SoCal board sits somewhere legal", () => {
const world = new World(SOCAL_CITY);
function coordinates(airport: Airport): LatLng[] {
const out: LatLng[] = [[airport.lat, airport.lng]];
for (const runway of airport.runways) {
const { low, high } = runwayThresholds(runway);
out.push(low, high, [runway.lat, runway.lng]);
}
for (const point of airport.field ?? []) out.push(point);
for (const apron of airport.aprons ?? []) out.push(...apron.polygon);
for (const taxiway of airport.taxiways ?? []) out.push(...taxiway.path);
for (const terminal of airport.terminals ?? []) out.push([terminal.lat, terminal.lng]);
return out;
}
for (const airport of AIRPORTS) {
it(`${airport.id} is on land, on the board, and inside its own fence`, () => {
const field = airport.field;
assert.ok(field, `${airport.id} declares no field outline`);
for (const point of coordinates(airport)) {
assert.equal(world.isLand(point[0], point[1]), true, `${airport.id}: ${point} is water`);
}
const bounds = SOCAL_CITY.bounds;
assert.ok(
airport.lat > bounds.minLat && airport.lat < bounds.maxLat &&
airport.lng > bounds.minLng && airport.lng < bounds.maxLng,
`${airport.id} is off the board`,
);
// Everything the kit draws has to be inside the graded plate, or it is
// drawn at plate height over ground that is somewhere else — and a plate
// graded from an apron corner that hangs off the field is graded from
// ground the field does not cover. Van Nuys's east ramp did exactly that.
const inside: LatLng[] = [];
for (const runway of airport.runways) {
const { low, high } = runwayThresholds(runway);
inside.push(low, high);
}
for (const apron of airport.aprons ?? []) inside.push(...apron.polygon);
for (const taxiway of airport.taxiways ?? []) inside.push(...taxiway.path);
for (const terminal of airport.terminals ?? []) inside.push([terminal.lat, terminal.lng]);
for (const point of inside) {
assert.equal(
world.pointInPolygon(point[0], point[1], field), true,
`${airport.id}: ${point[0].toFixed(5)},${point[1].toFixed(5)} is outside the field`,
);
}
});
it(`${airport.id} has no district or park scattered over it`, () => {
const field = airport.field!;
const lats = field.map((point) => point[0]);
const lngs = field.map((point) => point[1]);
for (let lat = Math.min(...lats); lat <= Math.max(...lats); lat += 0.0004) {
for (let lng = Math.min(...lngs); lng <= Math.max(...lngs); lng += 0.0004) {
if (!world.pointInPolygon(lat, lng, field)) continue;
for (const district of SOCAL_CITY.districts) {
assert.equal(
world.pointInPolygon(lat, lng, district.polygon), false,
`${airport.id}: district ${district.id} reaches ${lat.toFixed(4)},${lng.toFixed(4)}`,
);
}
assert.equal(world.inPark(lat, lng), false, `${airport.id}: park over ${lat},${lng}`);
}
}
});
}
it("draws exactly one control tower at LAX", () => {
// The pack's landmark is the tower, because `label: true` is what puts LAX
// on the minimap and `minimap.ts` reads `city.landmarks`, not `city.airports`.
const towers = SOCAL_CITY.landmarks.filter((landmark) => /LAX Control Tower/.test(landmark.name));
assert.equal(towers.length, 1);
assert.equal(LAX.tower, undefined, "LAX declares a second tower on top of its landmark");
for (const airport of AIRPORTS) assert.equal(airport.tower, undefined);
const tower = towers[0]!;
let nearest = Infinity;
for (const terminal of LAX.terminals ?? []) {
nearest = Math.min(nearest, metresBetween([tower.lat, tower.lng], [terminal.lat, terminal.lng]));
}
assert.ok(nearest < 500, `the tower is ${nearest.toFixed(0)} m from the nearest terminal`);
// And the flat two-kilometre pad that used to stand in for the airport is
// gone, or it would be drawn on top of its own field.
assert.equal(SOCAL_CITY.landmarks.some((landmark) => landmark.name === "LAX"), false);
});
});
describe("no field is graded onto a hillside", () => {
/**
* The Burbank guard, and the reason `Mount Thom` moved.
*
* `airports.ts` lays the whole plate at the highest ground the airport
* covers, so a field that reaches onto rising ground stands proud of its own
* city by the difference. Ten scene units of relief here is 1.15 km; the bound
* below is 0.6 units, about 70 real metres at this board's 3.4× exaggeration,
* which is a graded platform rather than a table.
*/
const world = new World(SOCAL_CITY);
const LIMIT = 0.6;
for (const airport of AIRPORTS) {
it(`${airport.id} covers less than ${LIMIT} units of relief`, () => {
const field = airport.field!;
const lats = field.map((point) => point[0]);
const lngs = field.map((point) => point[1]);
let low = Infinity;
let high = -Infinity;
for (let lat = Math.min(...lats); lat <= Math.max(...lats); lat += 0.0004) {
for (let lng = Math.min(...lngs); lng <= Math.max(...lngs); lng += 0.0004) {
if (!world.pointInPolygon(lat, lng, field)) continue;
const ground = world.groundAt(lat, lng);
if (ground < low) low = ground;
if (ground > high) high = ground;
}
}
assert.ok(
high - low < LIMIT,
`${airport.id} spans ${(high - low).toFixed(3)} units of ground; its plate would float`,
);
});
}
});
describe("what the six airports cost", () => {
/**
* The SoCal board's real projection with flat ground. A 1:1 fake world would
* hide every scale mistake in the kit, and flat ground is what makes the
* triangle count reproducible.
*/
function socalWorld(): World {
const world = new World(SOCAL_CITY);
return Object.assign(Object.create(Object.getPrototypeOf(world) as object), world, {
groundAt: () => 0,
elevationSampled: () => 0,
}) as World;
}
it("draws all six for a few thousand triangles in a handful of draw calls", () => {
const group = createAirports(socalWorld(), AIRPORTS);
let triangles = 0;
let draws = 0;
group.traverse((object) => {
const mesh = object as THREE.Mesh & { isMesh?: boolean; isInstancedMesh?: boolean; count?: number };
if (!mesh.isMesh) return;
const indices = mesh.geometry.index?.count ?? mesh.geometry.getAttribute("position").count;
const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
triangles += (indices / 3) * instances;
draws += 1;
});
// Measured at 6,428 triangles in 8 draw calls. The allowance for this
// workstream was 35,000 on the SoCal cell; the headroom is not an invitation,
// and a change that doubles this is a change worth arguing for.
assert.ok(triangles < 12_000, `the Southland fields cost ${triangles} triangles`);
assert.ok(draws <= 12, `the Southland fields cost ${draws} draw calls`);
// Most of that is aeroplanes on stand, which is the single biggest thing
// making an airport read as an airport rather than as a car park.
const stands = group.getObjectByName("airports:stands") as THREE.InstancedMesh | undefined;
assert.ok(stands, "nothing is parked at any gate on this board");
});
});
+293
View File
@@ -0,0 +1,293 @@
/**
* The bridge kit's judgement, which is the part a screenshot cannot check.
*
* `bridges.ts` decides what *kind* of bridge a `Bridge` record describes: which
* reaches of deck hang from a cable, where the anchorages go, and which stretches
* stand on piers instead. Those decisions are invisible in a picture except as
* their consequences — the Bay Bridge's Yerba Buena crossing is right when there
* is no cable over the island, and "no cable" is also exactly what a broken
* classifier looks like. So they are asserted here.
*
* The world below is San Francisco's real projection, not a unit square: one
* scene unit is 94.34 m and heights carry the pack's 3.6× exaggeration. That
* matters because the first version of the span limit compared an exaggerated
* tower height against an unexaggerated deck length, decided the Bay Bridge
* could suspend two and a half kilometres, and drew it. A fake world with a
* tidy 1:1 scale would have passed.
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { buildBridge, planBridge } from "../../engine/bridges.ts";
import type { GeometrySink, SurfaceKind } from "../../engine/bridges.ts";
import type { Bridge } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
const EXAGGERATION = 3.6;
/** San Francisco's projection, with the ground wherever the caller says. */
function bayWorld(groundAt: (lat: number, lng: number) => number = () => 0): World {
const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
return {
project(lat: number, lng: number): [number, number] {
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
},
groundAt,
metres(value: number): number {
return (value / METRES_PER_UNIT) * EXAGGERATION;
},
metresPerUnit: METRES_PER_UNIT,
} as unknown as World;
}
/** Collects what a build emitted, without a `Batch` or a renderer in sight. */
function sink(): GeometrySink & { parts: { name: string; geometry: THREE.BufferGeometry }[] } {
const materials = new Map<string, THREE.Material>();
const parts: { name: string; geometry: THREE.BufferGeometry }[] = [];
return {
parts,
material(kind: SurfaceKind, color: number): THREE.Material {
const key = `${kind}:${color}`;
const hit = materials.get(key);
if (hit) return hit;
const made = new THREE.MeshBasicMaterial({ color });
made.name = key;
materials.set(key, made);
return made;
},
add(name, geometry) {
parts.push({ name, geometry });
},
};
}
const ROADWAY = new THREE.MeshBasicMaterial({ color: 0x3a3f42 });
/** The pack's Golden Gate, coordinate for coordinate. */
const GOLDEN_GATE: Bridge = {
name: "Golden Gate Bridge",
path: [
[37.8025, -122.4752],
[37.8106, -122.4775],
[37.8155, -122.4783],
[37.825, -122.479],
[37.8325, -122.4798],
[37.8375, -122.4806],
],
towers: [
[37.8155, -122.4783],
[37.825, -122.479],
],
towerHeight: 227,
deckHeight: 67,
sag: 0.55,
color: 0xc0442c,
};
/** The pack's Bay Bridge: two west-span towers, a tunnel, and one more tower. */
const BAY_BRIDGE: Bridge = {
name: "Bay Bridge",
path: [
[37.7905, -122.3885],
[37.7965, -122.3805],
[37.8035, -122.3725],
[37.8095, -122.3648],
[37.8155, -122.3535],
[37.8205, -122.3405],
[37.8225, -122.3275],
],
towers: [
[37.7955, -122.3815],
[37.8035, -122.3725],
[37.8165, -122.3515],
],
towerHeight: 160,
deckHeight: 58,
sag: 0.4,
color: 0x9aa6b2,
};
/** The pack's San MateoHayward: eleven kilometres of trestle, one high span. */
const SAN_MATEO: Bridge = {
name: "San MateoHayward Bridge",
path: [
[37.5745, -122.2585],
[37.578, -122.255],
[37.5865, -122.2405],
[37.6, -122.212],
[37.615, -122.175],
[37.628, -122.128],
[37.6305, -122.1235],
],
towers: [[37.5865, -122.2405]],
towerHeight: 58,
deckHeight: 14,
sag: 0.3,
color: 0x9aa6b2,
};
// ---- The classifier --------------------------------------------------------
test("the Golden Gate is a main span between its towers", () => {
const plan = planBridge(bayWorld(), GOLDEN_GATE);
const main = plan.reaches.filter((reach) => reach.kind === "main");
assert.equal(main.length, 1, "the strait is one main span, not several");
const [span] = main;
assert.ok(span);
assert.deepEqual(
[span.from, span.to],
plan.towerStations,
"the main span does not run tower to tower",
);
// One cable run: anchorage, tower, tower, anchorage.
assert.equal(plan.chains, 1);
});
test("the Golden Gate anchors its cable short of the shore", () => {
const plan = planBridge(bayWorld(), GOLDEN_GATE);
// The pack runs the path 1.4 km past each tower so the span has something to
// land on. A real anchorage sits about half a main span out and the rest is
// approach viaduct — anchoring at the end of the path instead is what used to
// run the side cables up the Presidio bluff.
const kinds = plan.reaches.map((reach) => reach.kind);
assert.equal(kinds[0], "approach", "the first reach should be viaduct, not cable");
assert.equal(kinds[kinds.length - 1], "approach");
assert.equal(kinds.filter((kind) => kind === "side").length, 2);
});
test("the Bay Bridge does not suspend a cable over Yerba Buena", () => {
const plan = planBridge(bayWorld(), BAY_BRIDGE);
const towers = plan.towerStations;
const [west, centre, east] = towers;
assert.ok(west !== undefined && centre !== undefined && east !== undefined);
const between = plan.reaches.find((reach) => reach.from === centre && reach.to === east);
assert.ok(between, "no reach runs from the west span's far tower to the east span's");
// 2.35 km at 160 m of tower is fifteen tower-heights. Nothing ever built
// reaches nine, and this one crosses an island.
assert.equal(between.kind, "approach");
const main = plan.reaches.filter((reach) => reach.kind === "main");
assert.equal(main.length, 1, "only the west span is suspended tower-to-tower");
assert.deepEqual([main[0]?.from, main[0]?.to], [west, centre]);
// Two separate cable runs: the west span's, and the single-tower east span's.
assert.equal(plan.chains, 2);
});
test("a trestle with one channel tower gets a hump, not a cable across the bay", () => {
const plan = planBridge(bayWorld(), SAN_MATEO);
assert.equal(plan.chains, 1, "the ship-channel tower should carry exactly one cable run");
const suspended = plan.reaches.filter((reach) => reach.kind !== "approach");
const suspendedLength = suspended.length;
assert.ok(suspendedLength > 0, "the tower is holding nothing up");
// Eleven kilometres of crossing, and the cable covers a few hundred metres of
// it. `sideLimit` for a 58 m tower is 58 × 9 × 0.55 = 287 m.
const cableSpan = suspended.reduce((sum, reach) => sum + (reach.to - reach.from), 0);
assert.ok(
cableSpan < plan.stationCount * 0.2,
`the cable covers ${cableSpan} of ${plan.stationCount} stations`,
);
});
// ---- What comes out --------------------------------------------------------
test("a bridge is two buckets: painted structure, and roadway", () => {
const into = sink();
const cost = buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
const names = new Set(into.parts.map((part) => part.name));
assert.deepEqual([...names].sort(), ["Golden Gate Bridge", "bridge:roadway"]);
assert.ok(cost.roadway > 0, "the deck has no running surface");
assert.ok(cost.structure > cost.roadway, "the structure should outweigh one flat plate");
// The parts the whole kit exists for. `byPart` is the census, not the buckets.
for (const part of ["deck", "tower", "cable", "hanger", "pier", "anchorage"]) {
assert.ok((cost.byPart[part] ?? 0) > 0, `the bridge has no ${part}`);
}
});
test("every part carries the attributes a merge needs", () => {
const into = sink();
buildBridge(bayWorld(), BAY_BRIDGE, into, ROADWAY);
// `mergeGeometries` returns null when attribute sets disagree, and `Batch`
// drops the whole bucket. A part missing a UV takes the bridge with it.
for (const part of into.parts) {
for (const attribute of ["position", "normal", "uv"]) {
assert.ok(
part.geometry.getAttribute(attribute),
`${part.name} lost its ${attribute}`,
);
}
assert.ok(part.geometry.getIndex(), `${part.name} is not indexed`);
}
});
test("the towers stand at their authored height and the deck at its own", () => {
const into = sink();
buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
const world = bayWorld();
const box = new THREE.Box3();
for (const part of into.parts) {
part.geometry.computeBoundingBox();
if (part.geometry.boundingBox) box.union(part.geometry.boundingBox);
}
assert.ok(
Math.abs(box.max.y - world.metres(227)) < 0.2,
`the towers top out at ${box.max.y.toFixed(2)}, not ${world.metres(227).toFixed(2)}`,
);
const roadway = into.parts.filter((part) => part.name === "bridge:roadway");
const deck = new THREE.Box3();
for (const part of roadway) {
part.geometry.computeBoundingBox();
if (part.geometry.boundingBox) deck.union(part.geometry.boundingBox);
}
assert.ok(
Math.abs(deck.max.y - world.metres(67)) < 0.05,
`the deck sits at ${deck.max.y.toFixed(2)}, not ${world.metres(67).toFixed(2)}`,
);
// The ramp at each end drops the deck onto the shore, so the lowest roadway
// is below the authored deck height rather than at it.
assert.ok(deck.min.y < deck.max.y - 0.2, "the deck never lands on anything");
});
test("a pier is not driven through an island", () => {
// Yerba Buena, as a hill under the middle of the crossing: ground above the
// deck for a stretch of it. The approach must walk onto that rather than
// standing on stilts over the top of it.
const island = bayWorld((lat, lng) =>
lat > 37.806 && lat < 37.813 && lng > -122.368 && lng < -122.36 ? 4 : 0,
);
const into = sink();
buildBridge(island, BAY_BRIDGE, into, ROADWAY);
const flat = sink();
buildBridge(bayWorld(), BAY_BRIDGE, flat, ROADWAY);
const piers = (parts: typeof into.parts) =>
parts.filter((part) => {
part.geometry.computeBoundingBox();
const box = part.geometry.boundingBox;
return box !== null && box.min.y < -0.05;
}).length;
assert.ok(
piers(into.parts) < piers(flat.parts),
"the island did not remove a single pier",
);
});
test("the whole crossing stays inside its triangle allowance", () => {
// The Bay Area board had 21,256 spare triangles when this kit was written and
// five crossings to spend them on. This is the per-bridge share, and it is
// here because the cheapest way to lose it is a spacing constant: halving
// `stationSpacing` quadruples nothing visible and doubles the deck.
const world = bayWorld();
for (const bridge of [GOLDEN_GATE, BAY_BRIDGE, SAN_MATEO]) {
const into = sink();
const cost = buildBridge(world, bridge, into, ROADWAY);
const total = cost.structure + cost.roadway;
assert.ok(total < 4_000, `${bridge.name} costs ${total} triangles`);
}
});
+101
View File
@@ -0,0 +1,101 @@
/**
* The aeroplane glyph has a floor AND a ceiling, and the ceiling is the newer half.
*
* The floor is a screen-space rule: never smaller than legible, because position
* and heading are what a reader wants off a map and neither survives half a
* pixel. It scales by the distance to *that aircraft*, which is the same thing
* as "how far the camera has zoomed" only when everything in frame is equally
* far away. On a whole-board pose it is. Beside a landmark it is not — at the
* Golden Gate the bridge is a couple of units from the camera and the traffic
* over the Pacific is a couple of thousand, so the floor fired hard on the
* aeroplane and not at all on the bridge, and an airliner was drawn about two
* and a half times the length of the main span.
*
* These tests pin both ends: that the board still gets a symbol it can read, and
* that no camera anywhere can produce a state-sized aeroplane again.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { glyphScale } from "../../engine/flights.ts";
/** The field of view the city scenes actually use, near enough for a ratio. */
const FOV = 50;
describe("glyph scale", () => {
it("never shrinks an aeroplane below the size it was drawn at", () => {
for (const d of [0.5, 5, 20, 34]) {
assert.ok(glyphScale(d, FOV) >= 1, `close camera at ${d} must not shrink the glyph`);
}
});
it("still grows the glyph across the whole-board range, where the floor is the point", () => {
const near = glyphScale(200, FOV);
const far = glyphScale(400, FOV);
assert.ok(near > 1, "a board-distance aeroplane must be enlarged to stay readable");
assert.ok(far > near, "the floor must keep tracking distance until the ceiling binds");
});
it("stops growing at all, however far the aircraft is", () => {
const capped = glyphScale(2280, FOV);
assert.equal(
glyphScale(4000, FOV),
capped,
"past the ceiling the scale must be flat, not merely slower",
);
assert.equal(glyphScale(1e9, FOV), capped, "and flat all the way out");
});
/*
* Deliberately asserts a REDUCTION and not an absolute size, because the
* ceiling is a mitigation and calling it a cure in a test name would be the
* test lying about the product.
*
* 2,280 units is the measured Golden Gate case, where the uncapped glyph
* reached about 81x — roughly two and a half times the bridge's 1,280 m main
* span at ~94 m to the unit. The ceiling takes that to about one and a half.
* It is still bigger than the bridge. The complete fix is to clamp against the
* camera's focus distance rather than the aircraft's, which is a signature
* change and is written up in `flights.ts`.
*/
it("cuts the worst case by a third without touching any board distance", () => {
/** The floor alone, with no ceiling — what the scale used to be. */
const uncapped = (d: number, fov: number): number => {
const frustum = 2 * d * Math.tan((fov * Math.PI) / 360);
return Math.max(1, (0.016 * frustum) / 0.42);
};
// 1,160 units is the far end of the orbit over the California corridor — a
// pose people actually use. It must be untouched at every field of view the
// scenes run at, because below 0.012 of the frame the wings stop resolving
// and a ceiling of 26 put it at 0.0123.
for (const fov of [42, 50, 60]) {
assert.equal(
glyphScale(1160, fov),
uncapped(1160, fov),
`the ceiling must not bind at 1160 units and ${fov} degrees`,
);
}
// And the chapter-zoom case must actually come down.
assert.ok(
glyphScale(2280, 50) < uncapped(2280, 50) * 0.7,
"the ceiling must remove at least 30% of the worst case",
);
});
it("is monotonic, so an aeroplane never grows as it approaches", () => {
let previous = 0;
for (const d of [1, 10, 50, 100, 300, 700, 900, 2000, 5000]) {
const s = glyphScale(d, FOV);
assert.ok(s >= previous, `scale fell between distances at ${d}`);
previous = s;
}
});
it("returns 1 for degenerate inputs rather than emptying the sky", () => {
for (const [d, fov] of [[0, FOV], [-1, FOV], [10, 0], [10, 180], [NaN, FOV], [10, NaN]]) {
assert.equal(glyphScale(d as number, fov as number), 1);
}
});
});
+92 -38
View File
@@ -35,24 +35,34 @@ import type { Bridge, City, Road } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";
/**
* The smallest thing `structures.ts` will accept: a flat projection, ground at
* zero, and metres straight through.
* The smallest thing `structures.ts` will accept: San Francisco's projection,
* ground at sea level, and no heightfield.
*
* A real `World` builds a heightfield, which is 0.53M lattice points and a
* couple of seconds — none of which any assertion here depends on.
* A real `World` builds one, which is 0.53M lattice points and a couple of
* seconds — none of which any assertion here depends on. The *scale* does
* matter and used to be a tidy 20 units per degree with metres straight
* through: `bridges.ts` sizes its members against `metresPerUnit` and compares
* span lengths against tower heights, so a world whose projection and whose
* `metres()` disagree gives a bridge nothing real to be checked against.
*/
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
function flatWorld(city: Partial<City>): World {
const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
return {
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
project(lat: number, lng: number): [number, number] {
return [(lng + 122) * 20, -(lat - 37) * 20];
return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
},
groundAt(): number {
return 0;
},
metres(value: number): number {
return value / 100;
return (value / METRES_PER_UNIT) * 3.6;
},
metresPerUnit: METRES_PER_UNIT,
} as unknown as World;
}
@@ -73,6 +83,11 @@ const GOLDEN_GATE: Bridge = {
color: 0xc0553b,
};
/** Everything painted the bridge's own colour, which is everything but the road. */
function structureOf(root: THREE.Object3D): THREE.Mesh | undefined {
return meshes(root).find((mesh) => mesh.name !== "bridge:roadway");
}
function meshes(root: THREE.Object3D): THREE.Mesh[] {
const found: THREE.Mesh[] = [];
root.traverse((object) => {
@@ -92,29 +107,32 @@ function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
// ---- Bridges ---------------------------------------------------------------
test("a suspension bridge is one material and one draw call", () => {
test("a suspension bridge is two draw calls: structure and roadway", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
// The spec's number is six; a bridge is painted one colour throughout, so
// anything above one is a part that was left out of the bucket.
// The spec's number was six for a bridge painted one colour throughout, and
// it is two now for a reason worth stating: the deck of a bridge is a road,
// and painting it International Orange with the towers is most of why the
// Golden Gate used to read as a red line. Everything structural is still one
// material — anything above two is a part that fell out of a bucket.
const distinct = materialsIn(bridge);
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
assert.equal(distinct.size, 2, `the bridge holds ${distinct.size} materials, not two`);
assert.equal(meshes(bridge).length, 2, "the bridge did not merge into two meshes");
assert.ok(structureOf(bridge), "nothing in the bridge is painted the bridge's colour");
});
test("merging kept every part of the bridge", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
const merged = meshes(bridge)[0];
const merged = structureOf(bridge);
assert.ok(merged);
// The arithmetic, because a bucket that failed to merge comes out as one
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
// *part* of a bridge and otherwise looks entirely healthy. Two towers are six
// frusta, two fenders and five struts each 13 boxes, 24 vertices apiece —
// the deck box is four strips over about fifty stations, and the cables and
// their hangers are the rest. Two thousand is well under the floor.
const vertices = merged.geometry.getAttribute("position").count;
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
assert.ok(vertices > 2_000, `the bridge merged down to ${vertices} vertices`);
// The merge only happens because every part carries the same attributes.
for (const name of ["position", "normal", "uv"]) {
@@ -127,30 +145,38 @@ test("merging kept every part of the bridge", () => {
});
test("the bridge is still shaped like a bridge after the merge", () => {
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
const merged = meshes(bridge)[0];
const world = flatWorld({});
const bridge = createBridge(world, GOLDEN_GATE);
const merged = structureOf(bridge);
assert.ok(merged);
merged.geometry.computeBoundingBox();
const box = merged.geometry.boundingBox;
assert.ok(box);
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
// transforms into the geometry is where a merge goes wrong — a part that lost
// its translation collapses onto the origin and the box stops matching.
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
assert.ok(box.min.y > 0, "something sank below the water line");
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
// Towers to 8.66 units — 227 m at 94 m per unit and 3.6× exaggeration — with
// the deck and its cables below. Baking the transforms into the geometry is
// where a merge goes wrong: a part that lost its translation collapses onto
// the origin and the box stops matching.
const top = world.metres(227);
assert.ok(Math.abs(box.max.y - top) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
// Tower feet and pier footings go under the surface on purpose; nothing
// should be a whole tower's worth of them.
assert.ok(box.min.y > -1, `something sank to ${box.min.y.toFixed(2)}`);
assert.ok(box.max.z - box.min.z > 20, "the bridge has no span");
});
test("two bridges are two draw calls, not sixty-eight", () => {
test("two bridges are three draw calls, not sixty-eight", () => {
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
assert.equal(meshes(group).length, 2);
// Different colours, so genuinely two materials. Each bridge builds its own
// batch, which is deliberate: the cache cannot outlive the build, because
// `createScene().dispose()` walks the scene disposing every material it finds
// and a shared cache would hand the next board a disposed one.
assert.equal(materialsIn(group).size, 2);
// Two structures — different colours, so genuinely two materials — and one
// roadway, because both decks are the same asphalt and one batch covers the
// whole board. That batch still cannot outlive the build: `createScene()
// .dispose()` walks the scene disposing every material it finds, and a cache
// that survived would hand the next board a disposed one.
assert.equal(meshes(group).length, 3);
assert.equal(materialsIn(group).size, 3);
const names = meshes(group).map((mesh) => mesh.name).sort();
assert.deepEqual(names, ["bay-bridge", "bridge:roadway", "golden-gate"]);
});
// ---- Roads -----------------------------------------------------------------
@@ -180,7 +206,7 @@ test("identical roads share one material and one mesh", () => {
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
});
test("a freeway keeps its median stroke as a second material", () => {
test("a freeway carries its markings as texture, not as a second ribbon", () => {
const freeway: Road = {
kind: "freeway",
width: 0.14,
@@ -190,8 +216,36 @@ test("a freeway keeps its median stroke as a second material", () => {
],
};
const group = createRoads(flatWorld({ roads: [freeway] }));
// Two colours is two calls, and that is the floor rather than a regression:
// the stroke is a different colour from the deck it sits on.
assert.equal(meshes(group).length, 2);
assert.equal(materialsIn(group).size, 2);
// One ribbon, one call. The median stroke used to be a second draped ribbon
// in a second colour; verge, shoulders, edge lines and median now live in the
// surface texture, which costs no triangles and reads as a road rather than
// as a line on a map.
assert.equal(meshes(group).length, 1);
assert.equal(materialsIn(group).size, 1);
});
test("a road is drawn wider than its carriageway, and streets less so", () => {
const shape = (kind: Road["kind"]): number => {
const road: Road = {
kind,
width: 0.2,
path: [
[37.7, -122.4],
[37.9, -122.4],
],
};
const mesh = meshes(createRoads(flatWorld({ roads: [road] })))[0];
assert.ok(mesh);
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
assert.ok(box);
return box.max.x - box.min.x;
};
// The widening is the graded right-of-way the texture paints, and a freeway
// gets more of it than a boulevard does. Both are wider than the authored
// 0.2, which is the carriageway alone.
const street = shape("street");
const freeway = shape("freeway");
assert.ok(street > 0.2 && street < 0.35, `a street came out ${street.toFixed(3)} wide`);
assert.ok(freeway > street, "a freeway is no wider than a street");
});
+298
View File
@@ -0,0 +1,298 @@
/**
* The visible terrain is decimated where the ground is flat, and this is what
* holds the decimation honest.
*
* Everything `lodPatches` does is invisible by construction and therefore
* invisible to review: a wrong tolerance, a wrong diagonal or a dropped land
* test all produce a mesh that builds, renders and passes every other test in
* this directory, and shows up only as a board that has quietly lost its
* coastline or grown a crack. The four facts below are the ones the pictures
* were checked against, and each of them is a number.
*
* The boards are synthetic and small — twenty cells a side — for the reason
* `seaAndTerrain.test.ts` gives: none of this is about California, and a real
* pack would couple a render test to a city's coastline.
*/
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { createTerrain } from "../../engine/terrain.ts";
import type { City, ScenePalette } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";
/** A square island in the middle of a one-degree board, at 0.05° per cell. */
const BASE: Omit<City, "hills"> = {
id: "lod-board",
name: "LOD Board",
center: { lat: 37, lng: -122 },
bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 },
latScale: 100,
verticalExaggeration: 2,
cellLat: 0.05,
cellLng: 0.05,
coastFalloff: 0.02,
/*
* The island's rim sits half a cell outside the lattice corners it wants, so
* its land cells run 4..15 on both axes. That is deliberate: the patch levels
* are aligned to their own multiple, and an island whose interior straddled
* the alignment would make this file a test of where the coast happens to
* fall rather than of whether flat ground collapses.
*/
landmasses: [
[
[36.65, -122.35],
[37.35, -122.35],
[37.35, -121.65],
[36.65, -121.65],
],
],
parks: [],
inlandWater: [],
districts: [],
landmarks: [],
bridges: [],
roads: [],
chapters: [],
};
/** Flat: no hills at all, so the whole island is one plane at sea level. */
const FLAT: City = { ...BASE, hills: [] };
/**
* Rough: a hill every other cell, which is the frequency the lattice itself is
* sized for. Nothing here may collapse, because a bilinear patch across two
* cells of this is wrong by most of a hill.
*/
const ROUGH: City = {
...BASE,
hills: (() => {
const hills: City["hills"] = [];
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 6; j++) {
hills.push({
name: `h${i}-${j}`,
lat: 36.75 + i * 0.1,
lng: -122.25 + j * 0.1,
elevation: 600,
radius: 0.05,
});
}
}
return hills;
})(),
};
async function board(city: City): Promise<World> {
const world = new World(city);
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
return world;
}
/** Triangles the surface would have had if every land cell were drawn alone. */
function cellByCellTriangles(world: World): number {
const { latSteps, lngSteps, land } = world.lattice();
const w = lngSteps + 1;
let cells = 0;
for (let i = 0; i < latSteps; i++) {
for (let j = 0; j < lngSteps; j++) {
const a = i * w + j;
if (land[a] && land[a + 1] && land[a + w] && land[a + w + 1]) cells++;
}
}
return cells * 2;
}
/** The ground the cell-by-cell surface covered, in square scene units. */
function cellByCellArea(world: World): number {
const { latSteps, lngSteps, lats, lngs, land } = world.lattice();
const w = lngSteps + 1;
let area = 0;
for (let i = 0; i < latSteps; i++) {
for (let j = 0; j < lngSteps; j++) {
const a = i * w + j;
if (!land[a] || !land[a + 1] || !land[a + w] || !land[a + w + 1]) continue;
const [x0, z0] = world.project(lats[i] as number, lngs[j] as number);
const [x1, z1] = world.project(lats[i + 1] as number, lngs[j + 1] as number);
area += Math.abs((x1 - x0) * (z1 - z0));
}
}
return area;
}
/**
* The footprint of a range of the index, in square scene units.
*
* Area rather than a cell list because that is the property the decimation has
* to preserve exactly: the patches cover the same ground, they just cover it
* with fewer triangles. A merge that swallowed a coastal cell, or a T-junction
* that left a gap, changes this number and nothing else.
*/
function footprint(geo: THREE.BufferGeometry, start: number, count: number): number {
const index = geo.getIndex() as THREE.BufferAttribute;
const pos = geo.getAttribute("position") as THREE.BufferAttribute;
let area = 0;
for (let at = start; at < start + count; at += 3) {
const a = index.getX(at);
const b = index.getX(at + 1);
const c = index.getX(at + 2);
// Twice the signed area of the triangle projected onto the ground plane.
area += Math.abs(
(pos.getX(b) - pos.getX(a)) * (pos.getZ(c) - pos.getZ(a)) -
(pos.getX(c) - pos.getX(a)) * (pos.getZ(b) - pos.getZ(a)),
) / 2;
}
return area;
}
function visibleTriangles(mesh: THREE.Mesh): number {
return mesh.geometry.drawRange.count / 3;
}
function casterTriangles(mesh: THREE.Mesh): number {
const geo = mesh.geometry;
return ((geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count) / 3;
}
test("flat ground collapses and cell-scale relief does not", async () => {
const flat = await board(FLAT);
const rough = await board(ROUGH);
const flatMesh = createTerrain(flat);
const roughMesh = createTerrain(rough);
const flatBase = cellByCellTriangles(flat);
const roughBase = cellByCellTriangles(rough);
assert.ok(flatBase > 200, `the flat board is too small to be a test: ${flatBase} triangles`);
/*
* A plane is a plane at any resolution, so the flat island must come out at
* the coarsest level the patch list allows — a sixteenth of the cell-by-cell
* count in the interior, plus whatever the coast leaves unaligned.
*/
assert.ok(
visibleTriangles(flatMesh) < flatBase / 4,
`flat ground kept ${visibleTriangles(flatMesh)} of ${flatBase} triangles`,
);
/*
* And the opposite, which is the half that a too-loose tolerance would break
* silently: ground that moves every cell has to keep every cell. This is the
* failure that turns a mountain range into a bump map, and it is the reason
* the tolerance is a measured number rather than a large one.
*/
assert.ok(
visibleTriangles(roughMesh) > roughBase * 0.9,
`relief at lattice frequency was decimated to ${visibleTriangles(roughMesh)} of ${roughBase}`,
);
});
test("the collapsed surface covers exactly the ground the cells covered", async () => {
for (const city of [FLAT, ROUGH]) {
const world = await board(city);
const mesh = createTerrain(world);
const drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count);
const expected = cellByCellArea(world);
/*
* The coastline is the whole point of this assertion. A patch is only
* collapsed when every one of its lattice points is on land, so the set of
* ground covered is unchanged down to the last stair-step — and if a merge
* ever reached across the shore, or a T-junction left a hole, the area is
* where it shows.
*/
assert.ok(
Math.abs(drawn - expected) < expected * 1e-6,
`${city.id} covers ${drawn} square units against ${expected}`,
);
}
});
test("no point of the collapsed surface strays from the heightfield", async () => {
const world = await board(ROUGH);
const mesh = createTerrain(world);
mesh.updateMatrixWorld(true);
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const w = lngSteps + 1;
const raycaster = new THREE.Raycaster();
const down = new THREE.Vector3(0, -1, 0);
const from = new THREE.Vector3();
let worst = 0;
let sampled = 0;
for (let i = 0; i <= latSteps; i++) {
for (let j = 0; j <= lngSteps; j++) {
const k = i * w + j;
if (!land[k]) continue;
const [x, z] = world.project(lats[i] as number, lngs[j] as number);
// Nudged inward, because a ray down the exact rim of the mesh is a
// coin toss between hitting the edge triangle and missing the board.
from.set(x + 1e-4, 10_000, z + 1e-4);
raycaster.set(from, down);
const hit = raycaster.intersectObject(mesh, false)[0];
if (!hit) continue;
sampled++;
worst = Math.max(worst, Math.abs(hit.point.y - world.metres(height[k] as number)));
}
}
assert.ok(sampled > 100, `only ${sampled} lattice points landed on the surface`);
/*
* `LOD_HEIGHT_TOLERANCE` is 0.1 scene units and the surface sits 0.012 above
* the heightfield to clear the shore plate, so 0.12 is the tolerance plus
* that lift plus a rounding allowance. This is the assertion that a raised
* tolerance has to walk past: the decimation may not move the ground.
*/
assert.ok(worst < 0.12, `the surface strays ${worst} scene units from the heightfield`);
});
test("a colour boundary the height test cannot see stops the merge", async () => {
/*
* The coast is flat and its colour is not. `groundColor` ramps `sand` into
* `flats` over the first three metres of elevation, which is a band the
* coastal falloff makes tens of cells wide and which no height tolerance
* loose enough to be useful can protect. So the same board is built twice:
* once with a palette whose beach and flats are the same colour, and once
* with them far apart. The second must keep more triangles, and the only
* mechanism that can produce that difference is the colour guard.
*/
const beach: Partial<ScenePalette> = { sand: 0xffffff, flats: 0x000000 };
const plain: Partial<ScenePalette> = { sand: 0x9d9c93, flats: 0x9d9c93 };
// A single broad, low hill: the island climbs through the sand ramp gently
// enough that the height test is happy everywhere.
const gentle: City["hills"] = [
{ name: "swell", lat: 37, lng: -122, elevation: 40, radius: 0.4 },
];
const flatColoured = await board({ ...BASE, hills: gentle, palette: plain });
const rampColoured = await board({ ...BASE, hills: gentle, palette: beach });
const a = visibleTriangles(createTerrain(flatColoured));
const b = visibleTriangles(createTerrain(rampColoured));
assert.ok(b > a, `the colour guard changed nothing: ${b} triangles against ${a}`);
});
test("the shadow caster is coarser than the surface and stands on the same ground", async () => {
const world = await board(ROUGH);
const mesh = createTerrain(world);
const geo = mesh.geometry;
const seen = visibleTriangles(mesh);
const cast = casterTriangles(mesh);
assert.ok(cast > 0, "the relief stopped casting a shadow");
/*
* The caster's floor is `SHADOW_CASTER_STRIDE`, so on ground rough enough to
* defeat every merge it is a quarter of the surface and never more. A caster
* that came out the same size as the surface would mean the stride had been
* lost and the depth pass was paying full price for the board.
*/
assert.ok(cast <= seen / 3, `the caster kept ${cast} triangles against ${seen} visible`);
// Same board, so the same island: the caster may be blockier at the rim, but
// it may not be somewhere else.
const seenArea = footprint(geo, geo.drawRange.start, geo.drawRange.count);
const castArea = footprint(geo, geo.drawRange.count, (geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count);
assert.ok(
castArea <= seenArea * 1.0001 && castArea > seenArea * 0.5,
`the caster covers ${castArea} square units against the surface's ${seenArea}`,
);
});