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:
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 east–west. LAX's runways are on 82.9° true;
|
||||
* due east–west 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user