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