diff --git a/PROVENANCE.json b/PROVENANCE.json index da10b6e..4b1eed4 100644 --- a/PROVENANCE.json +++ b/PROVENANCE.json @@ -13,10 +13,10 @@ "kind": "generated-social-card", "origin": "repository-generated", "license": "Apache-2.0", - "sha256": "5906c7ef22175307f18984525ae5c7bd7fe6f40eb2652a850d3cb44b44ba8228", + "sha256": "344cf9a20f27e5bd8e3530de548c877c44bc9c5150af954b3100ffc6418879d1", "generator": "scripts/brand-assets/capture.mjs", "inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"], - "intakeDate": "2026-08-06", + "intakeDate": "2026-08-22", "intakeNote": "Generated from this repository's built office scene and local card template by the documented capture workflow; no external image was copied." }, { @@ -24,10 +24,10 @@ "kind": "generated-social-card", "origin": "repository-generated", "license": "Apache-2.0", - "sha256": "decfd9b70c0416baf47b0484040079c7f2aaa7bb8a60cf5add7a3069eac05277", + "sha256": "3301293c446cd0ad1818649241ca2c318deaef9f13a7714ec049d79780b5707d", "generator": "scripts/brand-assets/capture.mjs", "inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"], - "intakeDate": "2026-08-06", + "intakeDate": "2026-08-22", "intakeNote": "Generated from this repository's built California scene and local card template by the documented capture workflow; no external image was copied." } ], diff --git a/TODO.md b/TODO.md index eef97e2..99034d9 100644 --- a/TODO.md +++ b/TODO.md @@ -41,3 +41,53 @@ Worth knowing before starting (the rest is in `~/.claude/skills/tera-capture`): added there too, because a frame with a stray card in it still renders and still looks deliberate. - Commit tera first, then re-run, so the manifest records a clean sha. + +## The Bay Area board drops a frame in twenty, on desktop only + +`bay-area.desktop.p95FrameIntervalMs` carries a **33.4 ms allowance and that is a +recorded defect, not a target.** The board renders a median frame in 16.7 ms and +drops roughly one frame in twenty: p50 16.7, p95 33.3, and 443–456 frame samples +in a window where every other cell returns 480. + +It is desktop-only, and it is **not fill rate**: the mobile cell runs the *same* +2.26 M triangles at a comparable pixel count — 1.32 MP against desktop's 1.30 — +and holds 16.7 ms flat. The obvious suspect is the shadow map, which `stage.ts` +sizes **2048 on desktop and 1024 on handheld**, over what is now the heaviest +shadow-casting scene in the product. + +**It is not a regression, and this was checked rather than assumed.** Measured at +the commit before the airports and bridges landed, with the same harness: +p95 33.3, p50 16.7, 443 samples, **2,771,606 triangles**. After that work: +p95 33.3, p50 16.7, 456 samples, **2,265,056 triangles** — the board got +506,550 triangles *lighter* while gaining SFO, both bridges and a surfaced +freeway. The stutter was simply invisible until `bay-area` became a measured +cell, which it had never been. + +The allowance is there so the cell still guards the numbers that are healthy — +triangles, draw calls, and the mobile frame time — rather than sitting +permanently red and therefore permanently ignored. **Fix the stutter and put the +cap back to 16.7.** Start with the desktop shadow-map size and the shadow +frustum over the SF board; a 2048 map over 2.26 M triangles of casters is the +first thing to rule in or out. + +## The aeroplane glyph is still larger than the Golden Gate + +`GLYPH_MAX_SCALE` in `src/engine/flights.ts` is 52 and that is a mitigation, not +a cure. The glyph has a screen-space *floor* — never smaller than legible — which +scales by the distance to the **aircraft**, when what makes it look wrong is how +far the camera is from **what it is looking at**. At a whole-board pose those are +the same thing; at the Golden Gate chapter the bridge is two units from the camera +and the traffic is two thousand, so the floor fires hard on the aeroplane and not +at all on the bridge. + +A ceiling of 52 was chosen because the raw scale at 1,160 units — the far end of +the orbit over the California corridor — is 51.0 at a 60-degree field of view, so +anything lower shrinks aeroplanes at a pose people actually use. (26 was tried +first and put the glyph at 0.0123 of the frame, against the 0.012 at which +`flights.ts` says the wings stop resolving.) 52 takes the worst case from about +two and a half times the bridge's main span down to about one and a half. + +The complete fix is to clamp against the camera's focus distance rather than the +aircraft's, so the glyph collapses toward its authored size whenever the viewer +has zoomed in on something near, at any aircraft range. That is a signature change +through `glyphScale`, `tick` and their callers. diff --git a/scripts/look.mjs b/scripts/look.mjs index 8fc6233..ad7fd1a 100644 --- a/scripts/look.mjs +++ b/scripts/look.mjs @@ -19,7 +19,8 @@ */ import { spawn } from "node:child_process"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, readFileSync } from "node:fs"; +import { createServer } from "node:net"; import { chromium } from "playwright"; const args = process.argv.slice(2); @@ -33,11 +34,88 @@ const has = (f) => args.includes(f); const OUT = "/tmp/tera-look"; mkdirSync(OUT, { recursive: true }); -const PORT = 4700 + Math.floor(Math.random() * 200); -const server = spawn("npx", ["vite", "preview", "--port", String(PORT), "--strictPort"], { - stdio: "ignore", +/** + * The port is asked for, not guessed, and the build is then verified. + * + * This used to draw a random port in 4700–4899 and start `vite preview + * --strictPort` on it. When the draw collided with an abandoned preview from an + * earlier run — and this box accumulated a hundred and forty-seven of them in + * one afternoon — the new preview exited on the strict-port check, `page.goto` + * succeeded against the squatter, and Playwright silently photographed somebody + * else's dist. Three shots came back byte-identical while the bundle under them + * provably changed. A screenshot tool that can photograph the wrong build is + * worse than no screenshot tool, because you believe it. + * + * So: take a port from the kernel rather than from `Math.random`, then read the + * URL the preview actually bound out of its own stdout, then fetch `/` and + * assert it is the `dist/index.html` sitting on this disk. Any one of the three + * would have caught it; all three cost nothing. + */ +const PORT = await new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); }); -await new Promise((r) => setTimeout(r, 4000)); +// `detached`, and vite's own binary rather than `npx`, because of the *other* +// half of the orphan story: `npx` is a wrapper, `server.kill()` killed only the +// wrapper, and the preview it had spawned went on holding its port forever. A +// hundred and forty-seven of them accumulated in one afternoon. Detached gives +// the pair a process group, and killing the group at the end kills both. +const server = spawn( + new URL("../node_modules/.bin/vite", import.meta.url).pathname, + ["preview", "--port", String(PORT), "--strictPort"], + { detached: true, stdio: ["ignore", "pipe", "pipe"] }, +); +const shutdown = () => { + try { + process.kill(-server.pid, "SIGTERM"); + } catch { + /* already gone */ + } +}; +process.on("exit", shutdown); +const bound = await new Promise((resolve) => { + let seen = ""; + const settle = setTimeout(() => resolve(null), 30000); + const read = (chunk) => { + seen += String(chunk); + const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen); + if (match) { + clearTimeout(settle); + resolve(Number(match[1])); + } + }; + server.stdout.on("data", read); + server.stderr.on("data", read); +}); +if (bound === null) { + console.error(`look: vite preview never announced a URL on ${PORT}; is dist/ built?`); + shutdown(); + process.exit(1); +} +if (bound !== PORT) { + console.error(`look: preview bound ${bound}, not ${PORT} — refusing to photograph it`); + shutdown(); + process.exit(1); +} + +// And the served page is this checkout's build, hashed script tag and all. +{ + const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text()); + const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); + const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null; + if (bundle(served) === null || bundle(served) !== bundle(onDisk)) { + console.error( + `look: :${PORT} is serving ${bundle(served)}, dist/ holds ${bundle(onDisk)} — ` + + `something else owns that port. Not photographing it.`, + ); + shutdown(); + process.exit(1); + } +} const phone = has("--phone"); const browser = await chromium.launch({ @@ -104,4 +182,7 @@ const real = errors.filter((e) => !/404|Failed to load resource/.test(e)); console.log(real.length === 0 ? "look: no console errors" : `look: ERRORS ${JSON.stringify(real)}`); await browser.close(); -server.kill(); +shutdown(); +// Explicit, because the preview's stdout pipes are inherited by a grandchild +// and Node will happily wait on them for the rest of the afternoon. +process.exit(0); diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs index ce3be68..a03310b 100755 --- a/scripts/performance-budget.mjs +++ b/scripts/performance-budget.mjs @@ -25,6 +25,33 @@ const SCENES = { active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false, }, office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true }, + /* + * The two metro boards, added after they became the boards with the most on + * them and were still the boards nothing measured. + * + * For a long time the matrix was california, california-drive and office. That + * was defensible while the metro boards were terrain and buildings, and stopped + * being defensible the moment SFO, LAX, the Golden Gate, the Bay Bridge and a + * surfaced freeway all landed on them — every one of those is city-frame + * geometry, and every one of them arrived in a frame with no budget watching + * it. A cap you do not measure is a cap you do not have. + * + * Their own numbers rather than California's: `sf` carries the densest built + * ground in the product and `socal` the widest basin, and holding either to a + * board tuned for a whole state would be arbitrary in both directions. These + * are set from the first measured run with headroom deliberately left, in the + * same spirit as the others. + */ + "bay-area": { + host: "tera.lumbridgecorp.com", + query: "?city=sf", + ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, + }, + socal: { + host: "tera.lumbridgecorp.com", + query: "?city=socal", + ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, + }, }; const args = process.argv.slice(2); @@ -37,6 +64,51 @@ function positive(name, fallback) { if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`); return value; } +/* + * WHY THE TWO METRO BOARDS CARRY CAPS THREE TIMES CALIFORNIA'S. + * + * Measured on the first run that included them: bay-area 2,265,056 triangles and + * socal 1,417,648, against california's 391,169. That is not a regression and it + * is not slack — it is what those boards are. California is one state at a + * standoff where a building is a speck; the Bay Area is the densest built ground + * in the product with every lot, the freeway network, both bridges and SFO in + * frame at once, and the Southland is the widest basin with LAX and five more + * fields on it. Holding either to a cap tuned for a whole state would be + * arbitrary in both directions. + * + * Both render at 60 fps (p95 16.7-16.8 ms) on the box that measured them, which + * has a Radeon RX 6700 XT. That is the honest limit of what these numbers prove. + * + * TWO THINGS A READER SHOULD KNOW BEFORE TREATING THESE AS COMFORTABLE: + * + * - The mobile cell measures the SAME geometry as desktop — 2,263,784 against + * 2,265,056 — because the handheld path reduces the pixel ratio and the + * shadow map and does not reduce the scene. A phone draws every triangle a + * desktop does. The mobile budget here is therefore a frame-time gate and not + * a geometry one, and it is the number most likely to be wrong on real + * hardware nobody in this repo has tested on. + * - These cells did not exist until the round that put SFO, LAX, the Golden + * Gate, the Bay Bridge and a surfaced freeway on them. Every one of those is + * city-frame geometry and every one arrived in a frame with no budget + * watching it. A cap you do not measure is a cap you do not have. + * + * `bay-area.desktop.p95FrameIntervalMs` IS 33.4 AND THAT IS A RECORDED DEFECT, + * NOT A TARGET. The board renders a median frame in 16.7 ms and drops roughly + * one frame in twenty: p50 16.7, p95 33.3, and 443-456 frame samples in a window + * where every other cell returns 480. It is desktop-only, and it is not fill + * rate — the mobile cell runs the SAME 2.26 M triangles at a comparable pixel + * count (1.32 MP against 1.30) and holds 16.7 ms flat. The obvious suspect is + * the shadow map, which `stage.ts` sizes 2048 on desktop and 1024 on handheld, + * over what is now the heaviest shadow-casting scene in the product. + * + * Measured at the commit BEFORE the airports and bridges landed, with this same + * harness: p95 33.3, p50 16.7, 443 samples, 2,771,606 triangles. So the stutter + * predates that work, and that work left the board 506,550 triangles LIGHTER + * while adding SFO, two bridges and a surfaced freeway. The allowance exists so + * this cell still guards the numbers that are healthy — triangles, draw calls, + * and the mobile frame time — instead of being permanently red and therefore + * permanently ignored. Fix the stutter and put it back to 16.7; see TODO.md. + */ function sceneBudget(value, label) { if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`); for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) { @@ -202,7 +274,7 @@ async function measure(browser, port, sceneName, viewportName, budget, requestLo const before = requestLog.length; await page.addInitScript(instrumentation); const scene = SCENES[sceneName]; - const url = `http://${scene.host}:${port}/`; + const url = `http://${scene.host}:${port}/${scene.query ?? ""}`; try { await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs }); try { diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json index 5860f5b..b884cbb 100644 --- a/scripts/performance-budgets.json +++ b/scripts/performance-budgets.json @@ -2,16 +2,64 @@ "version": 1, "scenes": { "california": { - "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 }, - "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 } + "desktop": { + "p95FrameIntervalMs": 16.7, + "maxDrawCalls": 650, + "maxTriangles": 750000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 650, + "maxTriangles": 750000 + } }, "california-drive": { - "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 }, - "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 } + "desktop": { + "p95FrameIntervalMs": 16.7, + "maxDrawCalls": 650, + "maxTriangles": 750000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 650, + "maxTriangles": 750000 + } }, "office": { - "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 }, - "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 } + "desktop": { + "p95FrameIntervalMs": 16.7, + "maxDrawCalls": 550, + "maxTriangles": 550000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 550, + "maxTriangles": 550000 + } + }, + "bay-area": { + "desktop": { + "p95FrameIntervalMs": 33.4, + "maxDrawCalls": 320, + "maxTriangles": 2600000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 320, + "maxTriangles": 2600000 + } + }, + "socal": { + "desktop": { + "p95FrameIntervalMs": 16.7, + "maxDrawCalls": 320, + "maxTriangles": 1700000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 320, + "maxTriangles": 1700000 + } } } } diff --git a/server/src/flights/adsb.ts b/server/src/flights/adsb.ts index b66191f..c532466 100644 --- a/server/src/flights/adsb.ts +++ b/server/src/flights/adsb.ts @@ -21,7 +21,27 @@ import { getJson, userAgent } from "../http.ts"; import { isOpenAdsbUrl } from "./licence.ts"; import type { WireAircraft } from "../../../src/server/wire.ts"; -/** The shared dump1090/readsb aircraft record, as both feeds emit it. */ +/** + * The shared dump1090/readsb aircraft record, as both feeds emit it. + * + * Declared field by field rather than as an index signature, and the list grew + * because the fields that were missing from it were the ones the map needed + * most. For a long time this read hex/flight/lat/lon/alt_baro/track, which is + * enough to put a dart somewhere and not enough to make it fly: a client handed + * positions alone can only interpolate between the last two it was sent, so + * every aeroplane arrived at the newest known point and stopped dead until the + * next snapshot. The velocity was in every row of the feed the whole time and + * this file threw it away. + * + * Sampled live from `api.adsb.lol/v2/point` while writing this, so the names and + * the units are observed rather than remembered: + * + * ```json + * { "hex": "a923cd", "flight": "UAL505 ", "r": "N68834", "t": "B739", + * "gs": 249.2, "track": 357.7, "baro_rate": 1344, "alt_baro": 4950, + * "seen_pos": 0.183 } + * ``` + */ interface RawAircraft { hex?: string; flight?: string; @@ -29,6 +49,54 @@ interface RawAircraft { lon?: number; alt_baro?: number | string; track?: number; + /** Ground speed in knots. `0.0` on a parked aircraft or a ground vehicle. */ + gs?: number; + /** Barometric climb rate, feet per minute, positive up. */ + baro_rate?: number; + /** Geometric climb rate, feet per minute. Present when `baro_rate` is not. */ + geom_rate?: number; + /** Registration — the tail number, e.g. `"N68834"`. */ + r?: string; + /** ICAO type designator, e.g. `"B739"`. */ + t?: string; + /** Seconds since this row's *position* was last updated. */ + seen_pos?: number; +} + +/** One knot in metres per second. The wire carries SI; the feed does not. */ +const KNOT_MS = 0.514_444; +/** One foot per minute in metres per second. */ +const FPM_MS = 0.00508; + +/** + * A finite number, or `undefined` — so an optional wire field is either a + * measurement or absent, and never `NaN` dressed as one. + * + * Every velocity below goes through this. A feed that sends `"gs": null` for a + * target it has a position but no velocity for is normal traffic, not an error, + * and the honest thing to do with it is to say nothing: a client that + * dead-reckons a null speed as zero draws a parked airliner at 10,000 feet, and + * one that reads it as `NaN` moves the aircraft to nowhere at all. + */ +function finite(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * A short printable code from the feed — a registration or a type designator — + * or `undefined`. + * + * Trimmed, because the feeds pad `flight` and are not consistent about the + * others, and length-capped because these end up on a card in a browser and the + * row is somebody else's data. Nothing is invented and nothing is expanded: the + * designator is published as `B739` and this repo does not ship a table that + * turns it into "Boeing 737-900", because a table like that is one more thing + * that can be wrong about a real aeroplane. + */ +function code(value: string | undefined, max: number): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed === "" || trimmed.length > max ? undefined : trimmed; } interface AircraftEnvelope { @@ -200,6 +268,16 @@ function normalise( const id = a.hex ?? callsign; if (id === undefined || id === "") continue; const address = icao24(a.hex); + const speed = finite(a.gs); + // `baro_rate` is what the airframe's altimeter says and `alt_baro` is the + // altitude beside it; `geom_rate` is the GNSS answer and is what a row + // carries when the barometric one is unavailable. Either is a climb. + const climb = finite(a.baro_rate) ?? finite(a.geom_rate); + const age = finite(a.seen_pos); + // Eight characters covers every civil registration in use; four is the + // width of an ICAO type designator, and the feed emits nothing longer. + const registration = code(a.r, 12); + const kind = code(a.t, 8); aircraft.push({ id, callsign: callsign === "" ? undefined : callsign, @@ -216,6 +294,37 @@ function normalise( // "ground" for anything that is not flying. The wire carries metres. altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 0, heading: typeof a.track === "number" ? a.track : 0, + /** + * The velocity, and the conditions under which it is carried at all. + * + * **Both halves of the gate matter.** A speed is only sent when the feed + * reported a positive one *and* reported a track to go with it, because + * the consumer of these two numbers is a dead-reckoner and the pair is + * what it integrates. `heading` above falls back to `0` for a row with no + * track — which is harmless for a symmetrical glyph that is not moving, + * and is a claim that a ground vehicle is taxiing due north at thirty + * knots the moment anything advances it. Ground vehicles and parked + * aircraft report `gs: 0.0` with a null track and are exactly this case. + * + * So: no track, no speed. `engine/flights.ts` holds such a track + * motionless rather than flying it along an invented heading, which is + * the right answer for something that is genuinely parked. + */ + ...(speed === undefined || speed <= 0 || typeof a.track !== "number" + ? {} + : { groundSpeed: speed * KNOT_MS }), + // Barometric first because it is what the altitude above is, so a climb + // drawn from this rate is consistent with the height it is drawn at. + // Geometric is a few percent different in real air and identical here. + ...(climb === undefined ? {} : { verticalRate: climb * FPM_MS }), + // How stale the position already was when the feed answered. Small on a + // healthy feed and carried anyway: it is the client's only way to know + // what instant these coordinates describe. See `WireAircraft.ageSeconds`. + ...(age === undefined || age < 0 ? {} : { ageSeconds: age }), + // ODbL, off the same row as the position, and publishable on the same + // terms: this is the enrichment a commercial feed was once wanted for. + ...(registration === undefined ? {} : { registration }), + ...(kind === undefined ? {} : { type: kind }), }); } return { aircraft, observedAt: observedAtMs(body.now) }; diff --git a/src/adapters/http.ts b/src/adapters/http.ts index 40cf8db..c21952c 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -1069,6 +1069,13 @@ class HttpFlights implements TrafficSource { // Only a live body carries an address, and only a live body was observed. // The plan's aircraft are this repo's own arithmetic and say so. ...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }), + // The tail number and the type designator, off the same ODbL row as the + // position. `Aircraft` has nowhere to put either — they move no pixels — + // so they travel here, beside the address, for exactly the same reason. + // This is the enrichment the owner wanted a commercial feed for; the + // community feeds carried it all along and the server was dropping it. + ...(wire?.registration === undefined ? {} : { registration: wire.registration }), + ...(wire?.type === undefined ? {} : { type: wire.type }), observed: this.mode === "live", attribution: this.attribution(), from: this.region.center, @@ -1226,7 +1233,46 @@ class HttpFlights implements TrafficSource { this.mode = "fallback"; return ELSEWHERE_SECONDS; } - this.aircraft = here; + /** + * How stale these coordinates already are, in seconds, at the moment they + * are adopted. + * + * Two terms, and both of them are real. `WireAircraft.ageSeconds` is how + * old the fix was when the *upstream* answered — a fraction of a second on + * a healthy feed. `Date.now() - observedAt` is everything since: this + * box's cache TTL, which is five to fifteen seconds by design, plus the + * request that carried it. A dead-reckoner told only the first term draws + * the whole sky a cache-TTL behind, uniformly, which is the sort of error + * that never gets noticed because everything is wrong together. + * + * Computed once here rather than per poll because `poll()` hands back these + * same objects for the body's whole life and the flight layer skips a + * repeated position without looking at it — the age matters at the instant + * the layer first sees the position, and that is this instant. + * + * Clamped, and the clamp is not decoration: `observedAt` comes off the + * wire, so a server with a wrong clock can make this negative (a fix from + * the future) or enormous (a fix from last week), and either would be + * integrated into a position. Anything outside the window is treated as + * "no useful answer" and the position is taken as current. + */ + const observedAt = Number.isFinite(body.observedAt) ? body.observedAt : Date.now(); + const latency = clampSeconds((Date.now() - observedAt) / 1000); + + /** + * The wire records, as the engine's `Aircraft`. + * + * A copy rather than a pass-through, which the live path did not need until + * the wire started carrying velocity: `WireAircraft` is structurally an + * `Aircraft` and always was, but `ageSeconds` is the one field whose value + * is different on the two sides of this line. On the wire it means "how old + * when the server saw it"; to the engine it means "how old when you were + * handed it", and the difference is the round trip. + */ + this.aircraft = here.map((a) => ({ + ...a, + ageSeconds: clampSeconds((a.ageSeconds ?? 0) + latency), + })); // Only the live path has anything to record: a plan carries routes, not // transponders. Cleared at the top of this method, so a record that has // left the feed leaves this map with it rather than surviving to answer a @@ -1245,6 +1291,26 @@ class HttpFlights implements TrafficSource { } } +/** + * A staleness in seconds, or zero for anything that is not a usable one. + * + * The ceiling is `MAX_STALE_SECONDS` and the floor is zero. A fix cannot be + * from the future, however confidently a clock says so, and one older than the + * ceiling is not something to advance a position from — `engine/flights.ts` + * stops reckoning at a minute for the same reason. + */ +function clampSeconds(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.min(value, MAX_STALE_SECONDS); +} + +/** + * The oldest a fix may be said to be. A minute, matching the point at which the + * flight layer stops dead-reckoning and the point at which both flight sources + * give up holding their last snapshot. + */ +const MAX_STALE_SECONDS = 60; + /** * Slack on the region tests, in nautical miles. * diff --git a/src/aircraft/asset.ts b/src/aircraft/asset.ts index d38ec32..669ae89 100644 --- a/src/aircraft/asset.ts +++ b/src/aircraft/asset.ts @@ -59,12 +59,42 @@ export function createElectricAircraftMaterials( roughness: 0.3, clearcoat: 0.65, }), + /** + * The canopy, and the most expensive number ever written in this repo. + * + * This material used to carry `transmission: 0.08`, and it cost the + * California board **304,659 triangles and 191 draw calls** — measured, by + * patching that one value in a copy of the built bundle and serving both: + * 703,267 triangles / 562 calls with it, 398,608 / 371 without. That is + * 43% of the whole frame, for a canopy that is a few dozen pixels of dark + * glass on a glyph-scale aeroplane. + * + * The mechanism is not a cost per pixel, which is why it never showed up as + * one. three.js runs a **transmission backdrop pass** whenever any rendered + * material has `transmission > 0`: the entire opaque scene is drawn a second + * time into a render target for the transparent surface to refract. One + * value of 0.08 on one small mesh therefore made the board draw its terrain, + * its blocks and every freeway piece **twice per frame**, everywhere, for + * every viewer — including the phone. + * + * Nothing visible was bought with it. The material is already + * `transparent` at `opacity: 0.86`, so the canopy reads as dark glass from + * the alpha blend and the clearcoat; 8% refraction on top of that is below + * the level anything in this scene resolves at. Shot before and after in + * the chase camera on the California route — `/?city=california`, "Fly the + * California route" — and the two frames are indistinguishable. + * + * `src/assets/materials.ts` keeps `transmission: 0.92` on the office + * glazing and should: that is a wall of windows a metre from the camera at + * 1 unit = 1 m, it is the difference between glass and a grey panel there, + * and the office scene has four hundred thousand triangles of headroom to + * pay the pass with. This scene did not. + */ glass: new THREE.MeshPhysicalMaterial({ name: "electric-aircraft.glass", color: 0x10232d, roughness: 0.08, metalness: 0.08, - transmission: 0.08, transparent: true, opacity: 0.86, clearcoat: 1, diff --git a/src/cities/sf.ts b/src/cities/sf.ts index 99c7914..1eb4226 100644 --- a/src/cities/sf.ts +++ b/src/cities/sf.ts @@ -44,6 +44,7 @@ * nothing there but water and the flanks of two mountain ranges. */ +import type { Airport } from "../engine/airports.ts"; import type { Bridge, City, District, Hill, Landmark, LatLng } from "../engine/types.ts"; /** @@ -1222,43 +1223,265 @@ export const MARIN_101: LatLng[] = [ [38.012, -122.538], ]; -/** - * Runways, drawn as roads because that is exactly what they are at this scale: - * a pale straight strip laid on flat ground. - * - * Worth the eight lines. SFO's crossing pairs on their square of fill are the - * one shape on the Peninsula's bay edge you can name from ten thousand feet, - * and the reason San Jose's downtown is short is standing on the other one. - */ -export const SFO_RUNWAYS: LatLng[][] = [ - [ - [37.6151, -122.36], - [37.6229, -122.3948], - ], - [ - [37.6171, -122.3594], - [37.6249, -122.3942], - ], - [ - [37.633, -122.3709], - [37.605, -122.3791], - ], - [ - [37.6335, -122.3735], - [37.6055, -122.3817], - ], -]; +// ---- Airports ------------------------------------------------------------- -export const SJC_RUNWAYS: LatLng[][] = [ - [ - [37.3705, -121.9385], - [37.3555, -121.9195], +/** + * San Francisco International, on the fill at the bay edge below the city. + * + * The board has had real aircraft over it since the ADS-B layer landed, and + * every one of them was descending onto nothing. This is the ground they were + * descending onto. + * + * ## The headings are the whole of it + * + * SFO is two crossing pairs, and that pattern is its signature from any + * altitude a board camera sits at: long before a terminal is a terminal, the + * eye reads four bars making an X on a square of fill. So the numbers below + * that matter most are the two bearings, and they are **true**, not the + * magnetic ones the runways are named after. + * + * A runway designator is magnetic and rounded to the nearest ten degrees. San + * Francisco's declination is about 13.5° east, so "28R" is a runway pointing + * **298.6° true** and "1L" one pointing **26.5°**. Building from the painted + * numbers instead would lay the whole airport thirteen degrees out and put the + * 28s on a heading that misses the city they point at. The two check against + * each other and against history: at the ~17° east declination of the years the + * designators were assigned, 118.6° true is 101.6° magnetic — "10" — and 26.5° + * true is 9.5° magnetic — "01". + * + * The two pairs are **92.1° apart**, which is the other thing worth being exact + * about. SFO crosses at very nearly a right angle; an airfield whose runways + * cross at seventy degrees is a different airport, and reads as a generic one. + * + * ## The relative geometry + * + * Everything is placed from the airport reference point at 37.6189, −122.3750, + * which is what makes the four runways consistent with each other rather than + * four independent guesses: + * + * - **10L/28R** is 11,870 ft (3,618 m). **10R/28L** is 11,381 ft (3,469 m), + * 229 m — 750 ft — to its south-south-west. That separation is the famous + * one: 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. The 28 thresholds sit abeam each other at the shoreline, so the + * 149 m 10R/28L gives away comes off its western end. + * - **1L/19R** is 7,650 ft (2,332 m). **1R/19L** is 8,650 ft (2,637 m), 213 m + * — 700 ft — to its east-south-east. The 01 thresholds sit abeam at the + * south end and the extra length runs north. + * + * The 28 thresholds land ~70 m inside the bay edge traced in `PENINSULA`, which + * is not luck: that outline was drawn with "SFO's bay edge — the runways are + * built out onto the mud" written on the vertex, and now they are. + * + * Terminals are **massed rather than surveyed**. The horseshoe is right — the + * International Terminal across the west end, Terminals 1 and 3 as the arms + * reaching east, Terminal 2 and the garages in the court, the tower in the + * middle of it — and it sits in the wedge between the west ends of the 28s and + * the south ends of the 01s, which is where SFO's terminals are. Individual + * boarding-area fingers are not modelled: at 94 m per scene unit a boarding + * area is two pixels, and the aircraft parked against it are not. + */ +export const SFO: Airport = { + id: "KSFO", + name: "San Francisco International", + lat: 37.6189, + lng: -122.375, + // 13 ft. Carried because it is a fact about the place; the kit grades the + // field onto the terrain rather than lifting it to this, which on a board + // exaggerating relief 3.6× would stand SFO fourteen metres above its own + // shoreline. See `engine/airports.ts`. + elevation: 4, + + /** + * The fill, traced to meet `PENINSULA`'s bay edge without crossing it. + * + * The eastern four lie a few tens of metres inside coastline vertices this + * pack already had — the airport is on made ground and its eastern boundary + * *is* the shore. The western edge runs a few hundred metres east of US-101, + * which is where the fence actually is. + */ + field: [ + [37.6425, -122.3782], + [37.6398, -122.3592], + [37.6232, -122.3548], + [37.6072, -122.3576], + [37.6048, -122.381], + [37.6105, -122.3935], + [37.6262, -122.3968], + [37.6355, -122.3905], ], - [ - [37.3685, -121.9405], - [37.3535, -121.9215], + + runways: [ + // 10L [37.62861, -122.39335] -> 28R [37.61305, -122.35733] + { id: "10L/28R", lat: 37.62083, lng: -122.37534, heading: 118.6, length: 3618, width: 61, designators: ["10L", "28R"] }, + // 10R [37.62616, -122.39311] -> 28L [37.61124, -122.35857] + { id: "10R/28L", lat: 37.6187, lng: -122.37584, heading: 118.6, length: 3469, width: 61, designators: ["10R", "28L"] }, + // 1L [37.60783, -122.38525] -> 19R [37.62657, -122.37345] + { id: "1L/19R", lat: 37.6172, lng: -122.37935, heading: 26.5, length: 2332, width: 61, designators: ["1L", "19R"] }, + // 1R [37.60697, -122.38309] -> 19L [37.62817, -122.36975] + { id: "1R/19L", lat: 37.61757, lng: -122.37642, heading: 26.5, length: 2637, width: 61, designators: ["1R", "19L"] }, ], -]; + + /** + * The four outer parallels — A and B outside the 10s, F and Z outside the 01s. + * + * Typed as coordinates rather than computed here, because a city pack is data + * and `airports.ts` imports three.js. `parallelTaxiway()` produced them and + * `sfoAirport.test.ts` asserts they still match what it produces, so the + * derivation is checked without the pack having to run it. + */ + taxiways: [ + { id: "A", path: [[37.62939, -122.39126], [37.61487, -122.35763]] }, + { id: "B", path: [[37.62434, -122.39281], [37.61046, -122.36066]] }, + { id: "F", path: [[37.60907, -122.38586], [37.62621, -122.37507]] }, + { id: "Z", path: [[37.60711, -122.38091], [37.6267, -122.36858]] }, + ], + + aprons: [ + // The terminal court and the stands around it, square to the 01s the way + // the whole complex is. + { + id: "terminal", + polygon: [ + [37.61834, -122.37961], + [37.62211, -122.38915], + [37.61407, -122.39421], + [37.6103, -122.38467], + ], + }, + // The north field: cargo and the maintenance base, and the reason there is + // a kilometre and a half of airport north of the 19 thresholds. + { + id: "north-field", + polygon: [ + [37.6314, -122.3738], + [37.63381, -122.37989], + [37.62706, -122.38414], + [37.62465, -122.37805], + ], + }, + ], + + /** + * The horseshoe, and where the aeroplanes go. + * + * Terminals 1 and 3 are the two arms and they run out to meet the + * International Terminal at the closed end, so the complex reads as a U at + * the scale it is seen from rather than as five separate slabs. `gates` puts + * the stands on the **outside** of the U on all three, which is where SFO's + * are: the court inside holds Terminal 2, the garages and the roadway, and + * nothing that needs a wingspan. + */ + terminals: [ + // Heights are the real ones — a terminal is two or three storeys over a very + // large footprint — and they are the numbers to keep even though the board + // exaggerates its vertical 3.6× and makes a 26 m building stand ninety + // metres tall. Trimming them to compensate would put SFO on a different + // vertical scale from every house in San Bruno behind it, and the eye + // notices that far sooner than it notices a tall terminal. + { id: "international", lat: 37.61749, lng: -122.39016, length: 600, width: 120, height: 28, heading: 26.5, gates: { count: 6, side: -1 } }, + { id: "t3", lat: 37.6187, lng: -122.38559, length: 500, width: 100, height: 17, heading: 116.5, gates: { count: 6, side: -1 } }, + { id: "t1", lat: 37.61387, lng: -122.38863, length: 500, width: 100, height: 17, heading: 116.5, gates: { count: 6, side: 1 } }, + { id: "t2", lat: 37.61596, lng: -122.3863, length: 300, width: 80, height: 15, heading: 26.5 }, + { id: "garages", lat: 37.61516, lng: -122.38427, length: 340, width: 90, height: 21, heading: 26.5 }, + // The north field, square to the 10s rather than to the terminals, which is + // what stops the maintenance base reading as more of the same building. A + // widebody hangar is genuinely as tall as a six-storey block. + { id: "maintenance", lat: 37.62806, lng: -122.38135, length: 260, width: 120, height: 25, heading: 118.6 }, + { id: "cargo", lat: 37.63013, lng: -122.37715, length: 220, width: 100, height: 14, heading: 118.6, gates: { count: 3, side: 1 } }, + ], + + // No `tower`: this pack draws SFO's as a labelled `Landmark`, because that is + // what the minimap reads. See `LANDMARKS` above. +}; + +/** + * Mineta San José, two parallels at the head of the valley. + * + * Here because it was already on this board as a pair of pale strips and the + * kit is what those strips wanted to be — not because the South Bay needs an + * airport modelled. Two 11,000 ft runways 700 ft apart on 131.5° true (which + * is "12" once the same 13.4° of easterly declination comes off it), the + * terminal frontage on the north-east side where Highway 87 runs, and nothing + * else: from a camera that can see San Jose at all, SJC is its runways. + * + * The plate is kept east of −121.946 and between 37.348 and 37.377 on purpose. + * That is the gap this pack's districts already leave — `santa-clara` stops at + * the airport's west fence, `san-jose-north` above it and `san-jose-downtown` + * below — and a field plate that crossed into one of them would have houses + * scattered over it. + */ +export const SJC: Airport = { + id: "KSJC", + name: "Norman Y. Mineta San José International", + lat: 37.3626, + lng: -121.9291, + elevation: 19, + /** + * The plate is **asymmetric about the runways** on purpose: it runs 430 m + * south-west of the centreline and only 230 m north-east, because this pack's + * `BAYSHORE_101` passes about 240 m north-east of runway 12L and a field that + * reached past it would have a freeway drawn across the middle of the airport. + * + * It is also kept east of −121.946 and between 37.348 and 37.377, which is the + * gap this pack's districts already leave — `santa-clara` stops at the west + * fence, `san-jose-north` above and `san-jose-downtown` below — and a plate + * that crossed into one would have houses scattered over it. + */ + field: [ + [37.35343, -121.91214], + [37.34899, -121.91708], + [37.37042, -121.94756], + [37.37486, -121.94261], + ], + runways: [ + // 12L [37.3733, -121.94249] -> 30R [37.35334, -121.91411] + { id: "12L/30R", lat: 37.36332, lng: -121.9283, heading: 131.5, length: 3353, width: 46, designators: ["12L", "30R"] }, + // 12R [37.37186, -121.94409] -> 30L [37.3519, -121.91571] + { id: "12R/30L", lat: 37.36188, lng: -121.9299, heading: 131.5, length: 3353, width: 46, designators: ["12R", "30L"] }, + ], + taxiways: [{ id: "A", path: [[37.37377, -121.94061], [37.35489, -121.91375]] }], + aprons: [ + { + id: "terminal", + polygon: [ + [37.36036, -121.92315], + [37.3615, -121.92187], + [37.36817, -121.93136], + [37.36702, -121.93263], + ], + }, + ], + // Terminals A and B as one frontage on the north-east side, where Highway 87 + // and Airport Boulevard run, with the stands facing back at the runways. + terminals: [ + { id: "ab", lat: 37.3644, lng: -121.9271, length: 620, width: 90, height: 16, heading: 131.5, gates: { count: 7, side: 1 } }, + ], +}; + +/** Every airport on this board, in the order a pack would list them. */ +export const AIRPORTS: Airport[] = [SFO, SJC]; + +/** + * ## Runways are no longer roads + * + * They were, until this pack got an airport: four hand-typed two-point paths on + * *magnetic* headings — thirteen degrees out of true, with the 01/19 pair nearly + * nine hundred metres too long — handed to `createRoads` as `kind: "street"`, + * because a pale strip on flat ground was as close as the road builder could + * get. The kit draws the same centrelines properly, with paint on them, the + * fill under them and the terminals beside them, so those entries are gone from + * `ROADS` above. + * + * **The two cannot both be present.** A road ribbon drapes 0.14 units — thirteen + * metres at this board's scale — above the terrain and a runway quad sits flush + * on it, so a board carrying both floats a dark forty-seven-metre stripe over + * every runway on the Peninsula. It was photographed before it was deleted. + * + * `runwayCentreline()` in `engine/airports.ts` reproduces those paths from the + * `SFO` and `SJC` declarations above, if anything ever wants a runway as a line + * again. + */ export const ROADS: City["roads"] = [ { path: MARKET_STREET, width: 0.34, kind: "street" }, @@ -1284,8 +1507,6 @@ export const ROADS: City["roads"] = [ { path: COAST_HIGHWAY, width: 0.2, kind: "street" }, { path: HIGHWAY_92, width: 0.2, kind: "street" }, { path: HIGHWAY_84, width: 0.2, kind: "street" }, - ...SFO_RUNWAYS.map((path) => ({ path, width: 0.5, kind: "street" as const })), - ...SJC_RUNWAYS.map((path) => ({ path, width: 0.45, kind: "street" as const })), ]; // ---- Bridges -------------------------------------------------------------- @@ -1513,9 +1734,24 @@ export const LANDMARKS: Landmark[] = [ // campus — a building named for a company would put a trademark on the map, // and ARCHITECTURE.md §3.1 is the reason there is not one in this repo. { + /** + * The airport's tower, and the one piece of SFO that is **not** in `SFO` + * below. + * + * `Airport` has a `tower` field and the kit draws a much better one — a + * tapered shaft with a cab on it, in the airport's own palette. It is + * deliberately not used here, because this landmark was already on the board + * and a landmark is more than a mesh: `label: true` is what puts SFO on the + * **minimap**, and `minimap.ts` reads `city.landmarks`, not the airport. Two + * towers four hundred metres apart is what you get if you forget that, and + * it was photographed before it was noticed. + * + * Moved to sit in the terminal court with the rest of the complex, which is + * where SFO's tower actually stands. + */ name: "SFO Control Tower", - lat: 37.618, - lng: -122.3838, + lat: 37.6174, + lng: -122.3883, height: 67, footprint: 0.00014, shape: "tower", @@ -2203,10 +2439,15 @@ export const DISTRICTS: District[] = [ { id: "santa-clara", name: "Santa Clara", + // The east edge stops at Mineta's west fence rather than at the freeway. + // Both eastern vertices were 0.004° further east and the corner reached over + // the airport's graded plate, which put a wedge of scattered houses across + // the north-west end of 12L/30R. Same argument, and same fix, as + // `millbrae-burlingame` up at SFO. polygon: [ [37.372, -122.0], - [37.369, -121.942], - [37.34, -121.95], + [37.369, -121.946], + [37.34, -121.954], [37.343, -122.008], ], minHeight: 7, @@ -2693,6 +2934,15 @@ export const SAN_FRANCISCO_CITY: City = { bridges: BRIDGES, roads: ROADS, chapters: CHAPTERS, + + /** + * SFO and SJC, drawn by `engine/airports.ts` as graded plates with the real + * runway headings on them. The four runways that used to be pale `Road` + * strips are gone from `ROADS` above and must stay gone: a road ribbon drapes + * thirteen metres over the terrain at this scale, so a board carrying both + * would float a dark stripe across every runway the kit lays flush. + */ + airports: AIRPORTS, }; export default SAN_FRANCISCO_CITY; diff --git a/src/cities/socal.ts b/src/cities/socal.ts index 16daaff..3e9289e 100644 --- a/src/cities/socal.ts +++ b/src/cities/socal.ts @@ -30,6 +30,7 @@ * show. */ +import type { Airport } from "../engine/airports.ts"; import type { Bridge, City, @@ -834,11 +835,21 @@ export const HILLS: Hill[] = [ radius: 0.048, }, { + // Moved north-east, from 34.205/-118.33 with a 0.04 radius, when Burbank + // Airport went onto the board and stood on a hillside. A 700 m peak whose + // falloff is 4.4 km across, sited half a kilometre north of runway 08/26, + // put 478 m of mountain on the 26 threshold and 191 m on the 08 threshold — + // a nine-degree slope across a field that is, in life, flat. It also had + // downtown Burbank sitting 165 m up a ramp. The ridge crest above Burbank + // really is at about 34.22, and this is where it belongs; the change reads + // as the Verdugos ending in the right place rather than leaning on the + // Valley floor. `socalAirports.test.ts` holds every field to the relief it + // may stand on, so this cannot quietly come back. name: "Mount Thom", - lat: 34.205, - lng: -118.33, - elevation: 700, - radius: 0.04, + lat: 34.221, + lng: -118.318, + elevation: 720, + radius: 0.036, }, { name: "San Rafael Hills", @@ -1235,7 +1246,10 @@ export const I405: LatLng[] = [ [34.04, -118.43], [34.01, -118.403], [33.98, -118.39], - [33.945, -118.386], // past LAX + // Moved east from -118.386 when LAX went onto the board: at that longitude the + // freeway ran across the 25R touchdown zone, and the real 405 passes about + // three kilometres east of the airport, not through it. + [33.945, -118.3765], // past LAX [33.91, -118.372], [33.875, -118.35], [33.85, -118.315], @@ -1247,6 +1261,10 @@ export const I405: LatLng[] = [ [33.745, -118.01], [33.72, -117.955], [33.69, -117.9], + // Added when John Wayne went onto the board: without it this leg cut the + // corner and drew a freeway straight across runway 02L/20R. The real 405 + // passes about a kilometre north-west of the field and meets the 55 beyond it. + [33.6885, -117.87], [33.678, -117.845], [33.66, -117.78], [33.63, -117.7], @@ -1257,7 +1275,13 @@ export const I5: LatLng[] = [ [34.32, -118.46], [34.27, -118.42], [34.23, -118.39], - [34.19, -118.36], + // These two replace a single vertex at 34.19/-118.36, which ran the Golden + // State diagonally across Burbank's runway 08/26. The real 5 comes down the + // far side of Hollywood Way, north-east of the field — which is why the + // airport's north-east corner is cut off the way it is, and why `BUR`'s 08/26 + // stops 700 m short of the freeway. + [34.222, -118.368], + [34.196, -118.338], [34.16, -118.335], [34.13, -118.28], [34.105, -118.25], @@ -1561,22 +1585,26 @@ export const KATELLA: LatLng[] = [ ]; /** - * LAX's two runway complexes, north and south of the terminals. + * ## The runways are no longer roads * - * Drawn as roads because that is what they are: a fifty-metre-wide paved strip - * lying on the ground. Two parallel bars pointing due west at the ocean is the - * single most recognisable piece of ground plan on the coastal plain, and it is - * three points of data. + * `LAX_RUNWAYS_NORTH` and `LAX_RUNWAYS_SOUTH` used to live here: two hand-typed + * two-point paths lying on **33.9535 and 33.9405 due east–west**, handed to + * `createRoads` as `kind: "street"`, because a pale strip on flat ground was as + * close as the road builder could get to an airport. Both numbers were wrong in + * the same way. LAX's runways are not due east–west — they run **82.9° true**, + * which over three and a half kilometres is four hundred metres of drift the + * pair did not have — and there are four of them, not two. `AIRPORTS` below + * draws the real ones, with the paint on them, the fill under them and the + * horseshoe between them. + * + * **The two cannot both be present.** A road ribbon drapes 0.14 units above the + * terrain — fifty-five metres at this board's scale — while a runway quad sits + * flush on it, so a board carrying both floats a dark stripe over every runway + * in Westchester. The Bay Area pack hit this first and photographed it. + * + * `runwayCentreline()` in `engine/airports.ts` reproduces a runway as a + * two-point path, if anything ever wants one as a line again. */ -export const LAX_RUNWAYS_NORTH: LatLng[] = [ - [33.9535, -118.434], - [33.9535, -118.402], -]; - -export const LAX_RUNWAYS_SOUTH: LatLng[] = [ - [33.9405, -118.434], - [33.9405, -118.402], -]; export const ROADS: City["roads"] = [ { path: I405, width: 0.14, kind: "freeway" }, @@ -1597,8 +1625,6 @@ export const ROADS: City["roads"] = [ { path: VENTURA_BLVD, width: 0.08, kind: "street" }, { path: COLORADO_BLVD, width: 0.08, kind: "street" }, { path: KATELLA, width: 0.08, kind: "street" }, - { path: LAX_RUNWAYS_NORTH, width: 0.16, kind: "street" }, - { path: LAX_RUNWAYS_SOUTH, width: 0.16, kind: "street" }, ]; // ---- Bridges -------------------------------------------------------------- @@ -1608,13 +1634,13 @@ export const ROADS: City["roads"] = [ * worth the geometry — nothing else in the metro spans water rather than * freeway. * - * A caveat for whoever renders these: `structures.ts` sizes bridge members in - * scene units — a 0.25-unit deck radius, 0.34-unit towers — and those constants - * were tuned at San Francisco's 94 m per unit. At 391 they come out about four - * times too heavy, so both of these render chunkier than life. They are kept - * because the port is one of the three or four silhouettes that say Southern - * California from above, and a heavy bridge still reads as a bridge. Making the - * members scale-relative belongs in `structures.ts`, not here. + * A caveat for whoever renders these. `bridges.ts` now sizes members in metres + * over metres-per-unit rather than in the fixed scene-unit constants that were + * tuned at San Francisco's 94 m per unit, so these are no longer four times too + * heavy — but the legibility floor under that arithmetic still is a floor, and + * at 391 m per unit it is what makes this pair read a little chunkier than life. + * They are kept because the port is one of the three or four silhouettes that + * say Southern California from above. */ /** The Vincent Thomas, over the main channel. Green, and suspension. */ @@ -1661,6 +1687,439 @@ export const LONG_BEACH_GATEWAY: Bridge = { export const BRIDGES = [VINCENT_THOMAS, LONG_BEACH_GATEWAY]; +// ---- Airports ------------------------------------------------------------- + +/** + * The Southland fields, and why these six. + * + * ## The headings, first, because they are the whole of the recognition + * + * A runway designator is **magnetic** and rounded to the nearest ten degrees. + * Los Angeles's declination is about 11.8° east, so a runway painted "25" is + * pointing somewhere near 262° true and one painted "16" near 172°. Building + * from the painted numbers lays a whole airport twelve degrees out of true — + * over LAX's longest runway that is seven hundred metres of drift — so every + * `heading` below is a **true** bearing and every one of them is checked against + * its designator in `socalAirports.test.ts`. + * + * Only LAX gets its bearing from a surveyed figure. The other five are + * `designator × 10 + 11.8°`, which is accurate to about half of the five degrees + * the rounding already allows and is invisible at 391 m to the scene unit. That + * is stated rather than hidden: it is the difference between a number that was + * looked up and a number that was derived, and the next person deserves to know + * which is which. + * + * ## Six, and the ones that were left out + * + * The live ADS-B layer over this board carries a hundred and sixty-odd aircraft + * in the middle of a weekday. Sampled at 02:40 Pacific — which is what the feed + * would give me while this was written, and is therefore a thin sample rather + * than a survey — twenty-two aircraft were airborne over the basin and six of + * the eleven flying below eight thousand feet near any field were in LAX's + * pattern, with Van Nuys and Burbank next. That ordering matches the annual + * operations counts, which is what actually decided the list: LAX at ~700,000 + * movements, Van Nuys at ~220,000 (the busiest general-aviation field in the + * world), John Wayne and Long Beach around ~300,000 each with their flight + * schools, Burbank ~130,000, Ontario ~70,000 but the region's freight door. + * + * Deliberately absent, and all of them real airports: **Santa Monica**, whose + * single 3,500 ft strip disappears into the Westside grid at this scale; + * **Chino**, nine kilometres from Ontario, which would read as a smudge beside + * it; **Torrance**, **Hawthorne**, **Fullerton**, **El Monte** and **Whiteman**, + * which are one short runway each; and **March Air Reserve Base**, whose + * 13,300 ft runway is the second longest in Southern California but sits three + * kilometres from the eastern edge of the board with thirty thousand movements a + * year. An airport nobody flies into is scenery, and scenery costs triangles. + * + * ## Every one of them is a hole cut in a district + * + * `blocks.ts` has never heard of an airport. It scatters lots across a district + * polygon and the only thing that keeps houses off a runway is the polygon + * stopping short. Four of these — LAX, Van Nuys, John Wayne and the western half + * of Burbank — sit on a district edge and are cut out with a notch. Long Beach + * and Ontario sit in the middle of continuously built ground, so each is a hole + * reached by a **narrow corridor** to the nearest district edge: Cherry Avenue + * north out of Long Beach, Haven Avenue south out of Ontario. The corridor + * costs one column of lots, which at 164 m to the lot reads as the six-lane + * arterial that is genuinely there. `socalAirports.test.ts` sweeps the whole of + * every field against every district and every park, because checking the + * corners is what lets a runway keep a housing tract in the middle of it. + */ + +/** + * Los Angeles International: four parallels in two pairs, either side of the + * horseshoe. + * + * From the altitude this board is looked at, LAX **is** that pattern — two long + * bars, a gap with buildings in it, two more long bars — and it is as + * recognisable as the Hollywood sign. Everything below is placed as a metre + * offset from the airport reference point at 33.9425, −118.4081 and converted, + * rather than typed as four independent thresholds, because four hand-typed + * pairs of coordinates will not be consistent with each other and the + * consistency is the shape. + * + * ### The bearing, and how the numbers check out + * + * All four runways are on **82.9° true**. That single number is why the whole + * field is drawn from one frame, and it survives two independent checks. + * Subtract the 11.8° east declination and it 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 06L/24R and 06R/24L, which is why LAX has runways called both 24 and + * 25 lying exactly parallel to each other. Any reading that makes the 24s a + * different bearing from the 25s is wrong about LAX. + * + * ### The relative geometry + * + * Lateral offsets are from the reference point, positive to the south: + * + * - **06L/24R** at −853 m, 8,926 ft (2,721 m) long. **06R/24L** 213 m — 700 ft + * — south of it, 10,285 ft (3,135 m). + * - **07L/25R** at +610 m, 12,091 ft (3,685 m) and the longest on the field. + * **07R/25L** 244 m — 800 ft — south of it, 11,095 ft (3,382 m). + * + * That leaves 1,250 m of airport between 24L and 25R, and the horseshoe is what + * fills it: the Tom Bradley International Terminal closing the west end across + * the axis, Terminals 1–3 as the north arm and 4–8 as the south arm, the parking + * structures in the court. `gates` puts the stands on the **outside** of both + * arms, which is where LAX's are — the court holds roadway and cars and nothing + * that needs a wingspan. + * + * Terminal heights are the real ones. The board exaggerates its vertical 3.4× + * and makes a 17 m concourse stand fifty-eight metres tall; trimming that would + * put the airport on a different vertical scale from every house in Westchester + * behind it, and the eye notices the mismatch long before it notices a tall + * terminal. + * + * No `tower` is declared: this pack draws LAX's as a labelled `Landmark`, + * because `label: true` is what puts the airport on the minimap and + * `minimap.ts` reads `city.landmarks` rather than `city.airports`. + */ +export const LAX: Airport = { + id: "KLAX", + name: "Los Angeles International", + lat: 33.9425, + lng: -118.4081, + // 125 ft. Carried because it is a fact about the place; the kit grades the + // field onto the terrain rather than lifting it to this. See + // `engine/airports.ts`. + elevation: 38, + + /** + * The property, and it is **square to Westchester rather than to the + * runways**, which is the detail that makes LAX look like LAX from above: a + * cardinal rectangle with a pattern seven degrees off it inside. Westchester + * Parkway is the north fence, Imperial Highway the south, Aviation Boulevard + * the east, and the dunes above Dockweiler the west. + */ + field: [ + [33.9545, -118.433], + [33.9545, -118.382], + [33.931, -118.382], + [33.931, -118.433], + ], + + runways: [ + // 06L [33.94845, -118.42526] -> 24R [33.95147, -118.39602] + { id: "06L/24R", lat: 33.94996, lng: -118.41064, heading: 82.9, length: 2721, width: 46, designators: ["06L", "24R"] }, + // 06R [33.94636, -118.42685] -> 24L [33.94984, -118.39317] + { id: "06R/24L", lat: 33.9481, lng: -118.41001, heading: 82.9, length: 3135, width: 46, designators: ["06R", "24L"] }, + // 07L [33.93515, -118.42571] -> 25R [33.93925, -118.38611] + { id: "07L/25R", lat: 33.9372, lng: -118.40591, heading: 82.9, length: 3685, width: 61, designators: ["07L", "25R"] }, + // 07R [33.93311, -118.42417] -> 25L [33.93687, -118.38783] + { id: "07R/25L", lat: 33.93499, lng: -118.406, heading: 82.9, length: 3382, width: 61, designators: ["07R", "25L"] }, + ], + + /** + * The four outer parallels, B and C on the north complex and D and E on the + * south. + * + * Typed as coordinates rather than computed here, because a city pack is data + * and `airports.ts` imports three.js. `parallelTaxiway()` produced them and + * `socalAirports.test.ts` asserts they are still what it produces, so the + * derivation is checked without the pack having to run it. + */ + taxiways: [ + { id: "B", path: [[33.94989, -118.42449], [33.95271, -118.39719]] }, + { id: "C", path: [[33.94521, -118.4257], [33.94849, -118.39395]] }, + { id: "D", path: [[33.9365, -118.42493], [33.94039, -118.38727]] }, + { id: "E", path: [[33.93188, -118.423], [33.93543, -118.3886]] }, + ], + + aprons: [ + { id: "terminal", polygon: [[33.94524, -118.41833], [33.94709, -118.4005], [33.9396, -118.39937], [33.93776, -118.41721]] }, + // The Imperial Cargo Complex, on the south side under the 25L approach. + { id: "cargo", polygon: [[33.93628, -118.39298], [33.937, -118.386], [33.9337, -118.3855], [33.93298, -118.39248]] }, + { id: "maintenance", polygon: [[33.94571, -118.43051], [33.94631, -118.42471], [33.94354, -118.4243], [33.94294, -118.4301]] }, + ], + + terminals: [ + { id: "tbit", lat: 33.94192, lng: -118.41369, length: 560, width: 130, height: 30, heading: 352.9, gates: { count: 8, side: -1 } }, + { id: "north-arm", lat: 33.94517, lng: -118.4085, length: 850, width: 85, height: 17, heading: 82.9, gates: { count: 8, side: -1 } }, + { id: "south-arm", lat: 33.93989, lng: -118.40705, length: 950, width: 85, height: 17, heading: 82.9, gates: { count: 9, side: 1 } }, + // The central terminal area's parking structures, as three blocks rather + // than one. A single 700 m mass filled the court, and a filled court is not + // a horseshoe — the U only reads because there is a gap with something + // smaller in it. + { id: "parking-west", lat: 33.94221, lng: -118.41089, length: 230, width: 120, height: 20, heading: 82.9 }, + { id: "parking-centre", lat: 33.94246, lng: -118.40853, length: 230, width: 120, height: 20, heading: 82.9 }, + { id: "parking-east", lat: 33.9427, lng: -118.40617, length: 230, width: 120, height: 20, heading: 82.9 }, + // The west maintenance base, square to the runways rather than to the + // horseshoe, which is what stops it reading as more of the same building. + { id: "maintenance", lat: 33.94456, lng: -118.42761, length: 280, width: 130, height: 26, heading: 82.9 }, + { id: "cargo", lat: 33.93492, lng: -118.3895, length: 400, width: 110, height: 14, heading: 82.9, gates: { count: 4, side: -1 } }, + ], +}; + +/** + * Hollywood Burbank, in the gap between the Verdugos and the Valley floor. + * + * Two runways crossing at seventy degrees, which is the shape here: 15/33 is the + * long one running down out of the Verdugo gap, 08/26 is the short one along the + * north fence, and they cross west of the terminal. Everything at Burbank is + * small — 6,886 ft and 5,802 ft, a terminal that is genuinely two storeys — and + * that is the point of drawing it beside LAX rather than instead of it. + * + * The bearings are the designators plus the 11.8° east declination: 161.8° and + * 91.8° true. See the section comment. + */ +export const BUR: Airport = { + id: "KBUR", + name: "Hollywood Burbank", + lat: 34.2007, + lng: -118.3585, + elevation: 237, + /** + * Held inside 34.190–34.2115 and −118.3735 to −118.349 on purpose: that is the + * gap `van-nuys` and `burbank` were reshaped to leave, and a field that + * crossed into either would have tract houses on the runway. + */ + /** + * Both diagonals are the fence, not tidiness. The **north-east** cut is + * Hollywood Way and the Golden State beyond it: I-5 passes about 700 m + * north-east of the 26 threshold, and a square corner there put a freeway + * ribbon across the runway. The **south-west** cut is the Burbank industrial + * quarter, which is city and not airport, and every square metre of field that + * is not holding a runway up is a square metre of plate standing proud of the + * ground at the low end — see `Mount Thom` in `HILLS` for what that cost here. + */ + field: [ + [34.2108, -118.3715], + [34.2108, -118.361], + [34.205, -118.3505], + [34.1915, -118.3505], + [34.1915, -118.36], + [34.2, -118.3715], + ], + runways: [ + // 15 [34.20993, -118.3638] -> 33 [34.19201, -118.35668] + { id: "15/33", lat: 34.20097, lng: -118.36024, heading: 161.8, length: 2099, width: 46, designators: ["15", "33"] }, + // 08 [34.20526, -118.37082] -> 26 [34.20476, -118.35162] + { id: "08/26", lat: 34.20501, lng: -118.36122, heading: 91.8, length: 1769, width: 46, designators: ["08", "26"] }, + ], + taxiways: [ + { id: "A", path: [[34.20955, -118.36205], [34.19317, -118.35554]] }, + { id: "C", path: [[34.20407, -118.37], [34.20362, -118.35253]] }, + ], + aprons: [{ id: "terminal", polygon: [[34.20032, -118.35469], [34.19716, -118.35343], [34.19643, -118.35612], [34.19959, -118.35737]] }], + // Terminals A and B as one frontage east of 15/33, where Hollywood Way runs, + // with the stands facing back at the runway. + terminals: [ + { id: "ab", lat: 34.1985, lng: -118.35524, length: 340, width: 70, height: 12, heading: 161.8, gates: { count: 6, side: 1 } }, + { id: "cargo", lat: 34.20366, lng: -118.35383, length: 260, width: 80, height: 13, heading: 91.8 }, + ], +}; + +/** + * Van Nuys: two parallels down the middle of the Valley, and no airline at all. + * + * It is on this board because it is the busiest general-aviation airport in the + * world and because the aircraft over the Valley are largely its — not because + * anything about it is famous. So it is drawn as what it is: a long thin field + * with an 8,000 ft runway, a 4,000 ft one beside it, and hangar rows down the + * east side instead of a terminal. No `gates` anywhere on it, because the kit + * parks airliners and nothing that lives here is one. + * + * The north fence is pulled in to 34.227 — Van Nuys really reaches Roscoe at + * 34.232 — because `north-valley` starts at 34.2316 at this longitude and a + * field that crossed it would have houses on runway 16R. + */ +export const VNY: Airport = { + id: "KVNY", + name: "Van Nuys", + lat: 34.2098, + lng: -118.4899, + elevation: 244, + // Trimmed in at the north and west from 34.227/-118.499 — Van Nuys really does + // reach Roscoe — because the Valley floor rises about a metre in a hundred + // across this field and the kit grades a plate to the highest ground it + // covers. Every metre of field that is not holding a runway up is a metre of + // lip standing proud at the low end. What is left spans 33 m. + field: [ + [34.2235, -118.4955], + [34.2235, -118.481], + [34.1975, -118.481], + [34.1975, -118.4955], + ], + runways: [ + // 16R [34.22064, -118.49298] -> 34L [34.19896, -118.4892] + { id: "16R/34L", lat: 34.2098, lng: -118.49109, heading: 171.8, length: 2439, width: 46, designators: ["16R", "34L"] }, + // 16L [34.2121, -118.48971] -> 34R [34.20122, -118.48781] + { id: "16L/34R", lat: 34.20666, lng: -118.48876, heading: 171.8, length: 1223, width: 23, designators: ["16L", "34R"] }, + ], + taxiways: [{ id: "A", path: [[34.22011, -118.49135], [34.19985, -118.48782]] }], + /** + * The east ramp runs the whole length of the field, Saticoy to Sherman Way, + * which is what Van Nuys's east side is. It is drawn that long for a second + * reason as well: `airports.ts` grades the field to the highest of the + * airport's own authored coordinates, and an apron corner is one of them. The + * Valley floor's high point inside this fence is under the middle of this + * ramp, so a short ramp graded the plate to ground lower than the ground it + * covers. See the note on `FIELD_LIFT` in the section comment. + */ + aprons: [{ id: "east-ramp", polygon: [[34.21511, -118.48797], [34.19991, -118.48532], [34.2004, -118.48124], [34.2156, -118.48389]] }], + terminals: [ + { id: "east-hangars", lat: 34.20872, lng: -118.48523, length: 780, width: 90, height: 14, heading: 171.8 }, + { id: "west-hangars", lat: 34.21411, lng: -118.49446, length: 420, width: 80, height: 13, heading: 171.8 }, + ], +}; + +/** + * Long Beach, and the one field on this board that is a genuine X. + * + * 12/30 runs 10,003 ft south-east across the whole property and the 08s cross + * it near their western ends, which is what gives Long Beach the crossed-strip + * plan that reads from any altitude. The terminal is the small 1941 one on the + * south-west side; the very large building on the north fence is the old + * Douglas plant, which is a real 420 m shed and the largest single mass on the + * field. + * + * Bearings are the designators plus declination: 131.8° and 91.8° true. + */ +export const LGB: Airport = { + id: "KLGB", + name: "Long Beach", + lat: 33.8177, + lng: -118.1516, + elevation: 18, + /** + * The south-west corner is cut off, and that is not tidiness: **Signal Hill** + * is 111 m of real oil-field hill 1.9 km south-west of the airport, and a + * rectangle reaching -118.169 at 33.8055 put its flank inside the field. The + * kit grades a field to the highest ground it covers, so that one corner + * lifted the whole plate a hundred metres and stood Long Beach Airport on a + * table. Cut back, the field spans 21 m of relief and lies flat. + */ + field: [ + [33.8305, -118.1665], + [33.8305, -118.1375], + [33.8065, -118.1375], + [33.8065, -118.148], + [33.816, -118.1665], + ], + runways: [ + // 12 [33.82683, -118.16389] -> 30 [33.80857, -118.13931] + { id: "12/30", lat: 33.8177, lng: -118.1516, heading: 131.8, length: 3049, width: 61, designators: ["12", "30"] }, + // 08L [33.823, -118.16018] -> 26R [33.82246, -118.13978] + { id: "08L/26R", lat: 33.82273, lng: -118.14998, heading: 91.8, length: 1887, width: 46, designators: ["08L", "26R"] }, + // 08R [33.82089, -118.15891] -> 26L [33.82043, -118.14105] + { id: "08R/26L", lat: 33.82066, lng: -118.14998, heading: 91.8, length: 1653, width: 46, designators: ["08R", "26L"] }, + ], + taxiways: [ + { id: "D", path: [[33.82529, -118.16409], [33.80823, -118.14113]] }, + { id: "B", path: [[33.82414, -118.15927], [33.82365, -118.1406]] }, + ], + aprons: [{ id: "terminal", polygon: [[33.81745, -118.15694], [33.81356, -118.1517], [33.81041, -118.15509], [33.8143, -118.16033]] }], + terminals: [ + { id: "terminal", lat: 33.81303, lng: -118.15722, length: 260, width: 60, height: 12, heading: 131.8, gates: { count: 5, side: -1 } }, + { id: "douglas", lat: 33.82803, lng: -118.14836, length: 420, width: 160, height: 20, heading: 91.8 }, + ], +}; + +/** + * John Wayne, pointing north-east at the hills, which is the whole reason + * anyone in Newport Beach has an opinion about it. + * + * 02L/20R is 5,701 ft with the short 02R/20L general-aviation strip beside it, + * and the Riley terminal on the south-east side along MacArthur with the stands + * facing back across at the runway. On 31.8° true — the designators plus + * declination. + */ +export const SNA: Airport = { + id: "KSNA", + name: "John Wayne", + lat: 33.6757, + lng: -117.8682, + elevation: 17, + /** + * Square to the runways rather than to the compass, which is the one field on + * this board that is: John Wayne is a diagonal parcel wedged between MacArthur + * Boulevard and the 405, and a cardinal rectangle around it left two-thirds of + * the plate as empty green with the runways pushed into one corner. + */ + field: [ + [33.66833, -117.87686], + [33.68513, -117.86435], + [33.68073, -117.85582], + [33.66393, -117.86833], + ], + runways: [ + // 02L [33.66907, -117.87379] -> 20R [33.68233, -117.86391] + { id: "02L/20R", lat: 33.6757, lng: -117.86885, heading: 31.8, length: 1738, width: 46, designators: ["02L", "20R"] }, + // 02R [33.67081, -117.8687] -> 20L [33.67753, -117.8637] + { id: "02R/20L", lat: 33.67417, lng: -117.8662, heading: 31.8, length: 880, width: 23, designators: ["02R", "20L"] }, + ], + taxiways: [{ id: "A", path: [[33.66901, -117.87205], [33.68106, -117.86308]] }], + aprons: [{ id: "terminal", polygon: [[33.6756, -117.86637], [33.67988, -117.86318], [33.67855, -117.86061], [33.67428, -117.8638]] }], + terminals: [ + { id: "riley", lat: 33.67866, lng: -117.86356, length: 420, width: 85, height: 16, heading: 31.8, gates: { count: 6, side: -1 } }, + ], +}; + +/** + * Ontario, the freight door, out where the basin runs into the Inland Empire. + * + * Two long parallels 560 m apart with the passenger terminals and the cargo + * ramp between them — which is the layout, and the reason Ontario looks like a + * much bigger airport than its passenger numbers suggest. 12,198 ft and + * 10,200 ft on 91.8° true. + * + * It is the only field on this board that had to be reached by a corridor cut + * south to the district edge rather than notched from one; see the section + * comment. + */ +export const ONT: Airport = { + id: "KONT", + name: "Ontario International", + lat: 34.056, + lng: -117.6012, + elevation: 288, + field: [ + [34.063, -117.625], + [34.063, -117.577], + [34.049, -117.577], + [34.049, -117.625], + ], + runways: [ + // 08L [34.05904, -117.62135] -> 26R [34.058, -117.58105] + { id: "08L/26R", lat: 34.05852, lng: -117.6012, heading: 91.8, length: 3718, width: 61, designators: ["08L", "26R"] }, + // 08R [34.05392, -117.61642] -> 26L [34.05304, -117.58272] + { id: "08R/26L", lat: 34.05348, lng: -117.59957, heading: 91.8, length: 3109, width: 46, designators: ["08R", "26L"] }, + ], + taxiways: [ + { id: "A", path: [[34.05776, -117.62031], [34.05677, -117.58218]] }, + { id: "B", path: [[34.05515, -117.61528], [34.05433, -117.58376]] }, + ], + aprons: [{ id: "terminal", polygon: [[34.05779, -117.60764], [34.05748, -117.59572], [34.05406, -117.59585], [34.05437, -117.60777]] }], + terminals: [ + { id: "t2-t4", lat: 34.056, lng: -117.60228, length: 700, width: 110, height: 16, heading: 91.8, gates: { count: 8, side: -1 } }, + { id: "cargo", lat: 34.05474, lng: -117.5871, length: 500, width: 130, height: 16, heading: 91.8, gates: { count: 5, side: 1 } }, + ], +}; + +/** Every airport on this board, biggest first. */ +export const AIRPORTS: Airport[] = [LAX, BUR, VNY, LGB, SNA, ONT]; + // ---- Landmarks ------------------------------------------------------------ /** @@ -1872,16 +2331,24 @@ export const LANDMARKS: Landmark[] = [ }, // ---- LAX ---- - // Not a building: a two-kilometre pad of apron and terminal, which with the - // two runway strips beside it is what actually reads as an airport. + // This used to be a flat two-kilometre pad of a landmark standing in for an + // airport, beside two runway-shaped roads. `LAX` in `AIRPORTS` draws the real + // thing now and the pad would sit on top of its own field, so what is left + // here is the one piece of LAX the kit does not draw: the tower, at its real + // 277 ft. + // + // It is a landmark rather than the airport's own `tower` because `label: true` + // is what puts LAX on the minimap — `minimap.ts` reads `city.landmarks` and + // has never heard of `city.airports`. Declaring both would stand two towers + // four hundred metres apart, which is how the Bay Area pack found this out. { - name: "LAX", - lat: 33.9445, - lng: -118.4045, - height: 12, - footprint: 0.0095, - shape: "box", - color: 0x76736c, + name: "LAX Control Tower", + lat: 33.9421, + lng: -118.4022, + height: 84, + footprint: 0.00028, + shape: "cylinder", + color: 0xc9ccce, label: true, }, { @@ -2391,12 +2858,25 @@ export const DISTRICTS: District[] = [ }, { id: "van-nuys", + // Two airports come out of this one polygon. Vertices 3-6 are the notch for + // Burbank, cut in from the east boundary; vertices 9-12 are the notch for + // Van Nuys, cut in from the west, where the ground it also gives up is the + // Sepulveda Basin and already open. `burbank` picks up the eastern strip + // that the first notch removes, so nothing is left unbuilt but the fields. name: "Van Nuys & North Hollywood", polygon: [ [34.24, -118.5], [34.235, -118.34], + [34.2125, -118.3423], + [34.2125, -118.3745], + [34.189, -118.3745], + [34.189, -118.3447], [34.176, -118.346], [34.181, -118.506], + [34.194, -118.5047], + [34.194, -118.48], + [34.229, -118.48], + [34.229, -118.5011], ], minHeight: 12, maxHeight: 48, @@ -2439,12 +2919,18 @@ export const DISTRICTS: District[] = [ }, { id: "burbank", + // The north-west corner is cut back from -118.368 to -118.348, which is + // Hollywood Way and the east fence of the airport. What that leaves out is + // the eastern half of Burbank's field; `van-nuys` gives up the western + // half, and between them the two polygons leave one clean hole. name: "Burbank", polygon: [ - [34.212, -118.368], + [34.2125, -118.348], [34.206, -118.282], [34.15, -118.29], [34.157, -118.375], + [34.189, -118.3709], + [34.189, -118.348], ], minHeight: 13, maxHeight: 62, @@ -2521,11 +3007,24 @@ export const DISTRICTS: District[] = [ }, { id: "ontario", + // Ontario International, cut out the same way Long Beach is: a hole with a + // corridor, here running south to the boundary along Haven Avenue. Nothing + // on the eastern half of this board is close enough to a district edge to + // notch, and a warehouse district with a runway drawn through it is worse + // than one arterial's worth of missing lots. name: "Ontario & Rancho Cucamonga", polygon: [ [34.125, -117.69], [34.12, -117.48], [34.015, -117.492], + [34.0181, -117.601], + [34.0475, -117.601], + [34.0475, -117.5755], + [34.0645, -117.5755], + [34.0645, -117.6265], + [34.0475, -117.6265], + [34.0475, -117.606], + [34.0182, -117.606], [34.021, -117.702], ], minHeight: 11, @@ -2571,12 +3070,23 @@ export const DISTRICTS: District[] = [ }, { id: "inglewood", + // The last four vertices are the LAX notch: the polygon runs up the west + // boundary, turns in along the airport's south fence, round the east and + // north fences, and back out to the boundary. `blocks.ts` has never heard + // of an airport, so this notch is the only thing keeping tract houses off + // the 25R touchdown zone. The strip it also gives up — between the fence at + // -118.433 and the boundary — is the Dockweiler dunes, which are genuinely + // unbuilt, and that is why the notch opens west rather than east. name: "Inglewood & Hawthorne", polygon: [ [33.985, -118.43], [33.98, -118.32], [33.915, -118.33], [33.92, -118.438], + [33.929, -118.4369], + [33.929, -118.38], + [33.9565, -118.38], + [33.9565, -118.4335], ], minHeight: 11, maxHeight: 50, @@ -2651,9 +3161,24 @@ export const DISTRICTS: District[] = [ }, { id: "long-beach", + // Long Beach Airport sits three and a half kilometres inside this polygon in + // every direction, so it cannot be notched from an edge — it is a hole + // reached by a corridor running north to the boundary along Cherry Avenue. + // The corridor is 460 m wide and takes about one column of lots with it, + // which at 164 m to the lot is what a six-lane arterial looks like here in + // any case. Vertices 2-9 walk down the corridor, round the field, and back + // up it. name: "Long Beach", polygon: [ [33.868, -118.23], + [33.8653, -118.166], + [33.832, -118.166], + [33.832, -118.169], + [33.8055, -118.169], + [33.8055, -118.136], + [33.832, -118.136], + [33.832, -118.161], + [33.865, -118.161], [33.862, -118.09], [33.752, -118.102], [33.758, -118.24], @@ -2797,12 +3322,20 @@ export const DISTRICTS: District[] = [ }, { id: "irvine", + // John Wayne straddles this polygon's west boundary, so the last four + // vertices notch it out from that edge. `newport-beach` stops at about + // 33.662 under the field and `santa-ana` starts at 33.705 above it, so this + // is the only district the airport touches. name: "Irvine", polygon: [ [33.735, -117.865], [33.73, -117.71], [33.63, -117.722], [33.636, -117.877], + [33.662, -117.8739], + [33.662, -117.854], + [33.6935, -117.854], + [33.6935, -117.87], ], minHeight: 12, maxHeight: 84, @@ -3136,6 +3669,13 @@ export const SOCAL_CITY: City = { inlandWater: INLAND_WATER, hills: HILLS, districts: DISTRICTS, + + /** + * The basin's six fields, drawn by `engine/airports.ts`. LAX's runways-as- + * roads are gone from `ROADS` above and must stay gone: a board carrying both + * floats a dark stripe over every runway the kit lays flush. + */ + airports: AIRPORTS, landmarks: LANDMARKS, bridges: BRIDGES, roads: ROADS, diff --git a/src/engine/airports.ts b/src/engine/airports.ts new file mode 100644 index 0000000..6e58016 --- /dev/null +++ b/src/engine/airports.ts @@ -0,0 +1,866 @@ +/** + * Airports — the one piece of infrastructure you can name from twenty + * kilometres up. + * + * The board has had real aircraft in the sky since `flights.ts` landed, and + * every one of them was climbing away from, or descending onto, nothing at all. + * This is the ground half of that layer. + * + * ### The pattern is the recognition, not the buildings + * + * A terminal is a shed. From the altitude a city board is looked at, an airport + * is **two or three long pale bars at fixed angles to each other on a flat + * apron**, and the angles are the whole of the identity: SFO's crossing pairs, + * LAX's four east–west parallels, Heathrow's two. Get the headings and the + * relative lengths right and the shape is unmistakable before a single building + * is drawn; get them wrong and the most detailed terminal model in the world + * reads as a generic airfield. + * + * So this kit is arranged around that priority. A runway is **one quad**. Its + * markings — threshold bars, centreline, edge lines, aiming points, the painted + * designator — are a canvas texture drawn once per airport and shared by every + * runway on it through an atlas, so four runways with four different sets of + * numbers on them are still one mesh, one material and one upload. Everything + * above that (taxiways, aprons, terminals, a tower, aircraft on stand) is a + * handful of boxes on top. + * + * Measured on the Bay Area board, the whole of SFO — four runways, six + * taxiways, three aprons, nine terminal masses, a tower and twenty-two parked + * airliners — is 2,910 triangles in 7 draw calls. That is the argument for + * doing it this way rather than modelling jet bridges. + * + * ### Everything here is batched, for the reason `structures.ts` gives + * + * Same two rules, and this module keeps its own copy of them rather than + * importing `structures.ts`'s `Batch`, which is private to that file: materials + * are cached by role for the life of one build, geometry is merged per + * material, and the cache is deliberately **not** module-level, because + * `createScene().dispose()` walks the scene disposing every material it finds + * and a cache that outlived a build would hand the next board a disposed + * material and render it black. + * + * The corollary is the same too: every geometry below carries position, normal + * **and uv**, indexed, whether or not it has a texture, because `mergeGeometries` + * silently drops a bucket whose attribute sets disagree. + * + * ### An airport is a graded platform, and that is why it is flat + * + * The field is laid at one height for the whole airport: the highest ground it + * covers, plus a hair. That is what grading *is* — the reason a runway is a + * usable runway is that somebody spent a great deal of money making the ground + * under it one plane. + * + * The height deliberately does **not** come from `Airport.elevation`. Field + * elevation is a fact about the place (it is what the altimeter reads on the + * ground, and later layers will want it), but a board exaggerates its relief — + * 3.6× on the Bay Area — so SFO's true 4 m would stand the whole field 14 m + * proud of the shoreline it is built on and the plate would read as a floating + * slab from any low camera. Sampling the ground instead keeps the platform + * sitting on the terrain it is part of. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { airlinerGeometry, AIRLINER_LENGTH } from "./aircraftGeometry.ts"; +import { LOD_HEIGHT_TOLERANCE } from "./terrain.ts"; +import type { Airport, LatLng, Runway, Taxiway } from "./types.ts"; +import type { World } from "./world.ts"; + +// ---- The authored contract ------------------------------------------------ +// +// The airport data types live in `types.ts`, beside `City`, `Bridge` and +// `Road`, and are re-exported here so the kit reads as one import. +// +// They moved there rather than staying here for a reason worth recording, +// because it is a trap the whole repo is arranged around: `City` has to name +// `Airport`, `src/index.ts` reaches `City`, and the package surface promises +// that nothing reachable from it imports three.js. A type-only import is erased +// at build time and would have been harmless — but `src/test/integration/ +// barrel.test.ts` reads the import graph as *source*, because that is the only +// way to assert the promise, and it cannot tell an erased edge from a real one. +// Putting the plain data where the plain data lives makes the question moot +// instead of teaching the guard to look away. + +export type { Airport, Apron, Runway, Taxiway, Terminal, Tower } from "./types.ts"; + +// ---- Palette -------------------------------------------------------------- + +/** + * Exported because a city pack in a different landscape may need to move these, + * and because the contrast between them is a design decision worth being able + * to see in one place. + * + * The one that matters is `field` against `runway`: concrete runways are + * *lighter* than the ground around them, which is the opposite of the road + * convention two files over, and it is what SFO actually looks like. If a pack + * ever darkens the field, the bars have to stay the lighter of the two or the + * pattern stops reading at distance. + */ +export const AIRPORT_PALETTE = { + /** + * Graded fill: bay mud, decomposed granite, mown grass. All of it drab. + * + * **Drab, and specifically not green**, which it was: `0x7b8070` is an olive + * with more green in it than red, and a second board proved that unusable. + * On the Bay Area's cream shore and on Southern California's tan basin the + * same swatch was the most saturated green thing in frame, so six real + * airfields read as golf courses until you were close enough to count the + * runways. Hue is what says *vegetation* at board scale; value is what says + * *graded platform*. This is the same drabness with the green taken out and + * the contrast against the ground carried by being darker instead. + */ + field: 0x8e8a7e, + /** + * Grooved concrete, and the one relationship in this table that is load- + * bearing: a runway must stay LIGHTER than the field it is laid on, because + * from twenty kilometres up the pattern of pale bars *is* the airport. It is + * brighter than it was for the same reason the field is drabber — the two + * were four steps of value apart and the bars did not carry. + */ + runway: 0xbcbeb8, + /** Asphalt, and darker than the runways on purpose. */ + taxiway: 0x70747a, + apron: 0x94968f, + terminal: 0xb4b8bb, + roof: 0x8d9296, + tower: 0xc9ccce, + aircraft: 0xe2e6e9, + /** Paint. Unlit, so it survives dusk; see `markingMaterial`. */ + paint: 0xf2f3ee, +} as const; + +// ---- Authoring helpers ---------------------------------------------------- +// +// These run at pack-authoring time and return plain data. They are exported +// because a pack that computes a parallel taxiway from the runway it parallels +// cannot drift out of alignment with it, and a hand-typed one can. + +const DEG = Math.PI / 180; +const METRES_PER_DEGREE_LAT = 111_320; + +/** Metres per degree of longitude at a latitude. */ +function metresPerDegreeLng(lat: number): number { + return METRES_PER_DEGREE_LAT * Math.cos(lat * DEG); +} + +/** `[east, north]` unit vector for a true bearing. */ +function bearingVector(heading: number): [number, number] { + return [Math.sin(heading * DEG), Math.cos(heading * DEG)]; +} + +/** Move from a coordinate by metres east and metres north. */ +export function offsetLatLng( + origin: { lat: number; lng: number }, + east: number, + north: number, +): LatLng { + return [ + origin.lat + north / METRES_PER_DEGREE_LAT, + origin.lng + east / metresPerDegreeLng(origin.lat), + ]; +} + +/** The two threshold coordinates, low designator first. */ +export function runwayThresholds(runway: Runway): { low: LatLng; high: LatLng } { + const [east, north] = bearingVector(runway.heading); + const half = runway.length / 2; + return { + low: offsetLatLng(runway, -east * half, -north * half), + high: offsetLatLng(runway, east * half, north * half), + }; +} + +/** + * The centreline as a two-point path. + * + * For anything that wants a runway as a line rather than a surface — a minimap, + * an approach path, or a board that has not wired the kit in yet and is drawing + * runways as pale roads. + */ +export function runwayCentreline(runway: Runway): LatLng[] { + const { low, high } = runwayThresholds(runway); + return [low, high]; +} + +/** + * A taxiway running alongside a runway, `offset` metres to one side. + * + * `side` is +1 for the right of the low-to-high direction and −1 for the left. + * `trim` shortens it at both ends, which is what keeps a parallel taxiway from + * running out past the threshold it serves. + */ +export function parallelTaxiway( + runway: Runway, + side: 1 | -1, + offset: number, + trim = 0, + id?: string, +): Taxiway { + const [east, north] = bearingVector(runway.heading); + // Right of the heading is the heading turned a quarter clockwise. + const rightEast = north; + const rightNorth = -east; + const half = Math.max(0, runway.length / 2 - trim); + const acrossE = rightEast * offset * side; + const acrossN = rightNorth * offset * side; + return { + ...(id === undefined ? {} : { id }), + path: [ + offsetLatLng(runway, acrossE - east * half, acrossN - north * half), + offsetLatLng(runway, acrossE + east * half, acrossN + north * half), + ], + }; +} + +// ---- Batching ------------------------------------------------------------- + +interface Bucket { + readonly name: string; + readonly material: THREE.Material; + readonly castShadow: boolean; + readonly receiveShadow: boolean; + readonly parts: THREE.BufferGeometry[]; +} + +/** One build's worth of materials and geometry, merged on the way out. */ +class Batch { + private readonly buckets = new Map(); + + add( + name: string, + geometry: THREE.BufferGeometry, + material: THREE.Material, + shadows: { cast?: boolean; receive?: boolean } = {}, + ): void { + const key = `${material.uuid}|${name}`; + const bucket = this.buckets.get(key); + if (bucket) { + bucket.parts.push(geometry); + return; + } + this.buckets.set(key, { + name, + material, + castShadow: shadows.cast ?? false, + receiveShadow: shadows.receive ?? true, + parts: [geometry], + }); + } + + flush(into: THREE.Object3D): void { + for (const bucket of this.buckets.values()) { + const merged = + bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false); + if (!merged) { + // Losing a bucket in silence is the failure the module comment warns + // about, so say which one and why rather than rendering an airport with + // no paint on it. + console.warn(`airports: "${bucket.name}" has mismatched attributes and was not merged`); + continue; + } + if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose(); + const mesh = new THREE.Mesh(merged, bucket.material); + mesh.name = bucket.name; + mesh.castShadow = bucket.castShadow; + mesh.receiveShadow = bucket.receiveShadow; + into.add(mesh); + } + this.buckets.clear(); + } +} + +// ---- Geometry ------------------------------------------------------------- + +/** + * A flat, axis-free quad in the ground plane. + * + * `(alongX, alongZ)` is a unit vector in scene space; the across direction is + * its right-hand perpendicular, so a quad built from a bearing has the same + * handedness as everything else on the board. UVs run `u` along and `v` across, + * which is what lets a long thin runway index into a horizontal band of an + * atlas. + */ +function groundQuad( + cx: number, + y: number, + cz: number, + alongX: number, + alongZ: number, + halfLength: number, + halfWidth: number, + uv: { u0: number; u1: number; v0: number; v1: number } = { u0: 0, u1: 1, v0: 0, v1: 1 }, +): THREE.BufferGeometry { + const rightX = -alongZ; + const rightZ = alongX; + const positions: number[] = []; + const uvs: number[] = []; + for (const [s, t, u, v] of [ + [-halfLength, -halfWidth, uv.u0, uv.v0], + [halfLength, -halfWidth, uv.u1, uv.v0], + [-halfLength, halfWidth, uv.u0, uv.v1], + [halfLength, halfWidth, uv.u1, uv.v1], + ] as const) { + positions.push(cx + alongX * s + rightX * t, y, cz + alongZ * s + rightZ * t); + uvs.push(u, v); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("normal", new THREE.Float32BufferAttribute([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + // Wound so the face normal comes out +Y; the other order renders the whole + // airport as a hole in the ground under a one-sided material. + geometry.setIndex([0, 2, 1, 1, 2, 3]); + return geometry; +} + +/** + * A flat strip along a polyline — a taxiway. + * + * The **left rail is emitted first**, which is not a style choice: with the two + * rails the other way round the winding reverses, `computeVertexNormals` hands + * every triangle a normal pointing at the ground, and a one-sided material + * draws nothing at all. This was written right-rail-first and the taxiways were + * simply absent from the board — no warning, no black stripe, nothing to see. + * `structures.ts`'s `bandGeometry` carries the same rule and the same scar. + */ +function stripGeometry(points: readonly THREE.Vector3[], width: number): THREE.BufferGeometry { + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + const half = width / 2; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + const previous = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + if (!point || !previous || !next) continue; + const dx = next.x - previous.x; + const dz = next.z - previous.z; + const length = Math.hypot(dx, dz) || 1; + const leftX = -(dz / length); + const leftZ = dx / length; + positions.push( + point.x + leftX * half, point.y, point.z + leftZ * half, + point.x - leftX * half, point.y, point.z - leftZ * half, + ); + const v = index / Math.max(1, points.length - 1); + uvs.push(0, v, 1, v); + if (index < points.length - 1) { + const a = index * 2; + indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** A flat polygon in the ground plane, triangulated. */ +function slabGeometry(points: readonly [number, number][], y: number): THREE.BufferGeometry | null { + if (points.length < 3) return null; + // `ShapeGeometry` builds in XY and faces +Z. Feeding it `(x, −z)` and turning + // it a quarter about X puts north back at −z and the normal at +Y. + let ring = points.map(([x, z]) => new THREE.Vector2(x, -z)); + // A clockwise ring comes out of `ShapeGeometry` facing away from the camera + // and is invisible under a one-sided material, so orient it here rather than + // asking every pack to trace its outlines in one direction. + if (THREE.ShapeUtils.area(ring) < 0) ring = ring.reverse(); + const geometry = new THREE.ShapeGeometry(new THREE.Shape(ring)); + geometry.rotateX(-Math.PI / 2); + geometry.translate(0, y, 0); + return geometry; +} + +/** A box standing on the ground, turned to a bearing. */ +function massGeometry( + cx: number, + groundY: number, + cz: number, + alongX: number, + alongZ: number, + length: number, + width: number, + height: number, +): THREE.BufferGeometry { + const geometry = new THREE.BoxGeometry(width, height, length); + /** + * The box's own length axis is +Z, and `rotateY(φ)` sends +Z to + * `(sin φ, cos φ)` in (x, z) — so φ is `atan2(alongX, alongZ)` and **not** + * `atan2(alongX, −alongZ)`, which is the bearing you would write if you were + * converting a compass reading rather than aiming an axis that is already a + * scene-space vector. + * + * That negation was here, and it is worth recording because of how it failed. + * It does not rotate a building by a wrong angle, it *mirrors* it about the + * east–west line: SFO's terminal horseshoe came out on 153.5° instead of + * 26.5°, which is a plausible-looking building on a plausible-looking apron, + * lying across the airport at right angles to everything else. The tell was + * that the aircraft on stand — which take their positions from the along + * vector directly and never went through this function — were parked in a + * neat row beside nothing at all. + */ + geometry.rotateY(Math.atan2(alongX, alongZ)); + geometry.translate(cx, groundY + height / 2, cz); + return geometry; +} + +// ---- Runway markings ------------------------------------------------------ + +const ATLAS_WIDTH = 2048; +const ATLAS_BAND = 128; + +/** Next power of two at or above `n`; keeps the atlas mipmappable everywhere. */ +function powerOfTwo(n: number): number { + let size = 1; + while (size < n) size *= 2; + return size; +} + +/** + * Every runway on one airport, painted into one texture. + * + * Each runway gets a horizontal band `ATLAS_BAND` pixels tall: x runs along the + * runway from the low-designator threshold, y runs across it. The band is + * transparent apart from the paint, because this is an overlay laid a hair + * above the concrete rather than the concrete itself — which is what lets the + * surface be lit by the sun while the paint is not (see `markingMaterial`). + * + * Drawn in **metres and mapped through**, never in pixels: `alongPx` and + * `acrossPx` are the only two places the resolution appears, so the marks stay + * the right size when a runway of a different length shares the atlas. + */ +function markingsAtlas(runways: readonly Runway[]): THREE.Texture | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = ATLAS_WIDTH; + canvas.height = powerOfTwo(ATLAS_BAND * Math.max(1, runways.length)); + const context = canvas.getContext("2d"); + if (!context) return null; + + const paint = `#${AIRPORT_PALETTE.paint.toString(16).padStart(6, "0")}`; + context.fillStyle = paint; + context.strokeStyle = paint; + + runways.forEach((runway, index) => { + const top = index * ATLAS_BAND; + const alongPx = (metres: number) => (metres / runway.length) * ATLAS_WIDTH; + const acrossPx = (metres: number) => (metres / runway.width) * ATLAS_BAND; + const centre = top + ATLAS_BAND / 2; + + // Edge lines, 0.9 m wide, inset a metre from the pavement edge. + const edge = acrossPx(runway.width / 2 - 1); + for (const side of [-1, 1]) { + context.fillRect(0, centre + side * edge, ATLAS_WIDTH, Math.max(1, acrossPx(0.9))); + } + + // Centreline: 30 m stripes with 20 m gaps, which is the real cadence and + // is also what makes a runway read as a runway rather than a road. + const stripe = Math.max(1, acrossPx(0.9)); + for (let m = 60; m < runway.length - 60; m += 50) { + context.fillRect(alongPx(m), centre - stripe / 2, alongPx(30), stripe); + } + + for (const end of [0, 1] as const) { + // `at` measures inward from this end whichever end it is, so both ends + // are painted by one pass and neither is a transposed copy of the other. + const at = (metres: number, span: number): number => + end === 0 ? alongPx(metres) : ATLAS_WIDTH - alongPx(metres) - alongPx(span); + + // Threshold bars — the piano keys. Eight of them for a 45 m runway, and + // they are the single most recognisable mark on a paved surface. + const bars = 8; + const barWidth = acrossPx(1.8); + const gap = acrossPx((runway.width - 4 - bars * 1.8) / (bars - 1)); + const barsTop = centre - (bars * barWidth + (bars - 1) * gap) / 2; + for (let bar = 0; bar < bars; bar += 1) { + context.fillRect(at(6, 30), barsTop + bar * (barWidth + gap), alongPx(30), barWidth); + } + + // Aiming point: two 45 m blocks 300 m in. What a pilot flies at, and at + // board scale the thing that stops the runway being a bare stripe. + for (const side of [-1, 1]) { + context.fillRect( + at(300, 45), + centre + side * acrossPx(11) - acrossPx(3), + alongPx(45), + acrossPx(6), + ); + } + + const designator = runway.designators?.[end]; + if (designator) { + // Painted numerals are 20 m tall and read across the runway, so they + // are drawn into a turned frame. At a board camera they are a smudge in + // the right place, which is exactly what they are from the air. + context.save(); + context.translate(at(70, 0), centre); + context.rotate(end === 0 ? -Math.PI / 2 : Math.PI / 2); + context.font = `700 ${Math.round(acrossPx(13))}px ui-monospace, monospace`; + context.textAlign = "center"; + context.textBaseline = end === 0 ? "top" : "bottom"; + context.fillText(designator, 0, 0); + context.restore(); + } + } + }); + + const texture = new THREE.CanvasTexture(canvas); + // Straight through: the atlas is authored top-down and the v coordinates + // below are canvas rows over canvas height, so flipping it would put every + // runway's paint on a different runway. + texture.flipY = false; + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + return texture; +} + +/** + * Paint, and unlit on purpose — the same call `structures.ts` makes for lane + * markings, for the same reason. Runway paint is retroreflective and its whole + * job is to be a fixed known white; putting it through the tone-mapping + * shoulder with everything else turns a threshold bar into a grey smear at + * midday and loses it entirely at dusk, which is the hour this board is most + * often looked at. + */ +function markingMaterial(map: THREE.Texture | null): THREE.Material { + return new THREE.MeshBasicMaterial({ + ...(map ? { map } : { color: AIRPORT_PALETTE.paint }), + transparent: true, + // Off, so the paint never occludes the aircraft parked beyond it. It is + // drawn a hair above a surface it exactly covers, so it has nothing to sort + // against but itself. + depthWrite: false, + toneMapped: false, + }); +} + +// ---- The build ------------------------------------------------------------ + +/** + * The stack, in scene units: fill, then apron, then taxiway, then runway, then + * paint. + * + * These have to be **far enough apart to survive the depth buffer**, and the + * first pass at them was not. Tenths of a millimetre of scene apart is under + * the depth resolution at any distance a board camera actually sits at — at 40 + * units out with a far plane three board spans away, a 24-bit buffer resolves + * about a thousandth of a unit — so four coplanar surfaces spaced 0.001 apart + * flicker against each other as the camera moves. 0.01 is the step + * `structures.ts` already uses for road markings and it is the right order of + * magnitude: at 94 m per unit and 3.6× exaggeration the whole stack is 1.3 m of + * real-world height, which nothing can see, over an airport that is genuinely + * built in layers anyway. + * + * The order is the order a paving crew would lay them, which is also the order + * that makes a taxiway crossing a runway disappear under it rather than cut a + * notch out of it. + * + * `FIELD_LIFT` is the one with a hard floor under it, and it is not the ground. + * Two separate things push the terrain up past `groundAt`: + * + * 1. `terrain.ts` builds its relief mesh at `world.metres(e) + 0.012` — a bias + * of its own, to hold the surface off the flat shore plate at y=0 — so an + * airport laid at `groundAt` plus a hundredth is laid *under the terrain*, + * and the whole field vanishes except for the sliver that overhangs the + * water. That was found by painting the plate magenta and looking, because + * it fails silently: no warning, no z-fighting, just no airport. + * 2. The terrain LOD collapses a near-flat patch to a single quad, and that + * quad may sit up to `LOD_HEIGHT_TOLERANCE` **above** the lattice points it + * replaced. A plate cleared only of (1) is therefore still pierced by the + * ground it is standing on wherever a collapsed patch bulges — which is how + * Van Nuys grew a tan wedge through the middle of the field. + * + * So the lift is derived from the tolerance rather than typed next to it: two + * constants that must not drift apart are one constant. The margin on top is + * the same order as the paint stack below, and at 391 m per unit the whole + * thing is under a metre of real height. + */ +const FIELD_LIFT = LOD_HEIGHT_TOLERANCE + 0.04; +const APRON_LIFT = 0.01; +const TAXIWAY_LIFT = 0.02; +const RUNWAY_LIFT = 0.03; +const PAINT_LIFT = 0.04; + +/** Length a parked aircraft is drawn at, metres. A narrowbody on stand. */ +const PARKED_AIRCRAFT_LENGTH = 40; + +/** Every coordinate the field has to be at least as high as. */ +function fieldSamples(airport: Airport): LatLng[] { + const samples: LatLng[] = [[airport.lat, airport.lng]]; + for (const runway of airport.runways) { + const { low, high } = runwayThresholds(runway); + samples.push(low, high, [runway.lat, runway.lng]); + } + for (const point of airport.field ?? []) samples.push(point); + for (const apron of airport.aprons ?? []) for (const point of apron.polygon) samples.push(point); + return samples; +} + +function fieldHeight(world: World, airport: Airport): number { + let highest = -Infinity; + for (const [lat, lng] of fieldSamples(airport)) { + const ground = world.groundAt(lat, lng); + if (ground > highest) highest = ground; + } + return (Number.isFinite(highest) ? highest : 0) + FIELD_LIFT; +} +/** + * The materials one build uses, made once and shared by every airport in it. + * + * Not module-level, and that is the same trap `structures.ts` records: + * `createScene().dispose()` walks the scene disposing every material it finds, + * so a cache that outlived a build would hand the next board a disposed + * material and render the airport black. + */ +interface Surfaces { + field: THREE.Material; + runway: THREE.Material; + taxiway: THREE.Material; + apron: THREE.Material; + terminal: THREE.Material; + roof: THREE.Material; + tower: THREE.Material; + aircraft: THREE.Material; + paint: THREE.Material; +} + +function surfaces(runways: readonly Runway[]): Surfaces { + const lambert = (color: number) => new THREE.MeshLambertMaterial({ color }); + return { + field: lambert(AIRPORT_PALETTE.field), + runway: lambert(AIRPORT_PALETTE.runway), + taxiway: lambert(AIRPORT_PALETTE.taxiway), + apron: lambert(AIRPORT_PALETTE.apron), + terminal: lambert(AIRPORT_PALETTE.terminal), + roof: lambert(AIRPORT_PALETTE.roof), + tower: lambert(AIRPORT_PALETTE.tower), + aircraft: lambert(AIRPORT_PALETTE.aircraft), + paint: markingMaterial(markingsAtlas(runways)), + }; +} + +/** + * One airport's geometry, dropped into a batch that may already hold others. + * + * `bandOffset` is where this airport's runways start in the board-wide markings + * atlas, and `bandTotal` is its height — the two together are why every runway + * on a board can share one texture and therefore one mesh. + */ +function buildAirport( + world: World, + airport: Airport, + batch: Batch, + paints: Surfaces, + stands: THREE.Matrix4[], + bandOffset: number, + bandTotal: number, +): void { + const unit = 1 / world.metresPerUnit; + const y = fieldHeight(world, airport); + + // ---- The graded plate --------------------------------------------------- + + if (airport.field && airport.field.length >= 3) { + const slab = slabGeometry( + airport.field.map(([lat, lng]) => world.project(lat, lng)), + y, + ); + if (slab) batch.add("airports:field", slab, paints.field); + } + + // ---- Aprons ------------------------------------------------------------- + + for (const apron of airport.aprons ?? []) { + const slab = slabGeometry( + apron.polygon.map(([lat, lng]) => world.project(lat, lng)), + y + APRON_LIFT, + ); + if (slab) batch.add("airports:apron", slab, paints.apron); + } + + // ---- Taxiways ----------------------------------------------------------- + + for (const taxiway of airport.taxiways ?? []) { + const points = taxiway.path.map(([lat, lng]) => { + const [x, z] = world.project(lat, lng); + return new THREE.Vector3(x, y + TAXIWAY_LIFT, z); + }); + if (points.length < 2) continue; + batch.add( + "airports:taxiway", + stripGeometry(points, (taxiway.width ?? 25) * unit), + paints.taxiway, + ); + } + + // ---- Runways, and the paint on them ------------------------------------- + + airport.runways.forEach((runway, index) => { + const [x, z] = world.project(runway.lat, runway.lng); + const [east, north] = bearingVector(runway.heading); + // Scene space runs x east and z south, so a bearing's north component is a + // negative z. + const alongX = east; + const alongZ = -north; + const halfLength = (runway.length / 2) * unit; + const halfWidth = (runway.width / 2) * unit; + batch.add( + "airports:runway", + groundQuad(x, y + RUNWAY_LIFT, z, alongX, alongZ, halfLength, halfWidth), + paints.runway, + ); + const band = bandOffset + index; + batch.add( + "airports:markings", + groundQuad(x, y + PAINT_LIFT, z, alongX, alongZ, halfLength, halfWidth, { + u0: 0, + u1: 1, + v0: (band * ATLAS_BAND) / bandTotal, + v1: ((band + 1) * ATLAS_BAND) / bandTotal, + }), + paints.paint, + { receive: false }, + ); + }); + + // ---- Terminals, and the aircraft against them --------------------------- + + const dummy = new THREE.Object3D(); + for (const terminal of airport.terminals ?? []) { + const [x, z] = world.project(terminal.lat, terminal.lng); + const [east, north] = bearingVector(terminal.heading); + const alongX = east; + const alongZ = -north; + const height = world.metres(terminal.height); + batch.add( + "airports:terminal", + massGeometry(x, y, z, alongX, alongZ, terminal.length * unit, terminal.width * unit, height), + paints.terminal, + { cast: true }, + ); + // A slightly wider, flatter cap. One extra box per terminal buys the roof + // line that stops a mass reading as an extruded footprint, and a roof is + // most of what is visible of a building from directly above. + batch.add( + "airports:terminal-roof", + massGeometry( + x, y + height, z, alongX, alongZ, + terminal.length * unit * 1.02, + terminal.width * unit * 1.06, + world.metres(2.5), + ), + paints.roof, + { cast: true }, + ); + + const gates = terminal.gates; + if (!gates || gates.count <= 0) continue; + const rightX = -alongZ; + const rightZ = alongX; + // Nose-in: the aircraft faces the terminal, so its nose points back along + // the stand's outward direction. + const noseX = -gates.side * rightX; + const noseZ = -gates.side * rightZ; + const rotation = Math.atan2(noseX, noseZ); + // Clear of the wall by the building's own half width plus most of an + // aircraft. Anything less parks the tails inside the terminal. + const standOut = (terminal.width / 2 + PARKED_AIRCRAFT_LENGTH * 0.6) * unit * gates.side; + for (let gate = 0; gate < gates.count; gate += 1) { + // Evenly along the face, half a pitch in from each corner. + const along = ((gate + 0.5) / gates.count - 0.5) * terminal.length * unit; + dummy.position.set( + x + alongX * along + rightX * standOut, + // On the apron, because that is what it is standing on. + y + APRON_LIFT, + z + alongZ * along + rightZ * standOut, + ); + dummy.rotation.set(0, rotation, 0); + dummy.scale.setScalar((PARKED_AIRCRAFT_LENGTH * unit) / AIRLINER_LENGTH); + dummy.updateMatrix(); + stands.push(dummy.matrix.clone()); + } + } + + // ---- The tower ---------------------------------------------------------- + + if (airport.tower) { + const [x, z] = world.project(airport.tower.lat, airport.tower.lng); + const shaftHeight = world.metres(airport.tower.height * 0.86); + const shaft = new THREE.CylinderGeometry(7 * unit, 10 * unit, shaftHeight, 8, 1, false); + shaft.translate(x, y + shaftHeight / 2, z); + batch.add("airports:tower", shaft, paints.tower, { cast: true }); + const cab = new THREE.BoxGeometry( + 19 * unit, + world.metres(airport.tower.height * 0.14), + 19 * unit, + ); + cab.translate(x, y + shaftHeight + world.metres(airport.tower.height * 0.07), z); + batch.add("airports:tower", cab, paints.tower, { cast: true }); + } +} + +/** + * Every airport on a board, as one small set of merged meshes. + * + * **Merged across airports, not per airport**, and that is a decision rather + * than a convenience. A per-airport group is nicer to read in the scene graph + * and costs one bucket per surface class per field: the Bay Area's two airports + * came to 17 draw calls that way, and Southern California's six — LAX, Burbank, + * Long Beach, John Wayne, Ontario, Van Nuys — would have come to about fifty, + * against a board budget of 650 with eighty-eight spare. Sharing the buckets + * makes the count a function of how many *kinds* of surface an airport has + * rather than of how many airports a board declares, which is the property a + * published kit needs. The runway paint shares one atlas for the same reason. + * + * Returns an empty group when a pack declares none, so a scene can add it + * unconditionally and a city without an airport costs one `Group` and no draw + * call. + */ +export function createAirports(world: World, airports: readonly Airport[]): THREE.Group { + const group = new THREE.Group(); + group.name = "airports"; + group.userData.airportIds = airports.map((airport) => airport.id); + if (airports.length === 0) return group; + + const runways = airports.flatMap((airport) => airport.runways); + const bandTotal = powerOfTwo(ATLAS_BAND * Math.max(1, runways.length)); + const paints = surfaces(runways); + const batch = new Batch(); + const stands: THREE.Matrix4[] = []; + + let bandOffset = 0; + for (const airport of airports) { + buildAirport(world, airport, batch, paints, stands, bandOffset, bandTotal); + bandOffset += airport.runways.length; + } + batch.flush(group); + + /** + * Aircraft on stand, instanced. + * + * They reuse `airlinerGeometry` — the same ~100 triangles the sky is drawn + * from — but **not** the same sizing rule, and the contrast is the point. An + * aeroplane in flight is a map symbol: it is drawn at a fixed 0.42 units on + * every board so it stays findable, which over SoCal makes it four times life + * size and nobody has ever noticed. An aeroplane on stand is standing next to + * a truthfully-sized terminal, so it has to be truthfully sized too — 40 m, + * converted through this board's own scale. + */ + if (stands.length > 0) { + const parked = new THREE.InstancedMesh(airlinerGeometry(), paints.aircraft, stands.length); + parked.name = "airports:stands"; + parked.castShadow = true; + stands.forEach((matrix, index) => parked.setMatrixAt(index, matrix)); + parked.instanceMatrix.needsUpdate = true; + group.add(parked); + } + + return group; +} + +/** One airport, for a caller that has exactly one. */ +export function createAirport(world: World, airport: Airport): THREE.Group { + return createAirports(world, [airport]); +} diff --git a/src/engine/bridges.ts b/src/engine/bridges.ts new file mode 100644 index 0000000..55f81f0 --- /dev/null +++ b/src/engine/bridges.ts @@ -0,0 +1,838 @@ +/** + * The suspension-bridge kit. + * + * Two of the objects on the Bay Area board are worth flying to and both are the + * same problem: a repeated tower, a main cable hanging in a parabola between the + * tower tops, a regular series of hangers down to a deck, and — for every metre + * of the crossing that is not suspended from anything — a viaduct standing on + * piers. The Golden Gate is that kit with two towers and one main span; the Bay + * Bridge is the same kit with a suspended half, a crossing of Yerba Buena that + * cannot be suspended from anything, and a single-tower span on the Oakland + * side. Neither is special-cased. What separates them is arithmetic over the + * `Bridge` record the city pack already writes, which is the point: a pack + * author adds a crossing by writing a path, some towers and two heights, and + * gets the right *kind* of bridge back without naming it. + * + * ### What the classifier does, and why a bridge needs one + * + * A `Bridge` says where the deck runs and where the towers stand. It does not + * say which parts hang from a cable, and it cannot: the same five fields + * describe the Golden Gate, an eleven-kilometre trestle across the South Bay + * with one hump in it for the ship channel, and a crossing that dives through an + * island. Drawing all three as "a catenary between every pair of towers" is what + * the previous builder did, and it is why the Bay Bridge hung a two-and-a-half + * kilometre cable over the top of Yerba Buena — a span half again longer than + * any suspension span ever built, over dry land. + * + * So each reach of deck between two stations — a path end or a tower — is + * classified by whether a cable could actually hold it up: + * + * - **main**: tower to tower, no longer than a tower can carry. Full + * catenary, hangers the whole way. + * - **side**: an anchorage to a tower, at the same limit. Same cable, less sag. + * - **approach**: everything else. A deck on piers, with a pier skipped + * wherever the ground has already come up to meet it — which is what makes + * the Yerba Buena crossing land on the island instead of standing on stilts + * over it. + * + * A tower with no suspended reach on either side — the ship-channel tower of a + * long trestle — gets a **local** chain: anchorages placed on the deck a span + * either side of it, so the cable is a hump over the channel rather than a wire + * stretched the length of the bay. That is one more use of the same catenary, + * not a fourth kind of bridge. + * + * ### Everything comes out as geometry, not meshes + * + * Nothing here constructs a `Mesh` or a `Material`. Parts are handed to a + * `GeometrySink` — `structures.ts`'s `Batch` — which caches one material per + * colour and merges every part of a bridge into a single buffer. That is why a + * bridge with two towers, two cables, seventy hangers and thirty piers is two + * draw calls: the painted structure, and the roadway on top of it. + * + * The corollary is the one that bites: every geometry returned from here must + * carry **position, normal, uv and an index**, because `mergeGeometries` refuses + * a bucket whose attribute sets disagree and drops it without throwing. `strip` + * below sets UVs it has no texture for, for exactly that reason — and the + * roadway, which does have one, is the reason the convention is u across and v + * in metres along. + * + * ### Members are sized in metres, with a floor + * + * `socal.ts` asked for this in a comment: bridge members used to be constants in + * scene units tuned at San Francisco's 94 m per unit, so at Los Angeles' 391 the + * Vincent Thomas came out four times too heavy. Every cross-section here is + * `metres / metresPerUnit` instead — with a minimum, because true scale on the + * SoCal board puts the deck of the Long Beach Gateway below one pixel and an + * invisible bridge is a worse answer than a chunky one. Heights are the + * exception and stay on `world.metres()`, which carries the board's vertical + * exaggeration: a deck has to sit at the same exaggerated height as the hills it + * lands on or it lands inside them. + */ + +import * as THREE from "three"; + +import type { Bridge, LatLng } from "./types.ts"; +import type { World } from "./world.ts"; + +/** + * The three ways a surface out here is shaded — see `structures.ts`, which owns + * the materials this names. Declared here rather than there because the sink + * below is the boundary between the two modules and a boundary that names a + * type should own it. + */ +export type SurfaceKind = "deck" | "solid" | "marking"; + +/** + * Where parts go. `structures.ts`'s `Batch` satisfies this structurally; nothing + * here needs to know that a bucket exists, only that a part with a name, a + * geometry and a material is somebody else's problem after this. + */ +export interface GeometrySink { + material(kind: SurfaceKind, color: number): THREE.Material; + add( + name: string, + geometry: THREE.BufferGeometry, + material: THREE.Material, + shadows?: { cast?: boolean; receive?: boolean }, + ): void; +} + +/** What a bridge needs from the board it stands on. */ +type BridgeWorld = Pick; + +// ---- Geometry primitives --------------------------------------------------- + +/** + * A quad strip between two rails, with UVs running 0..1 across and in **metres** + * along. + * + * Winding is `left → left+1 → right`, which puts the face normal on the side the + * left rail is anti-clockwise from — up, for a deck whose left rail is the + * left-hand one. A downward or inward face is the same call with the rails + * swapped, which is how the four faces of a deck box are built from one helper. + */ +function strip(left: readonly THREE.Vector3[], right: readonly THREE.Vector3[]): THREE.BufferGeometry { + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + let along = 0; + const count = Math.min(left.length, right.length); + for (let i = 0; i < count; i += 1) { + const l = left[i]; + const r = right[i]; + if (!l || !r) continue; + const previous = left[i - 1]; + if (i > 0 && previous) along += l.distanceTo(previous); + positions.push(l.x, l.y, l.z, r.x, r.y, r.z); + uvs.push(0, along, 1, along); + if (i < count - 1) { + const a = i * 2; + indices.push(a, a + 2, a + 1, a + 2, a + 3, a + 1); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** A box with its transform baked in, ready to merge. */ +function block( + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + yaw = 0, +): THREE.BufferGeometry { + const geometry = new THREE.BoxGeometry(width, height, depth); + if (yaw !== 0) geometry.rotateY(yaw); + geometry.translate(x, y, z); + return geometry; +} + +/** + * A four-sided frustum: the same box, narrower at the top. + * + * This is what makes a tower leg read as a tower leg rather than a post. A + * suspension tower carries its own weight plus half the cable load, so it is + * visibly fatter at the waterline than at the saddle, and the taper is most of + * what the eye uses to tell a 227 m tower from a 60 m one when both are a + * hundred pixels tall. + */ +function taper( + x: number, + z: number, + bottomY: number, + topY: number, + bottomWidth: number, + topWidth: number, + bottomDepth: number, + topDepth: number, + yaw: number, +): THREE.BufferGeometry { + const geometry = new THREE.BoxGeometry(1, 1, 1); + const position = geometry.getAttribute("position"); + const height = topY - bottomY; + for (let i = 0; i < position.count; i += 1) { + const up = position.getY(i) > 0; + const width = up ? topWidth : bottomWidth; + const depth = up ? topDepth : bottomDepth; + position.setX(i, position.getX(i) * width); + position.setZ(i, position.getZ(i) * depth); + position.setY(i, up ? height / 2 : -height / 2); + } + geometry.computeVertexNormals(); + if (yaw !== 0) geometry.rotateY(yaw); + geometry.translate(x, bottomY + height / 2, z); + return geometry; +} + +/** A swept tube — a main cable, a hanger, a stay. */ +function cord(points: readonly THREE.Vector3[], radius: number, radial = 4): THREE.BufferGeometry { + const curve = new THREE.CatmullRomCurve3([...points]); + return new THREE.TubeGeometry(curve, Math.max(2, points.length - 1), radius, radial, false); +} + +// ---- The deck -------------------------------------------------------------- + +interface Station { + /** Centre of the deck, at the top of the deck slab. */ + point: THREE.Vector3; + /** Unit tangent along the deck, in the ground plane. */ + tangent: THREE.Vector3; + /** Unit left-of-travel normal, in the ground plane. */ + left: THREE.Vector3; + /** Ground height under this sample, in scene units. */ + ground: number; + /** Distance from the start of the deck, in scene units. */ + along: number; +} + +/** + * Resample the authored path evenly along its length. + * + * The packs write a bridge as four to seven points, which is enough to say where + * it goes and nowhere near enough to hang anything off: the old builder took + * those points literally, so the Golden Gate's cable had three control points + * and its deck was a straight pipe between them. Everything below — hanger + * spacing, pier spacing, the point a tower stands at — is expressed in distance + * along the deck, and this is what makes distance along the deck mean something. + */ +function stations(world: BridgeWorld, path: LatLng[], spacing: number): Station[] { + const raw: { point: THREE.Vector3; ground: number }[] = []; + for (let i = 0; i < path.length - 1; i += 1) { + const from = path[i]; + const to = path[i + 1]; + if (!from || !to) continue; + const [x0, z0] = world.project(from[0], from[1]); + const [x1, z1] = world.project(to[0], to[1]); + const legLength = Math.hypot(x1 - x0, z1 - z0); + const steps = Math.max(1, Math.round(legLength / spacing)); + const last = i === path.length - 2 ? steps : steps - 1; + for (let s = 0; s <= last; s += 1) { + const t = s / steps; + const lat = from[0] + (to[0] - from[0]) * t; + const lng = from[1] + (to[1] - from[1]) * t; + const [x, z] = world.project(lat, lng); + raw.push({ point: new THREE.Vector3(x, 0, z), ground: world.groundAt(lat, lng) }); + } + } + + const out: Station[] = []; + let along = 0; + for (let i = 0; i < raw.length; i += 1) { + const here = raw[i]; + if (!here) continue; + const previous = raw[Math.max(0, i - 1)] ?? here; + const next = raw[Math.min(raw.length - 1, i + 1)] ?? here; + const dx = next.point.x - previous.point.x; + const dz = next.point.z - previous.point.z; + const length = Math.hypot(dx, dz) || 1; + if (i > 0) along += here.point.distanceTo(previous.point); + out.push({ + point: here.point.clone(), + tangent: new THREE.Vector3(dx / length, 0, dz / length), + left: new THREE.Vector3(-dz / length, 0, dx / length), + ground: here.ground, + along, + }); + } + return out; +} + +/** + * How finely the deck is sampled, in scene units. + * + * Four deck-widths — 60 m on the Bay Area board — is set by the suspended spans + * rather than by the deck: this is also the grid the cable parabola and the + * hangers are quantised to, and a coarser one leaves the Golden Gate with nine + * hangers a span and a cable made of straight lines. + */ +function stationSpacing(deckHalf: number): number { + return Math.max(deckHalf * 4, 0.28); +} + +/** A point offset laterally from a station, at a given height. */ +function offset(station: Station, lateral: number, y: number): THREE.Vector3 { + return new THREE.Vector3( + station.point.x + station.left.x * lateral, + y, + station.point.z + station.left.z * lateral, + ); +} + +// ---- Reaches --------------------------------------------------------------- + +type ReachKind = "main" | "side" | "approach"; + +interface Reach { + kind: ReachKind; + /** Station indices, inclusive. */ + from: number; + to: number; +} + +/** The station nearest a lat/lng — where a tower actually meets the deck. */ +function nearestStation(world: BridgeWorld, list: Station[], at: LatLng): number { + const [x, z] = world.project(at[0], at[1]); + let best = 0; + let bestDistance = Infinity; + for (let i = 0; i < list.length; i += 1) { + const station = list[i]; + if (!station) continue; + const distance = Math.hypot(station.point.x - x, station.point.z - z); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + return best; +} + +/** + * How long a reach a cable on this bridge could plausibly hold, in scene units. + * + * Nine tower-heights is not a structural formula, it is a fit to what has been + * built: the Golden Gate's towers stand 227 m over a 1,280 m main span (5.6), + * the Verrazzano 211 over 1,298 (6.2), the Akashi Kaikyō 297 over 1,991 (6.7). + * Nothing reaches nine. The Bay Bridge's two-and-a-half kilometre gap between + * its west-span towers and its east-span one is 15, which is the number that + * tells you those three towers are not one bridge — and that is the whole job of + * this constant. + * + * The height has to come in as **metres**, not as the scene-unit `towerY`. A + * board exaggerates height and does not exaggerate distance — 3.6× here, 13× on + * the California board — so comparing an exaggerated height against an + * unexaggerated length said the Bay Bridge could suspend two and a half + * kilometres, and it drew exactly that. + */ +function suspendableSpan(towerHeightM: number, metresPerUnit: number): number { + return (towerHeightM * 9) / metresPerUnit; +} + +/** + * Split the deck into reaches, and decide which of them a cable holds up. + * + * The subtlety is the terminal reach. A pack writes both ends of a crossing well + * inland so the span has something to land on — the Golden Gate's path runs from + * the Presidio to Fort Baker, 1.4 km past the towers at each end — but a real + * anchorage sits about half a main span beyond the tower and the rest of that + * distance is approach viaduct. Anchoring the cable at the end of the path + * instead is what used to run the Golden Gate's side cables up the hillside. + */ +function classify( + list: Station[], + towerAt: number[], + towerHeightM: number, + metresPerUnit: number, +): Reach[] { + const limit = suspendableSpan(towerHeightM, metresPerUnit); + // A side span is held from one end only, so it gets the shorter allowance: + // the Golden Gate's are 343 m against a 1,280 m main span, and a reach that + // long from a single tower is an approach viaduct in every real crossing. + const sideLimit = limit * 0.55; + const marks = [0, ...towerAt, list.length - 1]; + const reaches: Reach[] = []; + const stationAt = (from: number, to: number, distance: number): number => { + const target = (list[from]?.along ?? 0) + distance; + for (let at = from; at <= to; at += 1) if ((list[at]?.along ?? 0) >= target) return at; + return to; + }; + for (let i = 0; i < marks.length - 1; i += 1) { + const from = marks[i]; + const to = marks[i + 1]; + if (from === undefined || to === undefined || to <= from) continue; + const length = (list[to]?.along ?? 0) - (list[from]?.along ?? 0); + const startsOnTower = i > 0; + const endsOnTower = i < marks.length - 2; + if (startsOnTower && endsOnTower) { + reaches.push({ kind: length > limit ? "approach" : "main", from, to }); + continue; + } + if (!startsOnTower && !endsOnTower) { + reaches.push({ kind: "approach", from, to }); + continue; + } + if (length <= sideLimit) { + reaches.push({ kind: "side", from, to }); + continue; + } + // Too long to be one side span: viaduct out to the anchorage, cable in. + if (endsOnTower) { + const anchor = stationAt(from, to, length - sideLimit); + if (anchor > from) reaches.push({ kind: "approach", from, to: anchor }); + reaches.push({ kind: "side", from: anchor, to }); + } else { + const anchor = stationAt(from, to, sideLimit); + reaches.push({ kind: "side", from, to: anchor }); + if (to > anchor) reaches.push({ kind: "approach", from: anchor, to }); + } + } + return reaches; +} + +// ---- Cable chains ---------------------------------------------------------- + +interface Chain { + /** Station indices the cable passes over: anchorage, tower(s), anchorage. */ + nodes: number[]; + /** Which of those are towers, and so carry the cable at tower height. */ + towers: Set; +} + +/** + * Group suspended reaches into cable runs. + * + * A main cable is continuous from one anchorage, over every tower it crosses, to + * the next: it is not per-span. Breaking the run wherever an approach interrupts + * it is what gives the Bay Bridge two cables — one over the pair of west-span + * towers, one over the single east-span tower — instead of one improbable wire + * from San Francisco to Oakland. + */ +function chains(reaches: Reach[], towerAt: number[]): Chain[] { + const towers = new Set(towerAt); + const out: Chain[] = []; + let open: number[] | null = null; + for (const reach of reaches) { + if (reach.kind === "approach") { + if (open) { + out.push({ nodes: open, towers }); + open = null; + } + continue; + } + if (!open) open = [reach.from]; + open.push(reach.to); + } + if (open) out.push({ nodes: open, towers }); + return out; +} + +/** + * The channel tower: a tower no cable reached, given a cable of its own. + * + * The South Bay crossings are eleven kilometres of low trestle with one raised + * span in the middle for shipping, and the packs write that as a single tower on + * a very long path. Both reaches either side are approaches, so `chains` returns + * nothing and the tower stands there holding air. This puts an anchorage on the + * deck a span either side of it, which reads from the air as the hump those + * bridges actually have. + */ +function localChains( + list: Station[], + reaches: Reach[], + towerAt: number[], + towerHeightM: number, + metresPerUnit: number, +): Chain[] { + const reached = new Set(); + for (const reach of reaches) { + if (reach.kind === "approach") continue; + reached.add(reach.from); + reached.add(reach.to); + } + const reach = suspendableSpan(towerHeightM, metresPerUnit) * 0.45; + const out: Chain[] = []; + for (const at of towerAt) { + if (reached.has(at)) continue; + const centre = list[at]?.along ?? 0; + let from = at; + let to = at; + while (from > 0 && centre - (list[from - 1]?.along ?? 0) < reach) from -= 1; + while (to < list.length - 1 && (list[to + 1]?.along ?? 0) - centre < reach) to += 1; + if (to > from) out.push({ nodes: [from, at, to], towers: new Set([at]) }); + } + return out; +} + +// ---- The build ------------------------------------------------------------- + +export interface BridgeParts { + /** Painted structure: deck box, towers, cables, hangers, piers. */ + structure: number; + /** The running surface on top of the deck. */ + roadway: number; + /** Triangles by part — deck, tower, cable, hanger, pier. For the census. */ + byPart: Record; +} + +/** + * Build one bridge into `sink`, and report what it cost in triangles. + * + * The count is returned rather than measured afterwards because the merged mesh + * cannot tell you which bridge paid for what, and the triangle budget on the Bay + * Area board is the constraint this whole kit is written against. + */ +export function buildBridge( + world: BridgeWorld, + bridge: Bridge, + sink: GeometrySink, + roadwayMaterial: THREE.Material, +): BridgeParts { + const paint = sink.material("solid", bridge.color); + const cost: BridgeParts = { structure: 0, roadway: 0, byPart: {} }; + // `part` names what is being added, for the triangle census only. Everything + // painted goes into one bucket keyed on the bridge's name, because one bridge + // is one colour and one colour is one draw call — naming the buckets per part + // would put the Golden Gate back to six meshes for one orange object. + const add = (part: string, geometry: THREE.BufferGeometry, roadway = false) => { + const index = geometry.getIndex(); + const triangles = index ? index.count / 3 : (geometry.getAttribute("position")?.count ?? 0) / 3; + if (roadway) cost.roadway += triangles; + else cost.structure += triangles; + cost.byPart[part] = (cost.byPart[part] ?? 0) + triangles; + sink.add( + roadway ? "bridge:roadway" : bridge.name, + geometry, + roadway ? roadwayMaterial : paint, + { cast: !roadway }, + ); + }; + + // ---- Sizes ---- + // + // Cross-sections in true metres with a legibility floor; heights through + // `world.metres`, which carries the board's vertical exaggeration. See the + // module comment for why those two are not the same conversion. + const perUnit = world.metresPerUnit; + const size = (metres: number, floor: number) => Math.max(metres / perUnit, floor); + // Three of these are wider than the real member, and the reason is the + // board's vertical exaggeration. San Francisco draws height at 3.6× and distance at + // 1×, so a tower at true scale is 227 m tall and 12 m thick — a 62:1 needle + // where the real thing is 19:1, and the first render of this kit had two + // wires standing in the strait. Widening the tower back to 19:1 across the + // deck would make it wider than the deck it carries, so the stoutness goes + // into **depth along the bridge** instead, which is where a real tower is + // already deeper than it is wide (10.4 m × 17.7 m at the Golden Gate's + // saddle) and where nothing else needs the room. The deck follows at 42 m + // against a true 27, which is the same allowance `socal.ts` records for its + // landmarks: at this scale a true-width deck is under two pixels. + const deckHalf = size(21, 0.09); + const deckDepth = size(14, 0.04); + const legWidth = size(20, 0.05); + const legDepth = size(46, 0.1); + const cableRadius = size(8, 0.015); + const hangerHalf = size(1.9, 0.005); + const pierHalf = size(8, 0.025); + + const deckY = world.metres(bridge.deckHeight); + const towerY = world.metres(bridge.towerHeight); + + /** + * Where a leg or a pier starts, given the ground under it. + * + * On land that is the ground, buried a little. Over water it is the seabed — + * but only down to about 25 m, because the bay has trenches in it and a + * column drawn to the bottom of one is a long thin thing nobody can see doing + * nothing. The clamp is also what stops a foot appearing to float: the + * surface is at zero, so anything below it is hidden by the water. + * + * This started as `Math.min(ground, 0)`, which is right in water and wrong on + * land: it sank a pier through Yerba Buena to sea level and stood the island + * crossing on stilts. + */ + const seabed = -world.metres(25); + const footing = (ground: number, margin: number) => + Math.max(ground, seabed) - world.metres(margin); + + const list = stations(world, bridge.path, stationSpacing(deckHalf)); + if (list.length < 2) return cost; + const towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower)); + const reaches = classify(list, towerAt, bridge.towerHeight, perUnit); + const runs = [ + ...chains(reaches, towerAt), + ...localChains(list, reaches, towerAt, bridge.towerHeight, perUnit), + ]; + + // ---- The deck ---- + // + // Flat at `deckHeight` for its whole length, except the last stretch at each + // end, which ramps down onto whatever the shore is. That ramp is not a + // flourish: both ends of the Golden Gate are written well inland so the span + // has something to land on, and a deck held rigidly at 67 m simply disappeared + // into the Presidio bluff with no visible touchdown. + const total = list[list.length - 1]?.along ?? 0; + const rampLength = Math.min(total * 0.18, Math.max(deckHalf * 12, 1.2)); + const deckTopAt = (station: Station): number => { + const fromEnd = Math.min(station.along, total - station.along); + if (fromEnd >= rampLength) return deckY; + const landing = Math.max(station.ground + deckDepth + size(4, 0.01), 0); + if (landing >= deckY) return deckY; + const t = rampLength <= 0 ? 1 : fromEnd / rampLength; + // Smoothstep, so the deck leaves the shore tangentially rather than kinking. + return landing + (deckY - landing) * (t * t * (3 - 2 * t)); + }; + + const deckTop = list.map(deckTopAt); + + /** + * The deck box, and the one place this module spends triangles on distance + * rather than on detail. + * + * Every station carries a quad on four faces — 32 triangles a station — and + * the station grid is set by what a suspended span needs. A causeway needs + * nothing of the sort: the San Mateo–Hayward crossing is eleven kilometres of + * dead-straight trestle, and sampling it at 60 m spent 1,800 triangles + * subdividing a straight line. So an approach is emitted at every third + * station and a suspended reach at every one, which took the five Bay Area + * crossings from 18,044 triangles to 12,318 with nothing visible changing. + * Reach boundaries are always kept, or the deck would tear at the anchorage. + */ + const wanted = new Set([0, list.length - 1]); + for (const reach of reaches) { + wanted.add(reach.from); + wanted.add(reach.to); + const stride = reach.kind === "approach" ? 3 : 1; + for (let at = reach.from; at <= reach.to; at += stride) wanted.add(at); + } + for (const at of towerAt) { + // Both sides of a tower, so a leg never lands between two deck samples. + wanted.add(Math.max(0, at - 1)); + wanted.add(at); + wanted.add(Math.min(list.length - 1, at + 1)); + } + const deckAt = [...wanted].sort((a, b) => a - b); + + const topLeft: THREE.Vector3[] = []; + const topRight: THREE.Vector3[] = []; + const underLeft: THREE.Vector3[] = []; + const underRight: THREE.Vector3[] = []; + for (const at of deckAt) { + const station = list[at]; + const y = deckTop[at]; + if (!station || y === undefined) continue; + topLeft.push(offset(station, deckHalf, y)); + topRight.push(offset(station, -deckHalf, y)); + underLeft.push(offset(station, deckHalf, y - deckDepth)); + underRight.push(offset(station, -deckHalf, y - deckDepth)); + } + + add("deck", strip(topLeft, topRight), true); + add("deck", strip(underRight, underLeft)); + add("deck", strip(underLeft, topLeft)); + add("deck", strip(topRight, underRight)); + + // ---- Towers ---- + // + // Two legs and a ladder of portal struts between them, not one post. The + // openings between those struts are the single most recognisable thing about + // the Golden Gate's towers, and a solid box has none of them. + const towerTops = new Map(); + for (const at of towerAt) { + const station = list[at]; + if (!station) continue; + const base = footing(station.ground, 8); + const yaw = Math.atan2(station.tangent.x, station.tangent.z); + const height = towerY - base; + towerTops.set(at, towerY); + for (const side of [-1, 1] as const) { + const leg = offset(station, side * deckHalf, 0); + // Three stacked frusta rather than one: real towers step in at intervals + // rather than tapering evenly, and three steps is where the silhouette + // stops changing. + const steps = [0, 0.42, 0.76, 1]; + for (let i = 0; i < steps.length - 1; i += 1) { + const a = steps[i] ?? 0; + const b = steps[i + 1] ?? 1; + const wide = (t: number) => legWidth * (1 - 0.34 * t); + const deep = (t: number) => legDepth * (1 - 0.38 * t); + add( + "tower", + taper(leg.x, leg.z, base + height * a, base + height * b, wide(a), wide(b), deep(a), deep(b), yaw), + ); + } + // A fender at the waterline. The south tower of the Golden Gate stands in + // open water inside one, and without it a tower leg appears to be stuck + // through the surface like a pin. + add("tower", taper(leg.x, leg.z, base, deckY * 0.14, legWidth * 1.5, legWidth * 1.25, legDepth * 1.45, legDepth * 1.2, yaw)); + } + // Portal struts. The lowest sits under the deck, the rest climb to the + // saddle; the spacing tightens upward, which is what the real bracing does. + const centre = offset(station, 0, 0); + for (const frac of [0.2, 0.52, 0.71, 0.86, 0.985]) { + const y = base + height * frac; + const strutDepth = legDepth * (1 - 0.3 * frac); + add("tower", block(centre.x, y, centre.z, deckHalf * 2 + legWidth, legWidth * 0.62, strutDepth, yaw)); + } + } + + // ---- Cables and hangers ---- + const hangerSpacing = Math.max(deckHalf * 3.4, size(60, 0.22)); + for (const chain of runs) { + for (const side of [-1, 1] as const) { + const lateral = side * deckHalf; + const points: THREE.Vector3[] = []; + const hangers: { at: number; top: number }[] = []; + for (let i = 0; i < chain.nodes.length - 1; i += 1) { + const fromAt = chain.nodes[i]; + const toAt = chain.nodes[i + 1]; + if (fromAt === undefined || toAt === undefined) continue; + const fromTower = chain.towers.has(fromAt); + const toTower = chain.towers.has(toAt); + const fromY = fromTower ? towerY : (deckTop[fromAt] ?? deckY) + deckDepth * 0.6; + const toY = toTower ? towerY : (deckTop[toAt] ?? deckY) + deckDepth * 0.6; + /** + * Sag, measured against the drop from the saddle to the deck rather + * than against tower height. + * + * `sag` is authored as a fraction of tower height, and read literally + * it puts the Bay Bridge's cable a third of a tower above its own deck + * at midspan — which is a trestle with a curve in it, not a suspension + * bridge. On every real one the main cable comes down to within a few + * metres of the deck at the centre of the main span; that near-touch is + * the silhouette, and it is what the eye is actually recognising from + * eight kilometres up. So the authored number is kept as the thing that + * separates one bridge from another — a deeper cable on the Golden Gate + * than on the shallow-sagging Long Beach Gateway — and mapped onto the + * range a cable actually occupies. + * + * A side span drops most of a tower's height on its own and needs only + * enough curve not to look like a guy wire. + */ + const drop = Math.max(towerY - deckY, 0); + const main = fromTower && toTower; + const sag = main + ? drop * Math.min(0.95, Math.max(0.55, 0.55 + bridge.sag * 0.7)) + : drop * 0.12; + const fromAlong = list[fromAt]?.along ?? 0; + const toAlong = list[toAt]?.along ?? 0; + for (let at = fromAt; at <= toAt; at += 1) { + const station = list[at]; + if (!station) continue; + const t = toAlong === fromAlong ? 0 : (station.along - fromAlong) / (toAlong - fromAlong); + // A parabola, not a sine: a cable under a deck of uniform weight + // hangs in one, and the difference is visible at the tower, where a + // sine leaves the saddle horizontally and a real cable does not. + const y = fromY + (toY - fromY) * t - 4 * sag * t * (1 - t); + const floorY = (deckTop[at] ?? deckY) + deckDepth * 0.35; + const clamped = Math.max(y, floorY); + if (at > fromAt || i === 0) points.push(offset(station, lateral, clamped)); + const carriesHanger = at !== fromAt && at !== toAt && !chain.towers.has(at); + if (carriesHanger) hangers.push({ at, top: clamped }); + } + } + if (points.length < 2) continue; + add("cable", cord(points, cableRadius, 4)); + + // Anchorages. A main cable does not stop in mid-air: it runs into a block + // of concrete, and without one the side spans ended in two red wires + // pointing at the Presidio. One at each end of the run that is not a + // tower — the tower ends are where the cable crosses a saddle and carries + // on. + for (const end of [chain.nodes[0], chain.nodes[chain.nodes.length - 1]]) { + if (end === undefined || chain.towers.has(end)) continue; + const station = list[end]; + const point = points[end === chain.nodes[0] ? 0 : points.length - 1]; + if (!station || !point) continue; + const yaw = Math.atan2(station.tangent.x, station.tangent.z); + add( + "anchorage", + block(point.x, point.y - deckDepth * 0.3, point.z, legWidth * 1.4, deckDepth * 2.2, legDepth * 0.7, yaw), + ); + } + + let lastHanger = -Infinity; + for (const hanger of hangers) { + const station = list[hanger.at]; + if (!station) continue; + if (station.along - lastHanger < hangerSpacing) continue; + const deckAt = deckTop[hanger.at] ?? deckY; + const height = hanger.top - deckAt; + if (height <= deckDepth) continue; + lastHanger = station.along; + const point = offset(station, lateral, deckAt + height / 2); + add("hanger", block(point.x, point.y, point.z, hangerHalf * 2, height, hangerHalf * 2)); + } + } + } + + // ---- Piers ---- + // + // One every so often under an approach, and none at all where the ground has + // already risen to the deck: that is what walks the Bay Bridge onto Yerba + // Buena instead of standing it on stilts over the top of the island. + const pierSpacing = Math.max(deckHalf * 9, size(240, 0.7)); + for (const reach of reaches) { + if (reach.kind !== "approach") continue; + let last = -Infinity; + for (let at = reach.from + 1; at < reach.to; at += 1) { + const station = list[at]; + if (!station) continue; + if (station.along - last < pierSpacing) continue; + const top = (deckTop[at] ?? deckY) - deckDepth; + const foot = footing(station.ground, 6); + if (top - foot < deckDepth) continue; + last = station.along; + const yaw = Math.atan2(station.tangent.x, station.tangent.z); + const centre = offset(station, 0, 0); + // One column, not a pair of legs. Two legs at deck width made the Bay + // Bridge's skyway look like a viaduct carried on a wall — and the real + // skyway is single-column piers, which is also two thirds fewer triangles + // over eleven kilometres of South Bay trestle. + add( + "pier", + taper(centre.x, centre.z, foot, top, pierHalf * 1.5, pierHalf, pierHalf * 2.6, pierHalf * 1.8, yaw), + ); + // The pier cap: the crosshead the deck actually sits on, and the thing + // that stops a column appearing to be pushed through the deck like a nail. + add("pier", block(centre.x, top - deckDepth * 0.2, centre.z, deckHalf * 1.5, deckDepth * 0.5, pierHalf * 2.2, yaw)); + } + } + + return cost; +} + +/** + * How a bridge came out, for tests and for the census. Exported because the + * classifier is the part of this module with real judgement in it, and a picture + * cannot tell you that the Yerba Buena crossing was classified as an approach — + * it can only tell you the cable is gone, which is also what a bug looks like. + */ +export interface BridgePlan { + reaches: Reach[]; + chains: number; + towerStations: number[]; + stationCount: number; + deckLength: number; +} + +export function planBridge(world: BridgeWorld, bridge: Bridge): BridgePlan { + const perUnit = world.metresPerUnit; + const deckHalf = Math.max(15 / perUnit, 0.075); + const list = stations(world, bridge.path, stationSpacing(deckHalf)); + const towerAt = bridge.towers.map((tower) => nearestStation(world, list, tower)); + const reaches = classify(list, towerAt, bridge.towerHeight, perUnit); + return { + reaches, + chains: + chains(reaches, towerAt).length + + localChains(list, reaches, towerAt, bridge.towerHeight, perUnit).length, + towerStations: towerAt, + stationCount: list.length, + deckLength: list[list.length - 1]?.along ?? 0, + }; +} diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 941e838..cb1182c 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -320,7 +320,64 @@ export function sampleRoute(route: SimRoute, p: number): Aircraft { const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease; const heading = (Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI; - return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading }; + /** + * The velocity, so a simulated aircraft is the same kind of object a real one + * is. + * + * Not decoration. `createFlightLayer` dead-reckons anything that carries a + * ground speed and interpolates anything that does not, and a build with no + * API — a keyless clone, the boot state of every deployment, the fallback + * whenever the network goes away — flies exactly these aircraft. Leaving them + * without a velocity would mean the two paths went through different code and + * only one of them was ever looked at, which is the arrangement that let the + * live sky sit still for as long as it did. + * + * Both numbers are the derivative of the arithmetic three lines up rather + * than a plausible-looking constant: the speed is the leg's ground distance + * over its duration, and the climb is `d/dt` of the eased altitude, which + * is why a departure's rate is steepest at the start and tails to nothing — + * the same shape the ease was chosen for. + */ + const seconds = route.duration > 0 ? route.duration : 1; + const dLatM = (route.to[0] - route.from[0]) * METRES_PER_DEGREE_LAT; + const dLngM = + (route.to[1] - route.from[1]) * METRES_PER_DEGREE_LAT * Math.cos((lat * Math.PI) / 180); + const groundSpeed = Math.hypot(dLatM, dLngM) / seconds; + const verticalRate = ((route.toAlt - route.fromAlt) * 2 * (1 - t)) / seconds; + return { + id: `sim-${route.callsign}`, + callsign: route.callsign, + lat, + lng, + altitude, + heading, + groundSpeed, + verticalRate, + }; +} + +/** + * Metres in one degree of latitude, and the only geodesy in this file. + * + * A sphere, not an ellipsoid. The dead-reckoner integrates this over at most a + * minute of flight and the WGS-84 meridian varies by about half a percent from + * pole to equator — half a metre in a hundred, on a board where one scene unit + * is ninety-four of them. `World.metresPerUnit` is derived from the same + * constant, so the two agree by construction rather than by coincidence. + */ +const METRES_PER_DEGREE_LAT = 111_320; + +/** + * A caller-supplied string, trimmed, or `null` for anything that is not one. + * + * `undefined`, an empty string and a string of spaces are all "the feed said + * nothing" and must all reach the card as the same `null`, because a card that + * renders an empty row looks like a card whose data went missing. + */ +function text(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; } function nowSeconds(): number { @@ -432,6 +489,34 @@ export class AdsbFlights implements FlightSource { * the heading of is one this layer cannot draw honestly. */ heading: a.track as number, + /** + * The velocity, when the feed reported one, and the reason this + * layer's aircraft can fly between snapshots at all. + * + * Guarded on being a positive finite number rather than merely + * present: this feed sends `gs: 0.0` for ground vehicles and parked + * aircraft, and a zero is a fact about a stationary object rather + * than a missing measurement, so both end up absent and both are + * held still by `createFlightLayer`. The track is already known to be + * a number — the filter above dropped every record without one — so + * a speed carried here always has a direction to go with it. + * + * `server/src/flights/adsb.ts` does the same conversion on the same + * fields for the shipped path. Two copies of the arithmetic is one + * more than anybody wants, and the alternative is this class + * importing server code into the browser bundle. + */ + ...(typeof a.gs === "number" && Number.isFinite(a.gs) && a.gs > 0 + ? { groundSpeed: a.gs * KNOTS_TO_MS } + : {}), + ...(typeof a.baro_rate === "number" && Number.isFinite(a.baro_rate) + ? { verticalRate: a.baro_rate * FPM_TO_MS } + : typeof a.geom_rate === "number" && Number.isFinite(a.geom_rate) + ? { verticalRate: a.geom_rate * FPM_TO_MS } + : {}), + ...(typeof a.seen_pos === "number" && a.seen_pos >= 0 + ? { ageSeconds: a.seen_pos } + : {}), })); this.heldAt = nowSeconds(); return this.held; @@ -455,8 +540,20 @@ interface RawAircraft { lon?: number; alt_baro?: number; track?: number; + /** Ground speed, knots. `0.0` on a ground vehicle or a parked aircraft. */ + gs?: number; + /** Barometric climb rate, feet per minute, positive up. */ + baro_rate?: number; + /** Geometric climb rate, feet per minute — what a row carries instead. */ + geom_rate?: number; + /** Seconds since this row's position was last updated. */ + seen_pos?: number; } +/** The feed's units, converted once. The engine works in metres and seconds. */ +const KNOTS_TO_MS = 0.514_444; +const FPM_TO_MS = 0.00508; + // ---- Detail --------------------------------------------------------------- /** @@ -466,11 +563,17 @@ interface RawAircraft { * a city they recognise and being told what it is, so this type is written for * **anon** and carries nothing an account would be needed for. Everything in it * is either broadcast unencrypted by the aircraft itself — ADS-B is receivable - * with a forty-dollar dongle — or arithmetic on top of that. There is no route, - * no registration and no operator here, because the open feeds do not carry + * with a forty-dollar dongle — or arithmetic on top of that. + * + * There is no route and no operator here, because the open feeds do not carry * them and inventing them would be the same class of lie `synthetic` exists to - * prevent. `owner-decisions.md` reserves those for an openly-licensed registry - * we have not wired. + * prevent. The registration and the type **are** here, and used not to be: the + * comment this replaces said the open feeds did not carry those either, and + * that was simply a mistake — `r` and `t` are on every row adsb.lol and + * airplanes.live serve, under the same ODbL as the coordinates, and the server + * was dropping them on the floor. They were the stated reason to want a + * commercial feed, which makes getting them right the cheapest thing in this + * file. * * Two fields are about the *provenance* rather than the aeroplane, and they are * the reason this is a type and not an object literal built in the UI: @@ -504,6 +607,36 @@ export interface AircraftDetail { headingDeg: number; /** The heading as a 16-point compass name, for a card a human reads. */ headingCompass: string; + /** + * The tail number, e.g. `"N68834"`, or `null`. + * + * The paragraph above this type used to say there is no registration and no + * type here because the open feeds do not carry them. **That was wrong**, and + * it was wrong in the expensive direction: it was the stated reason to want a + * commercial feed. Both community feeds have carried `r` and `t` on every row + * all along, under the same ODbL as the position beside them — so this is the + * enrichment that was wanted, obtained legitimately, and an anonymous visitor + * clicking a dart now reads "B739 · N68834" rather than a hex address. + * + * `null` and never a guess. A registration is something somebody pastes into + * a registry lookup, so a wrong one names a different aeroplane — the same + * argument `icao24` makes, about the same kind of identifier. + */ + registration: string | null; + /** ICAO type designator, e.g. `"B739"`, or `null`. Never expanded to a name. */ + type: string | null; + /** + * Ground speed in knots, or `null` where the feed did not say. + * + * Knots because that is the unit a speed over the ground is read in, and + * rounded because the tenth of a knot the feed publishes is precision about + * a number that changes while the card is open. `null` for anything not + * moving: a parked aircraft reporting `gs: 0.0` reaches here with no speed at + * all rather than with a zero, and "—" is the honest thing to draw for it. + */ + groundSpeedKt: number | null; + /** Climb rate in feet per minute, positive up, or `null`. Aviation's unit. */ + verticalRateFpm: number | null; /** Nautical miles from the board's centre, or `null` when no centre was given. */ distanceNm: number | null; /** @@ -564,6 +697,20 @@ export interface AircraftDetailOptions { * correctly declines for the simulator. */ icao24?: string | null; + /** + * The registration and the ICAO type designator, when the caller was told + * them separately. + * + * Here rather than on `Aircraft` for the same reason `icao24` is, and it is + * the same boundary: `Aircraft` is what the *renderer* needs — a position, a + * height and a direction — and a tail number moves no pixels. The adapter + * keeps the wire record beside the position and hands these back when a card + * is asked for. Absent, they are `null`; nothing here is derived from + * anything else, because a type designator inferred from a callsign is a + * guess about a real aeroplane. + */ + registration?: string | null; + type?: string | null; /** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */ observed?: boolean; /** Credit lines the feed asks for, shown on the card. */ @@ -604,6 +751,20 @@ export function aircraftDetail( altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE), headingDeg: aircraft.heading, headingCompass: compassPoint(aircraft.heading), + registration: text(options.registration), + type: text(options.type), + // From the aircraft rather than from the options, because unlike the two + // above it these are numbers the renderer genuinely uses: the layer + // dead-reckons on them, so they are already on `Aircraft` and reading them + // from a second place would be a second chance to disagree. + groundSpeedKt: + typeof aircraft.groundSpeed === "number" && Number.isFinite(aircraft.groundSpeed) + ? Math.round(aircraft.groundSpeed / KNOTS_TO_MS) + : null, + verticalRateFpm: + typeof aircraft.verticalRate === "number" && Number.isFinite(aircraft.verticalRate) + ? Math.round(aircraft.verticalRate / FPM_TO_MS) + : null, distanceNm: options.from === undefined ? null @@ -790,6 +951,49 @@ const JUMP_UNITS_PER_SECOND = 8; */ const GLYPH_MIN_SCREEN_FRACTION = 0.016; +/** + * A ceiling on the same scale, in multiples of the authored glyph. + * + * The floor above is a screen-space rule and it is right about a map: an + * aeroplane is drawn at a readable size wherever it is, because position and + * heading are what a reader wants and neither survives half a pixel. But the + * rule scales by the distance to *that aircraft*, not by how far the camera has + * zoomed, so the two are only the same thing when everything in frame is + * equally far away. On a whole-board pose they are. Beside a landmark they are + * not: at the Golden Gate chapter the bridge is a couple of units from the + * camera and the traffic over the Pacific is a couple of hundred, so the floor + * fires hard on the aeroplane and not at all on the bridge, and an airliner is + * drawn about two and a half times the length of the main span. + * + * A world-space ceiling is the missing half of the rule. The floor says "never + * smaller than legible"; this says "never larger than an aeroplane could + * plausibly be", and between them the glyph is a map symbol where there is + * nothing to compare it against and an aircraft where there is. + * + * 52 IS A MITIGATION AND NOT A CURE, and the arithmetic says why. The furthest + * a visitor orbits on the California corridor is about 1,160 units, where the + * raw scale is 33.9 at a 42-degree field of view and 51.0 at 60 — so any ceiling + * below 52 shrinks an aeroplane at a pose people actually use, and at 26 the + * glyph fell to 0.0123 of the frame against the 0.012 at which this file says + * the wings stop resolving. The Golden Gate case sits at about 2,280 units and a + * raw 81. A ceiling of 52 therefore takes the worst case down by a third — from + * roughly two and a half times the bridge's main span to about one and a half — + * and costs nothing at any board distance. It does not make the aeroplane + * smaller than the bridge. + * + * THE COMPLETE FIX IS A DIFFERENT INPUT, not a lower number. This function is + * handed the distance to the *aircraft*, and the thing that actually makes the + * glyph look wrong is how far the camera is from what it is LOOKING AT: at a + * whole-board pose everything in frame is equally far away and the floor is + * right about all of it, while at a chapter the bridge is two units away and the + * traffic is two thousand. Clamping against the camera's own focus distance + * would let the glyph collapse toward its authored size whenever the viewer has + * zoomed in on something near, at any aircraft range. That is a signature + * change through `tick` and its callers, and it wants its own pass rather than + * being smuggled into a constant. + */ +const GLYPH_MAX_SCALE = 52; + /** * The radius of the sphere a pointer actually has to hit, in glyph lengths. * @@ -901,6 +1105,169 @@ const SPEED_OVER_G = 200 / 9.80665; */ const ROLL_SETTLE_SECONDS = 2.5; +/** + * How long the layer will keep flying an aircraft on its last known velocity. + * + * Dead reckoning is an *interpolation of the near future*, not a simulation. A + * position and a velocity describe where something will be in the next few + * seconds very well and where it will be in ten minutes not at all — an + * airliner turns, descends and lands, and none of that is in the two numbers + * this layer was handed. So the propagation time is clamped: an aircraft coasts + * for a minute and then holds station until somebody tells it something new. + * + * A minute rather than a shorter, tidier number because a minute is what the + * rest of the file already treats as "the feed is gone" — `ADSB_HOLD_SECONDS` + * is the same figure, and both sources hold their last snapshot for exactly + * that long. Reckoning past the point where the snapshot itself would have + * expired would be flying an aeroplane on the strength of data the layer has + * already agreed to stop believing. + */ +const MAX_RECKON_SECONDS = 60; + +/** + * How quickly a dead-reckoned track slides onto a fresh observation, in seconds. + * + * **This constant is the whole difference between a fix and a flinch.** The + * reckoner is always a little wrong — the aircraft banked, or the wind changed, + * or the fix that started it was itself a second stale — so every observation + * arrives with the drawn aeroplane a few hundred metres from where the feed + * says it is. Teleporting it there is the artefact this layer has spent its + * whole life removing on other channels: a visible twitch on every aircraft on + * every refresh, five to fifteen seconds apart, forever. + * + * So the error is measured once, at the instant of the observation, and then + * *decayed*: the aeroplane flies the newly-truthful track and carries a + * shrinking offset on top of it. A first-order decay rather than a ramp for the + * same reason `ROLL_SETTLE_SECONDS` is one — it cannot overshoot, and it has no + * end time to be interrupted at, so an observation landing early is not a + * special case. + * + * Three seconds puts 96% of the correction inside a ten-second refresh while + * keeping the closing speed below the aircraft's own: a 400 m error closes at + * 133 m/s against an airliner's 250, so the correction reads as a course + * adjustment rather than as a sideways lurch. Much shorter and it is a twitch + * again; much longer and two aircraft on the same approach never quite agree + * about where the centreline is. + */ +const RECKON_SETTLE_SECONDS = 3; + +/** + * The largest error the layer will slide out rather than jump. + * + * Scene units, and a safety valve rather than a tuning knob. A correction is + * only worth easing if the two positions describe the same flight a moment + * apart; a fix that lands two kilometres from the reckoned position is a + * different claim altogether — a receiver reacquiring a target, an id reused, + * a feed skipping a minute — and easing that would drag the aeroplane across + * the county at three hundred knots with its trail attached. Past this the + * offset is simply dropped and the aircraft is where the feed says it is. + * + * 20 units is 1.9 km on the San Francisco board and about 3.3 km on California. + * A ten-second reckon of a turning airliner errs by well under a kilometre, so + * this bites only when something has genuinely gone wrong. + */ +const MAX_RECKON_CORRECTION_UNITS = 20; + +/** + * A position and the velocity it is moving with: everything needed to say where + * something will be shortly. + * + * Geographic rather than scene coordinates on purpose. `World.project` is + * linear in latitude and longitude but the two axes have different scales — a + * degree of longitude is shorter than a degree of latitude everywhere but the + * equator, and `lngScale` carries that — so integrating a heading in scene + * space would need the projection undone and redone anyway. Doing it in degrees + * means one `project` at the end and no assumptions about the board. + */ +export interface Reckoning { + lat: number; + lng: number; + /** Metres. */ + altitude: number; + /** Degrees clockwise from true north. */ + heading: number; + /** Metres per second over the ground. Zero holds station. */ + speed: number; + /** Metres per second, positive climbing. */ + climb: number; +} + +/** + * Where a track will be `seconds` from the state it was in. + * + * Pure, total, and exported so the arithmetic can be tested without a scene: a + * dead-reckoner that turns the wrong way, or converts degrees to metres at the + * wrong latitude, produces a sky that renders perfectly and is wrong by + * kilometres, which is precisely the class of defect this file's comments keep + * recording. + * + * Compass convention throughout: heading 0 is north and increases clockwise, so + * north is `+cos` on latitude and east is `+sin` on longitude. The longitude + * step is divided by the cosine of the latitude, because a degree of longitude + * carries fewer metres the further from the equator it is — get that backwards + * and every eastbound aircraft over California flies at four fifths of its + * reported speed. + * + * Negative time is refused rather than run backwards: every caller is asking + * about the present or the near future, and a negative interval here would mean + * a clock had gone backwards, which is a thing browser clocks do. + */ +export function reckonForward( + state: Reckoning, + seconds: number, +): { lat: number; lng: number; altitude: number } { + const dt = clamp(seconds, 0, MAX_RECKON_SECONDS); + if (!Number.isFinite(dt) || dt === 0 || !Number.isFinite(state.speed)) { + return { lat: state.lat, lng: state.lng, altitude: altitudeAfter(state, seconds) }; + } + const distance = state.speed * dt; + const radians = (state.heading * Math.PI) / 180; + const lat = state.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT; + // The cosine is taken at the *starting* latitude rather than at the mean of + // the two. A minute of flying moves an airliner about half a degree at most, + // over which the correction differs in the sixth decimal place, and using the + // start keeps this a closed form rather than an iteration. + const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos((state.lat * Math.PI) / 180); + const lng = + metresPerDegreeLng > 1 + ? state.lng + (Math.sin(radians) * distance) / metresPerDegreeLng + : state.lng; + return { lat, lng, altitude: altitudeAfter(state, dt) }; +} + +/** + * The altitude after an interval, with the one floor that matters: nothing + * descends through the ground. + * + * A steady 1,300 ft/min descent reckoned for a full minute puts an aircraft + * 400 m *below* the terrain if it landed in the meantime — which is exactly + * what happens to an arrival that lands while its feed is quiet, and it renders + * as an aeroplane buried in the bay. + */ +function altitudeAfter(state: Reckoning, seconds: number): number { + const dt = clamp(seconds, 0, MAX_RECKON_SECONDS); + if (!Number.isFinite(state.climb) || !Number.isFinite(dt)) return state.altitude; + return Math.max(0, state.altitude + state.climb * dt); +} + +/** + * A track's dead-reckoned state, plus the error it is still sliding out of. + * + * `since` is when `state` was true; `offset` is where the aeroplane was drawn + * at `offsetAt` minus where this state says it was, in scene units, and decays + * to nothing over `RECKON_SETTLE_SECONDS`. Keeping the two apart is what makes + * the correction continuous: the *truth* jumps when an observation lands, and + * the *drawing* does not, because the jump is absorbed into an offset that is + * already on its way out. + */ +interface ReckonTrack { + state: Reckoning; + /** Seconds on `nowSeconds`'s monotonic clock. */ + since: number; + offset: THREE.Vector3; + offsetAt: number; +} + interface TrailSample { position: THREE.Vector3; altitude: number; @@ -963,6 +1330,25 @@ interface Track { stale: boolean; /** Altitude at `head`, which is what the aircraft's colour is chosen from. */ headAltitude: number; + /** + * The dead-reckoned state, or `null` for a source that reported no velocity. + * + * The presence of this object is what decides which of the layer's two motion + * models a track gets, and both are needed: + * + * - **`null` — interpolate.** The head is lerped between the last two + * observations and stops when it arrives. This is the original behaviour + * and it is right for anything whose speed is unknown: a ground vehicle, + * a parked airframe, a position-only TIS-B target, a source that predates + * the velocity fields. Guessing a speed for those would be inventing + * motion, which is a worse lie than showing none. + * - **Present — reckon.** The head is integrated forward from the last fix + * along the aircraft's own track at its own speed, every frame, and slides + * onto each new observation instead of snapping to it. This is what makes + * a live feed refreshing every ten seconds look like flying rather than + * like a slideshow. + */ + reckon: ReckonTrack | null; } /** @@ -1119,6 +1505,9 @@ export function createFlightLayer(world: World): FlightLayer { headAltitude: a.altitude, missingSince: 0, stale: false, + // Filled in by `adoptReckoning` below, on this same observation, if + // the source said how fast the thing is going. + reckon: null, }; tracks.set(a.id, track); } @@ -1159,6 +1548,18 @@ export function createFlightLayer(world: World): FlightLayer { continue; } + /** + * Whether this observation was a teleport rather than a flight. + * + * Carried out of the branch below because the dead-reckoner needs to know + * it: a wrapped simulator route or a reused id is a *different aircraft* + * at this position, so its new state must be adopted outright rather than + * eased onto from where the old one was being drawn. Easing it would + * produce exactly what the teleport guard exists to prevent — an aeroplane + * sliding across the board over three seconds, trail attached. + */ + let jumped = false; + if (previous) { // The clamp is load-bearing on both ends. Two polls arriving in the same // millisecond — a manual refresh, a tab waking up — divide by nearly @@ -1174,6 +1575,7 @@ export function createFlightLayer(world: World): FlightLayer { position.z - previous.position.z, ); if (travelled / span > JUMP_UNITS_PER_SECOND) { + jumped = true; // A source that has moved something further than anything flies has // either looped a simulated route or reused an id. Either way the // history is about a different flight; keeping it would draw a trail @@ -1201,6 +1603,7 @@ export function createFlightLayer(world: World): FlightLayer { track.samples.push(sample); trim(track, now); + adoptReckoning(track, a, now, jumped); } /** @@ -1234,6 +1637,137 @@ export function createFlightLayer(world: World): FlightLayer { tick(); } + /** + * Scratch vectors for the reckoner. Reused because `adoptReckoning` runs once + * per aircraft per observation and `reckonedHead` once per aircraft per + * frame, and four hundred of either allocating a `Vector3` is a + * garbage-collection pause a pointer can feel. + */ + const reckonScratch = new THREE.Vector3(); + const truthScratch = new THREE.Vector3(); + + /** + * Take a fresh observation as the truth a track flies from, without letting + * the aeroplane jump to it. + * + * Three things happen here and they are in this order for a reason. + * + * **The fix is advanced to now.** `Aircraft.ageSeconds` says how stale the + * coordinates already were when the source handed them over — the receiver's + * last message, plus this box's cache TTL, plus whatever the browser was + * holding. Adopting them as though they described this instant would draw the + * entire sky that far behind, uniformly, which is the kind of error nobody + * ever notices because everything is wrong together. + * + * **The error is measured before the state is replaced.** Where the aeroplane + * is being *drawn* right now is a property of the old reckoning, so it has to + * be read while the old reckoning still exists; a moment later there is + * nothing left to compare against and the correction would be zero, which is + * the same thing as snapping. + * + * **A teleport is adopted outright.** See `jumped`. + * + * A source that reports no usable speed leaves `reckon` null and the track + * falls back to interpolating between observations, which is what every + * aircraft in this layer did before this function existed. + */ + function adoptReckoning(track: Track, a: Aircraft, now: number, jumped: boolean): void { + const speed = a.groundSpeed; + if (typeof speed !== "number" || !Number.isFinite(speed) || speed <= 0) { + track.reckon = null; + return; + } + const climb = + typeof a.verticalRate === "number" && Number.isFinite(a.verticalRate) ? a.verticalRate : 0; + const age = + typeof a.ageSeconds === "number" && Number.isFinite(a.ageSeconds) && a.ageSeconds > 0 + ? a.ageSeconds + : 0; + + const fix: Reckoning = { + lat: a.lat, + lng: a.lng, + altitude: a.altitude, + heading: a.heading, + speed, + climb, + }; + const caughtUp = reckonForward(fix, age); + const state: Reckoning = { + ...fix, + lat: caughtUp.lat, + lng: caughtUp.lng, + altitude: caughtUp.altitude, + }; + + // Where this track is currently being drawn, read off the reckoning that is + // about to be replaced. `null` for a track that has never had one — a brand + // new arrival has nothing to be eased from and belongs at the fix. + const drawn = + track.reckon !== null && !jumped ? reckonedHead(track.reckon, now, reckonScratch) : null; + + const offset = track.reckon?.offset ?? new THREE.Vector3(); + if (drawn === null) { + offset.set(0, 0, 0); + } else { + const [x, z] = world.project(state.lat, state.lng); + truthScratch.set(x, world.metres(state.altitude), z); + offset.subVectors(reckonScratch, truthScratch); + // Past the valve, the two positions are not the same flight a moment + // apart and easing between them would drag the aeroplane across the + // board. See `MAX_RECKON_CORRECTION_UNITS`. + if (offset.lengthSq() > MAX_RECKON_CORRECTION_UNITS ** 2) offset.set(0, 0, 0); + } + + track.reckon = { state, since: now, offset, offsetAt: now }; + + /** + * The climb angle, from the feed's own rate rather than from two altitudes. + * + * Better on both ends of the arithmetic: `climbAngle` divides a barometric + * difference by a horizontal distance, so it carries the noise of two + * altitude readings and the error of the span, and it reports a climb of + * zero for anything that has not moved. The vertical rate is a measurement + * the aircraft transmits, so a departure is nose-up on the first + * observation of it rather than on the second. + * + * `Math.max(speed, 1)` keeps the ratio finite for something crawling; at a + * metre a second the pitch saturates against `MAX_PITCH` anyway, which is + * the right answer for a helicopter going straight up. + */ + track.pitch = clamp(Math.atan2(climb, Math.max(speed, 1)), -MAX_PITCH, MAX_PITCH); + } + + /** + * Where a reckoned track is at this instant, written into `out`; returns the + * altitude in metres, which is what the colour band is chosen from. + * + * The offset decays exponentially from the moment it was measured, so the + * drawn position starts at wherever the aeroplane already was and converges + * on the truth without ever stopping to do it. Below a thousandth of a unit + * it is dropped rather than added, which is not an optimisation: it is what + * guarantees a straight leg eventually draws at *exactly* the reckoned + * position instead of asymptotically near it. + */ + function reckonedHead(r: ReckonTrack, now: number, out: THREE.Vector3): THREE.Vector3 { + const forward = reckonForward(r.state, now - r.since); + const [x, z] = world.project(forward.lat, forward.lng); + out.set(x, world.metres(forward.altitude), z); + const fade = Math.exp(-Math.max(0, now - r.offsetAt) / RECKON_SETTLE_SECONDS); + if (fade > 1e-3) out.addScaledVector(r.offset, fade); + reckonedAltitude = forward.altitude; + return out; + } + + /** + * The altitude `reckonedHead` last computed. + * + * A second return value, in a file that would otherwise allocate an object + * per aircraft per frame to carry it. The two are always read together and + * one statement apart. + */ + let reckonedAltitude = 0; + /** * Put a track into the pick list, or take it out. * @@ -1318,8 +1852,23 @@ export function createFlightLayer(world: World): FlightLayer { setPickable(track, !track.stale); if (track.stale) continue; - track.head.lerpVectors(from.position, to.position, alpha); - track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; + /** + * Two motion models, and which one a track gets is decided by whether the + * source told it how fast it is going. See `Track.reckon`. + * + * The reckoned branch is the one that matters for a live feed and it is + * deliberately *not* clamped to the newest observation: it flies past it, + * because the aircraft did. The interpolated branch below is the original + * behaviour and stops on arrival, which is the only honest thing to do + * with a position whose velocity nobody stated. + */ + if (track.reckon !== null) { + reckonedHead(track.reckon, now, track.head); + track.headAltitude = reckonedAltitude; + } else { + track.head.lerpVectors(from.position, to.position, alpha); + track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; + } track.mesh.position.copy(track.head); /** @@ -1407,7 +1956,20 @@ export function createFlightLayer(world: World): FlightLayer { // a genuinely new arrival — `tracks` is walked in insertion order, and the // ghosts are the oldest entries in it. if (track.stale) continue; - const spine = track.samples.length - 1; + /** + * How many observations the trail is drawn through before the head. + * + * The two motion models differ here, and the difference is not cosmetic. + * An **interpolated** track's newest observation is where it is *going*, + * so drawing to it would put the trail in front of the aeroplane — the + * spine stops one short and the last segment runs to the interpolated + * head, which lies between the two. A **reckoned** track has already flown + * past its newest observation, so that observation is history like every + * other one: leaving it out would cut the corner between the previous fix + * and the reckoned head, and a turning aircraft would trail a chord + * across the inside of its own turn. + */ + const spine = track.reckon !== null ? track.samples.length : track.samples.length - 1; if (spine < 1) continue; drawn += 1; const points = spine + 1; // the spine, plus the head @@ -1532,7 +2094,8 @@ export function glyphScale(distance: number, fovDegrees: number): number { if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1; if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1; const frustumHeight = 2 * distance * Math.tan((fovDegrees * Math.PI) / 360); - return Math.max(1, (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH); + const legible = (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH; + return Math.min(GLYPH_MAX_SCALE, Math.max(1, legible)); } /** diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 8e28a04..e42b899 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -54,6 +54,7 @@ import type { VehicleActionSnapshot, VehicleControllerState, } from "../transport/vehicleController.ts"; +import { createAirports } from "./airports.ts"; import { createBridges, createFreewayWorld, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import type { @@ -425,6 +426,10 @@ export async function createScene( scene.add(blocks); scene.add(createLandmarks(world, buildingReservations)); scene.add(createBridges(world)); + // Airfields. Laid flush on the terrain rather than draped over it like a + // road, which is why the packs no longer carry runways as `Road` records — + // carrying both floats a dark stripe thirteen metres above every runway. + scene.add(createAirports(world, city.airports ?? [])); /** * The city switching itself on after sunset. Built after `blocks` because it diff --git a/src/engine/structures.ts b/src/engine/structures.ts index 83c12f6..fa0e931 100644 --- a/src/engine/structures.ts +++ b/src/engine/structures.ts @@ -1,11 +1,26 @@ /** - * Bridges and roads — the lines that tie the landmasses together and give the + * Roads and bridges — the lines that tie the landmasses together and give the * grid something to hang off. * * Roads follow the terrain: each path is resampled far more finely than it is * written in the city pack, and every sample takes its height from the ground, * so a street climbs out of the flats instead of burrowing through the hill. * + * A road also has a **surface** rather than a colour. The cross-section — verge, + * shoulder, edge line, lanes, median — is painted once into a canvas texture and + * mapped across every ribbon, which is what took a city-pack freeway from a flat + * grey stroke lying on the terrain to something that reads as a road, at no + * triangle cost at all. See `paintRoadSurface`. The one corridor you can drive + * down is the exception and is built out of real ribbons by + * `createFreewayWorld`, because at chase-camera height an embankment has to have + * a normal. + * + * **The bridge kit lives in `bridges.ts`.** A suspension bridge is enough of a + * problem on its own — towers, a cable in a parabola, hangers, and a classifier + * that works out which parts of a crossing hang from anything — and both of the + * Bay Area's famous ones are configurations of it. What stays here is the + * batching it hands its geometry to, and the asphalt its decks wear. + * * ### Everything here is batched, and it has to be * * The city ran at 616 draw calls against a budget of 650 while the office spent @@ -33,8 +48,9 @@ * * The corollary for anyone adding a helper here: give every geometry the **same * attribute set** — position, normal, uv, indexed — or `mergeGeometries` - * refuses the bucket and silently drops it. That is why the ribbons below carry - * UVs they have no texture for. + * refuses the bucket and silently drops it. The UVs are load-bearing twice over + * now: the road surface is a texture that reads them, and a part without them + * takes its whole bucket with it. */ import * as THREE from "three"; @@ -42,26 +58,13 @@ import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts"; import type { TransportPack } from "../transport/types.ts"; import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts"; +import { buildBridge } from "./bridges.ts"; +import type { SurfaceKind } from "./bridges.ts"; import type { Bridge, LatLng } from "./types.ts"; import type { World } from "./world.ts"; // ---- Batching ------------------------------------------------------------- -/** - * The three ways a surface out here is shaded. - * - * `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip - * that has to survive being looked at from underneath on a bridge approach, and - * a tower is a closed solid where a back face is a waste. - * - * `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the - * one thing in the frame whose job is to be a fixed, known white — it is - * retroreflective, it is what a driver navigates by, and putting it through the - * ACES shoulder with everything else turns a lane line into a grey smear at - * midday and loses it entirely at dusk. - */ -type SurfaceKind = "deck" | "solid" | "marking"; - interface Bucket { readonly name: string; readonly material: THREE.Material; @@ -83,7 +86,23 @@ class Batch { private readonly materials = new Map(); private readonly buckets = new Map(); - /** The one material for a kind and colour in this build. */ + /** + * The one material for a kind and colour in this build. + * + * `SurfaceKind` is declared in `bridges.ts`, because that module is the other + * side of this one's `GeometrySink` boundary, but the three materials it names + * are made here and here only. + * + * `deck` and `solid` differ only in sidedness: a road deck is a one-sided + * strip that has to survive being looked at from underneath on a bridge + * approach, and a tower is a closed solid where a back face is a waste. + * + * `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is + * the one thing in the frame whose job is to be a fixed, known white — it is + * retroreflective, it is what a driver navigates by, and putting it through + * the ACES shoulder with everything else turns a lane line into a grey smear + * at midday and loses it entirely at dusk. + */ material(kind: SurfaceKind, color: number): THREE.Material { const key = `${kind}:${color.toString(16)}`; const hit = this.materials.get(key); @@ -165,12 +184,6 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14 return out; } -/** A tube swept along a path — a bridge deck, a cable, a barrier. */ -function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry { - const curve = new THREE.CatmullRomCurve3(points); - return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false); -} - /** * A draped strip running between two parallel offsets from a path, each at its * own lateral distance and its own height. @@ -333,6 +346,175 @@ function dashedRibbonGeometry( return geometry; } +// ---- The road surface ------------------------------------------------------ + +/** + * What a road is made of, drawn once on a canvas and mapped across every ribbon + * on the board. + * + * The defect this exists for: a city-pack road was one flat mid-grey ribbon the + * width of the carriageway, and at 94 m to the scene unit that is three or four + * pixels of unbroken value lying exactly on the terrain. It read as a line + * somebody drew on the map rather than as a road, which is precisely the + * complaint — a wireframe overlay, not a surface. + * + * The fix is a cross-section rather than more geometry. The ribbon is widened by + * half, the extra going to a graded verge, and this texture paints the whole + * width: verge, shoulder, edge line, lanes, and the median. The eye then reads + * *pale / dark / pale* with a warm line down the middle, which is what a road + * looks like from a mile up, and it costs no triangles at all — the alternative, + * a ribbon per stripe, is what `createFreewayWorld` does for the one corridor + * you can drive down, and it costs eight ribbons a carriageway. + * + * Two conventions the callers depend on: + * + * - **u runs 0..1 across the ribbon** and the widening is symmetric, so the + * fractions below are the same for a 19 m street and a 40 m freeway. + * - **v is distance along in scene units**, which is what `bandGeometry` and + * the bridge deck both write, so the repeat is set from `metresPerUnit` and + * the dash cycle comes out the same length in metres on every board. + */ +const ROAD_WIDEN = { freeway: 1.9, street: 1.5, bridge: 1 } as const; +/** Metres of road per vertical repeat of the texture — one dash cycle. */ +const ROAD_CYCLE_M = 24; + +function paintRoadSurface( + context: CanvasRenderingContext2D, + width: number, + height: number, + kind: "freeway" | "street" | "bridge", +): void { + const across = (fraction: number) => fraction * width; + /** + * How much of the ribbon is verge rather than pavement — and deliberately not + * `(1 - 1/ROAD_WIDEN)/2`, which is where the widening actually went. + * + * A texture's far mip is its average colour, and that average is what a road + * three pixels wide *is*. Giving the verge the whole of the widening made it + * 47% of the width, so the average came out olive and the freeways on the + * SoCal board drew as gold threads across the harbour. Keeping the verge to a + * narrow graded strip and putting the rest of the widening into pavement + * leaves the average a grey, which is what a road is. + */ + const verge = kind === "bridge" ? 0 : 0.13; + // Not the near-black asphalt looks like from a car. A road seen from a mile + // up is a mid grey — the first pass used a true kerbside value and drew the + // peninsula as a line of ink across pale sand. + const asphalt = kind === "freeway" ? "#454a4d" : "#4c4f51"; + + if (kind === "bridge") { + // A bridge deck has no verge to grade: the roadway runs to the edge of the + // structure and stops. Filling the margins with asphalt rather than earth is + // what keeps the deck from appearing to have soft shoulders over water. + context.fillStyle = asphalt; + context.fillRect(0, 0, width, height); + } else { + // A shade greener and darker than the flats it is cut into. A verge the + // colour of the ground is not a verge — the first pass painted one and the + // corridor still read as a single dark stroke on pale sand. + context.fillStyle = "#7b7358"; + context.fillRect(0, 0, width, height); + context.fillStyle = asphalt; + context.fillRect(across(verge), 0, across(1 - 2 * verge), height); + } + + const inner = kind === "bridge" ? 0.06 : verge; + // Shoulders: a lighter, dustier strip inside each edge. This is the band that + // does the most work at distance — it is what separates the dark carriageway + // from the ground on both sides at every sun angle. + // Pale, not another shade of asphalt. This is the band that carries the road + // at distance: dark carriageway between two light edges is what the eye reads + // as a road from a mile up, and a shoulder within a few values of the asphalt + // leaves one thick dark line instead. + context.fillStyle = kind === "freeway" ? "#8b8d87" : "#7f817c"; + context.fillRect(across(inner), 0, across(0.055), height); + context.fillRect(across(1 - inner - 0.055), 0, across(0.055), height); + + // Speckle. Asphalt that is one exact value reads as plastic the moment the + // camera comes down to deck height on a bridge or a drive chapter. + let seed = 0x9e3779b9; + const random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0xffffffff; + }; + context.globalAlpha = 0.14; + for (let i = 0; i < width * height * 0.05; i += 1) { + context.fillStyle = random() > 0.5 ? "#5a6063" : "#22272a"; + context.fillRect(Math.floor(random() * width), Math.floor(random() * height), 1, 1); + } + context.globalAlpha = 1; + + const line = Math.max(1, Math.round(width * 0.012)); + const edge = inner + 0.062; + context.fillStyle = "#e6e9e4"; + context.fillRect(across(edge), 0, line, height); + context.fillRect(across(1 - edge) - line, 0, line, height); + + const dash = (x: number, colour: string) => { + context.fillStyle = colour; + // 3 m of paint in a 12 m cycle, which is the US standard and reads as + // dashes rather than as a second solid line right down to a few pixels. + for (const start of [0, 0.5]) { + context.fillRect(x, (start + 0.02) * height, line, height * 0.21); + } + }; + + if (kind === "street") { + dash(across(0.5) - line / 2, "#e6e9e4"); + return; + } + // Divided highway: two yellow lines down the middle, and a lane divider in + // each carriageway. + context.fillStyle = "#e0bb52"; + context.fillRect(across(0.5) - line * 2, 0, line, height); + context.fillRect(across(0.5) + line, 0, line, height); + const laneInner = kind === "bridge" ? 0.2 : verge + 0.1; + dash(across(laneInner + (0.5 - laneInner) * 0.5), "#e6e9e4"); + dash(across(1 - laneInner - (0.5 - laneInner) * 0.5), "#e6e9e4"); +} + +/** + * The material a road ribbon or a bridge deck wears. + * + * Falls back to a flat colour where there is no DOM, which is every test in this + * repo: `check-no-binaries.mjs` means every texture on the board is drawn at + * runtime, and a module that can only build its materials in a browser cannot be + * unit-tested at all. + */ +function roadSurfaceMaterial( + metresPerUnit: number, + kind: "freeway" | "street" | "bridge", +): THREE.Material { + const flat = kind === "street" ? 0x6f6a5c : 0x4a4e50; + if (typeof document === "undefined") { + return new THREE.MeshLambertMaterial({ color: flat, side: THREE.DoubleSide }); + } + const canvas = document.createElement("canvas"); + canvas.width = 96; + canvas.height = 192; + const context = canvas.getContext("2d"); + if (!context) return new THREE.MeshLambertMaterial({ color: flat, side: THREE.DoubleSide }); + paintRoadSurface(context, canvas.width, canvas.height, kind); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.RepeatWrapping; + // v arrives in scene units; the cycle is authored in metres so that a dash is + // the same length on a 94 m board and a 391 m one. + texture.repeat.set(1, metresPerUnit / ROAD_CYCLE_M); + texture.anisotropy = 8; + texture.needsUpdate = true; + const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide }); + material.name = `road:${kind}`; + // `Material.dispose()` does not free the material's textures, and the scene's + // teardown sweep only reaches materials — so a board switch would orphan one + // canvas per road class on the GL context, which is the exact arithmetic + // `scene.ts` records for the renderer itself. Three's materials are event + // dispatchers and emit `dispose`, so the texture can simply follow its owner. + material.addEventListener("dispose", () => texture.dispose()); + return material; +} + function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material { if (typeof document === "undefined") { return new THREE.MeshBasicMaterial({ color: identity === "interstate" ? 0x2d5b8c : 0xe8edf0 }); @@ -356,7 +538,11 @@ function makeShieldMaterial(identity: "us-highway" | "interstate", shield: strin const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.needsUpdate = true; - return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide }); + const material = new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide }); + // As above: the shield canvas follows the material it belongs to, or every + // board switch leaves one per route on the context. + material.addEventListener("dispose", () => texture.dispose()); + return material; } /** @@ -606,111 +792,81 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro return group; } +/** + * Every road on a city board, as two draw calls. + * + * This used to be a flat ribbon the width of the carriageway plus, on a freeway, + * a second ribbon of warm paint down the middle — three meshes, and a corridor + * that read as a line on a map. It is now one ribbon half again wider carrying + * the cross-section as a texture: the verge, the shoulders, the edge lines and + * the median are all paint, so the road gained a surface and *lost* a third of + * its triangles along with a draw call. See `paintRoadSurface`. + * + * The one corridor you can drive down is not built here — `createFreewayWorld` + * builds that one out of real ribbons, because at chase-camera height a texture + * is a texture and an embankment has to have a normal. + */ export function createRoads(world: World): THREE.Group { const group = new THREE.Group(); group.name = "roads"; const batch = new Batch(); + const surfaces = { + freeway: roadSurfaceMaterial(world.metresPerUnit, "freeway"), + street: roadSurfaceMaterial(world.metresPerUnit, "street"), + }; for (const road of world.city.roads) { - const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578; const path = drapePath(world, road.path); - batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color)); - if (road.kind === "freeway") { - // One warm median stroke is enough at corridor scale to read as divided - // highway without spending a textured asset or a draw call per lane. - batch.add( - "road:median-stroke", - roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012), - batch.material("deck", 0xd7c27c), - ); - } + batch.add( + `road:${road.kind}`, + roadRibbonGeometry(path, road.width * ROAD_WIDEN[road.kind]), + surfaces[road.kind], + ); } batch.flush(group); return group; } /** - * A suspension bridge: deck, towers, and a main cable sagging between them. + * One bridge, as the kit in `bridges.ts` builds it: a deck box, tapered towers + * with portal bracing, main cables in parabola over the tower tops, hangers, and + * piers under whatever is not suspended from anything. * - * The cable is the detail worth the code. Two orange towers with a straight - * line between them read as a trestle; the catenary is what makes the shape at - * the mouth of the bay unmistakably the Golden Gate. + * Two meshes come out, not one. The structure is painted the bridge's own colour + * — International Orange, or the Bay Bridge's grey — and the roadway on top of + * it is the same asphalt surface every road on the board wears, because a bridge + * deck is a road and painting it orange was the single thing most responsible + * for the Golden Gate reading as a red line rather than as a crossing. + * + * The material cache is deliberately per call and not module-level: + * `createScene().dispose()` walks the scene disposing every material it finds, + * so a cache that outlived one build would hand the next board a disposed + * material and render it black. */ -export function createBridge(world: World, bridge: Bridge): THREE.Group { +export function createBridge(world: World, bridge: Bridge, roadway?: THREE.Material): THREE.Group { const group = new THREE.Group(); group.name = bridge.name; - - const deckY = world.metres(bridge.deckHeight); - const towerY = world.metres(bridge.towerHeight); - - /** - * One material for the whole bridge, and one mesh out of it. - * - * This used to read `const material = () => new THREE.MeshLambertMaterial(…)` - * and be called once per part, so the Golden Gate arrived as about - * thirty-four meshes with thirty-four identical materials — thirty-four draw - * calls the sorter had to keep apart, for one orange object. Everything a - * bridge is made of is painted the same colour, so everything a bridge is made - * of belongs in one bucket. - */ const batch = new Batch(); - const paint = batch.material("solid", bridge.color); - const part = (geometry: THREE.BufferGeometry) => - batch.add(bridge.name, geometry, paint, { cast: true }); - - const deckPoints = bridge.path.map(([lat, lng]) => { - const [x, z] = world.project(lat, lng); - return new THREE.Vector3(x, deckY, z); - }); - - part(tubeGeometry(deckPoints, 0.5)); - - const towerTops: THREE.Vector3[] = []; - for (const [lat, lng] of bridge.towers) { - const [x, z] = world.project(lat, lng); - part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z)); - - // Cross-braces, which is most of what you see of a tower at distance. - for (const frac of [0.55, 0.82]) { - part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z)); - } - towerTops.push(new THREE.Vector3(x, towerY, z)); - } - - const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]]; - for (let i = 0; i < anchors.length - 1; i++) { - const a = anchors[i]; - const b = anchors[i + 1]; - if (!a || !b) continue; - const isMainSpan = i > 0 && i < anchors.length - 2; - const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42); - - const pts: THREE.Vector3[] = []; - for (let s = 0; s <= 18; s++) { - const t = s / 18; - const p = a.clone().lerp(b, t); - p.y -= Math.sin(t * Math.PI) * sag; - pts.push(p); - } - part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false)); - - // Vertical hangers down to the deck. - for (let s = 2; s < 18; s += 2) { - const t = s / 18; - const p = a.clone().lerp(b, t); - const top = p.y - Math.sin(t * Math.PI) * sag; - if (top <= deckY + 0.2) continue; - const h = top - deckY; - part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z)); - } - } - + buildBridge(world, bridge, batch, roadway ?? roadSurfaceMaterial(world.metresPerUnit, "bridge")); batch.flush(group); return group; } +/** + * Every crossing on a board, in one batch. + * + * One `Batch` across all of them rather than one each, which is what makes the + * Bay Area's five crossings six draw calls instead of ten. Buckets are keyed on + * material *and* name, so this loses nothing: each bridge's structure has both + * its own colour and its own name and stays its own mesh — the scene graph still + * says which one is the Bay Bridge — while five decks of identical asphalt, + * sharing a material and a bucket name, merge into a single roadway. + */ export function createBridges(world: World): THREE.Group { const group = new THREE.Group(); group.name = "bridges"; - for (const b of world.city.bridges) group.add(createBridge(world, b)); + const batch = new Batch(); + const roadway = roadSurfaceMaterial(world.metresPerUnit, "bridge"); + for (const bridge of world.city.bridges) buildBridge(world, bridge, batch, roadway); + batch.flush(group); return group; } diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index c45bbcd..adf52d5 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -163,6 +163,235 @@ export function createShorePlates(world: World): THREE.Mesh { return mesh; } +/** + * Patch sizes the visible surface is allowed to collapse to, largest first, in + * lattice cells per side. + * + * Powers of two and aligned to their own multiple, which is what makes the + * bookkeeping in `lodPatches` a single lookup: a level-2 patch is either wholly + * inside a level-4 patch or wholly outside one, so a patch is rejected by + * testing its first cell rather than all of them. + * + * Stopping at 8 is a measurement, not a limit of the method. Each level costs a + * pass over the lattice, and the levels pay less as they get coarser: against + * the cell-by-cell surface, going to 4 takes the Bay Area from 581,008 + * triangles to 171,124 and going to 8 takes it to 157,378, while a fifth level + * would be scanning for flat ground that levels 4 and 8 have already claimed. + * The whole pass is *cheaper* than the surface it replaces, because the + * vertices it stops emitting cost more than the flatness test costs to run: + * `createTerrain` on the Bay Area went from 260 ms to 159 ms. + */ +const LOD_LEVELS = [8, 4, 2] as const; + +/** + * The same, for the shadow caster appended to the visible surface. + * + * It ends one level coarser and begins one level coarser, because the caster + * already starts at `SHADOW_CASTER_STRIDE` rather than at a single cell — the + * levels here are the *merges above* that stride, not a second decimation of + * the same ground. A 16 was measured and left out: it takes the Bay Area's + * caster from 53,806 triangles to 51,238 and California's from 17,632 to + * 17,602, which is not worth a fourth pass over the lattice. + */ +const LOD_CASTER_LEVELS = [8, 4] as const; + +/** + * How far the collapsed surface may sit from the one it replaces, in **scene + * units** of height. + * + * Scene units rather than metres deliberately: the boards disagree about metres + * by a factor of twenty — one unit of height is 148 m on California, 65 m in + * Southern California and 26 m on the Bay Area — and what has to stay invisible + * is a number of *pixels*, which is a scene-unit quantity. The three boards are + * 1003, 308 and 284 units across and are all looked at from a standoff that + * puts the board across most of a 1440-pixel viewport, so a unit is roughly + * 1.4, 4.5 and 4.9 pixels. 0.1 units is therefore a seventh of a pixel on the + * board with the loosest scale and half a pixel on the tightest. + * + * It is deliberately not tuned per board. A self-hoster's pack gets the same + * guarantee without having to declare anything, for the same reason + * `NEIGHBOURHOOD_LOT_METRES` in `blocks.ts` is a measurement of the ground + * rather than a list of city ids. + */ +export const LOD_HEIGHT_TOLERANCE = 0.1; +// ^ exported, because it is a floor under anything laid flat on the +// ground. `airports.ts` derives its field lift from it: a plate laid closer to +// the surface than this can be pierced by the collapsed quad that replaced the +// lattice under it, which showed as a wedge of bare ground through the middle +// of Van Nuys. Two constants that must not drift apart are one constant. + +/** + * How far the collapsed surface's colour may sit from the one it replaces, as a + * distance in linear RGB. + * + * Height is not enough on its own and the coast is where that shows. + * `groundColor` ramps `sand` into `flats` over the **first three metres** of + * elevation — a swing of 0.15 in red across a band the coastal falloff makes + * tens of kilometres wide — so a height tolerance loose enough to be free + * everywhere else is loose enough to walk the beach inland. Bounding the colour + * as well keeps the shoreline's pale rim exactly where it was and leaves the + * ramp free to be as steep as a pack likes. + * + * 0.012 is three parts in 255, which is under one step of an 8-bit framebuffer + * once the tone map has been through it. + */ +const LOD_COLOUR_TOLERANCE = 0.012; + +/** + * Which lattice cells of the visible surface are drawn, and at what size. + * + * Returns `[i, j, s]` triples: the patch rooted at lattice corner `(i, j)`, + * `s` cells on a side, drawn as one quad. + * + * **Why this exists.** The lattice is sized for the roughest ground on the + * board, and most of a board is not that. California's Central Valley is four + * hundred kilometres of ground that never leaves a hundred-metre band, and it + * was being spent at the same triangle density as the Sierra crest. Southern + * California's terrain measured 213,968 triangles and the Bay Area's 581,008 + * for exactly that reason: the floor of the LA basin and the floor of the bay's + * south valley are planes, and they were being tessellated like mountains. + * + * **Why it cannot be seen.** A patch is only collapsed when every lattice point + * inside it lies within `LOD_HEIGHT_TOLERANCE` of the quad that would replace + * it *and* within `LOD_COLOUR_TOLERANCE` of the colour that quad interpolates — + * both measured against the very diagonal `createTerrain` draws, so the test + * and the mesh agree. Flat ground collapses; a ridge cannot, because a ridge is + * exactly the case the bilinear surface gets wrong. + * + * **Why the coastline is untouched.** A patch is also only collapsed when every + * one of its lattice points is on land and all of them agree about `park`. Any + * patch straddling either boundary falls back to `base` cells, which are + * emitted by the same four-corner test the surface used before this existed. So + * the *set of ground covered* is identical to the cell-by-cell version, down to + * the last stair-step, and only the interior of a flat run is redrawn. + * + * The seam between a collapsed patch and its finer neighbour is a T-junction, + * and it is bounded by the same tolerance: the two surfaces share the patch's + * corners, cover the same footprint in x/z — the projection is linear in + * latitude and longitude, so a lattice rectangle stays a rectangle — and differ + * along the shared edge by at most `LOD_HEIGHT_TOLERANCE`. There is no hole, + * only a sliver thinner than a pixel. + */ +function lodPatches( + world: World, + pal: ScenePalette, + levels: readonly number[], + base: number, + matchColour: boolean, +): Int32Array { + const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice(); + const w = lngSteps + 1; + const tolerance = world.unitsToMetres(LOD_HEIGHT_TOLERANCE); + const taken = new Uint8Array(latSteps * lngSteps); + const out: number[] = []; + + // Held rather than allocated: `fits` runs a few million times on the Bay + // Area, and `groundColor` wants somewhere to put its answer. + const c00 = new THREE.Color(); + const c01 = new THREE.Color(); + const c10 = new THREE.Color(); + const c11 = new THREE.Color(); + const here = new THREE.Color(); + + const fits = (i0: number, j0: number, s: number): boolean => { + const parkAt = park[i0 * w + j0]; + for (let i = i0; i <= i0 + s; i++) { + const row = i * w; + for (let j = j0; j <= j0 + s; j++) { + const k = row + j; + if (!land[k]) return false; + // Park membership is a *colour* boundary and nothing else, so the depth + // pass has no opinion about it. + if (matchColour && park[k] !== parkAt) return false; + } + } + + const inPark = parkAt === 1; + const h00 = height[i0 * w + j0] as number; + const h01 = height[i0 * w + j0 + s] as number; + const h10 = height[(i0 + s) * w + j0] as number; + const h11 = height[(i0 + s) * w + j0 + s] as number; + if (matchColour) { + groundColor(pal, c00, inPark, h00); + groundColor(pal, c01, inPark, h01); + groundColor(pal, c10, inPark, h10); + groundColor(pal, c11, inPark, h11); + } + + const lat0 = lats[i0] as number; + const lat1 = lats[i0 + s] as number; + const lng0 = lngs[j0] as number; + const lng1 = lngs[j0 + s] as number; + + for (let i = i0; i <= i0 + s; i++) { + // The axes are not uniformly spaced — `buildAxis` runs a focus region + // fine and the rest coarse — so the interpolant comes off the coordinate + // rather than off the index. + const u = ((lats[i] as number) - lat0) / (lat1 - lat0); + const row = i * w; + for (let j = j0; j <= j0 + s; j++) { + const v = ((lngs[j] as number) - lng0) / (lng1 - lng0); + // Which of the patch's two triangles this point lands in. The diagonal + // joins (i0+s, j0) to (i0, j0+s), so u + v = 1 is the seam. + const near = u + v <= 1; + const a = near ? 1 - u - v : 1 - u; + const b = near ? u : 1 - v; + const cc = 1 - a - b; + const ha = near ? h00 : h01; + const hc = near ? h01 : h11; + const flat = a * ha + b * h10 + cc * hc; + const k = row + j; + const real = height[k] as number; + if (Math.abs(flat - real) > tolerance) return false; + if (!matchColour) continue; + const ca = near ? c00 : c01; + const ccol = near ? c01 : c11; + groundColor(pal, here, inPark, real); + const dr = a * ca.r + b * c10.r + cc * ccol.r - here.r; + const dg = a * ca.g + b * c10.g + cc * ccol.g - here.g; + const db = a * ca.b + b * c10.b + cc * ccol.b - here.b; + if (dr * dr + dg * dg + db * db > LOD_COLOUR_TOLERANCE * LOD_COLOUR_TOLERANCE) { + return false; + } + } + } + return true; + }; + + for (const s of levels) { + for (let i = 0; i + s <= latSteps; i += s) { + for (let j = 0; j + s <= lngSteps; j += s) { + if (taken[i * lngSteps + j]) continue; // inside a coarser patch already + if (!fits(i, j, s)) continue; + for (let a = i; a < i + s; a++) { + for (let b = j; b < j + s; b++) taken[a * lngSteps + b] = 1; + } + out.push(i, j, s); + } + } + } + + // Everything a patch did not claim, at `base`, by the original four-corner + // land test — which is what keeps the covered ground identical to the version + // before any of this existed. + for (let i = 0; i + base <= latSteps; i += base) { + for (let j = 0; j + base <= lngSteps; j += base) { + if (taken[i * lngSteps + j]) continue; + const a = i * w + j; + if ( + !land[a] || + !land[a + base] || + !land[a + base * w] || + !land[a + base * w + base] + ) { + continue; + } + out.push(i, j, base); + } + } + return Int32Array.from(out); +} + /** * The displaced ground. Indexed, and holding only the cells that are fully on * land — a partial cell would poke a stair-step out over the water that the @@ -170,7 +399,9 @@ export function createShorePlates(world: World): THREE.Mesh { */ export function createTerrain(world: World): THREE.Mesh { const pal = paletteFor(world); - const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice(); + // Which cells are drawn, and how big, is `lodPatches`' answer; this function + // only turns a corner into a vertex. + const { latSteps, lngSteps, lats, lngs, height, park } = world.lattice(); const positions: number[] = []; const colors: number[] = []; @@ -197,16 +428,15 @@ export function createTerrain(world: World): THREE.Mesh { return id; }; - for (let i = 0; i < latSteps; i++) { - for (let j = 0; j < lngSteps; j++) { - const a = i * (lngSteps + 1) + j; - const b = a + 1; - const c = a + (lngSteps + 1); - const d = c + 1; - if (!land[a] || !land[b] || !land[c] || !land[d]) continue; - indices.push(vertex(i, j), vertex(i + 1, j), vertex(i, j + 1)); - indices.push(vertex(i, j + 1), vertex(i + 1, j), vertex(i + 1, j + 1)); - } + const patches = lodPatches(world, pal, LOD_LEVELS, 1, true); + for (let p = 0; p < patches.length; p += 3) { + const i = patches[p] as number; + const j = patches[p + 1] as number; + const s = patches[p + 2] as number; + // The diagonal runs from (i+s, j) to (i, j+s). `lodPatches` splits its + // error test along the same one, so what it measured is what is drawn. + indices.push(vertex(i, j), vertex(i + s, j), vertex(i, j + s)); + indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s)); } const geo = new THREE.BufferGeometry(); @@ -242,24 +472,29 @@ export function createTerrain(world: World): THREE.Mesh { * it is also the cheapest: no second draw call, no second vertex buffer. */ const seen = indices.length; - for (let i = 0; i + SHADOW_CASTER_STRIDE <= latSteps; i += SHADOW_CASTER_STRIDE) { - for (let j = 0; j + SHADOW_CASTER_STRIDE <= lngSteps; j += SHADOW_CASTER_STRIDE) { - const s = SHADOW_CASTER_STRIDE; - const a = i * (lngSteps + 1) + j; - // All four corners on land, the same test the visible surface uses. A - // coarse cell that straddles the coast sits on the world's falloff at - // y≈0 and would cast nothing anyway. - if ( - !land[a] || - !land[a + s] || - !land[a + s * (lngSteps + 1)] || - !land[a + s * (lngSteps + 1) + s] - ) { - continue; - } - indices.push(vertex(i, j), vertex(i + s, j), vertex(i, j + s)); - indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s)); - } + /* + * The caster is `lodPatches` again, one level coarser at both ends. + * + * `SHADOW_CASTER_STRIDE` is its *floor* rather than its spacing: rugged + * ground still writes a facet every two lattice cells, which is the + * resolution the round before this one measured against full resolution and + * shipped. What changes is the flat ground, which used to pay the same + * stride and cannot record a shadow edge at any resolution — the Central + * Valley's depth is a plane, and a plane is two triangles whether it is + * drawn as two or as eight thousand. + * + * It runs with `matchColour` off. A depth pass reads no colour, so bounding + * one would only stop the caster merging across a park boundary that the + * shadow map cannot see. The height tolerance still applies, and it is the + * one that decides whether a shadow lands where it did. + */ + const casterPatches = lodPatches(world, pal, LOD_CASTER_LEVELS, SHADOW_CASTER_STRIDE, false); + for (let p = 0; p < casterPatches.length; p += 3) { + const i = casterPatches[p] as number; + const j = casterPatches[p + 1] as number; + const s = casterPatches[p + 2] as number; + indices.push(vertex(i, j), vertex(i + s, j), vertex(i, j + s)); + indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s)); } const cast = indices.length - seen; // `vertex()` may have emitted a few lattice corners the visible surface never @@ -340,6 +575,13 @@ export function createTerrain(world: World): THREE.Mesh { * If the board's triangle count ever comes back down, this is the first number * worth spending it on — a stride of 1 puts a shadow edge on every ridge the * map can resolve and costs nothing else. + * + * Since `lodPatches` arrived this is a **floor** rather than a spacing: rugged + * ground still writes a facet every two cells, and flat ground merges above it. + * The numbers above are what a uniform stride cost, and they are why the floor + * is 2 rather than 1; what the merge changed is that California's caster came + * down from 20,716 triangles to 17,632 and the Bay Area's from 143,704 to + * 53,806 without touching the resolution anywhere a shadow edge can exist. */ const SHADOW_CASTER_STRIDE = 2; diff --git a/src/engine/types.ts b/src/engine/types.ts index 001b20b..fd26fe7 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -74,7 +74,12 @@ export interface Bridge { towers: LatLng[]; towerHeight: number; deckHeight: number; - /** Suspension sag as a fraction of tower height. */ + /** + * How deeply the main cable sags, 0..1. Scaled onto the drop from the + * saddle to the deck, not read literally as a fraction of tower height — + * see `bridges.ts`. Read literally it left the Bay Bridge's cable a third of + * a tower above its own deck at midspan, which is a trestle with a curve in it. + */ sag: number; color: number; } @@ -96,6 +101,118 @@ export interface Road { * because a city's chapters are a numbered tour with a sentence each and an * office's views are usually just "Reception" and "The desk bay". */ +// ---- Airports ------------------------------------------------------------- +// +// Plain data, JSON-serialisable, no functions and no THREE types — a city pack +// is posted to the terrain worker as a structured clone, so an airport that +// acquired a method would stop the whole pack being sendable. Same rule as +// `City` itself; see ARCHITECTURE.md §5.1. +// +// `engine/airports.ts` is the renderer for these and re-exports every name, so +// a pack may import them from either place. + +/** + * One runway, given as its centre, its bearing and its size. + * + * **Centre and heading rather than two thresholds**, which is the opposite of + * how `Bridge` and `Road` are authored, and the reason is that a runway's + * heading is the fact worth being exact about. Two hand-typed thresholds encode + * a bearing implicitly and to whatever precision the fifth decimal place of a + * latitude happens to give you; a stated bearing can be checked against the + * published one by reading it. `runwayThresholds()` converts, for the callers + * that want the ends. + * + * `heading` is **true**, not magnetic. The two-digit designator painted on a + * runway is magnetic and rounded to ten degrees, so it is not the number to + * build geometry from: San Francisco's declination is about 13.5° east, which + * means "28R" is a runway pointing 298.6° true, and thirteen degrees is the + * difference between SFO's pattern and a plausible-looking airfield. + */ +export interface Runway { + /** Both designators, as they are said: `"10L/28R"`. */ + id: string; + /** Midpoint of the centreline. */ + lat: number; + lng: number; + /** TRUE bearing of the low-numbered end's direction, degrees clockwise from north. */ + heading: number; + /** Threshold to threshold, metres. */ + length: number; + /** Metres. */ + width: number; + /** The numbers painted on the two thresholds, low end first. */ + designators?: [string, string]; +} + +/** A taxiway centreline. Drawn as a flat strip; it does not follow terrain. */ +export interface Taxiway { + id?: string; + path: LatLng[]; + /** Metres. Defaults to 25, which is a Group V taxiway. */ + width?: number; +} + +/** A paved area: a terminal apron, a cargo ramp, a maintenance pad. */ +export interface Apron { + id?: string; + polygon: LatLng[]; +} + +/** + * A terminal mass — a building, not a plan. + * + * `gates` is what turns the box into an airport: the kit lines that many parked + * airliners nose-in along one long face. Aeroplanes on stand are the cheapest + * thing in this file and the most legible; a terminal without them is a + * warehouse. + */ +export interface Terminal { + id?: string; + lat: number; + lng: number; + /** Along `heading`, metres. */ + length: number; + /** Across `heading`, metres. */ + width: number; + /** Metres. */ + height: number; + /** TRUE bearing of the long axis, degrees clockwise from north. */ + heading: number; + /** Aircraft on stand, and which side of the long axis they park on. */ + gates?: { count: number; side: 1 | -1 }; +} + +/** The control tower. One per field; it is a landmark, not a category. */ +export interface Tower { + lat: number; + lng: number; + /** To the top of the cab, metres. */ + height: number; +} + +export interface Airport { + /** ICAO where there is one — `"KSFO"`. Only ever an id. */ + id: string; + name: string; + /** The airport reference point. */ + lat: number; + lng: number; + /** Field elevation, metres above sea level. Data, not a placement; see above. */ + elevation: number; + /** + * The graded outline — the fill, the fence line, whatever the airport's edge + * actually is. Optional: an airport with no outline is four bars on the bare + * terrain, which is right for a strip in a desert and wrong for SFO, whose + * pale rectangle of bay fill is half of what you recognise. + */ + field?: LatLng[]; + runways: Runway[]; + taxiways?: Taxiway[]; + aprons?: Apron[]; + terminals?: Terminal[]; + tower?: Tower; +} + export interface View { id: string; label: string; @@ -178,6 +295,13 @@ export interface City { roads: Road[]; chapters: Chapter[]; + /** + * Airfields, drawn by `engine/airports.ts` as graded plates with real + * runway headings. Optional because a pack that has none should not have to + * say so, and because every pack predates it. + */ + airports?: Airport[]; + /** Palette overrides; every field is optional. */ palette?: Partial; } @@ -331,6 +455,38 @@ export interface Aircraft { /** Degrees clockwise from true north. */ heading: number; callsign?: string; + /** + * Metres per second over the ground, when the source knew. + * + * The three fields below are what let `createFlightLayer` **dead-reckon** + * rather than merely interpolate, and the difference between the two is the + * difference between a sky and a photograph of one. A layer given positions + * alone can only replay the leg between the last two of them: it arrives at + * the newest known point and then sits motionless for the five to fifteen + * seconds until the next snapshot, which is exactly what a live ADS-B board + * looked like here for as long as this type had four numbers in it. + * + * Optional, and the layer's behaviour when they are absent is the old + * behaviour unchanged — interpolate between observations, hold still at the + * end. That is deliberate: a source that does not know how fast something is + * going must not have a speed guessed for it, and a stationary ground vehicle + * is a thing this feed genuinely reports. + */ + groundSpeed?: number; + /** Metres per second, positive climbing, when the source knew. */ + verticalRate?: number; + /** + * How old the position already was when the source handed it over, in + * seconds. + * + * A fix crosses a receiver, a cache and a browser hold before it is drawn, so + * the coordinates describe an instant several seconds in the past. A + * dead-reckoner that starts from them as though they were current draws the + * whole sky that far behind; one that is told the age advances the position to + * now first. Absent means "as of when you were told", which is the safe + * reading and the one every source that cannot answer honestly gets. + */ + ageSeconds?: number; } /** diff --git a/src/main.ts b/src/main.ts index ef3e632..751618d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2184,6 +2184,14 @@ function showAircraftDetail(aircraft: Aircraft | null): void { lng: resolved.lng, altitude: resolved.altitudeM, heading: resolved.headingDeg, + // The four the feed carries and the simulator does not. All four are `null` + // for a synthetic track and for a server one version behind, and the card + // drops the row rather than printing "unknown" — so there is no degraded + // state to handle here, only a shorter card. + type: resolved.type, + registration: resolved.registration, + groundSpeedKt: resolved.groundSpeedKt, + verticalRateFpm: resolved.verticalRateFpm, synthetic: !resolved.observed, attribution: resolved.attribution.length > 0 ? resolved.attribution.join(" · ") : null, }; diff --git a/src/server/wire.ts b/src/server/wire.ts index ddc84fa..3a4368c 100644 --- a/src/server/wire.ts +++ b/src/server/wire.ts @@ -180,6 +180,75 @@ export interface WireAircraft { /** Degrees clockwise from true north. */ heading: number; callsign?: string; + /** + * Metres per second over the ground, when the feed reported a real one. + * + * SI, like every other quantity on this wire — the feed's own `gs` is knots + * and the conversion happens once, in the adapter, for the same reason + * `altitude` is not feet. See the note there. + * + * **This is the field that makes the sky move.** A client that is handed only + * positions can interpolate between the last two of them and nothing more, so + * every aircraft replays a leg it has already flown, arrives at the newest + * known point and then sits perfectly still until the next snapshot lands + * five to fifteen seconds later. Carrying the velocity lets the client + * dead-reckon — advance each track along its own track angle at its own + * speed, every frame — and an aeroplane that is flying stops being a + * screenshot of one. + * + * Absent, never zeroed, and absent covers three different things that must + * all behave the same way downstream: a feed that did not report it, a + * ground vehicle or parked aircraft reporting `gs: 0.0`, and a + * position-only record with a null `track` — dead-reckoning any of those + * along an invented heading would be inventing motion, which is worse than + * showing none. `engine/flights.ts` holds a track still when this is missing. + */ + groundSpeed?: number; + /** + * Metres per second, positive climbing, when the feed reported a rate. + * + * Barometric where the feed has it and geometric otherwise; the two disagree + * by a few percent in real air and by nothing this renders. Absent rather + * than zero for the same reason as `groundSpeed`: level flight and no + * information are different facts, and only one of them may be drawn. + */ + verticalRate?: number; + /** + * How old the position fix already was when this snapshot was taken, in + * seconds — the feed's `seen_pos`. + * + * Carried because the client is the thing that has to place the aircraft and + * it cannot do that without knowing what instant the coordinates describe. + * Between the receiver's last message, this box's cache TTL and the browser's + * own hold, a fix reaches a viewer several seconds stale, and a dead-reckoner + * handed a stale position as if it were current draws the whole sky lagging + * by that much. `observedAt` on the body says when the *snapshot* was taken; + * this says how far behind that the row already was. + * + * Small — a fraction of a second on a healthy feed — and worth carrying + * anyway, because it is the difference between a client that can reason about + * time and one that assumes. + */ + ageSeconds?: number; + /** + * The tail number the feed published for this airframe, e.g. `"N68834"`. + * + * ODbL data off the same record as the position, and publishable on exactly + * the same terms — it is the enrichment a commercial feed was once wanted + * for, obtained legitimately. Absent, never invented: a registration is what + * somebody types into a registry lookup, and a wrong one names another + * aircraft altogether. + */ + registration?: string; + /** + * ICAO aircraft type designator, e.g. `"B739"`, `"A321"`. + * + * A four-character code and not a marketing name: the feed publishes the + * designator, and expanding it to "Boeing 737-900" would mean shipping a + * table this repo would then have to keep true. The card shows the code + * beside the registration, which is how a spotter reads it anyway. + */ + type?: string; /** * The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a * real one. diff --git a/src/test/data/aircraftGlassBudget.test.ts b/src/test/data/aircraftGlassBudget.test.ts new file mode 100644 index 0000000..2d68bc6 --- /dev/null +++ b/src/test/data/aircraftGlassBudget.test.ts @@ -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`, + ); + } +}); diff --git a/src/test/data/deadReckoning.test.ts b/src/test/data/deadReckoning.test.ts new file mode 100644 index 0000000..398aa35 --- /dev/null +++ b/src/test/data/deadReckoning.test.ts @@ -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[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 { + 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); + }); +}); diff --git a/src/test/integration/airportsAndCard.test.ts b/src/test/integration/airportsAndCard.test.ts new file mode 100644 index 0000000..6a2d9e9 --- /dev/null +++ b/src/test/integration/airportsAndCard.test.ts @@ -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`); + } + } + } +}); diff --git a/src/test/packs/sfoAirport.test.ts b/src/test/packs/sfoAirport.test.ts new file mode 100644 index 0000000..ae7678e --- /dev/null +++ b/src/test/packs/sfoAirport.test.ts @@ -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`, + ); + } + }); +}); diff --git a/src/test/packs/socalAirports.test.ts b/src/test/packs/socalAirports.test.ts new file mode 100644 index 0000000..606d571 --- /dev/null +++ b/src/test/packs/socalAirports.test.ts @@ -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"); + }); +}); diff --git a/src/test/render/bridgeKit.test.ts b/src/test/render/bridgeKit.test.ts new file mode 100644 index 0000000..7b80451 --- /dev/null +++ b/src/test/render/bridgeKit.test.ts @@ -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(); + 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 Mateo–Hayward: eleven kilometres of trestle, one high span. */ +const SAN_MATEO: Bridge = { + name: "San Mateo–Hayward 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`); + } +}); diff --git a/src/test/render/glyphScale.test.ts b/src/test/render/glyphScale.test.ts new file mode 100644 index 0000000..852fb08 --- /dev/null +++ b/src/test/render/glyphScale.test.ts @@ -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); + } + }); +}); diff --git a/src/test/render/structuresBatching.test.ts b/src/test/render/structuresBatching.test.ts index c6e1752..6a8f45a 100644 --- a/src/test/render/structuresBatching.test.ts +++ b/src/test/render/structuresBatching.test.ts @@ -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): 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 { // ---- 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"); }); diff --git a/src/test/render/terrainLod.test.ts b/src/test/render/terrainLod.test.ts new file mode 100644 index 0000000..ddf6d2e --- /dev/null +++ b/src/test/render/terrainLod.test.ts @@ -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 = { + 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 { + 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 = { sand: 0xffffff, flats: 0x000000 }; + const plain: Partial = { 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}`, + ); +}); diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 6a5dbe7..cc0f877 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -203,6 +203,19 @@ export interface AircraftDetailInput { altitude: number; /** Degrees clockwise from true north. */ heading: number; + /** + * ICAO type designator — `"B739"` — and tail number — `"N68834"`, or absent. + * + * Optional, and every caller may leave all four of these out: the simulator + * knows none of them, and neither does a server one version behind. The rows + * simply do not appear, which is why there is no "unknown" string here. + */ + type?: string | null; + registration?: string | null; + /** Ground speed in knots. */ + groundSpeedKt?: number | null; + /** Climb rate in feet per minute, positive up. */ + verticalRateFpm?: number | null; /** The track came out of the bundled simulator, not out of a receiver. */ synthetic?: boolean; /** The feed's own credit line, shown on the card that displays its data. */ @@ -225,6 +238,25 @@ export interface AircraftDetailView { attribution: string | null; } +/** + * Below this, in feet per minute, an aircraft is flying level. + * + * A cruising airliner's reported vertical rate wanders either side of zero by a + * few tens of feet a minute — pressure noise, not a manoeuvre — and drawing + * "+64 ft/min" for it says *climbing* about an aeroplane that is not. 100 is + * comfortably inside that noise and comfortably below anything deliberate. + */ +const LEVEL_FPM = 100; + +/** `-1240` → `"−1,240 ft/min"`; anything inside the noise band → `"Level"`. */ +function formatVerticalRate(fpm: number): string { + if (Math.abs(fpm) < LEVEL_FPM) return "Level"; + // U+2212, not a hyphen: this sits in a tabular-numeric column next to a + // heading and an altitude, and a hyphen is half the width of the digits. + const sign = fpm > 0 ? "+" : "\u2212"; + return `${sign}${Math.round(Math.abs(fpm)).toLocaleString()} ft/min`; +} + /** * Format one aircraft for the card. * @@ -252,12 +284,49 @@ export function formatAircraftDetail(aircraft: AircraftDetailInput): AircraftDet ? `${Math.round(((aircraft.heading % 360) + 360) % 360)}° ${compassPoint(aircraft.heading)}` : "—", }, - { - label: "Position", - value: `${formatCoordinate(aircraft.lat, "lat")} · ${formatCoordinate(aircraft.lng, "lng")}`, - }, ]; + /** + * What it is, above what it is doing. + * + * The type designator and the tail number are the two things that turn a dart + * into an aeroplane — "B739 · N68834" is a specific 737 with a history, and a + * hex address is a number. Both community feeds have carried them all along, + * under the same terms as the position, so this row costs an anonymous + * visitor nothing and is the half of the card they actually read. + * + * First, and `unshift` rather than a fourth entry, because identity reads + * before state — and absent entirely rather than "unknown" when the feed said + * nothing, which is every simulated track. + */ + const airframe = [aircraft.type, aircraft.registration] + .map((value) => (value ?? "").trim()) + .filter((value) => value !== ""); + if (airframe.length > 0) rows.unshift({ label: "Aircraft", value: airframe.join(" · ") }); + + /** + * Speed and climb, which are what say *landing at SFO* rather than *over the + * Peninsula*. Knots and feet per minute because those are the units the + * numbers are read in; a signed climb because the sign is the whole message. + * + * `typeof` rather than a truthiness test: a stationary aircraft reports zero + * knots and a cruising one reports zero feet per minute, and both are facts. + */ + if (typeof aircraft.groundSpeedKt === "number" && Number.isFinite(aircraft.groundSpeedKt)) { + rows.push({ + label: "Ground speed", + value: `${Math.round(aircraft.groundSpeedKt).toLocaleString()} kt`, + }); + } + if (typeof aircraft.verticalRateFpm === "number" && Number.isFinite(aircraft.verticalRateFpm)) { + rows.push({ label: "Climb", value: formatVerticalRate(aircraft.verticalRateFpm) }); + } + + rows.push({ + label: "Position", + value: `${formatCoordinate(aircraft.lat, "lat")} · ${formatCoordinate(aircraft.lng, "lng")}`, + }); + return { title: callsign !== "" ? callsign : hex !== "" ? hex.toUpperCase() : "Unknown aircraft", subtitle: synthetic