diff --git a/index.html b/index.html
index 1e360ba..fbb352e 100644
--- a/index.html
+++ b/index.html
@@ -686,9 +686,22 @@
four fifths of which was irrelevant to whatever you were doing.
It is at most two chips now, chosen for the mode you are actually in by
- `railHints()` in `src/ui/shortcuts.ts`, with no card around them: keycaps
- over the scene, a text shadow instead of a fill, and the full reference
- one press of `?` away. */
+ `railHints()` in `src/ui/shortcuts.ts`, and the full reference is one
+ press of `?` away.
+
+ **The fill came back, and the text shadow went.** The card was dropped
+ for a text shadow over the bare scene, which was measured against the
+ wrong background: on the deployed build the rail sits in the corner of a
+ sky, and the sky at its brightest is `skyHorizon` — 0xe9d8bb on the
+ California board, a pale cream. Fifty-six per cent white on that is
+ about 1.3:1 and a shadow does not save it; what you saw was grey text
+ you had to hunt for. This is now the same glass, hairline and shadow the
+ honesty line in the opposite corner has always carried, which is the
+ part of the original argument that was right — the rail is a corner
+ caption and it should look like the other one — and the ink is at full
+ strength, which puts it past 4.5:1 against the whitest sky the
+ atmosphere can render. It is still two chips and it is still not a
+ paragraph. */
.hint {
display: flex;
flex-wrap: wrap;
@@ -704,8 +717,14 @@
/* The rail is a corner, not a column. Without a ceiling it grew to meet
the mode pill in the middle of the screen. */
max-width: min(30rem, 42vw);
- color: var(--ink-2);
- text-shadow: 0 1px 3px rgba(3, 6, 10, 0.8);
+ color: var(--ink);
+ padding: var(--s1) var(--s2);
+ border: 1px solid var(--hairline);
+ border-radius: var(--r-sm);
+ background: var(--glass);
+ backdrop-filter: var(--blur);
+ -webkit-backdrop-filter: var(--blur);
+ box-shadow: var(--shadow);
}
/* One chip is one line. Wrapping happens *between* chips, which the flex
container already does; a chip that wraps inside itself is the thing
@@ -716,15 +735,22 @@
gap: 5px;
white-space: nowrap;
}
- .hint__label { color: var(--ink-3); }
+ /* One step down from the keycap it labels, not three. `--ink-3` is 40%
+ white, which is a quiet grey on a panel and invisible in a corner of
+ sky; on the fill above, `--ink-2` is still clearly the subordinate half
+ of the chip and still legible on its own. */
+ .hint__label { color: var(--ink-2); }
kbd {
font: inherit;
font-size: 9px;
padding: 1px 4px;
border: 1px solid var(--hairline);
border-radius: 3px;
- background: rgba(8, 12, 17, 0.7);
- color: var(--ink-2);
+ /* Opaque, because a keycap on the rail is now a dark chip on a dark
+ chip: at 0.7 the glass behind it showed through and the two washed
+ into one another over a bright sky. */
+ background: rgb(8, 12, 17);
+ color: var(--ink);
}
.rail-buttons { display: flex; align-items: center; gap: var(--s1); }
@@ -740,7 +766,10 @@
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
- color: var(--ink-2);
+ /* Full ink. This is the one control on the screen that has to be found
+ by somebody who does not yet know what any of this is, and it spends
+ most of its life over sky. */
+ color: var(--ink);
transition: background var(--t), color var(--t);
}
@media (hover: hover) {
diff --git a/scripts/look.mjs b/scripts/look.mjs
new file mode 100644
index 0000000..8fc6233
--- /dev/null
+++ b/scripts/look.mjs
@@ -0,0 +1,107 @@
+/**
+ * Render the built app and write a PNG you can open and judge.
+ *
+ * The defects this exists for cannot be asserted. "The ocean has no specular
+ * response", "the board ends in a hard diamond edge", "the aircraft are
+ * illegible at board scale" are all true of code that typechecks, passes every
+ * test and meets every performance budget. The only instrument that finds them
+ * is a picture, so this makes taking one cheap.
+ *
+ * node scripts/look.mjs [--url ] [--phone] [--at ]
+ * [--wait ] [--click ]
+ *
+ * Writes /tmp/tera-look/.png. `--at` pins the clock, because the sun's
+ * position is computed from the real one and a shot taken at 03:00 tells you
+ * nothing about how the water reads at noon.
+ *
+ * This box has an AMD card and no monitor; the ANGLE/Vulkan flags below are what
+ * make Chrome render headless here rather than falling back to a blank canvas.
+ */
+
+import { spawn } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { chromium } from "playwright";
+
+const args = process.argv.slice(2);
+const name = args[0] ?? "look";
+const flag = (f, d) => {
+ const i = args.indexOf(f);
+ return i === -1 ? d : args[i + 1];
+};
+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",
+});
+await new Promise((r) => setTimeout(r, 4000));
+
+const phone = has("--phone");
+const browser = await chromium.launch({
+ channel: "chrome",
+ args: [
+ "--use-gl=angle",
+ "--use-angle=vulkan",
+ "--enable-unsafe-swiftshader",
+ "--ignore-gpu-blocklist",
+ ],
+});
+
+const context = await browser.newContext({
+ viewport: phone ? { width: 390, height: 844 } : { width: 1600, height: 1000 },
+ deviceScaleFactor: 2,
+ timezoneId: "America/Los_Angeles",
+ ...(phone
+ ? {
+ isMobile: true,
+ hasTouch: true,
+ userAgent:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
+ "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ }
+ : {}),
+});
+await context.clock.setFixedTime(new Date(flag("--at", "2026-08-21T20:00:00Z")));
+
+const page = await context.newPage();
+const errors = [];
+page.on("console", (m) => {
+ if (m.type() === "error") errors.push(m.text());
+});
+
+await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, {
+ waitUntil: "networkidle",
+ timeout: 60000,
+});
+await page.waitForTimeout(Number(flag("--wait", "10000")));
+
+// The first-run flow covers the scene it is teaching you about.
+try {
+ await page.getByText(/^Skip$/).first().click({ timeout: 2500 });
+ await page.waitForTimeout(1200);
+} catch {
+ /* already dismissed, or not shown */
+}
+
+const click = flag("--click", "");
+if (click !== "") {
+ try {
+ await page.getByText(new RegExp(click, "i")).first().click({ timeout: 5000 });
+ await page.waitForTimeout(8000);
+ } catch {
+ console.log(`look: could not click ${click}`);
+ }
+}
+
+const path = `${OUT}/${name}.png`;
+await page.screenshot({ path });
+console.log(`look: wrote ${path}`);
+// Two 404s on a static preview are the zero-config path working: see deploy/STATIC.md.
+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();
diff --git a/src/arena/sourceHashes.ts b/src/arena/sourceHashes.ts
index 0afaa2c..d6c2815 100644
--- a/src/arena/sourceHashes.ts
+++ b/src/arena/sourceHashes.ts
@@ -27,6 +27,6 @@ export const ARENA_SOURCE_HASHES: Readonly> =
},
"studio-ops-v1": {
environment: "sha256:18375ef89e9f890356428a7b62fc6b48b94fc019dd8ca1ac05eabede6d70e03f",
- simulator: "sha256:6884955c43d7bf6488769b1c38a94a87591042322ee01ffb5e0ef013976dff1f",
+ simulator: "sha256:b8d351b8b9d90b84e52e2601e37fa8995395c145a5d2ec9b939e0c3cb9ec5edc",
},
});
diff --git a/src/assets/palette.ts b/src/assets/palette.ts
index 0badd90..1320a48 100644
--- a/src/assets/palette.ts
+++ b/src/assets/palette.ts
@@ -64,10 +64,24 @@ import type { SurfaceRole } from "./materials.ts";
*/
export const LIGHTNESS_HEADROOM = 0.22;
+/**
+ * The entries of `ScenePalette` that every pack is guaranteed to carry.
+ *
+ * `ScenePalette` has one optional colour — `alpine`, the bare rock above a tree
+ * line, which only a board measured in hundreds of kilometres has any use for —
+ * and an interior cannot descend from a colour that might not be there. Stated
+ * as a type rather than as a runtime fallback, because the alternative is a
+ * silent black carpet in somebody's office the first time an optional colour is
+ * added to the palette.
+ */
+type RequiredPaletteKey = {
+ [K in keyof ScenePalette]-?: undefined extends ScenePalette[K] ? never : K;
+}[keyof ScenePalette];
+
/** One role's derivation: a city colour, and how far to move it. */
export interface RoleShift {
/** Which entry of the city palette this role descends from. */
- from: keyof ScenePalette;
+ from: RequiredPaletteKey;
/** Hue shift in degrees. Wraps. */
dh: number;
/** Saturation shift, absolute, in 0..1. */
diff --git a/src/cities/california.ts b/src/cities/california.ts
index 1c21ba0..92c8c45 100644
--- a/src/cities/california.ts
+++ b/src/cities/california.ts
@@ -1,14 +1,62 @@
/**
- * California at corridor scale: Los Angeles to San Francisco.
+ * California at state scale: the whole corridor, Los Angeles to San Francisco.
*
- * This is deliberately sparse. San Francisco and Southern California remain
- * the detailed boards; this one is the connective tissue between them. A
- * roughly two-kilometre height cell and a handful of range-scale hills keep the
- * state readable without pretending a 600 km drive is one city mesh.
+ * This is the **default board** — the first frame an anonymous visitor sees —
+ * and for a long while it was the weakest thing in the product: a pale sand
+ * lozenge with a few soft brown smudges where the ranges should be and no
+ * cities on it at all. Everything below is the answer to *why*, and the three
+ * answers are worth writing down because they are not obvious and none of them
+ * is a bug in the engine.
+ *
+ * ### 1. Relief is measured in metres and this board is measured in kilometres
+ *
+ * `latScale: 58` puts one scene unit at **1,919 m**, against Southern
+ * California's 391 and San Francisco's 94. Relief does not scale with the
+ * board: the Sierra is 4 km high whether you are looking at 100 km of coast or
+ * 600. So the number that decides whether a board has mountains is not the
+ * exaggeration on its own, it is **scene units of height per metre of ground**
+ * — `verticalExaggeration / metresPerUnit`:
+ *
+ * | board | m/unit | exaggeration | units per metre |
+ * | --- | --- | --- | --- |
+ * | San Francisco | 94 | 3.6 | 0.0382 |
+ * | Southern California | 391 | 3.4 | 0.0087 |
+ * | California, before | 1,919 | 2.25 | **0.0012** |
+ * | California, now | 1,919 | 13 | 0.0068 |
+ *
+ * At 0.0012 a 3,000 m range stood 1.6 units off a board 284 units tall — half
+ * of one percent, which is a stain and not a mountain. 13 puts this board
+ * within striking distance of SoCal's vertical scale, so the two read as the
+ * same landscape at two zooms rather than as two different planets.
+ *
+ * ### 2. Ground colour saturates at 150 m, so the *parks* carry the ranges
+ *
+ * `terrain.ts` ramps `flats → upland` over the first 150 m and then stops: a
+ * 4,000 m summit and a 200 m hill are painted exactly the same colour. That is
+ * the right call for a city — see the comment there about Nob Hill — and it
+ * means a statewide board cannot get its structure from altitude. It has to get
+ * it from **land use**, which is what `parks` is: the Coast Ranges, the
+ * Transverse Ranges and the Sierra forest belt are drawn as wildland and come
+ * out green, the Central Valley stays farmed gold, and the crest above the tree
+ * line and the desert east of it are left out of the parks and come out rock.
+ * Southern California already works this way — its green San Gabriels are
+ * `ANGELES_FOREST`, not a height ramp.
+ *
+ * ### 3. A lot is a unit of the picture, not of the ground
+ *
+ * `blocks.ts` holds a fixed 0.42-unit lot, which is 40 m in San Francisco, 164 m
+ * in Southern California and **806 m here**. That is not a mistake to be
+ * corrected: the three boards are 1003, 308 and 284 units across and are all
+ * looked at from a comparable standoff, so a lot that is legible on one is
+ * legible on the others. What it does mean is that San Francisco *proper* is
+ * thirteen lots wide and can never be a dense settlement on this board by
+ * itself. So the districts here are **conurbations** — the Bay Area, the LA
+ * basin and its valleys, the Central Valley towns — which is also the honest
+ * unit at 600 km. City detail still lives one board in.
*/
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
-import type { City, LatLng } from "../engine/types.ts";
+import type { City, District, Hill, LatLng } from "../engine/types.ts";
function routePath(routeId: string): LatLng[] {
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === routeId);
@@ -32,58 +80,1334 @@ export const CALIFORNIA_US_101 = routePath("la-sf-us-101");
export const CALIFORNIA_I_5 = routePath("la-sf-i-5");
/**
- * Original, hand-authored coast silhouette for this coarse board. It is a
- * visual boundary, not survey data; the eastern and northern edges close well
- * outside the route so the heightfield's coastal falloff stays off the road.
+ * Longitude's foreshortening at this board's centre latitude.
+ *
+ * `World` computes exactly this from `city.center.lat` and uses it inside
+ * `elevationAt`, so any spacing measured here has to be measured the same way
+ * or a chain running east-west comes out with its peaks 22% further apart than
+ * one running north-south. Repeated rather than imported because `cities` must
+ * not import `engine` — see ARCHITECTURE.md §2 — and kept next to the one
+ * function that needs it.
+ */
+const LNG_SQUASH = Math.cos((35.3 * Math.PI) / 180);
+
+/**
+ * A range as a chain of peaks along a line, at the spacing that makes a chain
+ * into a ridge rather than into a row of domes.
+ *
+ * The arithmetic is in the note on `HILLS` below and this is where it is
+ * applied: two equal peaks a fraction `s` of a radius apart meet at
+ * `1.35 × (1 − s²/4)²` of their own height, so `0.9` puts the saddle at 85% of
+ * the summit — a continuous crest with notches in it. Writing that spacing out
+ * by hand is what produced the board's worst-looking mistake twice: the Coast
+ * Ranges shipped once at 1.5 radii and came out as separate domes, and the
+ * desert shipped once with one bell per range and came out as bubble wrap.
+ *
+ * Three things beyond the spacing, all of them about not looking generated:
+ *
+ * - **The ends fall away.** A range front is highest somewhere in the middle
+ * and dies out at both ends; a chain of identical peaks is a wall with
+ * square ends, and at 1,919 m to the scene unit a square end is very
+ * obviously a square end. `1 − 0.42 × (2t − 1)²` is the taper.
+ * - **The line is not a line.** Each peak is nudged perpendicular to the
+ * chain by up to a fifth of a radius, so the crest wanders the way a real
+ * range front does instead of being drawn with a ruler.
+ * - **The nudge is deterministic.** It comes off a hash of the range's own
+ * name and the peak's index, so a board looks the same on every reload and
+ * two people looking at it see the same mountains. `Math.random` here would
+ * be a different state on every page load.
+ */
+function ridge(
+ name: string,
+ from: LatLng,
+ to: LatLng,
+ elevation: number,
+ radius: number,
+): Hill[] {
+ const [lat0, lng0] = from;
+ const [lat1, lng1] = to;
+ const dLat = lat1 - lat0;
+ const dLng = lng1 - lng0;
+ const length = Math.hypot(dLat, dLng * LNG_SQUASH);
+ const steps = Math.max(1, Math.round(length / (radius * 0.9)));
+
+ // Unit normal to the chain, in the same squashed metric, then unsquashed on
+ // the way back into longitude so the nudge is the distance it claims to be.
+ const nLat = length === 0 ? 0 : (-dLng * LNG_SQUASH) / length;
+ const nLng = length === 0 ? 0 : dLat / length / LNG_SQUASH;
+
+ const hills: Hill[] = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const t = steps === 0 ? 0.5 : i / steps;
+ // A 32-bit integer hash of the name and the index. Any cheap avalanche
+ // would do; this one is fnv-1a's constants because they are memorable.
+ let h = 2_166_136_261 ^ i;
+ for (let k = 0; k < name.length; k += 1) {
+ h = Math.imul(h ^ name.charCodeAt(k), 16_777_619);
+ }
+ const wander = (((h >>> 8) & 0xffff) / 0xffff - 0.5) * 0.4 * radius;
+ hills.push({
+ name: steps === 1 ? name : `${name} ${i + 1}`,
+ lat: lat0 + dLat * t + nLat * wander,
+ lng: lng0 + dLng * t + nLng * wander,
+ elevation: Math.round(elevation * (1 - 0.42 * (2 * t - 1) ** 2)),
+ radius,
+ });
+ }
+ return hills;
+}
+
+/**
+ * Original, hand-traced coast silhouette for this board. It is a drawing, not
+ * survey data — see ARCHITECTURE.md §3.2 for why every coastline in this repo
+ * has to be.
+ *
+ * Two things about the shape are deliberate.
+ *
+ * **The polygon goes in through the Golden Gate.** San Francisco Bay used to be
+ * absent altogether and the peninsula was a straight line, which cost the board
+ * the one piece of California anybody can identify from orbit. Modelling the
+ * bay as `inlandWater` would have been cheaper by one draw call and wrong: the
+ * bay is tidal salt water joined to the Pacific, and a lake sitting next to a
+ * sea it does not touch reads as exactly that. So the trace runs south down the
+ * ocean, turns east through the Gate along the Marin shore, up into San Pablo
+ * Bay, back down the East Bay, around the south bay at Fremont, north again up
+ * the peninsula and out through the Gate on San Francisco's own waterfront.
+ * Winding stays consistent — land is always on the left — which is what
+ * `ShapeGeometry` needs to triangulate the shore plate.
+ *
+ * **Three of the four sides are the state's own edges, not the board's.** The
+ * Pacific is a coast, the south is the Mexican border and the east is the
+ * Colorado River and the Nevada line. Only the top of the board is a crop, and
+ * its closure runs along 38.13–38.20 N — a sixth of a degree past
+ * `bounds.maxLat`, far enough out that `coastalFalloff` (which ramps relief to
+ * zero within `coastFalloff` of *any* polygon edge) never sees it and never
+ * flattens the Sierra against the top of the frame. The Mexican border is the
+ * one deliberate exception: it sits *two hundredths* below `bounds.minLat`, so
+ * that the state's edge and the board's crop are the same line and there is no
+ * strip of sea along the bottom of the frame. See the note there.
*/
export const CORRIDOR_LAND: LatLng[] = [
- [38.2, -123.35],
- [37.92, -122.74],
- [37.79, -122.5],
- [37.45, -122.43],
- [37.08, -122.28],
- [36.62, -121.94],
- [36.15, -121.7],
- [35.65, -121.23],
- [35.18, -120.85],
- [34.72, -120.64],
- [34.42, -120.48],
- [34.18, -119.95],
- [34.03, -118.74],
- [33.72, -118.15],
- [33.35, -117.72],
- [33.1, -117.38],
- [33.05, -117.1],
- [38.25, -117.1],
+ // North closure, just off the top of the board.
+ [38.13, -123.05],
+
+ // ---- Pacific coast, north to south -------------------------------------
+ [38.05, -122.98],
+ [38.0, -123.02], // Point Reyes
+ [37.93, -122.79],
+ [37.84, -122.55], // Marin headland, the north side of the Gate
+
+ // ---- In through the Golden Gate: Marin shore, then San Pablo Bay -------
+ //
+ // San Pablo Bay stops at 38.04, one hundredth of a degree below the top of
+ // the board, and that number is load-bearing. The first trace ran it to 38.14
+ // — off-board, which seemed harmless — and the closure edge along 38.13 cut
+ // straight across it. A self-intersecting contour is not a rendering error
+ // anyone sees as an error: `isLand` still answered correctly, the terrain
+ // grid still left a hole, and `ShapeGeometry` quietly triangulated the slit
+ // shut, so the whole bay came out paved in the shore-plate colour with the
+ // Bay Bridge approach running over dry land. `src/test/packs/` now asserts
+ // the contour has no self-intersections, because this is exactly the class of
+ // defect that passes every other check.
+ [37.87, -122.48],
+ [37.93, -122.45],
+ [38.0, -122.47],
+ [38.04, -122.48], // head of San Pablo Bay
+ [38.04, -122.2],
+ //
+ // The bay is drawn about forty per cent wider than it is. The central bay is
+ // eleven kilometres across, which at 1,919 m to the unit is under six units
+ // and about twenty pixels from the state camera — narrow enough that the one
+ // shape in California everybody can name from orbit arrives as a scratch. The
+ // extra width is taken entirely off the East Bay flats, so San Francisco and
+ // the peninsula keep every lot they had. It is the same kind of emphasis as a
+ // vertical exaggeration of 13 and a two-kilometre-wide freeway: this is a
+ // board, not a survey.
+
+ // ---- Down the East Bay -------------------------------------------------
+ [37.98, -122.22],
+ [37.94, -122.29],
+ [37.86, -122.26], // Berkeley
+ [37.79, -122.22], // Oakland
+ [37.7, -122.14],
+ [37.6, -122.06],
+ [37.51, -121.98], // Fremont
+ [37.45, -121.92], // the south bay tip
+
+ // ---- North again up the peninsula, and out through the Gate ------------
+ [37.47, -122.09],
+ [37.56, -122.18],
+ [37.66, -122.26],
+ [37.75, -122.34],
+ [37.8, -122.38], // the San Francisco waterfront
+ [37.81, -122.47], // the south side of the Gate
+
+ // ---- Ocean coast again, San Francisco south ----------------------------
+ [37.77, -122.51], // Ocean Beach
+ [37.66, -122.5],
+ [37.5, -122.5],
+ [37.18, -122.4], // Año Nuevo
+
+ // ---- Monterey Bay ------------------------------------------------------
+ [37.02, -122.22],
+ [36.96, -122.03], // Santa Cruz
+ [36.88, -121.85],
+ [36.8, -121.79], // Moss Landing, the head of the bay
+ [36.7, -121.8],
+ [36.62, -121.89], // Monterey
+ [36.53, -121.95], // Point Lobos
+
+ // ---- Big Sur and the Santa Lucia coast ---------------------------------
+ [36.3, -121.9],
+ [36.1, -121.63],
+ [35.9, -121.5],
+ [35.67, -121.3],
+ [35.55, -121.1], // San Simeon
+ [35.4, -120.9],
+ [35.28, -120.87], // Morro Bay
+ [35.15, -120.68], // Pismo
+ [34.95, -120.65], // Point Sal
+ [34.75, -120.63],
+ [34.57, -120.65], // Point Arguello
+
+ // ---- Point Conception: the corner, and the only east-west coast in the
+ // state. Getting this turn right is most of what makes the silhouette south
+ // of Big Sur recognisable.
+ [34.45, -120.47],
+ [34.42, -120.1],
+ [34.4, -119.72], // Santa Barbara
+ [34.32, -119.42],
+ [34.25, -119.27], // Ventura
+ [34.16, -119.23],
+ [34.08, -119.06], // Point Mugu
+ [34.04, -118.81], // Point Dume
+ [34.02, -118.6],
+ [34.01, -118.49], // Santa Monica
+ [33.92, -118.44],
+ [33.8, -118.42],
+ [33.74, -118.41], // Palos Verdes, west
+ [33.71, -118.32],
+ [33.74, -118.25],
+ [33.76, -118.19], // San Pedro and the harbour
+ [33.72, -118.1], // Long Beach
+ [33.66, -118.0],
+ [33.6, -117.9], // Newport
+ [33.46, -117.71], // Dana Point
+ [33.32, -117.55],
+ [33.19, -117.39], // Oceanside
+ [33.07, -117.28],
+
+ // ---- North San Diego County, and the city --------------------------------
+ //
+ // Everything from here down is the fix for the shape the board used to have.
+ // The old trace stopped at Del Mar and closed with three ruled segments — one
+ // running back north-west, one due north along -117.45 for five degrees of
+ // latitude — which is the outline of a paper dart and not of California. It
+ // cut San Diego off the board, put the Peninsular Ranges, the Colorado
+ // Desert, the Salton basin and the eastern half of the Mojave in the sea, and
+ // left the state sitting on the water as a cut-out. The silhouette is the
+ // single most recognisable thing about this place and the board did not have
+ // it.
+ [33.0, -117.28], // Del Mar
+ [32.9, -117.26],
+ [32.85, -117.29], // Point La Jolla
+ [32.78, -117.26],
+ [32.71, -117.26],
+ [32.66, -117.25], // Point Loma
+ // San Diego Bay is not traced. The Golden Gate earns its excursion because it
+ // is the one shape in the state anybody can name from orbit; San Diego Bay is
+ // twenty kilometres long and three wide, which at 1,919 m to the unit is a
+ // scratch a pixel and a half across, and the winding cost of a second inlet
+ // is a self-intersection waiting to happen. The Silver Strand's outer shore
+ // stands in for both sides of it.
+ [32.63, -117.19],
+ [32.57, -117.13], // Imperial Beach
+
+ // ---- The Mexican border --------------------------------------------------
+ //
+ // One straight segment, and straight in life too — the 1848 treaty drew it as
+ // a single line from a marine league south of San Diego Bay to the junction
+ // of the Gila and the Colorado and it has not bent since.
+ //
+ // It sits two hundredths of a degree *below* `bounds.minLat`, which is closer
+ // in than any other closure and deliberate: the board's own crop and the
+ // state's own edge then fall on the same line, so the land covers the bottom
+ // row of the lattice and no strip of sea is left along the bottom of the
+ // frame pretending to be Baja. `coastFalloff` is 0.025°, so the only ground
+ // this flattens is the bottom row itself.
+ [32.53, -117.12],
+ [32.72, -114.72], // the Colorado at Yuma
+
+ // ---- Up the Colorado -----------------------------------------------------
+ //
+ // The one edge of the state that is a river rather than a ruled line, and the
+ // reason the south-east corner now has something to look at. North from Yuma,
+ // out east to Parker — -114.13 is the easternmost ground in California — and
+ // back north-west to Needles and the tri-point.
+ [32.85, -114.7],
+ [33.03, -114.62],
+ [33.25, -114.55],
+ [33.45, -114.53],
+ [33.68, -114.52],
+ [33.9, -114.5],
+ [34.05, -114.44],
+ [34.18, -114.33],
+ [34.28, -114.15], // Parker Dam
+ [34.4, -114.28],
+ [34.52, -114.38],
+ [34.65, -114.5],
+ [34.76, -114.62], // Needles
+ [34.88, -114.63],
+ [35.0, -114.63], // the Arizona / Nevada / California tri-point
+
+ // ---- The Nevada line, and the north closure ------------------------------
+ //
+ // One ruled segment from the tri-point to the corner at Lake Tahoe — 39.0 N,
+ // -120.0 — of which this board draws the part below its own top edge. The
+ // gradient is -1.342° of longitude per degree of latitude, which is worth
+ // writing down because it is what sites the two peaks in the White Mountains:
+ // the line is at -118.17 where White Mountain Peak stands and the peak is
+ // nine hundredths of a degree inside it, so both of those are placed against
+ // the line rather than against a round number. Anything east of it is Nevada,
+ // and Nevada is not on this board.
+ //
+ // Extended to 38.2 N, a sixth of a degree past `bounds.maxLat`, for the same
+ // reason every closure sits outside the bounds: `coastalFalloff` ramps relief
+ // to zero within `coastFalloff` of *any* polygon edge, and the board's crop
+ // must not flatten the Sierra against the top of the frame.
+ [38.2, -118.93],
+];
+
+/**
+ * The ranges, as chains of radial peaks.
+ *
+ * `World.elevationAt` sums overlapping hills as `tallest + 35% of the rest`, so
+ * a chain becomes a ridge and the gap between two chains becomes a valley for
+ * free. The whole of California's structure is three north-south chains — the
+ * outer Coast Range, the inner Diablo Range, and the Sierra — with the Salinas
+ * Valley and the Central Valley falling out as the flats between them, closed
+ * at the south end by the one east-west chain, the Transverse Ranges.
+ *
+ * **Spacing is the number that matters, and it is about 0.9 × radius.** Work it
+ * out from the falloff: two equal peaks a fraction `s` of a radius apart meet at
+ * `1.35 × (1 − s²/4)²` of their own height, so `s = 0.9` puts the saddle at 85%
+ * — a continuous crest with notches in it. The first version of this list was
+ * spaced 1.5 to 1.8 radii and the state came out as a row of separate domes,
+ * which is what a chain of `(1 − d²)²` bells looks like when they do not touch.
+ * Going much below 0.9 is the opposite failure: the 35% blend from four
+ * neighbours lifts the saddle *above* the summits and the range turns into a
+ * plateau.
+ *
+ * Every radius was then checked against the two corridor routes, because a
+ * freeway drapes onto whatever the heightfield says and a peak centred on the
+ * road turns US-101 into a rollercoaster. The Salinas Valley, the Santa Clara
+ * Valley and the whole of I-5's Central Valley run sit outside every radius.
+ * The four places the routes *do* climb are the four places a driver climbs in
+ * life: the Cuesta Grade above San Luis Obispo, Newhall and Tejon passes on
+ * I-5's way out of Los Angeles, and Altamont on the Bay approach.
+ */
+const HILLS: Hill[] = [
+ // ---- Outer Coast Range: Purisima to Bolinas Ridge, west of the Salinas
+ // and Santa Clara valleys.
+ { name: "Purisima Hills", lat: 34.75, lng: -120.3, elevation: 900, radius: 0.18 },
+ { name: "Casmalia Hills", lat: 34.95, lng: -120.2, elevation: 950, radius: 0.18 },
+ { name: "Irish Hills", lat: 35.15, lng: -120.35, elevation: 900, radius: 0.18 },
+ { name: "Cuesta Ridge", lat: 35.35, lng: -120.65, elevation: 800, radius: 0.16 },
+ { name: "Santa Lucia Range", lat: 35.55, lng: -120.95, elevation: 950, radius: 0.18 },
+ { name: "Rocky Butte", lat: 35.75, lng: -121.15, elevation: 1_200, radius: 0.2 },
+ { name: "Cone Peak", lat: 35.95, lng: -121.3, elevation: 1_500, radius: 0.2 },
+ { name: "Junipero Serra Peak", lat: 36.15, lng: -121.42, elevation: 1_700, radius: 0.2 },
+ { name: "Ventana Wilderness", lat: 36.35, lng: -121.55, elevation: 1_500, radius: 0.2 },
+ { name: "Sierra de Salinas", lat: 36.55, lng: -121.72, elevation: 1_150, radius: 0.18 },
+ { name: "Santa Cruz Mountains", lat: 37.05, lng: -122.03, elevation: 700, radius: 0.15 },
+ { name: "Loma Prieta", lat: 37.2, lng: -122.1, elevation: 1_000, radius: 0.16 },
+ { name: "Skyline Ridge", lat: 37.35, lng: -122.22, elevation: 900, radius: 0.15 },
+ { name: "Montara Ridge", lat: 37.5, lng: -122.38, elevation: 700, radius: 0.13 },
+ { name: "San Bruno Mountain", lat: 37.65, lng: -122.47, elevation: 400, radius: 0.1 },
+ { name: "San Francisco Hills", lat: 37.755, lng: -122.45, elevation: 260, radius: 0.06 },
+ { name: "Marin Headlands", lat: 37.9, lng: -122.6, elevation: 500, radius: 0.11 },
+ { name: "Bolinas Ridge", lat: 38.02, lng: -122.72, elevation: 600, radius: 0.12 },
+
+ // ---- Inner Coast Range: the Temblor and the Diablo, the wall along the
+ // Central Valley's west side. It is what makes the valley a valley rather
+ // than a plain that runs into the sea.
+ { name: "Temblor Range", lat: 35.1, lng: -119.95, elevation: 900, radius: 0.2 },
+ { name: "Carrizo Ridge", lat: 35.3, lng: -120.05, elevation: 1_000, radius: 0.2 },
+ { name: "La Panza Range", lat: 35.5, lng: -120.25, elevation: 950, radius: 0.2 },
+ { name: "Cholame Hills", lat: 35.7, lng: -120.45, elevation: 900, radius: 0.2 },
+ { name: "Diablo Range", lat: 35.9, lng: -120.6, elevation: 1_000, radius: 0.2 },
+ { name: "Priest Valley", lat: 36.1, lng: -120.7, elevation: 1_100, radius: 0.2 },
+ { name: "San Benito Mountain", lat: 36.3, lng: -120.62, elevation: 1_250, radius: 0.22 },
+ { name: "Panoche Hills", lat: 36.5, lng: -120.8, elevation: 1_150, radius: 0.2 },
+ { name: "Pacheco Ridge", lat: 36.7, lng: -121.0, elevation: 1_100, radius: 0.2 },
+ { name: "Pacheco Pass", lat: 36.9, lng: -121.15, elevation: 1_050, radius: 0.2 },
+ { name: "Henry Coe", lat: 37.1, lng: -121.28, elevation: 1_150, radius: 0.2 },
+ { name: "Mount Hamilton", lat: 37.34, lng: -121.62, elevation: 1_250, radius: 0.14 },
+ { name: "Mount Isabel", lat: 37.32, lng: -121.4, elevation: 1_100, radius: 0.18 },
+ { name: "Altamont", lat: 37.62, lng: -121.62, elevation: 600, radius: 0.14 },
+ { name: "Mount Diablo", lat: 37.88, lng: -121.91, elevation: 1_150, radius: 0.13 },
+ { name: "Vaca Mountains", lat: 38.04, lng: -122.06, elevation: 700, radius: 0.14 },
+ { name: "Berkeley Hills", lat: 37.87, lng: -122.19, elevation: 450, radius: 0.09 },
+
+ // ---- Sierra Nevada: the crest ------------------------------------------
+ // Thirteen peaks at ~0.25° spacing on a ~0.29° radius. This is the board's
+ // one genuinely big landform and the reason the exaggeration is 13.
+ { name: "Piute Peak", lat: 35.45, lng: -118.42, elevation: 2_300, radius: 0.28 },
+ { name: "Greenhorn Mountains", lat: 35.7, lng: -118.35, elevation: 2_800, radius: 0.28 },
+ { name: "Kern Plateau", lat: 35.95, lng: -118.3, elevation: 3_200, radius: 0.3 },
+ { name: "Olancha Peak", lat: 36.2, lng: -118.28, elevation: 3_600, radius: 0.3 },
+ { name: "Great Western Divide", lat: 36.45, lng: -118.28, elevation: 4_000, radius: 0.3 },
+ { name: "Mount Whitney", lat: 36.62, lng: -118.29, elevation: 4_300, radius: 0.28 },
+ { name: "Mount Williamson", lat: 36.85, lng: -118.38, elevation: 3_900, radius: 0.28 },
+ { name: "Palisade Crest", lat: 37.05, lng: -118.52, elevation: 3_800, radius: 0.28 },
+ { name: "Mount Humphreys", lat: 37.25, lng: -118.72, elevation: 3_700, radius: 0.28 },
+ { name: "Mount Ritter", lat: 37.45, lng: -118.92, elevation: 3_550, radius: 0.3 },
+ { name: "Mount Lyell", lat: 37.65, lng: -119.12, elevation: 3_450, radius: 0.3 },
+ { name: "Yosemite High Country", lat: 37.85, lng: -119.35, elevation: 3_350, radius: 0.3 },
+ { name: "Sonora Crest", lat: 38.05, lng: -119.6, elevation: 3_150, radius: 0.3 },
+
+ // ---- Sierra Nevada: the western foothills ------------------------------
+ // The long ramp out of the valley. Without it the range front rises out of
+ // flat farmland inside one cell and reads as a wall dropped on a table.
+ { name: "Kern Foothills", lat: 35.55, lng: -118.95, elevation: 800, radius: 0.3 },
+ { name: "Tule Foothills", lat: 35.85, lng: -118.95, elevation: 900, radius: 0.3 },
+ { name: "Kaweah Foothills", lat: 36.15, lng: -119.02, elevation: 1_000, radius: 0.3 },
+ { name: "Sequoia Foothills", lat: 36.45, lng: -119.1, elevation: 1_100, radius: 0.3 },
+ { name: "Kings Foothills", lat: 36.75, lng: -119.25, elevation: 1_050, radius: 0.3 },
+ { name: "San Joaquin Foothills", lat: 37.05, lng: -119.45, elevation: 1_000, radius: 0.3 },
+ { name: "Mother Lode", lat: 37.35, lng: -119.65, elevation: 1_000, radius: 0.3 },
+ { name: "Tuolumne Foothills", lat: 37.65, lng: -119.9, elevation: 950, radius: 0.32 },
+ { name: "Stanislaus Foothills", lat: 37.95, lng: -120.2, elevation: 900, radius: 0.32 },
+
+ // ---- White and Inyo Mountains, the far wall of the Owens Valley ---------
+ { name: "Inyo Mountains", lat: 36.45, lng: -117.85, elevation: 2_500, radius: 0.2 },
+ { name: "New York Butte", lat: 36.65, lng: -117.88, elevation: 2_700, radius: 0.2 },
+ { name: "Waucoba Mountain", lat: 36.85, lng: -117.92, elevation: 2_900, radius: 0.2 },
+ { name: "Westgard Pass", lat: 37.05, lng: -117.95, elevation: 3_100, radius: 0.2 },
+ { name: "Piute Mountain", lat: 37.25, lng: -117.95, elevation: 3_300, radius: 0.22 },
+ { name: "Cottonwood Basin", lat: 37.45, lng: -118.0, elevation: 3_600, radius: 0.22 },
+ // These two are sited against the Nevada line rather than against the crest.
+ // The line is at -118.17 at White Mountain Peak's latitude and at -118.43 at
+ // Montgomery's, and a peak placed east of it is a peak in Nevada — outside
+ // `landmasses`, so `isLand` is false under it and the summit renders as open
+ // water with a mountain's skirt on the California side of it.
+ { name: "White Mountain Peak", lat: 37.63, lng: -118.28, elevation: 4_000, radius: 0.22 },
+ { name: "Montgomery Peak", lat: 37.84, lng: -118.5, elevation: 3_700, radius: 0.22 },
+
+ // ---- Transverse Ranges: the east-west wall that closes the valley's south
+ // end and stands behind Los Angeles.
+ // The Santa Ynez sit a little north of true, and the low front behind Santa
+ // Barbara is broken out separately. Both are set by the road: US-101's
+ // authored path runs straight from Santa Barbara to Santa Maria, where the
+ // real freeway goes round the outside at Gaviota, so a range centred on the
+ // true crest put a kilometre of climb on a leg that tops out near 300 m.
+ { name: "Santa Barbara Front", lat: 34.48, lng: -119.72, elevation: 700, radius: 0.1 },
+ { name: "Santa Ynez Mountains", lat: 34.6, lng: -120.12, elevation: 1_000, radius: 0.16 },
+ { name: "Santa Ynez Crest", lat: 34.66, lng: -119.86, elevation: 950, radius: 0.15 },
+ { name: "San Rafael Mountains", lat: 34.6, lng: -119.6, elevation: 1_700, radius: 0.2 },
+ { name: "Sierra Madre Ridge", lat: 34.7, lng: -119.35, elevation: 2_100, radius: 0.2 },
+ { name: "Mount Pinos", lat: 34.8, lng: -119.1, elevation: 2_300, radius: 0.2 },
+ // Sited west of where I-5 crosses, not on it. Mount Pinos and the Tehachapis
+ // carry the high ground either side; the gap they leave between them is Tejon
+ // Pass, and the blend puts the road over it at about 1,300 m against 1,258 m
+ // in life. A 2,000 m peak centred on the crossing made the Grapevine a wall.
+ { name: "San Emigdio Mountains", lat: 34.88, lng: -119.02, elevation: 1_900, radius: 0.2 },
+ { name: "Tehachapi Mountains", lat: 35.0, lng: -118.6, elevation: 1_900, radius: 0.2 },
+ { name: "Tehachapi Pass", lat: 35.08, lng: -118.35, elevation: 1_800, radius: 0.2 },
+ { name: "Liebre Mountain", lat: 34.62, lng: -118.55, elevation: 1_500, radius: 0.18 },
+ { name: "Sierra Pelona", lat: 34.55, lng: -118.3, elevation: 1_600, radius: 0.18 },
+ { name: "Mount Gleason", lat: 34.45, lng: -118.05, elevation: 2_000, radius: 0.18 },
+ { name: "Mount Wilson", lat: 34.32, lng: -117.85, elevation: 2_500, radius: 0.18 },
+ { name: "Mount San Antonio", lat: 34.24, lng: -117.65, elevation: 2_900, radius: 0.18 },
+ { name: "Santa Susana Mountains", lat: 34.35, lng: -118.42, elevation: 1_200, radius: 0.15 },
+ // 720 m on a 0.12° radius, and both numbers are set by the road rather than by
+ // the range. US-101's authored path runs straight from downtown Los Angeles
+ // to Ventura, so it cuts the Santa Monicas where the real freeway goes round
+ // through the Conejo Valley; at 800 m on 0.15° the heightfield put 760 m of
+ // climb into a leg that tops out near 350 m in life. See the corridor test in
+ // `src/test/packs/`.
+ { name: "Santa Monica Mountains", lat: 34.1, lng: -118.7, elevation: 720, radius: 0.12 },
+ { name: "Hollywood Hills", lat: 34.13, lng: -118.4, elevation: 500, radius: 0.12 },
+
+ // ---- The western Mojave, and the ranges behind the LA basin -------------
+ //
+ // The first of the chains that use `ridge`. Everything from here to the spurs
+ // is desert, and the desert was the last part of the board to be wrong: it
+ // shipped once as a field of two dozen lone domes, evenly sized, each one a
+ // separate `(1 - d²)²` bell standing by itself on a flat, and from the state
+ // camera it read as bubble wrap. Basin and Range is not a field of hills. It
+ // is a field of *ridges* — long, narrow, parallel, all leaning the same way —
+ // with dry troughs between them, and a ridge is what a chain at 0.9 radii
+ // gives you.
+ ...ridge("Coso Range", [35.9, -117.82], [36.22, -117.7], 2_200, 0.15),
+ ...ridge("Argus Range", [35.68, -117.46], [36.08, -117.38], 1_900, 0.13),
+ ...ridge("Slate Range", [35.72, -117.26], [35.94, -117.2], 1_500, 0.11),
+ ...ridge("El Paso Mountains", [35.3, -117.78], [35.46, -117.52], 1_400, 0.12),
+ ...ridge("Rand Mountains", [35.18, -117.98], [35.4, -117.72], 1_300, 0.13),
+ ...ridge("Fremont Peak", [34.84, -117.96], [35.12, -117.74], 1_200, 0.13),
+ ...ridge("Shadow Mountains", [34.48, -117.64], [34.72, -117.42], 1_300, 0.13),
+ ...ridge("Ord Mountains", [34.58, -116.98], [34.86, -116.76], 1_300, 0.13),
+ ...ridge("Rodman Mountains", [34.62, -116.62], [34.88, -116.46], 1_400, 0.12),
+ { name: "Chino Front", lat: 33.95, lng: -117.35, elevation: 1_400, radius: 0.2 },
+ ...ridge("Santa Ana Mountains", [33.58, -117.34], [33.86, -117.6], 1_500, 0.14),
+ ...ridge("Elsinore Ridge", [33.38, -117.08], [33.62, -117.34], 1_300, 0.14),
+ ...ridge("Palomar Mountain", [33.18, -116.9], [33.42, -117.12], 1_600, 0.15),
+
+ /**
+ * ---- The Peninsular Ranges ----
+ *
+ * The wall behind San Diego and Palm Springs, and the reason the bottom
+ * corner of the frame is a landscape rather than a beach.
+ *
+ * These are not low desert hills. San Gorgonio is 3,506 m and San Jacinto
+ * 3,302, which makes them the second and third highest things on this board
+ * after the Sierra, and San Jacinto's east face drops 3,000 m onto a valley
+ * floor that is *below sea level* in under ten kilometres. That step is the
+ * single most dramatic piece of ground in the state and the board had none of
+ * it: the old bounds stopped at -117.55, half a degree west of the summit.
+ */
+ ...ridge("San Bernardino Mountains", [34.06, -116.68], [34.26, -117.08], 3_400, 0.16),
+ ...ridge("San Jacinto", [33.72, -116.58], [33.9, -116.76], 3_100, 0.13),
+ ...ridge("Santa Rosa Mountains", [33.4, -116.32], [33.68, -116.56], 2_300, 0.14),
+ ...ridge("Anza Highlands", [33.18, -116.38], [33.44, -116.62], 1_500, 0.15),
+ ...ridge("Volcan and Cuyamaca", [32.86, -116.52], [33.16, -116.62], 1_800, 0.14),
+ ...ridge("Laguna Mountains", [32.66, -116.24], [32.9, -116.46], 1_700, 0.13),
+ ...ridge("In-Ko-Pah Gorge", [32.6, -116.0], [32.78, -116.18], 1_200, 0.13),
+ { name: "San Diego Backcountry", lat: 32.9, lng: -116.86, elevation: 800, radius: 0.2 },
+
+ /**
+ * ---- The eastern Mojave and the Colorado Desert ----
+ *
+ * Twenty ranges between the Sierra's rain shadow and the river, and the whole
+ * of the board's south-east corner is these plus the two floors under them.
+ *
+ * They lean north-north-west because that is the way the Eastern California
+ * Shear Zone leans, and keeping every one of them within about twenty degrees
+ * of the same bearing is most of what makes the corner read as a *region*
+ * rather than as scattered lumps. It is the same argument the Coast Ranges
+ * make one chain at a time, applied to an area four times the size.
+ *
+ * None of this is wildland and none of it is farmed, so `parks` cannot touch
+ * it and `flats` never gets near it: every one of these is drawn in the two
+ * stops above `upland`, which is why the pack declares `alpine` at all. What
+ * carries them is the second thing — the terrain now casts its own shadow, so
+ * a ridge in an unpainted desert throws a shadow east in the morning and west
+ * in the evening, and the corner of the board that used to be blank is the
+ * part of the frame that changes most across a day.
+ */
+ ...ridge("Providence Mountains", [34.72, -115.78], [35.08, -115.46], 2_000, 0.14),
+ ...ridge("New York Mountains", [35.08, -115.44], [35.38, -115.2], 2_100, 0.13),
+ ...ridge("Clark Mountain", [35.38, -115.72], [35.62, -115.5], 2_300, 0.13),
+ ...ridge("Kingston Range", [35.58, -116.02], [35.86, -115.8], 2_100, 0.14),
+ ...ridge("Avawatz Mountains", [35.46, -116.52], [35.72, -116.34], 1_800, 0.13),
+ ...ridge("Soda Mountains", [35.04, -116.36], [35.3, -116.18], 1_000, 0.12),
+ ...ridge("Cady Mountains", [34.68, -116.44], [34.98, -116.16], 1_300, 0.13),
+ ...ridge("Piute Range", [34.86, -115.08], [35.2, -114.92], 1_300, 0.12),
+ ...ridge("Sacramento Mountains", [34.56, -114.98], [34.9, -114.84], 1_100, 0.11),
+ ...ridge("Old Woman Mountains", [34.3, -115.38], [34.68, -115.12], 1_700, 0.13),
+ ...ridge("Turtle Mountains", [34.12, -115.06], [34.42, -114.86], 1_300, 0.12),
+ ...ridge("Whipple Mountains", [34.18, -114.68], [34.42, -114.5], 1_200, 0.11),
+ ...ridge("Big Maria Mountains", [33.76, -114.66], [34.02, -114.5], 900, 0.11),
+ ...ridge("Bullion Mountains", [34.26, -116.34], [34.66, -116.08], 1_200, 0.13),
+ ...ridge("Sheep Hole Mountains", [33.98, -115.8], [34.32, -115.64], 1_100, 0.12),
+ ...ridge("Little San Bernardino", [33.82, -116.38], [34.16, -115.92], 1_600, 0.14),
+ ...ridge("Coxcomb Mountains", [33.82, -115.48], [34.14, -115.32], 1_200, 0.12),
+ ...ridge("Eagle Mountains", [33.66, -115.7], [33.94, -115.46], 1_600, 0.13),
+ ...ridge("Palen Mountains", [33.68, -115.2], [34.0, -115.04], 1_100, 0.12),
+ ...ridge("Mule Mountains", [33.44, -114.9], [33.7, -114.72], 700, 0.11),
+ ...ridge("Orocopia Mountains", [33.42, -115.94], [33.66, -115.72], 900, 0.12),
+ ...ridge("Chuckwalla Mountains", [33.38, -115.58], [33.68, -115.14], 900, 0.13),
+ ...ridge("Chocolate Mountains", [32.94, -114.92], [33.4, -115.44], 800, 0.15),
+ ...ridge("Cargo Muchacho", [32.76, -114.96], [32.94, -114.8], 600, 0.11),
+
+ /**
+ * ---- Death Valley ----
+ *
+ * Five ranges and, deliberately, no floor between them.
+ *
+ * `elevationAt` returns exactly 0 where no hill reaches and `groundColor`
+ * paints anything under 3 m with `palette.sand`. Everywhere else on the board
+ * that is a defect — the note on "the floors" below is about four hundred
+ * kilometres of farmland that was being painted as beach — and here it is the
+ * right answer twice over: the floor really is below sea level, and it really
+ * is a white salt pan. So Badwater gets the beach colour on purpose, with the
+ * highest ground for a hundred kilometres on both sides of it and `alpine`
+ * grey on top of that, which is the widest range of value the board reaches
+ * anywhere outside the Sierra.
+ *
+ * The Black Mountains are the constraint. They run down the *east* side of the
+ * valley and their radius is the only thing keeping the pan at zero: at 0.18
+ * the chain reached across the floor and lifted Badwater to 52 m, which is
+ * `flats` gold and turns the most recognisable salt flat in North America into
+ * a wheat field.
+ */
+ ...ridge("Panamint Range", [35.92, -117.22], [36.56, -117.24], 3_000, 0.15),
+ ...ridge("Cottonwood Mountains", [36.58, -117.3], [37.04, -117.5], 2_200, 0.14),
+ ...ridge("Last Chance Range", [36.94, -117.62], [37.26, -117.78], 2_300, 0.13),
+ ...ridge("Grapevine Mountains", [36.66, -117.16], [37.0, -117.32], 2_300, 0.12),
+ ...ridge("Funeral Mountains", [36.34, -116.98], [36.6, -116.88], 1_700, 0.12),
+ ...ridge("Black Mountains", [35.92, -116.78], [36.32, -116.62], 1_600, 0.11),
+ ...ridge("Owlshead Mountains", [35.62, -117.06], [35.9, -116.88], 1_200, 0.13),
+
+ /**
+ * ---- Spurs ----
+ *
+ * Short, low, irregularly placed peaks hanging off the main chains.
+ *
+ * A chain on its own gives a ridge with an even scallop along it, because the
+ * chain is evenly spaced and every bell is the same width — from the state
+ * camera the Coast Ranges came out looking like a caterpillar. Real range
+ * fronts are ridges *and* the spurs and side canyons running down off them,
+ * and it is the spurs that break the period. They are placed off-rhythm on
+ * purpose: no two are the same distance apart, and none of them lines up with
+ * a peak on the chain it hangs off.
+ */
+ { name: "Cuyama Spur", lat: 34.88, lng: -119.98, elevation: 640, radius: 0.11 },
+ { name: "Huasna Spur", lat: 35.07, lng: -120.44, elevation: 520, radius: 0.09 },
+ { name: "Santa Margarita Spur", lat: 35.44, lng: -120.5, elevation: 600, radius: 0.1 },
+ { name: "Adelaida Spur", lat: 35.69, lng: -120.86, elevation: 680, radius: 0.1 },
+ { name: "Nacimiento Spur", lat: 35.88, lng: -121.06, elevation: 820, radius: 0.11 },
+ { name: "Arroyo Seco Spur", lat: 36.24, lng: -121.28, elevation: 940, radius: 0.1 },
+ { name: "Carmel Spur", lat: 36.44, lng: -121.72, elevation: 780, radius: 0.09 },
+ { name: "Corralitos Spur", lat: 37.08, lng: -121.88, elevation: 520, radius: 0.09 },
+ { name: "Woodside Spur", lat: 37.42, lng: -122.24, elevation: 560, radius: 0.08 },
+ { name: "Panoche Spur", lat: 36.41, lng: -120.98, elevation: 780, radius: 0.11 },
+ { name: "Los Banos Spur", lat: 36.98, lng: -120.9, elevation: 640, radius: 0.1 },
+ { name: "Orestimba Spur", lat: 37.28, lng: -121.18, elevation: 700, radius: 0.1 },
+ { name: "Del Puerto Spur", lat: 37.52, lng: -121.42, elevation: 620, radius: 0.09 },
+ { name: "Kaweah Spur", lat: 36.28, lng: -118.72, elevation: 1_900, radius: 0.14 },
+ { name: "Kings Canyon Spur", lat: 36.88, lng: -118.9, elevation: 2_200, radius: 0.15 },
+ { name: "Mariposa Spur", lat: 37.52, lng: -119.5, elevation: 1_800, radius: 0.14 },
+ { name: "Merced Spur", lat: 37.72, lng: -119.72, elevation: 1_500, radius: 0.13 },
+ { name: "Isabella Spur", lat: 35.66, lng: -118.62, elevation: 1_500, radius: 0.13 },
+ { name: "Frazier Spur", lat: 34.72, lng: -118.92, elevation: 1_600, radius: 0.11 },
+ { name: "Castaic Spur", lat: 34.5, lng: -118.68, elevation: 1_100, radius: 0.1 },
+ { name: "Cajon Spur", lat: 34.22, lng: -117.5, elevation: 2_000, radius: 0.12 },
+ { name: "Trabuco Spur", lat: 33.62, lng: -117.62, elevation: 1_000, radius: 0.1 },
+
+ /**
+ * ---- The floors ----
+ *
+ * Broad, almost flat rises with no summit worth the name, and they fix the
+ * single most damaging thing about the old board.
+ *
+ * `terrain.ts` paints anything under 3 m with `palette.sand` — the beach
+ * colour — and `elevationAt` returns exactly 0 everywhere no hill reaches.
+ * So the Central Valley, the Mojave, the LA basin and the Salinas Valley,
+ * which between them are most of the land in frame, were all being painted as
+ * beach. That is where "a pale sand lozenge" came from: it was not a palette
+ * that needed darkening, it was four hundred kilometres of farmland sitting
+ * at sea level.
+ *
+ * These lift each floor onto `palette.flats` and give it a barely-perceptible
+ * grade. The summits are deliberately *low* — 28 m for the Central Valley,
+ * not the 90 m it was first given — because `groundColor` starts blending
+ * `flats` toward `upland` immediately above 3 m and reaches it at 150. At 90
+ * the valley came out 58% of the way to bare stone and the board was still
+ * pale; at 28 it is farmland with a hint of dust on it, which is the whole
+ * job. The two deserts are the exception and are meant to be up there: the
+ * Mojave really does sit at 600 m and really is the colour of `upland`.
+ */
+ { name: "Central Valley floor", lat: 36.6, lng: -120.0, elevation: 28, radius: 1.7 },
+ { name: "Mojave floor", lat: 34.9, lng: -117.8, elevation: 620, radius: 1.1 },
+ { name: "Owens Valley floor", lat: 36.9, lng: -118.1, elevation: 380, radius: 0.8 },
+ { name: "Salinas Valley floor", lat: 36.3, lng: -121.2, elevation: 26, radius: 0.5 },
+ { name: "Santa Clara Valley floor", lat: 37.3, lng: -121.85, elevation: 22, radius: 0.24 },
+ { name: "Los Angeles basin floor", lat: 34.0, lng: -118.15, elevation: 26, radius: 0.42 },
+ { name: "Oxnard Plain", lat: 34.22, lng: -119.12, elevation: 18, radius: 0.22 },
+ { name: "Antelope Valley floor", lat: 34.75, lng: -118.2, elevation: 700, radius: 0.42 },
+
+ /*
+ * The dry floors, east of the Transverse Ranges.
+ *
+ * Same job as the four above and one extra constraint: they have to *overlap*.
+ * A radial bell falls to zero with zero gradient at its own edge, so two
+ * floors that merely touch leave a seam at exactly 0 m between them — and 0 m
+ * is the beach colour, so the seam arrives as a pale sand stripe drawn across
+ * the middle of a desert. The Mojave floor reaches -116.7 and the eastern one
+ * starts from -115.9 on a radius of 1.0, which puts two tenths of a degree of
+ * overlap under the join and about 200 m of ground in it.
+ *
+ * The two irrigated valleys are the exception and are meant to be low: the
+ * Imperial and the Coachella are farmed, they are `flats` gold rather than
+ * `upland` rock, and that is the honest reason the bottom corner of the board
+ * has a colour in it that the desert around it does not.
+ */
+ { name: "Eastern Mojave floor", lat: 35.0, lng: -115.9, elevation: 780, radius: 1.0 },
+ { name: "Ward Valley floor", lat: 34.4, lng: -115.0, elevation: 450, radius: 0.55 },
+ { name: "Chuckwalla Valley floor", lat: 33.6, lng: -115.2, elevation: 250, radius: 0.4 },
+ { name: "Lower Colorado floor", lat: 33.4, lng: -114.7, elevation: 120, radius: 0.5 },
+ // Sited on Palm Springs and pulled back off the lake, which is a rendering
+ // constraint and not a geographic one. `isLand` is false inside
+ // `inlandWater`, so the terrain grid has a hole there — quantised to the
+ // lattice, and therefore stair-stepped — while the lake plate is a smooth
+ // polygon at y=0.05. Any ground the floor lifts along the shore stands *over*
+ // the water as a blocky rim, which arrives as a row of pale steps across the
+ // north end of the sea. The real Coachella Valley runs below sea level as it
+ // approaches the shore anyway, so a floor that dies out before it gets there
+ // is the honest shape as well as the one that renders.
+ { name: "Coachella Valley floor", lat: 33.82, lng: -116.32, elevation: 60, radius: 0.28 },
+ { name: "Imperial Valley floor", lat: 32.8, lng: -115.5, elevation: 14, radius: 0.38 },
+ { name: "Anza-Borrego floor", lat: 33.1, lng: -116.15, elevation: 220, radius: 0.28 },
+ { name: "Amargosa Desert floor", lat: 36.3, lng: -116.35, elevation: 700, radius: 0.3 },
+ { name: "Panamint Valley floor", lat: 36.0, lng: -117.35, elevation: 400, radius: 0.22 },
+ { name: "San Diego mesa", lat: 32.88, lng: -117.13, elevation: 110, radius: 0.2 },
+];
+
+/**
+ * Wildland, which on this board is what draws the mountains.
+ *
+ * See the header: ground colour saturates at 150 m, so `parks` is the only
+ * channel left that can distinguish a range from a farm. Each polygon is a
+ * range *envelope* traced to sit over the hill chain it belongs to — the first
+ * version was drawn independently of the hills and produced green stripes lying
+ * across gold domes, which looks exactly as wrong as it sounds.
+ *
+ * Two envelopes stop deliberately short.
+ *
+ * The Sierra one has its eastern edge about half a degree west of the crest, so
+ * the granite above the tree line is left painted as rock. Its western edge sits
+ * a little *up* the range front rather than at the base, because the Sierra
+ * foothills really are gold grassland and the conifers really do start around
+ * 600 m. Those two edges are the closest this two-colour ramp can get to a snow
+ * line and a tree line.
+ *
+ * The Coast Range one stops at 37.6 N. North of that the peninsula is
+ * continuously built and `createBlocks` skips every lot inside a park — an
+ * envelope drawn to the Golden Gate deletes San Francisco.
+ */
+const RANGES: LatLng[][] = [
+ // Outer Coast Range: Point Conception to the Santa Cruz Mountains.
+ [
+ [34.6, -120.315],
+ [34.85, -120.115],
+ [35.15, -120.165],
+ [35.4, -120.445],
+ [35.65, -120.745],
+ [35.9, -120.945],
+ [36.15, -121.115],
+ [36.4, -121.265],
+ [36.6, -121.465],
+ [36.78, -121.645],
+ [36.98, -121.815],
+ [37.15, -121.885],
+ [37.32, -122.045],
+ [37.45, -122.185],
+ [37.55, -122.38],
+ [37.6, -122.55],
+ [37.45, -122.48],
+ [37.25, -122.38],
+ [37.02, -122.23],
+ [36.85, -122.03],
+ [36.55, -121.98],
+ [36.3, -121.88],
+ [36.05, -121.63],
+ [35.8, -121.38],
+ [35.55, -121.13],
+ [35.3, -120.91],
+ [35.05, -120.71],
+ [34.8, -120.65],
+ ],
+ // Marin: the Headlands and Bolinas Ridge, north of the Gate.
+ [
+ [38.05, -122.53],
+ [37.95, -122.4],
+ [37.86, -122.46],
+ [37.85, -122.66],
+ [37.95, -122.82],
+ [38.05, -122.9],
+ ],
+ // Inner Coast Range: the Temblor and the Diablo.
+ [
+ [35.05, -119.715],
+ [35.35, -119.815],
+ [35.65, -120.165],
+ [35.95, -120.365],
+ [36.25, -120.385],
+ [36.55, -120.565],
+ [36.85, -120.765],
+ [37.15, -121.015],
+ [37.45, -121.315],
+ [37.7, -121.465],
+ [37.95, -121.715],
+ [38.05, -121.865],
+ [38.05, -122.165],
+ [37.9, -122.21],
+ [37.75, -122.05],
+ [37.55, -121.88],
+ [37.35, -121.83],
+ [37.15, -121.58],
+ [36.9, -121.38],
+ [36.65, -121.18],
+ [36.4, -120.93],
+ [36.15, -120.88],
+ [35.85, -120.75],
+ [35.55, -120.48],
+ [35.25, -120.18],
+ [35.05, -119.98],
+ ],
+ // Sierra Nevada forest belt: between the gold foothills and the bare crest.
+ [
+ [35.45, -118.6],
+ [35.75, -118.55],
+ [36.05, -118.52],
+ [36.35, -118.52],
+ [36.6, -118.54],
+ [36.85, -118.64],
+ [37.1, -118.8],
+ [37.35, -119.0],
+ [37.6, -119.25],
+ [37.85, -119.5],
+ [38.05, -119.84],
+ [38.05, -120.44],
+ [37.8, -120.24],
+ [37.5, -119.96],
+ [37.2, -119.74],
+ [36.9, -119.54],
+ [36.6, -119.36],
+ [36.3, -119.29],
+ [36.0, -119.24],
+ [35.7, -119.19],
+ [35.45, -119.09],
+ ],
+ // Transverse Ranges: Santa Ynez through the San Gabriels.
+ [
+ [34.72, -120.25],
+ [34.85, -119.75],
+ [35.02, -119.25],
+ [35.18, -118.75],
+ [35.22, -118.3],
+ [35.0, -118.18],
+ [34.75, -118.02],
+ [34.5, -117.82],
+ [34.35, -117.55],
+ [34.22, -117.65],
+ [34.25, -118.02],
+ [34.35, -118.3],
+ [34.44, -118.75],
+ [34.48, -119.2],
+ [34.48, -119.75],
+ [34.5, -120.2],
+ ],
+ // The Santa Monica Mountains, which separate the Westside from the Valley.
+ [
+ [34.18, -118.765],
+ [34.16, -118.405],
+ [34.09, -118.455],
+ [34.09, -118.815],
+ ],
+ // Santa Ana Mountains and the Palomar front, closing the south-east.
+ [
+ [34.05, -117.525],
+ [33.85, -117.175],
+ [33.45, -116.825],
+ [33.15, -116.875],
+ [33.2, -117.225],
+ [33.55, -117.475],
+ [33.85, -117.725],
+ ],
+ /*
+ * San Bernardino and San Jacinto: the conifer belt on the two 3,000 m peaks
+ * that stand over Palm Springs.
+ *
+ * Its eastern edge is the pass and it stops there, hard. That edge is the
+ * point of the polygon: on one side of it is 3,400 m of pine and on the other
+ * is the Coachella Valley at 30 m, and the board has exactly one channel that
+ * can show the difference — `groundColor` saturates at 150 m, so without this
+ * envelope the two would be painted the same value and the biggest step of
+ * relief in the state would read as a smudge.
+ */
+ [
+ [34.32, -117.15],
+ [34.32, -116.72],
+ [34.0, -116.62],
+ [33.72, -116.58],
+ [33.62, -116.78],
+ [33.9, -116.92],
+ [34.05, -117.08],
+ ],
+ /*
+ * The Lagunas and the Cuyamacas, behind San Diego. Chaparral and pine on the
+ * Pacific side of the divide; the eastern edge is where the ground falls into
+ * Anza-Borrego and stops being wildland of any kind.
+ */
+ [
+ [33.25, -116.78],
+ [33.2, -116.38],
+ [32.85, -116.25],
+ [32.62, -116.28],
+ [32.62, -116.62],
+ [32.9, -116.78],
+ ],
+];
+
+/**
+ * The Salton Sea, and it is the only inland water this board draws.
+ *
+ * A hundred kilometres of open water in the middle of a desert, and by some
+ * distance the most legible single object in the south-east of the frame: the
+ * two dry valleys either side of it are `flats` gold and `upland` rock, both
+ * warm, and the lake is the one cool value for two hundred kilometres in any
+ * direction. It is also the reason the basin around it reads as a basin.
+ *
+ * Drawn as `inlandWater` rather than as a concavity in the landmass, which is
+ * the opposite of the call made for San Francisco Bay twelve hundred lines up
+ * and for the opposite reason: the bay is tidal salt water joined to the
+ * Pacific through the Gate, and this is a closed sump with no outlet at all.
+ * It costs one draw call and it cannot self-intersect the coastline.
+ */
+const SALTON_SEA: LatLng[][] = [
+ [
+ [33.545, -116.09], // the north-west shore, below Mecca
+ [33.436, -116.096],
+ [33.361, -116.054],
+ [33.295, -116.001],
+ [33.234, -115.94],
+ [33.177, -115.873],
+ [33.126, -115.799],
+ [33.083, -115.714],
+ [33.075, -115.58], // the south-east shore, above Calipatria
+ [33.184, -115.574],
+ [33.259, -115.616],
+ [33.325, -115.669],
+ [33.386, -115.73],
+ [33.443, -115.797],
+ [33.494, -115.871],
+ [33.537, -115.956],
+ ],
+];
+
+/**
+ * The built state, as conurbations.
+ *
+ * Every polygon here is an urban *footprint* — the extent of continuous
+ * settlement — because at 806 m to the lot that is the smallest thing this
+ * board can draw with more than a handful of instances in it. Two exceptions
+ * carry a `downtown` palette and a real tower chance: the San Francisco
+ * peninsula tip and the LA basin core. They are only about 140 and 900 lots,
+ * but they are the two the eye is sent to by the chapter list, and a pale
+ * cluster with a few dark towers in it is what makes a patch of speckle read as
+ * a city centre rather than as a gravel bar.
+ *
+ * The count is the constraint. Every lot is ten triangles in the one instanced
+ * `blocks` mesh — ten rather than twelve because at this scale `blocks.ts` drops
+ * the face nobody can get under — and the California board is the tight one:
+ * 650 draw calls and 750k triangles, shared with the aircraft layer. About
+ * eight and a half thousand lots is what the corridor's reclaimed headroom
+ * pays for, which is why the Inland Empire, Sacramento and San Diego are absent
+ * rather than thin. They are all mostly outside `bounds` anyway, and a
+ * half-drawn city is worse than an honest edge.
+ */
+const DISTRICTS: District[] = [
+ // ---- The Los Angeles basin ---------------------------------------------
+ {
+ id: "la-core",
+ name: "Los Angeles",
+ polygon: [
+ [34.12, -118.52],
+ [34.12, -118.15],
+ [33.98, -118.05],
+ [33.86, -118.14],
+ [33.78, -118.3],
+ [33.86, -118.45],
+ [34.02, -118.55],
+ ],
+ gridAngle: 0.63,
+ minHeight: 30,
+ maxHeight: 260,
+ towerChance: 0.035,
+ palette: "downtown",
+ coverage: 0.82,
+ },
+ {
+ id: "san-fernando-valley",
+ name: "San Fernando Valley",
+ polygon: [
+ [34.32, -118.62],
+ [34.32, -118.28],
+ [34.15, -118.28],
+ [34.15, -118.62],
+ ],
+ gridAngle: 0.0,
+ minHeight: 16,
+ maxHeight: 52,
+ towerChance: 0.004,
+ palette: "residential",
+ coverage: 0.7,
+ },
+ {
+ id: "san-gabriel-valley",
+ name: "San Gabriel Valley",
+ polygon: [
+ [34.16, -118.2],
+ [34.16, -117.72],
+ [33.98, -117.68],
+ [33.96, -118.08],
+ ],
+ gridAngle: 0.06,
+ minHeight: 16,
+ maxHeight: 60,
+ towerChance: 0.005,
+ palette: "residential",
+ coverage: 0.68,
+ },
+ {
+ id: "la-harbour",
+ name: "San Pedro & Long Beach",
+ polygon: [
+ [33.86, -118.28],
+ [33.86, -118.09],
+ [33.73, -118.09],
+ [33.73, -118.28],
+ ],
+ gridAngle: 0.1,
+ minHeight: 12,
+ maxHeight: 46,
+ towerChance: 0.006,
+ palette: "industrial",
+ coverage: 0.66,
+ },
+ {
+ id: "orange-county",
+ name: "Orange County",
+ polygon: [
+ [33.9, -118.1],
+ [33.9, -117.72],
+ [33.62, -117.62],
+ [33.5, -117.78],
+ [33.66, -118.05],
+ ],
+ gridAngle: 0.55,
+ minHeight: 14,
+ maxHeight: 54,
+ towerChance: 0.005,
+ palette: "residential",
+ coverage: 0.68,
+ },
+
+ // ---- The Bay Area -------------------------------------------------------
+ {
+ id: "san-francisco",
+ name: "San Francisco",
+ polygon: [
+ [37.81, -122.51],
+ [37.81, -122.37],
+ [37.7, -122.37],
+ [37.7, -122.51],
+ ],
+ gridAngle: 0.46,
+ minHeight: 34,
+ maxHeight: 250,
+ towerChance: 0.06,
+ palette: "downtown",
+ coverage: 0.9,
+ },
+ {
+ id: "peninsula",
+ name: "The Peninsula",
+ polygon: [
+ [37.7, -122.5],
+ [37.7, -122.34],
+ [37.45, -122.1],
+ [37.36, -122.1],
+ [37.4, -122.32],
+ [37.55, -122.45],
+ ],
+ gridAngle: 0.55,
+ minHeight: 14,
+ maxHeight: 60,
+ towerChance: 0.005,
+ palette: "residential",
+ coverage: 0.66,
+ },
+ {
+ id: "santa-clara-valley",
+ name: "San Jose & Santa Clara",
+ polygon: [
+ [37.47, -122.03],
+ [37.44, -121.86],
+ [37.24, -121.72],
+ [37.18, -121.88],
+ [37.32, -122.05],
+ ],
+ gridAngle: 0.62,
+ minHeight: 15,
+ maxHeight: 90,
+ towerChance: 0.008,
+ palette: "residential",
+ coverage: 0.7,
+ },
+ {
+ id: "east-bay",
+ name: "Oakland & the East Bay",
+ polygon: [
+ [38.0, -122.21],
+ [37.98, -122.02],
+ [37.72, -121.86],
+ [37.5, -121.88],
+ [37.55, -122.0],
+ [37.78, -122.22],
+ ],
+ gridAngle: 0.42,
+ minHeight: 15,
+ maxHeight: 90,
+ towerChance: 0.007,
+ palette: "residential",
+ coverage: 0.6,
+ },
+
+ // ---- The Central Valley -------------------------------------------------
+ {
+ id: "stockton",
+ name: "Stockton",
+ polygon: [
+ [38.03, -121.38],
+ [38.03, -121.2],
+ [37.9, -121.18],
+ [37.9, -121.38],
+ ],
+ gridAngle: 0.35,
+ minHeight: 12,
+ maxHeight: 48,
+ towerChance: 0.004,
+ palette: "residential",
+ coverage: 0.66,
+ },
+ {
+ id: "modesto",
+ name: "Modesto & Turlock",
+ polygon: [
+ [37.72, -121.08],
+ [37.72, -120.88],
+ [37.45, -120.82],
+ [37.45, -121.0],
+ ],
+ gridAngle: 0.3,
+ minHeight: 11,
+ maxHeight: 40,
+ towerChance: 0.003,
+ palette: "residential",
+ coverage: 0.46,
+ },
+ {
+ id: "fresno",
+ name: "Fresno",
+ polygon: [
+ [36.88, -119.92],
+ [36.88, -119.66],
+ [36.66, -119.62],
+ [36.66, -119.88],
+ ],
+ gridAngle: 0.28,
+ minHeight: 12,
+ maxHeight: 60,
+ towerChance: 0.005,
+ palette: "residential",
+ coverage: 0.64,
+ },
+ {
+ id: "bakersfield",
+ name: "Bakersfield",
+ polygon: [
+ [35.46, -119.14],
+ [35.46, -118.88],
+ [35.26, -118.86],
+ [35.26, -119.12],
+ ],
+ gridAngle: 0.0,
+ minHeight: 11,
+ maxHeight: 46,
+ towerChance: 0.004,
+ palette: "residential",
+ coverage: 0.62,
+ },
+ {
+ id: "salinas",
+ name: "Salinas",
+ polygon: [
+ [36.86, -121.6],
+ [36.86, -121.44],
+ [36.6, -121.4],
+ [36.6, -121.56],
+ ],
+ gridAngle: 0.72,
+ minHeight: 10,
+ maxHeight: 34,
+ towerChance: 0.002,
+ palette: "residential",
+ coverage: 0.56,
+ },
+
+ // ---- The south coast ----------------------------------------------------
+ {
+ id: "santa-barbara",
+ name: "Santa Barbara",
+ polygon: [
+ [34.46, -119.78],
+ [34.46, -119.6],
+ [34.38, -119.58],
+ [34.38, -119.78],
+ ],
+ gridAngle: 1.05,
+ minHeight: 10,
+ maxHeight: 32,
+ towerChance: 0.002,
+ palette: "residential",
+ coverage: 0.56,
+ },
+ /**
+ * The one district south of the old bounds, and the reason the new coast is
+ * not two hundred kilometres of empty sand.
+ *
+ * Held to a compact core rather than to the real urban extent, which runs
+ * from the border to Oceanside and would be about twelve hundred lots. The
+ * board's triangle budget is spoken for — see the note on `DISTRICTS` — and a
+ * dense small city and a thin large one cost the same to look at, so this is
+ * the downtown, Coronado, the bay shore and the mesas immediately behind
+ * them. `downtown` rather than `residential` for the same reason San
+ * Francisco is: a handful of towers is what makes a patch of speckle read as
+ * a city centre instead of as a gravel bar, and at 806 m to the lot there is
+ * no other channel for it.
+ */
+ {
+ id: "san-diego",
+ name: "San Diego",
+ polygon: [
+ [32.9, -117.24],
+ [32.9, -117.06],
+ [32.7, -116.98],
+ [32.6, -117.06],
+ [32.62, -117.22],
+ [32.78, -117.26],
+ ],
+ gridAngle: 0.18,
+ minHeight: 16,
+ maxHeight: 140,
+ towerChance: 0.02,
+ palette: "downtown",
+ coverage: 0.5,
+ },
+ {
+ id: "ventura-oxnard",
+ name: "Ventura & Oxnard",
+ polygon: [
+ [34.32, -119.32],
+ [34.32, -119.06],
+ [34.15, -119.06],
+ [34.15, -119.28],
+ ],
+ gridAngle: 0.2,
+ minHeight: 11,
+ maxHeight: 40,
+ towerChance: 0.003,
+ palette: "residential",
+ coverage: 0.56,
+ },
];
export const CALIFORNIA_CITY: City = {
id: "california",
name: "California",
- center: { lat: 35.66, lng: -120.15 },
- bounds: { minLat: 33.15, maxLat: 38.05, minLng: -123.05, maxLng: -117.55 },
+ /**
+ * The middle of the board, not the middle of the corridor.
+ *
+ * Scene space is centred here, and the board reaches from -123.05 to -114.0
+ * now that the state's own eastern edge is on it. Left at the corridor's
+ * midpoint the origin sat 1.6° — seventy-five scene units — west of the
+ * middle of the bounds, which puts the satellite dome and the shadow box off
+ * to one side of the thing they are meant to cover.
+ */
+ center: { lat: 35.3, lng: -118.55 },
+
+ /**
+ * The board is the southern two thirds of California, and it stops where the
+ * state stops on three sides out of four.
+ *
+ * It used to stop at -117.55, which is a line through Temecula chosen for no
+ * reason except that the corridor did not need anything east of it. Half the
+ * Mojave, the whole Colorado Desert, the Salton basin, Death Valley and every
+ * one of the Peninsular Ranges were off the board, and — worse for the first
+ * frame anybody sees — the land ended in a ruled north-south line with open
+ * ocean beyond it. `maxLng` is now past Parker, so the eastern edge is the
+ * Colorado River and the Nevada line, which is a silhouette rather than a
+ * crop. `minLat` is the Mexican border for the same reason.
+ *
+ * `maxLat` is still a crop, and stays one: north of 38.05 is the Sacramento
+ * Valley and the Klamaths, which are another two hundred kilometres of board
+ * for a corridor that ends at San Francisco. A crop along the top of the
+ * frame, far from the camera and half in the fog, is the cheap direction to
+ * be wrong in; a crop down the side of the frame at the closest point to the
+ * camera was the expensive one.
+ */
+ bounds: { minLat: 32.55, maxLat: 38.05, minLng: -123.05, maxLng: -114.0 },
latScale: 58,
- verticalExaggeration: 2.25,
- // About 2.2 km. This board is a route atlas; city detail lives one level in.
- cellLat: 0.02,
- cellLng: 0.024,
+
+ /**
+ * 13, against 2.25. See the header table: this is the number that decides
+ * whether the state has mountains on it, and 2.25 put the Sierra 1.6 units
+ * off a board 284 units tall.
+ *
+ * It is shared with buildings, which is the reason it is not higher still. At
+ * 13 a 250 m downtown tower is 1.7 units — about a third of the height of the
+ * Santa Monica Mountains behind it, which is roughly the relationship a
+ * photograph would show. Push the exaggeration to 20 to get an even more
+ * dramatic Sierra and downtown Los Angeles becomes a bed of nails.
+ */
+ verticalExaggeration: 13,
+
+ /**
+ * About 2.4 km, coarsened by a tenth when the board grew east.
+ *
+ * The instinct when a board looks flat is that the lattice is too fine. It was
+ * not: the Sierra is 80 km wide, which is 33 cells at this spacing, and
+ * Southern California renders the San Gabriels — the range that board is
+ * famous for — across 37 coarse cells. Halving the cell here would have
+ * quadrupled the terrain and bought nothing the eye can find, because the
+ * missing structure was vertical, not horizontal.
+ *
+ * The tenth is the price of the eastern half of the state, and it is a price
+ * paid in a currency nobody can see. Extending `bounds` to the Colorado grew
+ * the land under the lattice by 60%, which at the old spacing put the terrain
+ * at 130,622 triangles against 81,546 — half the board's whole spare budget on
+ * one mesh. What matters on screen is not the cell in metres but the cell as a
+ * fraction of the board, because the camera retreats to frame whatever it is
+ * given: 0.022° is 1.27 units on a board now 428 across, where 0.02° was 1.16
+ * units on a board 284 across. The cell is 27% bigger on the ground and 27%
+ * *smaller* in the frame, and the terrain costs 105,762 triangles instead.
+ */
+ cellLat: 0.022,
+ cellLng: 0.027,
+
+ /**
+ * ~2.8 km, or a little over one cell.
+ *
+ * The falloff exists to ramp the terrain grid's stair-stepped rim down onto
+ * the smooth shore plate, so it has to be at least a cell wide. The price is
+ * that Big Sur — where the Santa Lucias stand 1,500 m up within eight
+ * kilometres of the surf — gets a narrow shelf before it climbs. At 1,919 m
+ * to the unit that shelf is under two units wide and the range still reads as
+ * falling into the sea.
+ */
coastFalloff: 0.025,
+
landmasses: [CORRIDOR_LAND],
- parks: [],
- inlandWater: [],
- hills: [
- { name: "Santa Monica Mountains", lat: 34.12, lng: -118.65, elevation: 900, radius: 0.34 },
- { name: "San Emigdio Mountains", lat: 34.88, lng: -119.05, elevation: 2_000, radius: 0.48 },
- { name: "Temblor Range", lat: 35.36, lng: -119.83, elevation: 1_300, radius: 0.56 },
- { name: "Santa Lucia Range", lat: 35.75, lng: -121.25, elevation: 1_580, radius: 0.7 },
- { name: "Diablo Range", lat: 36.63, lng: -121.18, elevation: 1_300, radius: 0.8 },
- { name: "Mount Hamilton", lat: 37.34, lng: -121.64, elevation: 1_280, radius: 0.34 },
- { name: "Santa Cruz Mountains", lat: 37.18, lng: -122.18, elevation: 1_150, radius: 0.52 },
- ],
- districts: [],
+ parks: RANGES,
+ inlandWater: SALTON_SEA,
+ hills: HILLS,
+ districts: DISTRICTS,
landmarks: [
- { name: "Los Angeles", lat: 34.0522, lng: -118.2437, height: 1_100, footprint: 0.055, shape: "tower", color: 0x9b856b, label: true },
- { name: "San Francisco", lat: 37.7749, lng: -122.4194, height: 1_000, footprint: 0.05, shape: "tower", color: 0x8799a8, label: true },
+ /**
+ * Two markers, not two buildings.
+ *
+ * Nothing in Los Angeles is 700 m tall. These are the board's pins — the
+ * chapter list sends you to them and `label: true` prints their names — and
+ * at 1,919 m to the unit an honest 310 m US Bank Tower would be 2.1 units
+ * on a board 284 units across, which is not a destination, it is a speck.
+ * They are deliberately narrow so they read as a spire over the city
+ * speckle rather than as a building sitting on top of it.
+ */
+ { name: "Los Angeles", lat: 34.0522, lng: -118.2437, height: 760, footprint: 0.028, shape: "tower", color: 0xb9a288, label: true },
+ { name: "San Francisco", lat: 37.7749, lng: -122.4194, height: 720, footprint: 0.026, shape: "tower", color: 0x9fb2c2, label: true },
],
bridges: [],
roads: [
@@ -97,7 +1421,16 @@ export const CALIFORNIA_CITY: City = {
shortLabel: "State",
number: "01",
description: "Los Angeles and San Francisco joined as one living route board.",
- focus: { lat: 35.66, lng: -120.15, distance: 370, height: 320, rotation: 0.68 },
+ /*
+ * Framed on the middle of the bounds, not on the middle of the corridor,
+ * and pulled back with the board: `scene.ts` sizes the fog, the far plane
+ * and the orbit limits from `boardSpan`, but the opening pose is authored
+ * here and does not scale itself. The board went from 284 units across to
+ * 428 when the state's own eastern edge arrived, and the old 370/320
+ * stand-off framed the Central Valley with San Diego and the Mojave off
+ * the side of the screen.
+ */
+ focus: { lat: 35.15, lng: -118.35, distance: 408, height: 352, rotation: 0.5 },
},
{
id: "la-sf-us-101",
@@ -132,17 +1465,50 @@ export const CALIFORNIA_CITY: City = {
focus: { lat: 37.7749, lng: -122.4194, distance: 38, height: 26, rotation: 0.8 },
},
],
+
+ /**
+ * California in late summer, which is when the state looks most like itself:
+ * gold grass, dark chaparral, bare granite above the tree line.
+ *
+ * The two changes that matter are `flats` and `upland`. They used to be
+ * 0xb7a16c and 0x9a8155 — two browns half a step apart — so the entire board
+ * was one value and the ranges could not separate from the farmland at any
+ * hour. `flats` is now the Central Valley's harvested gold and `upland` is a
+ * pale granite grey, which is a real difference in *hue* as well as value and
+ * survives both the ACES shoulder at noon and the warm sun at dusk. Green
+ * comes from `parks`; see the header.
+ */
palette: {
skyTop: 0x7da6c9,
skyHorizon: 0xe9d8bb,
- sea: 0x477891,
- lake: 0x527f91,
- shore: 0xc9b789,
- sand: 0xd9c693,
- flats: 0xb7a16c,
- upland: 0x9a8155,
- park: 0x66764c,
- parkHigh: 0x485d43,
+ sea: 0x3c6d8b,
+ lake: 0x4a7d93,
+ shore: 0xc4b184,
+ sand: 0xceba8c,
+ flats: 0xb49b57,
+ /**
+ * Warmer and a shade darker than the grey it used to be, because it is no
+ * longer being asked to be two things at once.
+ *
+ * `upland` was 0xa89d84 — a pale granite — and it had to serve both the
+ * Sierra crest and the Mojave, which are not the same colour in any light.
+ * With `alpine` carrying the rock above 900 m, this is free to be what
+ * covers most of the ground it is actually painted on: creosote and desert
+ * varnish on an alluvial fan, which is a warm mid brown.
+ */
+ upland: 0x9d8a63,
+ /**
+ * Bare granite above the tree line, and the reason the ranges in the
+ * south-east corner are visible at all.
+ *
+ * See `ScenePalette.alpine` and `ALPINE_FROM` in `terrain.ts`. Deliberately
+ * *lighter* than everything under it, which is the way round a photograph
+ * has it: exposed rock and old snow are the brightest ground in the state,
+ * and a board that darkens with altitude reads as a bruise.
+ */
+ alpine: 0xb9b3a4,
+ park: 0x76854e,
+ parkHigh: 0x3d5739,
},
};
diff --git a/src/engine/aircraftGeometry.ts b/src/engine/aircraftGeometry.ts
index d5e4e46..2fa660a 100644
--- a/src/engine/aircraftGeometry.ts
+++ b/src/engine/aircraftGeometry.ts
@@ -149,6 +149,22 @@ const NOSE_BASE_Z = 0.152;
const TAIL_JOINT_Z = -0.055;
const TAIL_TIP_Z = -0.21;
+/**
+ * Nose to tail, in scene units, at `scale = 1`.
+ *
+ * Exported because it is the unit `flights.ts` measures its legibility floor
+ * in: that layer asks "how many of these does the glyph have to be for a person
+ * to see it from here", and the answer has to be phrased in the length this
+ * file actually built rather than in a 0.42 copied into another module and left
+ * behind the day somebody shortens the tail cone.
+ *
+ * It is also the closest thing this aircraft has to a radius. Every extremity —
+ * the wingtips at 0.24 from the origin, the fin tip at 0.22, the nose at 0.21 —
+ * is inside a sphere of this radius with room to spare, which is what makes it
+ * the right number for a pick volume as well as for a size.
+ */
+export const AIRLINER_LENGTH = NOSE_TIP_Z - TAIL_TIP_Z;
+
/**
* How steeply the tail cone sweeps up, as a gradient (rise per unit of z).
*
diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts
index 7d56308..cf49424 100644
--- a/src/engine/blocks.ts
+++ b/src/engine/blocks.ts
@@ -25,6 +25,37 @@ import { seededRandom, type World } from "./world.ts";
const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
+/**
+ * The ground size at which a lot stops being a city block.
+ *
+ * `LOT` is fixed in **scene units**, which is right — the three boards are 1003,
+ * 308 and 284 units across and are looked at from comparable standoffs, so a
+ * lot that is legible on one is legible on the others. But it means a lot is
+ * 40 m in San Francisco, 164 m in Southern California and **806 m** on the
+ * statewide California board, and two things that are correct for a city are
+ * wrong at 806 m:
+ *
+ * - **The street lattice.** Skipping every fourth row and column leaves 44%
+ * of a district unbuilt. At 40 m those gaps are streets. At 806 m they are
+ * eight-hundred-metre voids, and Los Angeles came out as a chequerboard of
+ * separate white squares rather than as a city — the one thing the state
+ * board most needed it to be. Above the threshold the lots tile, and the
+ * district's `coverage` roll does all the thinning, which reads as urban
+ * fabric because its gaps are irregular.
+ * - **Casting shadows.** A shadow caster pays for itself twice, once in the
+ * shadow pass and once in the beauty pass. A 40 m building on a San
+ * Francisco hillside throws a shadow you can see; a 60 m building on a
+ * 806 m lot throws about one pixel, and paying a second pass over a hundred
+ * thousand triangles for it — on the board with the tightest budget of the
+ * three — is not a trade anyone would make on purpose. Lambert still shades
+ * the four walls, which is all the state camera can resolve anyway.
+ * - **The underside.** Same measurement, same argument; see the geometry.
+ *
+ * 260 m is comfortably above Southern California's 164 and far below
+ * California's 806, so neither of the detailed boards changes at all.
+ */
+const NEIGHBOURHOOD_LOT_METRES = 260;
+
const PALETTES = {
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
@@ -108,6 +139,12 @@ export function createBlocks(
const boxes: Box[] = [];
let seedBase = 1337;
+ // See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
+ // them are about how much ground a lot covers rather than about which board
+ // this is — a self-hoster's pack gets the same treatment without naming it.
+ const lotMetres = LOT * world.metresPerUnit;
+ const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;
+
for (const district of world.city.districts) {
const rand = seededRandom(seedBase);
seedBase += 7919;
@@ -137,9 +174,9 @@ export function createBlocks(
const steps = Math.ceil(reach / LOT);
for (let iu = -steps; iu <= steps; iu++) {
- if (((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
+ if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
for (let iv = -steps; iv <= steps; iv++) {
- if (((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
+ if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
@@ -225,6 +262,26 @@ export function createBlocks(
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
+ if (!lotIsABlock) {
+ /**
+ * Drop the underside at neighbourhood scale.
+ *
+ * `BoxGeometry` lays its groups out px, nx, py, ny, pz, nz, two triangles
+ * each, so the six indices from 18 are the floor. A building sits on the
+ * ground and that face is never visible — except on San Francisco's
+ * steepest blocks, where a 40 m lot spanning a 3.6×-exaggerated hillside can
+ * leave a corner clear of the terrain and you would see straight through the
+ * hole. So this is tied to the same measurement as the street lattice and
+ * the shadow pass: at 806 m to the lot the ground under a building is flat
+ * to within a hair and nothing can get beneath it, and one sixth of the
+ * board's largest triangle consumer goes back to the budget.
+ */
+ const index = geometry.getIndex();
+ if (index) {
+ const kept = Array.from(index.array).filter((_, at) => at < 18 || at >= 24);
+ geometry.setIndex(kept);
+ }
+ }
// The per-instance facade data, drawn from a stream of its own.
//
@@ -244,7 +301,7 @@ export function createBlocks(
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
mesh.name = "blocks";
- mesh.castShadow = true;
+ mesh.castShadow = lotIsABlock;
mesh.receiveShadow = true;
const matrix = new THREE.Matrix4();
diff --git a/src/engine/flights.ts b/src/engine/flights.ts
index e6a2355..941e838 100644
--- a/src/engine/flights.ts
+++ b/src/engine/flights.ts
@@ -17,7 +17,7 @@
*/
import * as THREE from "three";
-import { airlinerGeometry } from "./aircraftGeometry.ts";
+import { AIRLINER_LENGTH, airlinerGeometry } from "./aircraftGeometry.ts";
import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
@@ -618,16 +618,23 @@ export function aircraftDetail(
export interface FlightLayer {
group: THREE.Group;
/**
- * The aircraft meshes currently in the sky, as a **live** array, each
- * carrying `userData.aircraftId`.
+ * What a pointer can hit, as a **live** array, each entry carrying
+ * `userData.aircraftId`.
*
- * Here rather than on the caller because only this layer knows which mesh is
+ * Here rather than on the caller because only this layer knows which object is
* which track: the map from id to mesh is private and the group's child order
* is an artefact of when each aircraft appeared. It is the same shape
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
* pick is resolved from the object that was hit, and something has to say
* what the object stands for.
*
+ * What a ray meets is a **sphere** around the aeroplane rather than its
+ * triangles — see `raycastGlyph` — because a seventeen-pixel glyph with
+ * two-pixel wings is a game of marksmanship rather than an interface, and
+ * under a fingertip it is not even that. The array itself holds one object per
+ * *drawn* aircraft, and only while it is drawn: a track kept through a dropped
+ * refresh stops being clickable at the moment it stops being visible.
+ *
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
* position is broadcast unencrypted to anybody with a receiver, so the card
* it opens is available to an anonymous visitor and the picking that reaches
@@ -741,6 +748,69 @@ const MAX_SPAN = 30;
*/
const JUMP_UNITS_PER_SECOND = 8;
+/**
+ * How much of the screen an aeroplane is never allowed to fall below.
+ *
+ * A fraction of the viewport's **height**, and the single number that decides
+ * whether the live thing in this sky is visible at all.
+ *
+ * The arithmetic is unforgiving and it is worth writing down, because every
+ * board in this repo lost it. A glyph `AIRLINER_LENGTH` long at distance `d`
+ * covers `length / (2·d·tan(fov/2))` of the frame; at the 42° field of view
+ * `scene.ts` uses that is `length / (0.767·d)`. The camera sits at up to two
+ * board spans out, and a span is 230 units over San Francisco, 393 over the
+ * Southland and about 580 across California — so a 0.42-unit aeroplane on the
+ * default board came to **0.0005 of the frame, which is two thirds of one
+ * pixel**. That is not a small aeroplane, it is a dead pixel, and a person who
+ * has just arrived reads it as one: the feed was live, the callsigns were real,
+ * and the whole layer was indistinguishable from a smudge on the monitor.
+ *
+ * So the glyph is given a floor in *apparent* size and grows with distance to
+ * hold it. 0.016 is about seventeen pixels of aeroplane on a 1080-tall window
+ * and eleven on a phone — the size a flight-tracker icon is drawn at, which is
+ * the reference this is aiming for and not an accident. Below about 0.012 the
+ * wings stop resolving and it degenerates into the cross it used to be; much
+ * above 0.02 and a dozen of them start to look like a squadron flying formation
+ * over a state, which is the cartoon this is trying not to be.
+ *
+ * Two things this deliberately is *not*:
+ *
+ * - It is not a size in metres. `aircraftGeometry.ts` already argues that
+ * traffic here is a map symbol drawn in 3-D — 40 m over San Francisco, 164 m
+ * over the Southland, the same 0.42 units on both — and a floor in screen
+ * space is the same claim taken to its conclusion. What a person needs from
+ * an aeroplane on a map is its position and its heading, and neither of
+ * those is legible at half a pixel however truthful the span is.
+ * - It is not applied unconditionally. The scale is `max(1, …)`, so the
+ * authored geometry wins whenever the camera is close enough for it to be
+ * legible on its own — about 34 units, or a chapter's worth of standoff.
+ * Zooming in therefore *shrinks* an aeroplane back to the size the file that
+ * drew it intended, rather than leaving a state-sized airliner parked over a
+ * downtown.
+ */
+const GLYPH_MIN_SCREEN_FRACTION = 0.016;
+
+/**
+ * The radius of the sphere a pointer actually has to hit, in glyph lengths.
+ *
+ * The aeroplane's *triangles* are not the hit target and must not be. Even at
+ * the floor above it is seventeen pixels of aeroplane, which is four pixels of
+ * fuselage and a pair of wings a couple of pixels thick — a raycast against
+ * those is a test of mouse marksmanship, and on a touch screen, where the tap
+ * lands under a fingertip eight millimetres across, it is not winnable at all.
+ * The owner's ask was that clicking a plane works for a stranger, and a target
+ * you have to aim at does not.
+ *
+ * One glyph length gives a sphere two aeroplane-lengths across — about
+ * thirty-four pixels at the legibility floor, which is a comfortable tap and is
+ * still small enough that the pointer has to be *on* the aeroplane rather than
+ * merely in the same part of the sky. Two aircraft whose spheres overlap still
+ * resolve to the nearer one: `Raycaster` sorts its hits by distance, so what
+ * wins is the aeroplane in front rather than the one that happened to be created
+ * first.
+ */
+const PICK_RADIUS_GLYPHS = 1;
+
/**
* Altitude, as colour.
*
@@ -842,6 +912,17 @@ interface TrailSample {
interface Track {
mesh: THREE.Mesh;
+ /**
+ * Whether the aeroplane is currently in `pickables`.
+ *
+ * Tracked rather than derived because the answer changes on a rule the array
+ * cannot see — a track that has gone stale is still held and still has a mesh,
+ * and must stop being clickable the moment it stops being drawn. `Raycaster`
+ * has not consulted `visible` since r119, so an aeroplane hidden by `tick` is
+ * still a hit until somebody takes it out of the list, and a card raised on an
+ * aeroplane nobody can see is a card about an aeroplane that is not there.
+ */
+ picking: boolean;
/** Observations, oldest first. The last is where the aircraft is heading. */
samples: TrailSample[];
/** Seconds the current leg should take: the measured gap between the last two. */
@@ -911,6 +992,22 @@ export function createFlightLayer(world: World): FlightLayer {
const scratch = new THREE.Color();
+ /**
+ * The camera the layer was last drawn for, or `null` before the first frame.
+ *
+ * The layer has no camera of its own and CONTRACT §1 is why it must not
+ * acquire one: the camera belongs to the scene's `SceneKit`, and a layer that
+ * took a second reference would have to be told when the scene swapped. It is
+ * read off `onBeforeRender` instead, which is handed the camera actually being
+ * rendered for — so an office looking at the same layer through a different
+ * camera would size the glyphs for *that* view without anything having to be
+ * wired up.
+ *
+ * Before the first frame there is no answer and the glyphs stay at their
+ * authored size, which is the size they were before any of this existed.
+ */
+ let viewer: THREE.PerspectiveCamera | null = null;
+
/**
* One material per altitude band, built on demand.
*
@@ -966,7 +1063,18 @@ export function createFlightLayer(world: World): FlightLayer {
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
// that would rather drive the layer itself can call it and nothing here
// double-counts.
- trailLine.onBeforeRender = () => tick();
+ // It also carries the camera in, which is the only reason this layer knows how
+ // far away it is being looked at from — see `viewer`. Like every other write
+ // `tick` makes, the scale it computes here lands on the *next* frame: world
+ // matrices were resolved before any `onBeforeRender` ran. That is how this
+ // layer's position and attitude have always worked, it is one frame at 60 Hz,
+ // and the alternative is a camera reference this file is not entitled to hold.
+ trailLine.onBeforeRender = (_renderer, _scene, camera) => {
+ if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
+ viewer = camera as THREE.PerspectiveCamera;
+ }
+ tick();
+ };
group.add(trailLine);
// ---- Observations -------------------------------------------------------
@@ -990,10 +1098,14 @@ export function createFlightLayer(world: World): FlightLayer {
// The id, on the object, so a raycast hit resolves to an aeroplane
// without this layer having to expose its private track table.
mesh.userData.aircraftId = a.id;
+ // The pointer aims at the aeroplane and hits a sphere around it. See
+ // `raycastGlyph`.
+ mesh.raycast = raycastGlyph;
group.add(mesh);
pickables.push(mesh);
track = {
mesh,
+ picking: true,
samples: [],
span: MIN_SPAN,
pitch: 0,
@@ -1112,15 +1224,38 @@ export function createFlightLayer(world: World): FlightLayer {
}
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
+ setPickable(track, false);
+ // The geometry and the material are shared by every aircraft in the sky
+ // and belong to the layer, which frees them once in `dispose`.
group.remove(track.mesh);
- const at = pickables.indexOf(track.mesh);
- if (at >= 0) pickables.splice(at, 1);
tracks.delete(id);
}
tick();
}
+ /**
+ * Put a track into the pick list, or take it out.
+ *
+ * Idempotent, and the flag is what makes it cheap: a hundred aircraft holding
+ * station would otherwise walk the array with `indexOf` on every frame to
+ * discover that nothing had changed.
+ *
+ * `pickables` is spliced rather than rebuilt because `scene.ts` hands the
+ * array itself to the picker as a live target list — see `FlightLayer` — so
+ * the identity of the array has to survive.
+ */
+ function setPickable(track: Track, on: boolean) {
+ if (track.picking === on) return;
+ track.picking = on;
+ if (on) {
+ pickables.push(track.mesh);
+ return;
+ }
+ const at = pickables.indexOf(track.mesh);
+ if (at >= 0) pickables.splice(at, 1);
+ }
+
/**
* Whether a feed has handed back the same position it did last time.
*
@@ -1176,12 +1311,34 @@ export function createFlightLayer(world: World): FlightLayer {
*/
track.stale = track.missingSince !== 0 && now - to.at > track.span;
track.mesh.visible = !track.stale;
+ // An aeroplane that is no longer drawn must no longer be clickable, and
+ // saying so is not optional: `Raycaster` does not consult `visible`, so a
+ // hidden track left in the list goes on opening its card from a patch of
+ // empty sky for the rest of the grace period.
+ 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;
track.mesh.position.copy(track.head);
+ /**
+ * Big enough to be an aeroplane from wherever this is being watched.
+ *
+ * Per aircraft rather than once for the layer, because the board is deep:
+ * on the California corridor an arrival over Los Angeles and one over the
+ * Bay are hundreds of units apart along the view axis, and a single scale
+ * taken from the camera's orbit distance would leave the far one half the
+ * size of the near one — which reads as depth on a photograph and as an
+ * inconsistency on a map, where two aeroplanes at the same altitude are
+ * the same aeroplane.
+ *
+ * Uniform, so nothing about the shape changes: this is the glyph the
+ * geometry file drew, held at a legible size, and not a stretched one.
+ */
+ if (viewer !== null) {
+ track.mesh.scale.setScalar(glyphScale(viewer.position.distanceTo(track.head), viewer.fov));
+ }
// A heading of 0 is north, and north is -z, so an aircraft whose nose is
// modelled along +z has to be turned all the way round before the compass
// and the scene agree. The previous mapping was a bare negation of the
@@ -1351,6 +1508,92 @@ export function createFlightLayer(world: World): FlightLayer {
+/**
+ * How much to enlarge an aeroplane so that it is still an aeroplane from here.
+ *
+ * Pure arithmetic on two numbers the camera already knows, extracted so it can
+ * be tested without a WebGL context — the defect it exists to fix is a *visual*
+ * one and can only be confirmed with a picture, but the ratio it turns on is
+ * exactly the kind of thing that regresses silently under a refactor.
+ *
+ * `2·distance·tan(fov/2)` is the world-space height of the frustum at that
+ * distance — the ruler the frame is measured with — so the glyph's share of the
+ * screen is its length over that. Solving for the length that hits
+ * `GLYPH_MIN_SCREEN_FRACTION` and dividing by the length the geometry was
+ * authored at gives the scale, and `Math.max(1, …)` is the floor rather than a
+ * fit: an aeroplane close enough to read at its authored size keeps it.
+ *
+ * Degenerate inputs return 1 rather than throwing. A camera at zero distance
+ * from an aircraft is the chase view, a camera with no field of view is a
+ * caller in the middle of setting one up, and neither is a reason for the sky
+ * to disappear.
+ */
+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);
+}
+
+/**
+ * Scratch for `raycastGlyph`. Module-level and reused: a raycast runs once a
+ * frame against every aeroplane in the sky, and four hundred of them allocating
+ * a `Sphere` and a `Vector3` apiece is a garbage-collection pause a pointer can
+ * feel.
+ */
+const pickSphere = new THREE.Sphere();
+const pickPoint = new THREE.Vector3();
+const pickScale = new THREE.Vector3();
+
+/**
+ * What a pointer hits when it aims at an aeroplane: a sphere, not the aeroplane.
+ *
+ * Assigned onto each aircraft mesh in place of `Mesh.raycast`, which is the
+ * extension point three.js publishes for exactly this — `Object3D.raycast` is
+ * documented as the method a subclass or an instance supplies to say how it
+ * meets a ray, and `Points` and `Line` already answer it with a threshold
+ * instead of with geometry for the same reason this does.
+ *
+ * The reason is `PICK_RADIUS_GLYPHS`: at any camera distance where this layer is
+ * worth looking at, the aeroplane's own triangles are a few pixels of fuselage
+ * and a wing two pixels thick, and a pointer test against those is a game of
+ * marksmanship rather than an interface. A sphere at the glyph's own scale is
+ * the target a person thinks they are aiming at.
+ *
+ * Doing it here rather than with a second invisible object in the scene is worth
+ * the unusual assignment. A proxy mesh would be another `Object3D` per aircraft
+ * — four hundred more nodes to walk and four hundred more world matrices to
+ * compose every frame, for something that is never drawn — and it would have to
+ * be kept in step with the aeroplane's position and scale by hand. This costs
+ * one sphere test, allocates nothing, and cannot drift out of step because there
+ * is nothing to keep in step with.
+ *
+ * The scale is read off `matrixWorld` rather than off `this.scale` on purpose:
+ * the layer's group is at the identity today, and a raycast that silently starts
+ * lying if somebody ever moves or scales it is precisely the class of bug this
+ * file's comments keep recording.
+ */
+function raycastGlyph(
+ this: THREE.Mesh,
+ raycaster: THREE.Raycaster,
+ intersects: THREE.Intersection[],
+): void {
+ pickSphere.center.setFromMatrixPosition(this.matrixWorld);
+ pickScale.setFromMatrixScale(this.matrixWorld);
+ pickSphere.radius = PICK_RADIUS_GLYPHS * AIRLINER_LENGTH * pickScale.x;
+ // `intersectSphere` answers with the near hit, or with the far one when the
+ // ray starts inside — so a camera flying through the sphere still picks the
+ // aeroplane it is inside rather than nothing at all.
+ if (raycaster.ray.intersectSphere(pickSphere, pickPoint) === null) return;
+ const distance = raycaster.ray.origin.distanceTo(pickPoint);
+ // The near/far clamp is the caller's contract and `Mesh.raycast` honours it;
+ // an aeroplane behind the camera must not be pickable through the back of it.
+ if (distance < raycaster.near || distance > raycaster.far) return;
+ // Cloned rather than shared: the caller keeps the intersection, and every hit
+ // in a frame would otherwise be handed the same point object.
+ intersects.push({ distance, point: pickPoint.clone(), object: this });
+}
+
/**
* The climb angle of a leg, from the real numbers rather than the scene's.
*
diff --git a/src/engine/officeExterior.ts b/src/engine/officeExterior.ts
index a3cbc7d..191cc45 100644
--- a/src/engine/officeExterior.ts
+++ b/src/engine/officeExterior.ts
@@ -7,6 +7,18 @@
* a charge post, and a Model X parked in it whose lamps and cabin reflect a live
* {@link VehicleTelemetryState}.
*
+ * ### Not every anchor gets one
+ *
+ * An apron is ground, and one shipped site has none: `lumbridge-hq`'s studio
+ * floor is 188 m above the pavement outside it. A pack cannot author a stall
+ * anywhere but its own plan frame, so its anchor is a podium kerb described at
+ * a storey that is nowhere near the podium — and drawn literally, that is a
+ * paved pad and a car hanging in open sky beside a wall, which is what this
+ * product shipped for one build. `createOfficeExterior` therefore returns an
+ * inert, empty exterior for a site whose arrival storey is not on the ground.
+ * The rule is `arrivalGroundFor(site.elevation)` and is discussed where it
+ * lives, in `transport/exteriorVehicle.ts`.
+ *
* ### Why the car stands here rather than on the board
*
* There has been a Model X in this product since the freeway corridor shipped,
@@ -53,8 +65,8 @@ import {
} from "../assets/vehicles/index.ts";
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
import {
- apronKindFor,
apronMetrics,
+ arrivalGroundFor,
exteriorVehicleAppearance,
lampTint,
parkPose,
@@ -137,11 +149,42 @@ export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExte
if (arrival.label) root.userData.label = arrival.label;
if (site.label) root.userData.siteLabel = site.label;
- const kind = apronKindFor(site.elevation);
- const metrics = apronMetrics(
- { length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width },
- kind,
- );
+ // ---- Is there anywhere to put it? ---------------------------------------
+ //
+ // Everything below this line asserts ground: a paved pad, a 135 mm kerb, a
+ // charge post bolted to it and a car standing on all three. `site.elevation`
+ // is the one number that says whether that ground exists — it measures the
+ // arrival storey's floor against **the ground outside**, not against sea level
+ // — and above `GROUND_ARRIVAL_MAX_ELEVATION_M` it does not.
+ //
+ // The alternative shipped for one build and is the reason this check exists:
+ // `lumbridge-hq` is 188 m up a Transbay tower, the apron was built at its
+ // floor because that is the frame the stall is authored in, and the arrival
+ // viewpoint showed a Model X standing on top of a wall with nothing under it.
+ // See `ArrivalGround` for the two other repairs that were weighed and why the
+ // rule is derived from the site rather than from an office id.
+ //
+ // An inert exterior rather than `null` on purpose. The caller adds this object
+ // to the level group and calls `apply()` on every telemetry publish; making it
+ // return an empty root keeps that caller free of a question about architecture
+ // it should not have to ask, and an empty `Group` costs no draw call and no
+ // triangle. `setVehicleTelemetry` already documents itself as a possible
+ // no-op.
+ if (arrivalGroundFor(site.elevation) === "air") {
+ root.userData.suppressed = "no-ground";
+ return {
+ object: root,
+ apply() {},
+ dispose() {
+ root.clear();
+ },
+ };
+ }
+
+ const metrics = apronMetrics({
+ length: MODEL_X_METRICS.length,
+ width: MODEL_X_METRICS.width,
+ });
// Geometries this layer minted and must free. Registry materials are not in
// here and must not be: they belong to the office this apron stands outside.
@@ -158,8 +201,7 @@ export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExte
apron.rotation.y = arrival.rotation;
root.add(apron);
- const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a;
- const paving = materials.tinted("polishedConcrete", pavingColor);
+ const paving = materials.tinted("polishedConcrete", 0x6f7370);
const kerbMaterial = materials.get("skirting");
const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
const postShell = materials.get("deviceShell");
diff --git a/src/engine/scene.ts b/src/engine/scene.ts
index 8e4f398..8e28a04 100644
--- a/src/engine/scene.ts
+++ b/src/engine/scene.ts
@@ -341,6 +341,15 @@ export async function createScene(
);
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
+ /**
+ * The far end of the orbit, held rather than written twice.
+ *
+ * `chapterPose` below has to know it: a pose beyond it is not obeyed, it is
+ * silently clamped by `OrbitControls` on the next update, so a framing
+ * correction that asks for more than this is a framing correction that does
+ * nothing. See the note on the option itself for where 2.0 comes from.
+ */
+ const orbitMaxDistance = boardSpan * 2.0;
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
@@ -373,7 +382,7 @@ export async function createScene(
* The far plane already covers it: the furthest thing from the camera is then
* the back of the dome at 2.7 spans, against `far` at 3.0.
*/
- maxDistance: boardSpan * 2.0,
+ maxDistance: orbitMaxDistance,
shadowExtent: boardSpan * 0.75,
/**
* The middle of the board, which is **not** the origin.
@@ -566,12 +575,18 @@ export async function createScene(
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
+ const scale = chapterFraming({
+ aspect: kit.camera.aspect,
+ reach: Math.hypot(ch.focus.distance, ch.focus.height),
+ boardSpan,
+ orbitMax: orbitMaxDistance,
+ });
return {
target: new THREE.Vector3(x, groundY, z),
position: new THREE.Vector3(
- x + Math.sin(ch.focus.rotation) * ch.focus.distance,
- groundY + ch.focus.height,
- z + Math.cos(ch.focus.rotation) * ch.focus.distance,
+ x + Math.sin(ch.focus.rotation) * ch.focus.distance * scale,
+ groundY + ch.focus.height * scale,
+ z + Math.cos(ch.focus.rotation) * ch.focus.distance * scale,
),
};
}
@@ -862,6 +877,76 @@ export async function createScene(
* It exists so the engine renders with no server, no clock and no config, which
* is the acceptance test the whole repo is held to.
*/
+/**
+ * How wide a screen a `Chapter.focus` was written for.
+ *
+ * Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
+ * field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
+ * much of a board is in frame sideways is entirely a fact about the shape of the
+ * window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
+ * an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
+ * whole state on a laptop frames the middle third of it and runs the rest off
+ * both sides of the screen.
+ */
+const AUTHORED_ASPECT = 1.6;
+
+/**
+ * Where a stand-off stops being a whole-board shot and starts being a place on
+ * it, in board spans. See `chapterFraming`.
+ */
+const WHOLE_BOARD_FROM = 0.8;
+const WHOLE_BOARD_TO = 1.2;
+
+/**
+ * How much further back to stand than the pose asked for, given the window it
+ * is actually being looked at through.
+ *
+ * Returns a multiplier applied to `distance` **and** `height` together, so the
+ * angle the board is seen from — which is most of the character of a pack's
+ * opening shot — is exactly preserved and only the stand-off changes.
+ *
+ * Three things bound it, and the second and third were both learned from a
+ * screenshot rather than from the arithmetic.
+ *
+ * - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
+ * because the width in frame at a given distance is linear in the aspect.
+ * A window at least as wide as the authored one gets nothing at all.
+ * - **The orbit's own ceiling.** `setPose` hands the camera to
+ * `OrbitControls`, which clamps to `maxDistance` on its next update, so a
+ * correction that asks for more than that is not a correction — the pose
+ * quietly becomes something nobody wrote.
+ * - **Whether the pose was ever about the whole board.** Correcting every
+ * pose was tried and it ruins the close-ups: Southern California's opening
+ * shot stands off half a board span and is a dense city filling a tall
+ * screen to all four edges, and pulling it back three times put it in the
+ * top third of the frame over half a screen of empty ocean. Widening the
+ * frame is a correction for a shot that was trying to hold something and
+ * no longer can; on a shot that was deliberately *inside* its subject it is
+ * a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
+ * correction is off; above `WHOLE_BOARD_TO` it is fully on.
+ */
+export function chapterFraming(options: {
+ aspect: number;
+ reach: number;
+ boardSpan: number;
+ orbitMax: number;
+}): number {
+ const { aspect, reach, boardSpan, orbitMax } = options;
+ if (!Number.isFinite(reach) || reach <= 0) return 1;
+ const wanted =
+ Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
+ ? AUTHORED_ASPECT / aspect
+ : 1;
+ const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
+ const wide = Math.max(1, Math.min(wanted, ceiling));
+ const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
+ const share = Math.max(
+ 0,
+ Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
+ );
+ return 1 + (wide - 1) * share;
+}
+
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
return {
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts
index d4c34e7..e1558a7 100644
--- a/src/engine/scenekit.ts
+++ b/src/engine/scenekit.ts
@@ -51,6 +51,20 @@ const TOUCH_ROTATE_SCALE = 0.7;
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
+/**
+ * How long after a touch a `pointerType: "mouse"` event is assumed to be the
+ * browser's compatibility replay of that touch rather than a real mouse.
+ *
+ * Chrome finishes a tap by re-dispatching it as mouse events for pages written
+ * before pointer events existed, and the tail of that replay is a
+ * `pointerout`/`pointerleave` pair whose `pointerType` is `"mouse"`. Measured on
+ * the deployed build it lands about 32 ms after the tap; 800 ms is far enough
+ * out to cover a loaded phone and still shorter than any deliberate reach for a
+ * trackpad. Being wrong in this direction costs a hybrid laptop one stale card
+ * until the mouse moves again; being wrong in the other direction means no
+ * detail card can ever be read on a phone at all.
+ */
+const COMPAT_MOUSE_MS = 800;
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
@@ -319,9 +333,8 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.target.updateMatrixWorld();
const sunDirection = new THREE.Vector3();
- let sky: THREE.Texture | null = null;
- let skyTop = -1;
- let skyHorizon = -1;
+ const dome = makeSkyDome();
+ let domeAttached = false;
function applyLighting(state: LightingState) {
const [dx, dy, dz] = state.sun.direction;
@@ -358,14 +371,34 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
ambient.color.setHex(state.ambient.color);
ambient.intensity = state.ambient.intensity;
- // A null sky leaves `scene.background` alone entirely, which is what an
- // office wants: it has walls, and whatever is behind them is not sky.
- if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) {
- sky?.dispose();
- sky = makeSkyTexture(state.sky.top, state.sky.horizon);
- skyTop = state.sky.top;
- skyHorizon = state.sky.horizon;
- scene.background = sky;
+ // A null sky leaves the background alone entirely, which is what an office
+ // wants: it has walls, and whatever is behind them is not sky.
+ if (state.sky) {
+ if (!domeAttached) {
+ scene.add(dome);
+ domeAttached = true;
+ }
+ const u = dome.material.uniforms;
+ (u.uTop!.value as THREE.Color).setHex(state.sky.top, THREE.LinearSRGBColorSpace);
+ (u.uHorizon!.value as THREE.Color).setHex(state.sky.horizon, THREE.LinearSRGBColorSpace);
+ (u.uSunColor!.value as THREE.Color).setHex(state.sun.color, THREE.LinearSRGBColorSpace);
+ (u.uSunDirection!.value as THREE.Vector3).set(dx, dy, dz).normalize();
+ /**
+ * The glow follows the *key*, not the direction, and that is what keeps
+ * it off the night sky.
+ *
+ * `atmosphere.ts` floors the sun's direction at `shadowFloorDeg` — seven
+ * degrees — so that the shadow camera stays usable, which means the
+ * vector in a `LightingState` never actually sets. Taken literally it
+ * would park a sunrise on the horizon all night, at the azimuth the sun
+ * went down at. The intensity is the honest signal: it collapses through
+ * dusk and what is left at 2 a.m. is the moon's, so scaling by it gives a
+ * glow that fades out with the daylight it belongs to.
+ */
+ u.uSunGlow!.value = Math.min(1, Math.max(0, state.sun.intensity / SUN_GLOW_FULL_INTENSITY));
+ } else if (domeAttached) {
+ scene.remove(dome);
+ domeAttached = false;
}
if (!state.fog) {
@@ -481,10 +514,13 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
let tapX = 0;
let tapY = 0;
let tapAt = 0;
+ /** When the glass was last touched, in `event.timeStamp` units. */
+ let lastTouchAt = Number.NEGATIVE_INFINITY;
function onPointerDown(event: PointerEvent) {
applyPointerProfile(event.pointerType);
if (event.pointerType !== "touch") return;
+ lastTouchAt = event.timeStamp;
resetPick();
tapPointer = tapPointer === -1 ? event.pointerId : -2;
tapX = event.clientX;
@@ -495,6 +531,7 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
+ lastTouchAt = event.timeStamp;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
@@ -514,11 +551,28 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
wasPicking?.onChange(null);
}
- // Not for touch. A finger lifting fires `pointerleave` immediately after
- // `pointerup`, so honouring it here would wipe the pick a tap had just made,
- // in the same frame, every time.
+ /*
+ * Not for touch, and not for the compatibility mouse either.
+ *
+ * A finger lifting fires `pointerleave` immediately after `pointerup`, so
+ * honouring that would wipe the pick a tap had just made, in the same frame,
+ * every time. That much was anticipated. What was not is the *second* leave:
+ * Chrome replays a finished tap as legacy mouse events, and the recorded tail
+ * of a real tap on the canvas is
+ *
+ * pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
+ * mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
+ *
+ * — so the last event of a tap is a `pointerleave` claiming to be a mouse,
+ * about 32 ms later. Filtering on `pointerType` alone let that one through,
+ * which called `resetPick()` and fired `onChange(null)`: the card was written
+ * to the page and blanked before a thumb had left the glass, and no detail
+ * card of any kind could be read on a phone. It is a clock that tells these
+ * apart, not a type.
+ */
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
+ if (event.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
resetPick();
}
dom.addEventListener("pointerleave", onPointerLeave);
@@ -606,29 +660,167 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.dispose();
hemisphere.dispose();
ambient.dispose();
- sky?.dispose();
- if (scene.background === sky) scene.background = null;
+ if (domeAttached) scene.remove(dome);
+ domeAttached = false;
+ dome.geometry.dispose();
+ dome.material.dispose();
},
};
}
/**
- * A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture
- * is stretched to fill regardless, so the width buys nothing.
+ * The sun intensity at which the sky's glow around it is at full strength.
+ *
+ * `atmosphere.ts`'s day stops sit at 2.6, so this is reached a little before
+ * noon and held; everything below it — the whole of dusk and all of the
+ * night — scales down from there. See the note at the call site for why the
+ * intensity and not the direction is what the glow is allowed to read.
*/
-function makeSkyTexture(top: number, horizon: number): THREE.Texture {
- const canvas = document.createElement("canvas");
- canvas.width = 2;
- canvas.height = 256;
- const ctx = canvas.getContext("2d");
- if (!ctx) throw new Error("2D canvas context unavailable");
- const grad = ctx.createLinearGradient(0, 0, 0, 256);
- grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
- grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
- ctx.fillStyle = grad;
- ctx.fillRect(0, 0, 2, 256);
- const tex = new THREE.CanvasTexture(canvas);
- tex.magFilter = THREE.LinearFilter;
- tex.colorSpace = THREE.SRGBColorSpace;
- return tex;
+const SUN_GLOW_FULL_INTENSITY = 1.9;
+
+/**
+ * The sky, as a mesh in the world rather than a gradient on the screen.
+ *
+ * ## What was wrong with the gradient
+ *
+ * `Scene.background` with a plain 2D texture is drawn by three onto a
+ * screen-filling quad: the top of the *viewport* is `skyTop` and the bottom of
+ * the viewport is `skyHorizon`, whatever the camera happens to be doing. That
+ * is not a sky, it is a wash, and on a map board — where the camera is almost
+ * always tilted down and the true horizon sits high in the frame — it fails in
+ * a way you can name from a screenshot:
+ *
+ * - At dusk the warm band appeared along the **bottom** of the picture, under
+ * the board, while the actual horizon at the top of the frame stayed the
+ * deep blue of the zenith. The sunset was rendered upside down.
+ * - The world's far edge is faded out by `THREE.Fog` into `fog.color`, which
+ * `atmosphere.ts` makes the horizon colour exactly so the two meet. They
+ * could not meet, because the horizon colour was not at the horizon, so
+ * there was a visible seam wherever the ground ran out — and
+ * `interiors/daylight.ts` documents pinning its own horizon stop to the fog
+ * colour to hide it, which is the symptom stated in the source.
+ *
+ * ## Why a dome and not an equirectangular background
+ *
+ * three renders `Scene.background` in world space only for a `CubeTexture` or a
+ * PMREM (`CubeUVReflectionMapping`); a 2D texture tagged
+ * `EquirectangularReflectionMapping` still takes the screen-space plane path.
+ * Getting a world-oriented sky out of the background slot therefore means
+ * running a `PMREMGenerator` over a gradient on every colour change, which is
+ * the sharpest thing in the frame put through a blur chain built to destroy
+ * detail. A dome is one draw call, a thousand triangles, and it can also do the
+ * two things a gradient texture cannot: put the glow **around the sun** rather
+ * than uniformly around the compass, and keep the horizon band tight.
+ *
+ * ## How it sits in the scene
+ *
+ * `depthTest: false` with `renderOrder` far negative, which is exactly how
+ * three's own background box works: it is drawn first, writes no depth, and
+ * every other object in the scene paints over it. That makes the radius
+ * irrelevant — nothing is ever compared against it — so the sphere is a unit
+ * one, recentred on the camera in `onBeforeRender`, and can never be clipped by
+ * a near or far plane however large the board is.
+ *
+ * ## Colour, and why nothing is converted
+ *
+ * The components are written straight out with no tone mapping and no output
+ * transform, which reproduces exactly what the old texture path did: an
+ * sRGB-tagged background is decoded on sample and re-encoded on write, and
+ * three sets `toneMapped = false` for it. So `LightingState.sky` is displayed
+ * as the number the atmosphere table wrote, which is what
+ * `render/toneMapping.test.ts` asserts about those columns. Hence
+ * `setHex(hex, LinearSRGBColorSpace)` at the call site: it loads the byte
+ * values without a colour-space conversion, because the shader is not
+ * performing one either.
+ */
+function makeSkyDome(): THREE.Mesh {
+ const material = new THREE.ShaderMaterial({
+ uniforms: {
+ uTop: { value: new THREE.Color(0x8fb8d8) },
+ uHorizon: { value: new THREE.Color(0xd9e6ee) },
+ uSunDirection: { value: new THREE.Vector3(0, 1, 0) },
+ uSunColor: { value: new THREE.Color(0xffffff) },
+ uSunGlow: { value: 0 },
+ },
+ vertexShader: `
+varying vec3 vDirection;
+void main() {
+ // The dome is only ever translated, never rotated or scaled, so a unit
+ // sphere's own vertex position is already the world direction it stands for.
+ vDirection = position;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
+}
+`,
+ fragmentShader: `
+uniform vec3 uTop;
+uniform vec3 uHorizon;
+uniform vec3 uSunDirection;
+uniform vec3 uSunColor;
+uniform float uSunGlow;
+varying vec3 vDirection;
+
+void main() {
+ vec3 direction = normalize( vDirection );
+ float height = direction.y;
+
+ /*
+ * The exponent is what makes this read as air rather than as a ramp. A
+ * linear zenith-to-horizon blend spends half its colour change in the top
+ * forty-five degrees of sky, where there is nothing to see; a real sky does
+ * almost all of it in the first fifteen degrees above the horizon, which is
+ * also the only part of it a map board ever has in frame.
+ */
+ vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) );
+
+ // Below the horizon there is no sky. The ocean covers this in the city and
+ // the ground plane covers it in a sited office, so what it has to be is
+ // simply *not brighter* than the horizon it sits under.
+ color = mix( color, uHorizon * 0.82, clamp( - height * 5.0, 0.0, 1.0 ) );
+
+ /*
+ * Two glows, one warm quarter of sky.
+ *
+ * The first is round and centred on the sun: the aureole, tight enough to
+ * say where the sun is without drawing a disc — a hard white disc at map
+ * scale reads as a rendering artefact, and the environment map already
+ * carries a real sun lobe for anything reflective to catch.
+ *
+ * The second hugs the horizon and falls off with the *azimuth* to the sun,
+ * which is the half of a sunset the atmosphere's colour table cannot
+ * express: its keyframes are one horizon colour for the whole compass, so
+ * without this the sky behind the viewer is as orange as the sky the sun is
+ * setting into.
+ */
+ float toSun = max( dot( direction, uSunDirection ), 0.0 );
+ float aureole = pow( toSun, 5.0 ) * 0.22 + pow( toSun, 90.0 ) * 0.30;
+
+ vec2 flat0 = normalize( vec2( direction.x, direction.z ) + 1e-5 );
+ vec2 flatSun = normalize( vec2( uSunDirection.x, uSunDirection.z ) + 1e-5 );
+ float azimuth = max( dot( flat0, flatSun ), 0.0 );
+ float band = exp( - abs( height ) * 6.0 ) * pow( azimuth, 2.5 ) * 0.18;
+
+ color += uSunColor * uSunGlow * ( aureole + band );
+ gl_FragColor = vec4( color, 1.0 );
+}
+`,
+ side: THREE.BackSide,
+ depthTest: false,
+ depthWrite: false,
+ fog: false,
+ toneMapped: false,
+ });
+
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), material);
+ mesh.name = "sky";
+ // First in the opaque list, before anything that could occlude it.
+ mesh.renderOrder = -1000;
+ // The pose is written below, after culling would have run, so culling must
+ // not be allowed to run: the bounding sphere three would test is the one at
+ // the origin, which is nowhere near where this is drawn.
+ mesh.frustumCulled = false;
+ mesh.matrixAutoUpdate = false;
+ mesh.onBeforeRender = (_renderer, _scene, camera) => {
+ mesh.matrixWorld.copyPosition(camera.matrixWorld);
+ };
+ return mesh;
}
diff --git a/src/engine/stage.ts b/src/engine/stage.ts
index 1cf5407..d0673c7 100644
--- a/src/engine/stage.ts
+++ b/src/engine/stage.ts
@@ -176,13 +176,15 @@ export function deviceProfile(): DeviceProfile {
* Halved on a phone, and the city barely knows.
*
* Check what is actually in that map before defending its size. On the city
- * board the only casters are the buildings, the landmarks and the bridges —
- * `terrain.ts` sets `receiveShadow` and never `castShadow`, so the hills'
- * relief is the Lambert term and not a shadow at all. And `scene.ts` hands
- * the kit a shadow extent of 0.75 board spans, which for the Bay Area's
- * 1003 units is a 1504-unit box: at 2048 texels that is 0.73 units, about
- * 69 m at this city's scale, and a building footprint is one texel or less.
- * The map is already quantising past the things in it.
+ * board the casters are the buildings, the landmarks, the bridges and —
+ * since the relief started shadowing itself — a decimated copy of the
+ * terrain, a quarter of its triangles, appended to its own index buffer and
+ * swung in by `drawRange` for the depth pass alone (see `terrain.ts`,
+ * `SHADOW_CASTER_STRIDE`). And `scene.ts` hands the kit a shadow extent of
+ * 0.75 board spans, which for the Bay Area's 1003 units is a 1504-unit box:
+ * at 2048 texels that is 0.73 units, about 69 m at this city's scale, and a
+ * building footprint is one texel or less. The map is already quantising
+ * past the things in it.
*
* So 1024 on a phone costs the map a resolution it was not using. An office
* passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is
diff --git a/src/engine/structures.ts b/src/engine/structures.ts
index f4ff540..83c12f6 100644
--- a/src/engine/structures.ts
+++ b/src/engine/structures.ts
@@ -172,24 +172,51 @@ function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE
}
/**
- * A draped, flat road deck. A tube turns a freeway into a raised pipeline.
+ * A draped strip running between two parallel offsets from a path, each at its
+ * own lateral distance and its own height.
*
- * The UVs run 0..1 across the carriageway and in **metres** along it, which is
- * the sane convention if anyone ever puts a surface texture on a road. Right
- * now nothing does, and they are here for a duller reason: `mergeGeometries`
- * only merges geometries whose attribute sets match exactly, so a strip without
- * UVs cannot share a bucket with the tube barriers beside it.
+ * The flat symmetric case is a road deck; the asymmetric case is an embankment
+ * batter, and it is the reason this generalised. A ribbon whose two rails sit
+ * at different heights has a **tilted normal**, which is the entire mechanism
+ * by which a freeway stops reading as a line drawn on the ground: the crown
+ * catches the sun and the two flanks do not, so the corridor has a lit edge and
+ * a shaded one at every hour instead of being one flat value.
+ *
+ * The UVs run 0..1 across the strip and in **metres** along it, which is the
+ * sane convention if anyone ever puts a surface texture on a road. Right now
+ * nothing does, and they are here for a duller reason: `mergeGeometries` only
+ * merges geometries whose attribute sets match exactly, so a strip without UVs
+ * cannot share a bucket with the tube barriers beside it.
*/
-function roadRibbonGeometry(
+function bandGeometry(
points: readonly THREE.Vector3[],
- width: number,
- lift = 0,
+ offsetA: number,
+ liftA: number,
+ offsetB: number,
+ liftB: number,
): THREE.BufferGeometry {
+ /**
+ * The rail at the larger offset is always emitted first, whichever order the
+ * caller wrote them in.
+ *
+ * This is not tidiness. These strips are `deck` material, which is
+ * `DoubleSide`, and three.js negates the shading normal on a back face — so a
+ * strip whose two rails arrive in the opposite order to its neighbours has
+ * reversed winding, gets its up-pointing normal turned to face the ground,
+ * and renders as an unlit black band. That is exactly what the right-hand
+ * embankment did the first time it was built from `side * 1.75` and
+ * `side * 2.3`: on the `-1` side those two offsets are in decreasing order,
+ * and a black stripe ran the length of US-101.
+ */
+ const ordered = offsetA >= offsetB;
+ const leftOffset = ordered ? offsetA : offsetB;
+ const leftLift = ordered ? liftA : liftB;
+ const rightOffset = ordered ? offsetB : offsetA;
+ const rightLift = ordered ? liftB : liftA;
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
- const half = width / 2;
let along = 0;
for (let index = 0; index < points.length; index += 1) {
@@ -200,14 +227,38 @@ function roadRibbonGeometry(
const dx = next.x - previous.x;
const dz = next.z - previous.z;
const length = Math.hypot(dx, dz) || 1;
- const nx = -dz / length;
- const nz = dx / length;
+ const tx = dx / length;
+ const tz = dz / length;
+ // Left of travel, in the ground plane.
+ const nx = -tz;
+ const nz = tx;
if (index > 0) along += point.distanceTo(previous);
+
+ // The across-vector from the right rail to the left one, in three
+ // dimensions. Crossed with the tangent it gives the strip's true normal;
+ // the sign flip keeps that normal pointing at the sky whichever way round
+ // the two offsets were handed in.
+ const ax = nx * (leftOffset - rightOffset);
+ const ay = leftLift - rightLift;
+ const az = nz * (leftOffset - rightOffset);
+ let mx = ay * tz - az * 0;
+ let my = az * tx - ax * tz;
+ let mz = ax * 0 - ay * tx;
+ const mLength = Math.hypot(mx, my, mz) || 1;
+ mx /= mLength;
+ my /= mLength;
+ mz /= mLength;
+ if (my < 0) {
+ mx = -mx;
+ my = -my;
+ mz = -mz;
+ }
+
positions.push(
- point.x + nx * half, point.y + lift, point.z + nz * half,
- point.x - nx * half, point.y + lift, point.z - nz * half,
+ point.x + nx * leftOffset, point.y + leftLift, point.z + nz * leftOffset,
+ point.x + nx * rightOffset, point.y + rightLift, point.z + nz * rightOffset,
);
- normals.push(0, 1, 0, 0, 1, 0);
+ normals.push(mx, my, mz, mx, my, mz);
uvs.push(0, along, 1, along);
if (index < points.length - 1) {
const a = index * 2;
@@ -224,6 +275,15 @@ function roadRibbonGeometry(
return geometry;
}
+/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
+function roadRibbonGeometry(
+ points: readonly THREE.Vector3[],
+ width: number,
+ lift = 0,
+): THREE.BufferGeometry {
+ return bandGeometry(points, width / 2, lift, -width / 2, lift);
+}
+
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
return points.map((point, index) => {
const previous = points[Math.max(0, index - 1)] ?? point;
@@ -313,7 +373,12 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const batch = new Batch();
const asphalt = [0x353a3d, 0x303538];
const shoulder = [0x555759, 0x4e5153];
- const berm = [0x64705c, 0x74674c];
+ const berm = [0x8d8a66, 0x9a8c62];
+ // One shadow colour for both corridors' batters. Two would be one more
+ // material and one more draw call for a difference nobody can see on a
+ // surface that is, by construction, the part of the corridor facing away
+ // from the sun.
+ const batter = 0x5f5740;
const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
@@ -352,12 +417,43 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const route = plan.routes[roadIndex];
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
- // Broad earthwork under separate decks makes grade and curve changes read.
- batch.add(
- "freeway:berm",
- roadRibbonGeometry(path, 2.75, -0.09),
- batch.material("deck", berm[identityIndex] ?? berm[0]!),
- );
+ /**
+ * The earthwork, as a crown and two batters rather than one flat ribbon.
+ *
+ * This is the fix for the defect that mattered most on the California
+ * board: at 1,919 m to the scene unit the whole corridor is about eleven
+ * pixels wide from the default camera, and eleven pixels of flat mid-grey
+ * lying exactly on the ground reads as a line somebody drew on the map, not
+ * as a road. Three things change that, and none of them is width for its
+ * own sake:
+ *
+ * - **A graded right-of-way that is not the colour of the asphalt.** The
+ * crown runs out to ±1.75 in dry cut earth, so the corridor arrives as
+ * pale / dark / pale instead of as one dark stroke, and the eye reads
+ * three bands where it used to read one line.
+ * - **Batters with a real normal.** The flanks fall 0.09 units over 0.55,
+ * which is about nine degrees — enough that Lambert separates them from
+ * the crown at every sun angle, and enough that at dusk the corridor has
+ * a lit side and a shaded side.
+ * - **Sitting slightly proud of the ground.** The crown is at -0.02
+ * rather than -0.09, so the earthwork is a causeway across the flats
+ * rather than a trench cut into them.
+ *
+ * All three survive the drive chapters, where the same geometry is two
+ * hundred pixels of verge and a shallow embankment falling away to the
+ * fields — which is what US-101 through the Salinas Valley actually looks
+ * like out of a car window.
+ */
+ const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
+ batch.add("freeway:berm", roadRibbonGeometry(path, 3.5, -0.02), bermMaterial);
+ const batterMaterial = batch.material("deck", batter);
+ for (const side of [-1, 1] as const) {
+ batch.add(
+ "freeway:embankment",
+ bandGeometry(path, side * 1.75, -0.02, side * 2.3, -0.11),
+ batterMaterial,
+ );
+ }
for (const side of [-1, 1] as const) {
batch.add(
"freeway:shoulder",
@@ -384,11 +480,32 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
const guardPath = offsetPath(path, side * 1.27);
+ /**
+ * One tubular segment per draped sample, and three sides, not five.
+ *
+ * `drapePath` already samples every leg 52 times — roughly a point per
+ * kilometre along a 700 km corridor — so a tube at `length * 2` was
+ * subdividing an interval nothing curves inside. Between the four
+ * guardrails and the four median walls that was 82,000 triangles, an
+ * eighth of the whole board's budget, spent on two objects that are a
+ * hairline from the state camera and a thin grey rail from the chase
+ * camera. Halving the segments and dropping two radial sides gives back
+ * 55,000 of them, which is what pays for the state's relief and its
+ * cities; a five-sided 25 mm-radius tube and a three-sided one are the
+ * same handful of pixels at both distances this corridor is ever seen
+ * from.
+ *
+ * It also stopped casting. A shadow caster is drawn twice, and what this
+ * one casts is the shadow of a fifty-metre pipe standing in for a
+ * half-metre rail — a fiction lying a few centimetres from the object
+ * that threw it, at both distances this corridor is seen from. The sign
+ * posts still cast, because a sign standing clear of the road is the one
+ * roadside object whose shadow tells you where the ground is.
+ */
batch.add(
"freeway:outer-guardrail",
- new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
+ new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), 0.025, 3, false),
guardMaterial,
- { cast: true },
);
}
// Low concrete median walls keep both carriageways visually independent.
@@ -396,7 +513,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
batch.add(
"freeway:median-barrier",
- new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
+ new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), 0.055, 3, false),
barrierMaterial,
);
}
@@ -404,12 +521,19 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
// The matrices are collected across every corridor and committed to one
// `InstancedMesh` after the loop, because two corridors' worth of the same
// 0.018 m box is two draw calls for something nobody can resolve.
- const reflectorPoints = path.filter((_, index) => index % 2 === 0);
+ //
+ // Every sixth sample rather than every second: 2,296 boxes were 27,500
+ // triangles for studs the chase camera sees a dozen of at a time and the
+ // state camera cannot resolve at all. At this stride they are still about
+ // one every seven kilometres of a road whose lanes are two kilometres wide,
+ // and 20,000 triangles come back to the relief and the cities.
+ const reflectorStride = 6;
+ const reflectorPoints = path.filter((_, index) => index % reflectorStride === 0);
for (const pointIndex of reflectorPoints.keys()) {
const point = reflectorPoints[pointIndex];
if (!point) continue;
for (const offset of [-0.81, -0.47, 0.47, 0.81]) {
- const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point;
+ const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts
index face3ab..c45bbcd 100644
--- a/src/engine/terrain.ts
+++ b/src/engine/terrain.ts
@@ -36,6 +36,46 @@ export function paletteFor(world: World): ScenePalette {
return { ...DEFAULT_PALETTE, ...(world.city.palette ?? {}) };
}
+/**
+ * Where `palette.alpine` starts and where it wins, in metres.
+ *
+ * Only consulted for a pack that declares the colour; see `ScenePalette`.
+ *
+ * These are a *snow* line, not a tree line, and the difference is the whole
+ * tuning. The first pair was 900 and 2,300 — a tree line — and it was tried and
+ * photographed: at 900 every range in the Mojave has its top third above the
+ * threshold, so a desert two hundred kilometres wide came out with white caps on
+ * it and the Panamints, the Providences and the New York Mountains all read as
+ * small Sierras. The Mojave ranges are dark rock and the Sierra crest is bare
+ * granite and old snow, and only one of the two is pale.
+ *
+ * At 1,900 the desert keeps `upland` almost everywhere — the tallest thing in
+ * the eastern Mojave is 2,300 m and lands a fifth of the way along — while the
+ * Sierra crest at 3,150 to 4,300, the White Mountains, Telescope Peak and San
+ * Gorgonio, which are the four places in California that hold snow into the
+ * summer, are most of the way to it. That is the correct list.
+ *
+ * Wildland is unaffected either way: `groundColor` answers `inPark` first, so a
+ * forest belt is green to whatever height its envelope reaches and this ramp
+ * only ever paints ground that no park covers.
+ */
+const ALPINE_FROM = 1_900;
+const ALPINE_TO = 3_300;
+
+/**
+ * Ramp endpoints, held rather than allocated.
+ *
+ * `groundColor` runs once per emitted lattice vertex — 85,000 times on the
+ * California board and 294,000 on the Bay Area — and the two `new THREE.Color`
+ * calls it used to make per vertex were three quarters of a million short-lived
+ * objects on one board build, all of them the same three values. Module scope
+ * is safe here for the reason the `scratch` argument already is: this is
+ * single-threaded, synchronous, and the result is read into a flat array before
+ * the next call.
+ */
+const RAMP_TO = new THREE.Color();
+const RAMP_TOP = new THREE.Color();
+
/**
* Ground colour is about land *use*, not altitude.
*
@@ -61,16 +101,19 @@ function groundColor(
elevation: number,
): THREE.Color {
if (inPark) {
- return scratch
- .setHex(pal.park)
- .lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
+ return scratch.setHex(pal.park).lerp(RAMP_TO.setHex(pal.parkHigh), Math.min(1, elevation / 180));
}
if (elevation < 3) {
- return scratch.setHex(pal.sand).lerp(new THREE.Color(pal.flats), elevation / 3);
+ return scratch.setHex(pal.sand).lerp(RAMP_TO.setHex(pal.flats), elevation / 3);
}
- return scratch
+ const ground = scratch
.setHex(pal.flats)
- .lerp(new THREE.Color(pal.upland), Math.min(1, (elevation - 3) / 150));
+ .lerp(RAMP_TO.setHex(pal.upland), Math.min(1, (elevation - 3) / 150));
+ if (pal.alpine === undefined || elevation <= ALPINE_FROM) return ground;
+ return ground.lerp(
+ RAMP_TOP.setHex(pal.alpine),
+ Math.min(1, (elevation - ALPINE_FROM) / (ALPINE_TO - ALPINE_FROM)),
+ );
}
/** The smooth flat polygon under each landmass — the crisp coastline. */
@@ -104,6 +147,18 @@ export function createShorePlates(world: World): THREE.Mesh {
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
+ /**
+ * Receives, and deliberately does not cast.
+ *
+ * The plate is the landmass polygon lying flat at y=0 with the sea six
+ * hundredths of a unit beneath it — at California's 1.9 km per unit, a step
+ * of about a hundred metres. A caster that thin is a caster with no volume:
+ * at a low sun it would throw the whole coastline's silhouette out across the
+ * water as a hard-edged slab shadow, which is a rendering of the step and not
+ * of anything in the world. `createTerrain` below is the mesh with relief in
+ * it, and relief is the only thing here worth a shadow.
+ */
+ mesh.castShadow = false;
mesh.name = "shorePlates";
return mesh;
}
@@ -157,18 +212,338 @@ export function createTerrain(world: World): THREE.Mesh {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
+ // Normals first, and from the visible triangles alone. The caster's coarse
+ // triangles are about to join the same index buffer, and a vertex normal
+ // averaged over both would be a normal for neither.
geo.setIndex(indices);
geo.computeVertexNormals();
- const mesh = new THREE.Mesh(
- geo,
- new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
- );
+ /**
+ * The same relief again at a quarter of the triangles, appended to the same
+ * index buffer, and drawn only by the depth pass.
+ *
+ * The visible triangles occupy `[0, seen)` of the index and the caster's
+ * occupy the tail; `Mesh.onBeforeShadow` swings `drawRange` onto the tail and
+ * `onAfterShadow` swings it back. Both hooks exist for exactly this and fire
+ * either side of the one `renderBufferDirect` the shadow pass makes for this
+ * mesh, and the shadow pass runs before the colour pass, so the range is
+ * always right for whoever is reading it.
+ *
+ * **The obvious alternatives were tried and neither works.** A second mesh on
+ * a rendering layer only the shadow camera can see: `WebGLShadowMap.
+ * renderObject` tests `object.layers.test( camera.layers )` against the
+ * **scene** camera it was handed, not against `shadow.camera`, so a mesh
+ * hidden from the viewer is hidden from the depth pass by the same line — this
+ * was written, measured, and cast nothing at all. A second mesh with
+ * `colorWrite` off: `visible`, `material.visible` and `frustumCulled` are each
+ * one flag consulted identically by both passes, so it stays in the colour
+ * pass and `renderer.info` counts its triangles twice, which is the whole
+ * cost this exists to avoid. One geometry with two ranges is what is left, and
+ * 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));
+ }
+ }
+ const cast = indices.length - seen;
+ // `vertex()` may have emitted a few lattice corners the visible surface never
+ // needed, so the position and colour attributes are rebuilt alongside the
+ // index rather than reused from above.
+ geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
+ geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
+ const normal = geo.getAttribute("normal") as THREE.BufferAttribute;
+ if (normal.count < positions.length / 3) {
+ const grown = new Float32Array(positions.length);
+ grown.set(normal.array as Float32Array);
+ // A corner only the caster uses is never shaded, so any unit normal will
+ // do; up is the one that cannot be mistaken for a bug.
+ for (let k = normal.count * 3; k < grown.length; k += 3) grown[k + 1] = 1;
+ geo.setAttribute("normal", new THREE.BufferAttribute(grown, 3));
+ }
+ geo.setIndex(indices);
+ geo.setDrawRange(0, seen);
+
+ const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
+ /**
+ * `shadowSide = BackSide` is the acne cure, and it is exactly right for a
+ * heightfield.
+ *
+ * The depth pass then culls every face turned *toward* the sun — precisely
+ * the set of faces that were shadowing themselves — and keeps the faces
+ * turned away from it. The boundary between the two is the terminator, so the
+ * depth recorded along a ridge starts at the crest and runs down its far
+ * slope, and the valley floor beyond, which is still front-facing and
+ * therefore writes nothing, tests against it and lands in shadow. A lit slope
+ * has nothing in the map above it and cannot stipple.
+ *
+ * Without it, a constant `shadow.bias` has to cover a depth-per-texel that
+ * grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
+ * 0.73 on the Bay Area — and there is no single value that is free of acne at
+ * 40° and free of peter-panning at 8°. That is why this was left off through
+ * the previous round, and it is checked here by photographing the boards at
+ * a sun of 12° and of 2°.
+ */
+ material.shadowSide = THREE.BackSide;
+
+ const mesh = new THREE.Mesh(geo, material);
mesh.receiveShadow = true;
+ /**
+ * The relief casts at last.
+ *
+ * Until it did, a ridge shaded its own back slope through the Lambert N·L
+ * term and then darkened nothing beside it: the valley next to a mountain
+ * range stayed fully lit at every hour of the day, and a range read as a bump
+ * map rather than as geography. `stage.ts` states the omission and this is
+ * the line it was waiting on.
+ */
+ mesh.castShadow = true;
+ mesh.onBeforeShadow = () => geo.setDrawRange(seen, cast);
+ mesh.onAfterShadow = () => geo.setDrawRange(0, seen);
mesh.name = "terrain";
return mesh;
}
+/**
+ * How many lattice cells of the visible terrain go into one cell of the caster
+ * appended to it, per axis.
+ *
+ * Bounded below by the shadow map's own resolution and above by the board's
+ * triangle budget, and the budget is much the tighter of the two.
+ *
+ * *Below*: a caster finer than a shadow texel is detail the map cannot record.
+ * The city hands `SceneKit` a box of 1.5 board spans over 2048 texels — 0.21
+ * scene units on California — against a terrain lattice of about 1.2 units, so
+ * a stride of 2 still writes a facet several texels across.
+ *
+ * *Above*: triangles, and the number is unforgiving. California's terrain is
+ * 65,566 triangles; with a stride of 2 the whole board measures 732,206 against
+ * a budget of 750,000, so the depth pass has about 18,000 in hand. A stride of
+ * 1 would spend 65,566 and miss the budget by 47,000; a stride of 3 would spend
+ * 7,285 and buy back nothing worth having. Both were measured.
+ *
+ * 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.
+ */
+const SHADOW_CASTER_STRIDE = 2;
+
+// ---- The sea --------------------------------------------------------------
+
+/**
+ * How far the sea reaches, in board spans, measured from the board's centre.
+ *
+ * This number is the fix for the hard diamond the world used to end in. The
+ * plane was 1.8 times the board across — 0.9 spans from the middle — while
+ * `main.ts` gives the atmosphere a clear-day fog that closes at 3.9 spans and
+ * `scene.ts` gives the camera a far plane at 4. The sea therefore ran out
+ * roughly a fifth of the way to the fog, in full contrast, and what you saw was
+ * its silhouette: a blue quad with two straight edges meeting in a point, and
+ * the state of California apparently floating on it.
+ *
+ * The fade already existed and nothing was reaching it. This is the plane's
+ * *width*, so 18 puts its rim 9 spans from the middle, and the worst case it
+ * has to beat is about 6.4: the camera can retreat 2 spans from the scene
+ * origin, the origin can itself be half a span off the middle of the bounds,
+ * and the fog closes 3.9 spans beyond wherever the camera is. Past the camera's
+ * own far plane the water is clipped rather than drawn, and by the far plane it
+ * is 100% fog, and
+ * `atmosphere.ts` makes the fog colour the sky's own horizon colour on a clear
+ * day. So the horizon is where the sea's colour and the sky's colour have
+ * already become the same number: there is nothing there to see an edge in.
+ *
+ * It costs one quad. The plane is two triangles at any size.
+ */
+const SEA_SPAN_MULTIPLE = 18;
+
+/**
+ * The swell's wavelength, as a fraction of the board.
+ *
+ * Not a physical number and it should not be read as one. Real ocean swell is
+ * 100–200 m crest to crest, which at California's 1.9 km per scene unit is a
+ * tenth of a unit — below one screen pixel from any pose that has the board in
+ * frame, so a physically-scaled sea is a sea with no visible surface at all,
+ * which is where this started.
+ *
+ * What the number actually has to serve is the *glitter*: the eye reads water
+ * as water because the sun's reflection is broken into a shifting path rather
+ * than a mirror disc, and that path needs its texture to land at a handful of
+ * pixels per tile at board distance. It was tuned by photographing it. A board
+ * span over 20 puts a tile at some ninety screen pixels from a whole-board pose,
+ * which is large enough that the eye finds the repeat and the sea reads as
+ * woven fabric; over 45 the tile lands at forty and the mip chain has eaten it
+ * before it says anything. 34 is the value in between, where the swell is
+ * legible as swell when you look at it and never resolves into a pattern.
+ */
+const SWELL_TILE_SPANS = 1 / 34;
+
+/** Tiles per second each swell layer drifts. Slow: this is weather, not surf. */
+const SWELL_DRIFT_A = new THREE.Vector2(0.031, 0.017);
+const SWELL_DRIFT_B = new THREE.Vector2(-0.019, 0.024);
+
+/**
+ * How much smaller the second swell layer's tile is than the first, and how far
+ * round it is turned.
+ *
+ * Two samples of one map at two scales, two headings and two drift directions,
+ * for the price of one texture. A single scrolling normal map reads as a
+ * conveyor belt — the pattern is recognisable and it translates rigidly — and
+ * the cheapest cure is a second copy that is not in step with it, so that no
+ * crest keeps its shape for longer than the two layers stay aligned. 2.6 rather than a round
+ * number because an integer ratio re-aligns on a short period and you can see
+ * it happen, and 63° rather than nothing because the two copies otherwise share a
+ * dominant swell direction and read as one sea seen twice.
+ */
+const SWELL_LAYER_B_SCALE = 2.6;
+const SWELL_LAYER_B_TURN = (63 * Math.PI) / 180;
+
+/**
+ * The Fresnel term, as the fraction of the body colour left when you look
+ * straight down into the water.
+ *
+ * `MeshStandardMaterial` already has the *reflective* half of Fresnel: its
+ * environment term brightens toward grazing, so with `environmentRig.ts` on the
+ * scene the far water already picks up the horizon sky. What it has no notion
+ * of is the other half — that the light coming *out* of the water is a body
+ * colour seen through a surface that reflects less and less of the sky the more
+ * squarely you look at it, so the sea directly below the camera should be the
+ * deep colour and the sea at the horizon should be mostly sky.
+ *
+ * Multiplying the diffuse by 0.6 looking straight down and by 1 at grazing is
+ * that, cheaply and in the right direction. Without it the ocean is one value
+ * across the whole frame, which is the single loudest way water reads as a
+ * painted card.
+ */
+const SEA_DEEP_FACTOR = 0.6;
+
+/**
+ * Where the far ocean gives up its specular, in board spans of view distance.
+ *
+ * The glitter is a per-pixel normal against a narrow specular lobe, and at some
+ * distance one pixel covers more swell than the mip chain can average without
+ * flickering as the camera moves. Rather than let that flicker happen, the
+ * roughness is walked up toward `SEA_FAR_ROUGHNESS` past `SEA_CALM_NEAR`, which
+ * is beyond the board — the water that matters keeps every bit of its response
+ * and the water at the horizon becomes the flat hazy sheet it looks like from
+ * an aeroplane anyway.
+ */
+const SEA_CALM_NEAR = 1.6;
+const SEA_CALM_FAR = 5;
+const SEA_FAR_ROUGHNESS = 0.42;
+
+/**
+ * A tiling tangent-space normal map for the swell, as raw RGBA bytes.
+ *
+ * Returned as data rather than drawn on a canvas, and this is the one texture
+ * in the repo that is not a `TextureBin` drawing. A 2D canvas is the right tool
+ * for anything with *shapes* in it — a screen, a leaf, a carpet — and the wrong
+ * one here: the value being encoded is a surface derivative, `ctx` has no way
+ * to express one, and going through a canvas would mean rasterising a height
+ * field only to read it straight back out with `getImageData`. It also keeps
+ * this module free of the DOM, so a test can call it directly.
+ *
+ * The height field is a sum of sine waves whose wave numbers are **integers**,
+ * which is what makes the result tile: every component completes a whole number
+ * of cycles across the map, so the left edge and the right edge are the same
+ * sample. Phases are fixed constants rather than `Math.random`, so a board looks
+ * the same on every reload and two people looking at it see one sea.
+ *
+ * **Amplitude falls as 1/k², and the exponent is the whole difference between
+ * water and corduroy.** What a normal map encodes is not height but *slope*,
+ * and slope is amplitude times wave number — so the obvious 1/k spectrum gives
+ * every component in the sum exactly the same slope, the shortest one wins on
+ * sheer count of edges, and the sea comes out as one hard diagonal rib. This
+ * was tried and photographed. At 1/k² the slope falls as 1/k instead: the two
+ * long components carry the shape, the short ones sit on top as sparkle, and
+ * the eight directions stay spread far enough apart that no single one is
+ * legible as a stripe.
+ */
+export function swellNormalData(size = 128, strength = 6): Uint8Array {
+ /** `[wave number x, wave number y, phase in turns]`, amplitude is 1/|k|². */
+ const waves: readonly (readonly [number, number, number])[] = [
+ [2, 1, 0.13],
+ [-1, 2, 0.61],
+ [3, -2, 0.29],
+ [2, 4, 0.87],
+ [-5, 2, 0.44],
+ [4, 5, 0.07],
+ [-3, -7, 0.72],
+ [8, -2, 0.35],
+ ];
+
+ const height = new Float32Array(size * size);
+ for (const [kx, ky, phase] of waves) {
+ const amplitude = 1 / (kx * kx + ky * ky);
+ const p = phase * Math.PI * 2;
+ for (let y = 0; y < size; y++) {
+ const v = y / size;
+ for (let x = 0; x < size; x++) {
+ const u = x / size;
+ height[y * size + x] =
+ (height[y * size + x] as number) +
+ amplitude * Math.sin(2 * Math.PI * (kx * u + ky * v) + p);
+ }
+ }
+ }
+
+ // Central differences, wrapped, so the derivative tiles as cleanly as the
+ // height does. `size` scales the difference back into per-tile slope, which
+ // is what keeps `strength` independent of the resolution.
+ const data = new Uint8Array(size * size * 4);
+ const at = (x: number, y: number): number =>
+ height[((y + size) % size) * size + ((x + size) % size)] as number;
+
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const dx = (at(x + 1, y) - at(x - 1, y)) * 0.5 * strength;
+ const dy = (at(x, y + 1) - at(x, y - 1)) * 0.5 * strength;
+ // OpenGL convention: +Y in the map is +V in the texture, which is how
+ // three's `getTangentFrame` builds the bitangent.
+ const length = Math.hypot(-dx, -dy, 1);
+ const i = (y * size + x) * 4;
+ data[i] = Math.round(((-dx / length) * 0.5 + 0.5) * 255);
+ data[i + 1] = Math.round(((-dy / length) * 0.5 + 0.5) * 255);
+ data[i + 2] = Math.round((1 / length) * 0.5 * 255 + 127.5);
+ data[i + 3] = 255;
+ }
+ }
+ return data;
+}
+
+function swellNormalTexture(): THREE.DataTexture {
+ const size = 128;
+ const texture = new THREE.DataTexture(swellNormalData(size), size, size);
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ texture.magFilter = THREE.LinearFilter;
+ // Mipmapped, and it is doing real work: the mip chain of a normal map
+ // converges on "flat", so the sea a long way off stops perturbing its normal
+ // without anything having to decide when. Anisotropy is what keeps the water
+ // at a grazing angle — which is most of the frame from a map pose — from
+ // blurring to that flat mip several times too early.
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
+ texture.generateMipmaps = true;
+ texture.anisotropy = 8;
+ texture.needsUpdate = true;
+ texture.name = "swellNormal";
+ return texture;
+}
+
/** Ocean and bay: one plane under everything, plus any inland water. */
export function createWater(world: World): THREE.Group {
const pal = paletteFor(world);
@@ -178,6 +553,8 @@ export function createWater(world: World): THREE.Group {
const { bounds } = world.city;
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
+ const span = Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0));
+ const size = span * SEA_SPAN_MULTIPLE;
/**
* Standard rather than Lambert, and it is the whole difference between an
@@ -196,14 +573,166 @@ export function createWater(world: World): THREE.Group {
* dielectric: its reflection is a Fresnel term over a coloured body, which is
* exactly what metalness 0 with low roughness produces, and a metallic water
* would lose `pal.sea` entirely.
+ *
+ * The material on its own is still not a sea, and the three things below are
+ * why: a mirror-flat plane has one specular *point* rather than a glitter
+ * path, nothing about it moves, and its body colour does not change with the
+ * angle you look into it. `seaSwell` supplies all three.
*/
- const sea = new THREE.Mesh(
- new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
- new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }),
+ const material = new THREE.MeshStandardMaterial({
+ color: pal.sea,
+ /**
+ * Rougher than a mirror, and the number is what sets the width of the sun's
+ * path across the water.
+ *
+ * At 0.14 the specular lobe is about as tight as a smooth plane can make
+ * it, so a flat sea returns the sun as a small hard disc that is either in
+ * frame or not. The swell normals below spread it, and 0.2 spreads it
+ * further: together they give the long shivering streak toward the sun that
+ * is the single most recognisable thing about looking at the sea.
+ */
+ roughness: 0.2,
+ metalness: 0,
+ normalMap: swellNormalTexture(),
+ /**
+ * Pushed to the back of every depth argument it is in, and this is not
+ * cosmetic — without it the Bay Area and Southern California come out with
+ * the sea dithered across their flats.
+ *
+ * The sea sits six hundredths of a unit below y=0 and the terrain's coastal
+ * rim sits twelve thousandths above it, so seventy-two thousandths of a unit
+ * is the whole separation between an ocean and the ground it is supposed to
+ * be under. Ask what that is worth in the depth buffer at board distance:
+ * with `near` at 0.1 and the camera 800 units out — an ordinary whole-board
+ * pose on the Bay Area, which is 1,003 units across — one unit of world
+ * depth is about two and a half of the twenty-four-bit buffer's steps, so
+ * the entire gap is **a fifth of one step**. The two surfaces are the same
+ * number.
+ *
+ * Which of them wins is then decided by float error, and the plane is
+ * `SEA_SPAN_MULTIPLE` — eighteen board spans, two triangles, corners nine
+ * thousand units from the middle. Interpolating a depth near the board from
+ * vertex values of that magnitude leaves a few steps of noise in it, and a
+ * few steps of noise on top of a fifth of a step of signal is the dapple of
+ * blue over every flat piece of ground on the board. It arrived with the
+ * bigger plane, it is invisible on California — whose valley floor stands at
+ * 28 m, three and a half times the gap — and it is the reason to state the
+ * numbers rather than to nudge the `y` until a screenshot looks right.
+ *
+ * Polygon offset is the fix that does not need a per-board number: it is
+ * expressed in units of whatever the depth buffer can resolve *there*, so
+ * thirty-two of them is thirty-two steps on any board at any zoom, and the
+ * slope term covers the grazing angles where the noise is worst. It moves
+ * no pixel and changes no colour. It only ever loses the sea an argument it
+ * should never have been winning.
+ */
+ polygonOffset: true,
+ polygonOffsetFactor: 2,
+ polygonOffsetUnits: 32,
+ });
+ material.normalScale.set(0.95, 0.95);
+
+ const swellA = new THREE.Vector3(0, 0, size / (span * SWELL_TILE_SPANS));
+ const swellB = new THREE.Vector3(0, 0, swellA.z * SWELL_LAYER_B_SCALE);
+ const turn = new THREE.Matrix3().set(
+ Math.cos(SWELL_LAYER_B_TURN), -Math.sin(SWELL_LAYER_B_TURN), 0,
+ Math.sin(SWELL_LAYER_B_TURN), Math.cos(SWELL_LAYER_B_TURN), 0,
+ 0, 0, 1,
);
+ const uniforms = {
+ uSwellA: { value: swellA },
+ uSwellB: { value: swellB },
+ uSwellTurn: { value: turn },
+ uSeaDeep: { value: SEA_DEEP_FACTOR },
+ uSeaCalm: { value: new THREE.Vector2(span * SEA_CALM_NEAR, span * SEA_CALM_FAR) },
+ uSeaFarRough: { value: SEA_FAR_ROUGHNESS },
+ };
+
+ /**
+ * Three edits to the standard shader, each of which the material has no dial
+ * for and each of which the water needs.
+ *
+ * A patch rather than a `ShaderMaterial` written from scratch, because the
+ * lighting this surface has to obey — a sun, a hemisphere, an ambient, a
+ * shadow, a PMREM environment and a fog, all of them owned elsewhere and all
+ * of them changing with the hour — is exactly what `MeshStandardMaterial`
+ * already implements correctly. Reimplementing it to add a second texture
+ * lookup would mean owning a second copy of the light rig, which CONTRACT.md
+ * §4 spends its whole length arguing against.
+ */
+ material.onBeforeCompile = (shader) => {
+ Object.assign(shader.uniforms, uniforms);
+ shader.fragmentShader = shader.fragmentShader
+ .replace(
+ "#include ",
+ `#include
+uniform vec3 uSwellA;
+uniform vec3 uSwellB;
+uniform mat3 uSwellTurn;
+uniform float uSeaDeep;
+uniform vec2 uSeaCalm;
+uniform float uSeaFarRough;`,
+ )
+ // 1. The horizon gives up its specular before it can alias. See
+ // `SEA_CALM_NEAR`.
+ .replace(
+ "#include ",
+ `#include
+float seaFar = smoothstep( uSeaCalm.x, uSeaCalm.y, length( vViewPosition ) );
+roughnessFactor = mix( roughnessFactor, uSeaFarRough, seaFar );`,
+ )
+ // 2. Two drifting samples of the one swell map instead of the single
+ // static one the material would take, and 3. the Fresnel term on the
+ // body colour. Both live here because both need `tbn` and the
+ // unperturbed normal, which exist only between these two chunks.
+ .replace(
+ "#include ",
+ `vec3 seaFlatNormal = normal;
+mat2 seaTurn = mat2( uSwellTurn[0].xy, uSwellTurn[1].xy );
+vec3 seaNormalA = texture2D( normalMap, vNormalMapUv * uSwellA.z + uSwellA.xy ).xyz * 2.0 - 1.0;
+vec3 seaNormalB = texture2D( normalMap, seaTurn * ( vNormalMapUv * uSwellB.z ) + uSwellB.xy ).xyz * 2.0 - 1.0;
+// The second layer was sampled through a rotation, so its slope arrived in the
+// rotated frame; \`v * M\` is \`transpose(M) * v\`, which is the inverse of a
+// rotation and turns that slope back into this surface's own frame.
+seaNormalB.xy = seaNormalB.xy * seaTurn;
+vec3 mapN = normalize( seaNormalA + seaNormalB );
+mapN.xy *= normalScale * ( 1.0 - 0.9 * seaFar );
+normal = normalize( tbn * mapN );
+float seaFacing = saturate( dot( normalize( vViewPosition ), seaFlatNormal ) );
+diffuseColor.rgb *= mix( 1.0, uSeaDeep, seaFacing );`,
+ );
+ };
+ // Two materials that compile to different programs must not share a cache
+ // key, and `onBeforeCompile` is invisible to three's default key.
+ material.customProgramCacheKey = () => "tera:sea";
+
+ const sea = new THREE.Mesh(new THREE.PlaneGeometry(size, size), material);
sea.rotation.x = -Math.PI / 2;
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
sea.receiveShadow = true;
+ sea.name = "sea";
+ /**
+ * The swell drifts from the wall clock, on the mesh's own render hook.
+ *
+ * `createWater` returns a `Group` and its caller adds it to a scene; there is
+ * no `tick` on the way in and adding one would mean a new seam through
+ * `scene.ts` for two uniform writes. `Object3D.onBeforeRender` is the hook
+ * three already runs immediately before this mesh is drawn, which is the only
+ * moment these two values are read.
+ *
+ * `performance.now()` rather than a delta accumulated per frame, because a
+ * phase that is a pure function of the clock cannot drift, cannot double up
+ * if the mesh is ever drawn twice in a frame, and resumes correctly after a
+ * paused tab — a scene that has been backgrounded for a minute wakes up with
+ * the sea where it should be rather than a minute behind.
+ */
+ sea.onBeforeRender = () => {
+ const t = performance.now() / 1000;
+ swellA.x = SWELL_DRIFT_A.x * t;
+ swellA.y = SWELL_DRIFT_A.y * t;
+ swellB.x = SWELL_DRIFT_B.x * t;
+ swellB.y = SWELL_DRIFT_B.y * t;
+ };
group.add(sea);
for (const poly of world.city.inlandWater) {
@@ -217,9 +746,31 @@ export function createWater(world: World): THREE.Group {
geo,
new THREE.MeshStandardMaterial({
color: pal.lake,
- roughness: 0.2,
+ roughness: 0.24,
metalness: 0,
side: THREE.DoubleSide,
+ /**
+ * The five-hundredths below is not a separation, at board scale.
+ *
+ * A lake floats over the shore plate, which is the same landmass
+ * polygon lying flat at y=0, and 0.05 units is all there is between
+ * them. On San Francisco — 230 units across, camera a couple of hundred
+ * out — that is comfortably more than one step of the depth buffer and
+ * the two never argue. On the California board the camera stands 570
+ * units off and the depth buffer's resolution *there* is about 0.15
+ * units, three times the gap: the Salton Sea came out banded in
+ * alternating stripes of lake and shore, which reads as a rendering
+ * artefact because it is one.
+ *
+ * Polygon offset is the fix rather than a bigger `y`, because it is
+ * expressed in units of whatever the depth buffer can currently resolve
+ * — it scales itself with distance, where a hard-coded lift would have
+ * to be tuned per board and would leave the lake visibly hovering on the
+ * two boards that never needed it.
+ */
+ polygonOffset: true,
+ polygonOffsetFactor: -2,
+ polygonOffsetUnits: -4,
}),
);
lake.position.y = 0.05;
@@ -228,3 +779,4 @@ export function createWater(world: World): THREE.Group {
return group;
}
+
diff --git a/src/engine/types.ts b/src/engine/types.ts
index 7cc077f..001b20b 100644
--- a/src/engine/types.ts
+++ b/src/engine/types.ts
@@ -193,6 +193,23 @@ export interface ScenePalette {
upland: number;
park: number;
parkHigh: number;
+ /**
+ * Bare high ground, and **optional on purpose**.
+ *
+ * The unpainted ramp is `flats → upland` over the first 150 m and then flat
+ * forever, which is right for a city — a hill in San Francisco is built to its
+ * summit and the buildings do the talking. On a board measured in hundreds of
+ * kilometres it is not: the Sierra crest at 4,000 m and the Mojave floor at
+ * 600 come out the same number, so a granite skyline and a creosote flat are
+ * one colour and the only thing separating them is the shading.
+ *
+ * A pack that declares this gets a second stop above `upland` — see
+ * `groundColor` in `terrain.ts` for the two elevations it ramps between. A
+ * pack that does not is rendered exactly as before, which is why this is
+ * optional rather than a tenth required colour that every existing pack would
+ * have to answer for.
+ */
+ alpine?: number;
}
// ---- Lighting -------------------------------------------------------------
diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts
index 3950e46..63e5774 100644
--- a/src/interiors/officeScene.ts
+++ b/src/interiors/officeScene.ts
@@ -711,8 +711,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
// to put one. The exterior positions everything in the pack's own metres from
// the plan origin, so the only transform it needs is the storey its stall is
- // measured from — a podium deck at level 1 is 188 m off the street, and the
- // apron stands on the floor of `arrival.levelId` by the exterior's own wording.
+ // measured from — a stall on an upper storey is measured from that storey's
+ // floor, and the apron stands on it. A site with no ground outside gets an
+ // inert exterior instead; see `ArrivalGround` in transport/exteriorVehicle.ts.
const arrivalStall = office.site?.arrival;
let exterior: OfficeExterior | null = null;
if (options.exteriorVehicle && office.site && arrivalStall) {
diff --git a/src/offices/sites.ts b/src/offices/sites.ts
index 0379e60..bc36afd 100644
--- a/src/offices/sites.ts
+++ b/src/offices/sites.ts
@@ -29,12 +29,21 @@
*
* Two of the three are honest ground. `mateo-court` sits 1.2 m above its street
* and `frontier-valley` 4 m above an airfield, so a stall a few metres outside
- * the façade is a stall on the pavement. **`lumbridge-hq` is 188 m up a tower**
- * and there is no pavement outside its west wall at all — its anchor is the
- * kerb of the podium, authored beside the front door because the pack frame is
- * the only place it can be authored, and what "outside" means vertically for a
- * tower is the exterior layer's decision and not this file's. It is called out
- * here rather than left for somebody to discover from a car parked in the sky.
+ * the façade is a stall on the pavement, and both get a kerb, a bay and a car.
+ *
+ * **`lumbridge-hq` is 188 m up a tower** and there is no pavement outside its
+ * west wall at all. Its anchor is still true — a podium kerb does exist at the
+ * foot of that tower — but the only frame a pack can say it in is a storey 188 m
+ * above it, and an earlier build took that literally and parked a Model X in
+ * open sky beside the studio wall. The anchor stays; the exterior layer now
+ * declines to draw an apron for a site whose arrival storey is not on the
+ * ground, on the strength of `elevation` alone. See `ArrivalGround` in
+ * `src/transport/exteriorVehicle.ts` for the rule and for the two other repairs
+ * that were weighed against it.
+ *
+ * This file states the coordinate and never the decision. What is "outside" for
+ * a tower is architecture, and architecture belongs to the layer that renders
+ * it — which is also why the rule reads `site.elevation` and not `id`.
*/
import type { OfficeSite } from "../interiors/types.ts";
@@ -60,10 +69,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
seed: 115,
bodyColor: 0x8799a8,
},
- // West of the studio's own front door, which is the doorway 6.8 m along
- // `ext-west`. Parallel to the façade and nosed north, the way a kerbside bay
- // on a one-way downtown street runs. See the note at the top of this file
- // about what 188 m of elevation does to the word "outside".
+ // The podium kerb at the foot of the tower: parallel to the façade and nosed
+ // north, the way a kerbside bay on a one-way downtown street runs, and placed
+ // west of the studio's own front door (the doorway 6.8 m along `ext-west`)
+ // because a pack has no frame but its own to say it in.
+ //
+ // **Nothing renders here.** `elevation: 188` above puts this storey's floor
+ // that far over the pavement, and `officeExterior.ts` builds no apron for a
+ // site off the ground — so this is the address of a kerb rather than the
+ // position of a car, and the field is kept because the telemetry layer and
+ // `Plan.exteriorArrival` both read it and because the kerb is real. See the
+ // note at the top of this file.
arrival: {
levelId: "level-1",
position: { x: -4.0, z: 7.4 },
diff --git a/src/test/californiaCity.test.ts b/src/test/californiaCity.test.ts
index 969e7b3..3837a9a 100644
--- a/src/test/californiaCity.test.ts
+++ b/src/test/californiaCity.test.ts
@@ -28,6 +28,11 @@ describe("California corridor city", () => {
it("uses a state-scale field rather than city-scale cells", () => {
assert.ok(CALIFORNIA_CITY.cellLat >= 0.01);
assert.ok(CALIFORNIA_CITY.cellLng >= 0.01);
- assert.equal(CALIFORNIA_CITY.districts.length, 0);
+ // This used to assert zero districts, which was the pack's old promise that
+ // the state board carried no cities at all — and that emptiness was the
+ // defect, not the design. The board now declares its metros on purpose; a
+ // coarse *field* is what makes it state-scale, not an absence of built
+ // things. `packs/californiaBoard.test.ts` holds the districts to account.
+ assert.ok(CALIFORNIA_CITY.districts.length >= 12);
});
});
diff --git a/src/test/data/skyTraffic.test.ts b/src/test/data/skyTraffic.test.ts
new file mode 100644
index 0000000..5b8493b
--- /dev/null
+++ b/src/test/data/skyTraffic.test.ts
@@ -0,0 +1,247 @@
+/**
+ * The two properties that decide whether the live thing in this sky exists for
+ * a visitor: **can they see it**, and **can they hit it**.
+ *
+ * Both used to be false on the deployed build, and neither failure could be
+ * caught by any test in the repo, because both are about *apparent* size. The
+ * aircraft were positioned correctly, oriented correctly, coloured correctly and
+ * drawn at two thirds of a pixel — 0.42 scene units on a board the camera stands
+ * eleven hundred units back from. Every existing assertion about the flight
+ * layer passed on that build. A person looking at it saw an empty sky with a
+ * couple of smudges on the monitor.
+ *
+ * So what is asserted here is the *arithmetic that survives* the picture:
+ *
+ * - `glyphScale` is what turns a camera distance into a legible size, and the
+ * thing worth pinning is not the constant but the **identity**: whatever
+ * scale comes back, the glyph's share of the frame is the floor. A future
+ * refactor that halves the constant and doubles the divisor would leave
+ * every number in the function looking sensible and every aeroplane at half
+ * the size, and that is exactly what this catches.
+ * - `AIRLINER_LENGTH` is the unit that identity is measured in, so it is held
+ * against the geometry's own bounding box rather than against itself. The
+ * constant and the mesh drifting apart is a one-character mistake in
+ * `aircraftGeometry.ts` that nothing else would notice.
+ * - The pick target is a **sphere around the glyph** rather than the glyph's
+ * triangles, which is the whole of "clicking a plane works for a stranger".
+ * It is asserted through `Raycaster` — the same class `scenekit.ts` uses —
+ * rather than by reaching into `Mesh.raycast`, because the question is
+ * whether a ray *near* an aeroplane resolves to it.
+ * - A track that is being held through a dropped refresh but is no longer
+ * drawn is no longer clickable. `Raycaster` does not consult `visible`, so
+ * this is the one place in the layer where "not drawn" and "not there" have
+ * to be said twice.
+ *
+ * The world here is the flat two-line stand-in the integration suite already
+ * uses: this layer's whole contact with a `World` is `project` and `metres`, and
+ * a real heightfield is half a million samples of nothing to do with any of it.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+import { AIRLINER_LENGTH, airlinerGeometry } from "../../engine/aircraftGeometry.ts";
+import { createFlightLayer, glyphScale } from "../../engine/flights.ts";
+import type { Aircraft } from "../../engine/types.ts";
+
+/** Enough of a `World` for the flight layer: a projection and a vertical scale. */
+const flatWorld = {
+ project: (lat: number, lng: number) => [lng * 100, -lat * 100],
+ metres: (m: number) => m / 100,
+ metresPerUnit: 100,
+} as unknown as Parameters[0];
+
+/**
+ * The floor `flights.ts` states in its own comments, written out again.
+ *
+ * Restated rather than exported for the same reason `flights.test.ts` restates
+ * `TRAIL_POINTS`: a test that imports the constant it is checking asserts only
+ * that the code agrees with itself, and would follow a typo into production.
+ */
+const MIN_SCREEN_FRACTION = 0.016;
+
+/** A camera's vertical view extent at a distance, in world units. */
+function frustumHeight(distance: number, fovDegrees: number): number {
+ return 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
+}
+
+function jet(id: string, lat: number, lng: number, altitude = 9_000): Aircraft {
+ return { id, callsign: id.toUpperCase(), lat, lng, altitude, heading: 90 };
+}
+
+describe("the legibility floor", () => {
+ it("holds an aeroplane at the same share of the frame however far away it is", () => {
+ // A chapter's standoff, a board span, and the far end of the orbit over the
+ // California corridor — the three distances a visitor actually looks from.
+ for (const distance of [120, 580, 1_160]) {
+ for (const fov of [42, 60]) {
+ const share = (glyphScale(distance, fov) * AIRLINER_LENGTH) / frustumHeight(distance, fov);
+ assert.ok(
+ Math.abs(share - MIN_SCREEN_FRACTION) < 1e-9,
+ `at ${distance} units and ${fov}° the glyph is ${share} of the frame, not ${MIN_SCREEN_FRACTION}`,
+ );
+ }
+ }
+ });
+
+ it("is a floor and not a fit: close up, the authored geometry wins", () => {
+ // Inside about 34 units the aeroplane is already legible at the size
+ // `aircraftGeometry.ts` drew it, and enlarging it there would park a
+ // state-sized airliner over a downtown.
+ assert.equal(glyphScale(1, 42), 1);
+ assert.equal(glyphScale(20, 42), 1);
+ assert.ok(glyphScale(60, 42) > 1, "past a chapter's standoff it has to grow");
+ // Monotonic, so the scale never jumps as the camera pulls back.
+ let previous = 0;
+ for (let d = 1; d < 1_200; d += 37) {
+ const scale = glyphScale(d, 42);
+ assert.ok(scale >= previous, `scale went backwards at ${d}`);
+ previous = scale;
+ }
+ });
+
+ it("survives a camera that is not ready to be asked", () => {
+ // A chase camera sitting on the aircraft, and a caller mid-setup. Neither is
+ // a reason for the sky to vanish or to fill with NaN matrices.
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
+ assert.equal(glyphScale(bad, 42), 1, `distance ${bad}`);
+ assert.equal(glyphScale(500, bad), 1, `fov ${bad}`);
+ }
+ assert.equal(glyphScale(500, 180), 1, "a degenerate field of view is not a size");
+ });
+
+ it("measures the aeroplane the geometry actually built", () => {
+ const geo = airlinerGeometry();
+ geo.computeBoundingBox();
+ const box = geo.boundingBox;
+ assert.ok(box, "the airliner has no bounding box");
+ const length = box.max.z - box.min.z;
+ assert.ok(
+ Math.abs(length - AIRLINER_LENGTH) < 1e-6,
+ `the constant says ${AIRLINER_LENGTH} and the mesh is ${length} long`,
+ );
+ // Every extremity inside one glyph length of the origin, which is what makes
+ // that length usable as the pick radius as well as as the size.
+ const reach = Math.max(
+ box.max.length(),
+ box.min.length(),
+ new THREE.Vector3(box.max.x, box.max.y, box.min.z).length(),
+ );
+ assert.ok(reach < AIRLINER_LENGTH, `an extremity reaches ${reach}, outside the pick sphere`);
+ geo.dispose();
+ });
+});
+
+describe("pointing at an aeroplane", () => {
+ /**
+ * A camera looking down the −Z axis from above, and a ray through a point
+ * offset from the aeroplane by `offsetUnits` in world X.
+ *
+ * Built with `Raycaster.set` rather than `setFromCamera` so the offset is in
+ * world units and the assertion is about the *size of the target* rather than
+ * about a projection matrix.
+ */
+ function rayAt(x: number, y: number, z: number): THREE.Raycaster {
+ return new THREE.Raycaster(new THREE.Vector3(x, y, z + 50), new THREE.Vector3(0, 0, -1));
+ }
+
+ it("hits a sphere around the glyph rather than its wings", () => {
+ const layer = createFlightLayer(flatWorld);
+ try {
+ layer.update([jet("a1b2c3", 0, 0)]);
+ const mesh = layer.pickables[0];
+ assert.ok(mesh, "no pick target for an aircraft that is in the sky");
+ mesh.updateMatrixWorld(true);
+ const at = mesh.position;
+
+ // Dead centre, and then off to one side by two thirds of a glyph length —
+ // which is past the wingtips and past the nose, and is empty space as far
+ // as the aeroplane's triangles are concerned.
+ for (const offset of [0, 0.66 * AIRLINER_LENGTH]) {
+ const hits = rayAt(at.x + offset, at.y, at.z).intersectObjects(layer.pickables, false);
+ assert.equal(hits.length, 1, `nothing under a pointer ${offset} units from the aeroplane`);
+ assert.equal(hits[0]?.object.userData.aircraftId, "a1b2c3");
+ assert.ok(Number.isFinite(hits[0]?.distance), "a hit with no distance cannot be sorted");
+ }
+
+ // Well outside it, though, is still empty sky. The target is generous, not
+ // unbounded: a card that opens when the pointer is nowhere near an
+ // aeroplane is a different bug with the same cause.
+ const miss = rayAt(at.x + 3 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
+ layer.pickables,
+ false,
+ );
+ assert.equal(miss.length, 0, "the whole sky is not an aeroplane");
+ } finally {
+ layer.dispose();
+ }
+ });
+
+ it("grows the target with the glyph, so a distant aeroplane is no harder to hit", () => {
+ const layer = createFlightLayer(flatWorld);
+ try {
+ layer.update([jet("a1b2c3", 0, 0)]);
+ const mesh = layer.pickables[0] as THREE.Mesh;
+ assert.ok(mesh);
+ // What `tick` does when the camera is a long way off. The scale is the one
+ // channel the pick radius reads, so setting it is the whole of the test.
+ mesh.scale.setScalar(12);
+ mesh.updateMatrixWorld(true);
+ const at = mesh.position;
+ const hits = rayAt(at.x + 8 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
+ layer.pickables,
+ false,
+ );
+ assert.equal(hits.length, 1, "a scaled-up glyph must scale its target with it");
+ } finally {
+ layer.dispose();
+ }
+ });
+
+ it("stops being clickable at the moment it stops being drawn", () => {
+ /**
+ * A track missing from a snapshot is held for `TRACK_GRACE_SECONDS` so a
+ * dropped ADS-B refresh does not throw away a minute of trail. `tick` stops
+ * *drawing* it once it has finished arriving where it was last seen — and
+ * `Raycaster` never looks at `visible`, so without an explicit removal the
+ * card goes on opening from a patch of empty sky for half a minute.
+ */
+ const realNow = performance.now;
+ let clockMs = 0;
+ performance.now = () => clockMs;
+ const layer = createFlightLayer(flatWorld);
+ try {
+ layer.update([jet("a1b2c3", 0, 0), jet("ddeeff", 0.2, 0.2)]);
+ clockMs = 10_000;
+ layer.update([jet("a1b2c3", 0.1, 0.1), jet("ddeeff", 0.3, 0.3)]);
+ assert.equal(layer.pickables.length, 2);
+
+ // One target goes quiet. It keeps flying to where it was last seen…
+ clockMs = 20_000;
+ layer.update([jet("a1b2c3", 0.2, 0.2)]);
+ assert.equal(layer.pickables.length, 2, "a target absent for one refresh is still in the sky");
+
+ // …and then it is not drawn any more, so it is not a pick target either.
+ clockMs = 45_000;
+ layer.update([jet("a1b2c3", 0.3, 0.3)]);
+ assert.deepEqual(
+ layer.pickables.map((object) => object.userData.aircraftId),
+ ["a1b2c3"],
+ "an aeroplane nobody can see must not open a card",
+ );
+
+ // And it comes back, rather than being permanently unclickable.
+ clockMs = 55_000;
+ layer.update([jet("a1b2c3", 0.4, 0.4), jet("ddeeff", 0.4, 0.4)]);
+ assert.equal(layer.pickables.length, 2);
+ assert.equal(
+ new Set(layer.pickables).size,
+ 2,
+ "a returning aeroplane must not be listed twice",
+ );
+ } finally {
+ layer.dispose();
+ performance.now = realNow;
+ }
+ });
+});
diff --git a/src/test/integration/californiaSilhouette.test.ts b/src/test/integration/californiaSilhouette.test.ts
new file mode 100644
index 0000000..7f0e180
--- /dev/null
+++ b/src/test/integration/californiaSilhouette.test.ts
@@ -0,0 +1,316 @@
+/**
+ * The default board's shape, its dry corner, and the three defects around them
+ * that only a photograph ever caught.
+ *
+ * This file exists beside `packs/californiaBoard.test.ts` rather than inside it
+ * because everything here crosses a seam: the pack decides where the state
+ * stops, `terrain.ts` decides what colour the ground at that elevation is, and
+ * `scene.ts` decides how far back to stand. Each of the three below typechecked,
+ * rendered without a console error and met every performance budget.
+ *
+ * 1. **A state that was not the shape of the state.** The land polygon closed
+ * with three ruled segments — one running due north along -117.45 for five
+ * degrees of latitude — so San Diego, the Peninsular Ranges, the Colorado
+ * Desert, the Salton basin and half the Mojave were all rendered as open
+ * ocean, and California sat on the water as a paper dart.
+ * 2. **A desert with no second value in it.** `groundColor` saturates at
+ * 150 m, so the Mojave floor at 620 m and the Sierra crest at 4,300 were
+ * painted exactly the same number and the only thing separating a granite
+ * skyline from a creosote flat was the Lambert term.
+ * 3. **An opening pose that framed a third of the board.** `fov` is vertical,
+ * so the horizontal half-angle is a fact about the shape of the window: the
+ * pose that held the whole state on a laptop held its middle third on a
+ * phone, with the Sierra and the Colorado off both sides of the screen.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+
+import CALIFORNIA_CITY from "../../cities/california.ts";
+import SOCAL_CITY from "../../cities/socal.ts";
+import { chapterFraming } from "../../engine/scene.ts";
+import { createTerrain, createWater } from "../../engine/terrain.ts";
+import type { City, LatLng } from "../../engine/types.ts";
+import { World } from "../../engine/world.ts";
+
+/**
+ * A `World` whose heightfield is already up.
+ *
+ * `ready()` waits on a paint that never comes under the Node test runner, so
+ * this takes the documented synchronous path: `lattice()` builds the field on
+ * the calling thread when nobody awaited `ready()`.
+ */
+function builtWorld(city: City): World {
+ const world = new World(city);
+ world.lattice();
+ return world;
+}
+
+/**
+ * The Nevada line, as the pack draws it: one ruled segment from the corner at
+ * Lake Tahoe to the Arizona/Nevada/California tri-point on the Colorado.
+ */
+function nevadaLineLng(lat: number): number {
+ return -114.6339 - 1.3421 * (lat - 35.0016);
+}
+
+describe("the California silhouette", () => {
+ const world = new World(CALIFORNIA_CITY);
+
+ it("puts the state's own edges on three sides of the board", () => {
+ // Inside, and every one of them was ocean on the board that shipped before.
+ for (const [name, lat, lng] of [
+ ["San Diego", 32.72, -117.16],
+ ["the Laguna Mountains", 32.78, -116.4],
+ ["the Imperial Valley", 32.85, -115.5],
+ ["Palm Springs", 33.82, -116.54],
+ ["Joshua Tree", 33.98, -116.1],
+ ["the eastern Mojave", 34.9, -115.6],
+ ["Death Valley", 36.25, -116.83],
+ ["Needles", 34.8, -114.7],
+ ] as [string, number, number][]) {
+ assert.equal(world.isLand(lat, lng), true, `${name} should be on the board`);
+ }
+
+ // Outside, and each one is a different edge doing its job.
+ for (const [name, lat, lng] of [
+ ["Baja California, below the border", 32.3, -116.6],
+ ["Arizona, across the Colorado at Yuma", 32.75, -114.3],
+ ["Arizona, across the river at Parker", 34.2, -114.05],
+ ["Nevada, east of the line at Las Vegas' latitude", 36.1, -115.2],
+ ["Nevada, east of the line above Bishop", 37.4, -117.2],
+ ["the Pacific, west of Point Conception", 34.3, -120.8],
+ ] as [string, number, number][]) {
+ assert.equal(world.isLand(lat, lng), false, `${name} should be off the board`);
+ }
+ });
+
+ it("follows the Nevada line rather than a meridian", () => {
+ // A degree of latitude apart, and the boundary moves 1.34° of longitude with
+ // it. A vertical closure — which is what this used to be — passes the first
+ // of these and fails the third.
+ for (const lat of [35.4, 36.4, 37.4, 38.0]) {
+ const edge = nevadaLineLng(lat);
+ assert.equal(world.isLand(lat, edge - 0.25), true, `${lat}N inside the line`);
+ assert.equal(world.isLand(lat, edge + 0.25), false, `${lat}N outside the line`);
+ }
+ });
+
+ it("keeps the Salton Sea as inland water inside a land polygon", () => {
+ assert.equal(CALIFORNIA_CITY.inlandWater.length, 1);
+ // Water at the middle, land on both shores. A lake that has swallowed its
+ // own basin and a lake that is not there at all both pass a length check.
+ assert.equal(world.isLand(33.31, -115.84), false, "the middle of the sea");
+ assert.equal(world.isLand(33.31, -116.15), true, "the western shore");
+ assert.equal(world.isLand(33.31, -115.55), true, "the eastern shore");
+ });
+
+ it("stands real relief in the corner that used to be a blank flat", () => {
+ const built = builtWorld(CALIFORNIA_CITY);
+ // The named ranges, each of which is a chain and not a single bell.
+ for (const [name, lat, lng, floor] of [
+ ["San Gorgonio", 34.12, -116.86, 2_600],
+ ["San Jacinto", 33.81, -116.67, 2_400],
+ ["Telescope Peak", 36.2, -117.22, 2_400],
+ ["the Providence Mountains", 34.9, -115.62, 1_400],
+ ["the New York Mountains", 35.23, -115.32, 1_400],
+ ["the Kingston Range", 35.72, -115.91, 1_400],
+ ] as [string, number, number, number][]) {
+ const metres = built.elevationAt(lat, lng);
+ assert.ok(metres > floor, `${name} is only ${Math.round(metres)} m`);
+ }
+ });
+
+ it("draws those ranges as ridges rather than as a field of domes", () => {
+ const built = builtWorld(CALIFORNIA_CITY);
+ /**
+ * The test for a ridge is anisotropy: walking *along* a range front stays
+ * high and walking *across* it falls away, and the ratio between the two is
+ * the whole difference between the board that reads as basin and range and
+ * the board that read as bubble wrap. A single radial bell — which is what
+ * each of these used to be — scores exactly 1 here by construction, because
+ * `(1 - d²)²` has no direction in it.
+ *
+ * The two step vectors are derived from the range's own end points rather
+ * than written by hand, and longitude is squashed by `cos(centre latitude)`
+ * on the way in and out, because that is what `elevationAt` does when it
+ * measures a distance and a step that ignores it is 22% short east-west.
+ */
+ const SQUASH = Math.cos((CALIFORNIA_CITY.center.lat * Math.PI) / 180);
+ const ranges: Array<{ name: string; from: LatLng; to: LatLng; step: number }> = [
+ { name: "the Providence Mountains", from: [34.72, -115.78], to: [35.08, -115.46], step: 0.15 },
+ { name: "the Old Woman Mountains", from: [34.3, -115.38], to: [34.68, -115.12], step: 0.15 },
+ { name: "the Panamint Range", from: [35.92, -117.22], to: [36.56, -117.24], step: 0.18 },
+ ];
+
+ for (const { name, from, to, step } of ranges) {
+ const mid: LatLng = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
+ const dLat = to[0] - from[0];
+ const dLng = (to[1] - from[1]) * SQUASH;
+ const length = Math.hypot(dLat, dLng);
+ const along: LatLng = [(dLat / length) * step, ((dLng / length) * step) / SQUASH];
+ const across: LatLng = [(-dLng / length) * step, ((dLat / length) * step) / SQUASH];
+
+ const at = (offset: LatLng, sign: number): number =>
+ built.elevationAt(mid[0] + offset[0] * sign, mid[1] + offset[1] * sign);
+ const crest = built.elevationAt(mid[0], mid[1]);
+ const alongLow = Math.min(at(along, 1), at(along, -1));
+ const acrossLow = Math.min(at(across, 1), at(across, -1));
+
+ assert.ok(crest > 800, `${name} is only ${Math.round(crest)} m at its middle`);
+ assert.ok(
+ alongLow > acrossLow * 1.6,
+ `${name} falls to ${Math.round(alongLow)} m along the range and ` +
+ `${Math.round(acrossLow)} m across it, which is a dome and not a ridge`,
+ );
+ }
+ });
+
+ it("leaves Badwater a salt pan rather than lifting it onto farmland", () => {
+ const built = builtWorld(CALIFORNIA_CITY);
+ // Under 3 m is `palette.sand`, which is the beach colour and is exactly what
+ // the floor of Death Valley should be. The Black Mountains' radius is the
+ // only thing holding this: at 0.18 the chain reached across the valley and
+ // put the pan at 52 m, which is `flats` gold.
+ assert.ok(built.elevationAt(36.25, -116.83) < 3, "Badwater has been filled in");
+ // And it is a pan between two walls, not a plain.
+ assert.ok(built.elevationAt(36.2, -117.22) > 2_400, "the Panamints");
+ assert.ok(built.elevationAt(36.15, -116.66) > 900, "the Black Mountains");
+ });
+});
+
+describe("the third colour stop", () => {
+ /** Every emitted vertex colour, and the lattice index it came from. */
+ function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } {
+ const world = builtWorld(city);
+ const mesh = createTerrain(world);
+ const position = mesh.geometry.getAttribute("position");
+ const colour = mesh.geometry.getAttribute("color");
+ return {
+ world,
+ colourAt: (lat, lng) => {
+ const [x, z] = world.project(lat, lng);
+ let best = -1;
+ let bestDistance = Infinity;
+ for (let i = 0; i < position.count; i += 1) {
+ const d = Math.hypot(position.getX(i) - x, position.getZ(i) - z);
+ if (d < bestDistance) {
+ bestDistance = d;
+ best = i;
+ }
+ }
+ return new THREE.Color(colour.getX(best), colour.getY(best), colour.getZ(best));
+ },
+ };
+ }
+
+ it("separates the Sierra crest from the Mojave floor, which used to be one value", () => {
+ const { colourAt } = terrainColours(CALIFORNIA_CITY);
+ // Both are unpainted ground — no park envelope reaches either — so before
+ // `alpine` existed these two returned the identical `upland` hex however far
+ // apart they are in altitude.
+ const crest = colourAt(36.62, -118.29); // Mount Whitney, 4,300 m
+ const desert = colourAt(34.9, -117.0); // the Mojave floor, about 600 m
+ const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
+ assert.ok(
+ brightness(crest) > brightness(desert) * 1.15,
+ `the crest (${brightness(crest).toFixed(3)}) is no lighter than the desert ` +
+ `(${brightness(desert).toFixed(3)})`,
+ );
+ // Lighter *and* less saturated: bare granite is grey and a desert fan is
+ // brown, and a ramp that only raised the value would wash the desert out
+ // rather than change what it is.
+ const chroma = (c: THREE.Color): number =>
+ Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
+ assert.ok(chroma(crest) < chroma(desert), "the crest is as warm as the desert");
+ });
+
+ it("leaves a pack that declares no `alpine` exactly as it was", () => {
+ // The colour is optional for this reason: Southern California is 308 units
+ // across with a 3,000 m wall on it and has never wanted a snow line, and a
+ // tenth required palette entry would have made every existing pack answer
+ // for one.
+ assert.equal(SOCAL_CITY.palette?.alpine, undefined);
+ const { colourAt } = terrainColours(SOCAL_CITY);
+ const high = colourAt(34.29, -117.65); // Mount San Antonio, the board's roof
+ const low = colourAt(34.05, -117.9); // the basin floor below it
+ const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
+ // Not identical — one is in a forest envelope and one is not — but the high
+ // ground must not have been *lightened*, which is what a stop applied to a
+ // pack that never asked for one would do.
+ assert.ok(
+ brightness(high) <= brightness(low) + 0.02,
+ "Southern California's high ground picked up a snow line it never declared",
+ );
+ });
+});
+
+describe("the sea loses every depth argument it should never have won", () => {
+ it("pushes the ocean behind the ground it is under", () => {
+ /*
+ * The sea sits 0.06 units below y=0 and the terrain's coastal rim 0.012
+ * above it, and at a whole-board pose on the Bay Area that whole gap is a
+ * fifth of one step of the depth buffer. Which surface wins was decided by
+ * float error in a two-triangle plane eighteen board spans across, and what
+ * it looked like was the ocean dithered over every flat piece of ground on
+ * the board — on the two detailed boards, not on the state one, whose valley
+ * floor stands at 28 m.
+ *
+ * Asserted on the material rather than on a picture because a picture is
+ * what it took to find, and the whole point of the assertion is that the
+ * next person does not need one.
+ */
+ const water = createWater(builtWorld(SOCAL_CITY));
+ const sea = water.getObjectByName("sea");
+ assert.ok(sea instanceof THREE.Mesh, "no sea in the water group");
+ const material = (sea as THREE.Mesh).material as THREE.Material;
+ assert.equal(material.polygonOffset, true, "the sea does not yield in depth");
+ assert.ok(
+ material.polygonOffsetUnits > 0 && material.polygonOffsetFactor > 0,
+ "the sea's polygon offset pulls it toward the camera rather than away",
+ );
+ water.traverse((object) => {
+ if (object instanceof THREE.Mesh) object.geometry.dispose();
+ });
+ });
+});
+
+describe("chapter framing on a window the pose was not written for", () => {
+ // The California board: 428 units across, an opening pose reaching about 600,
+ // an orbit ceiling at two board spans.
+ const board = { boardSpan: 428, orbitMax: 856 };
+
+ it("leaves a wide window alone", () => {
+ for (const aspect of [1.6, 1.78, 2.1]) {
+ assert.equal(chapterFraming({ aspect, reach: 600, ...board }), 1);
+ }
+ });
+
+ it("stands back on a phone held upright", () => {
+ const scale = chapterFraming({ aspect: 390 / 844, reach: 600, ...board });
+ assert.ok(scale > 1.2, `only ${scale.toFixed(2)}× on a portrait screen`);
+ // And never past the orbit's ceiling, because `OrbitControls` clamps to it
+ // on the next update and a pose beyond it is a pose nobody wrote.
+ assert.ok(600 * scale <= board.orbitMax + 1e-6, "the pose is outside maxDistance");
+ });
+
+ it("leaves a close-up alone on any window", () => {
+ // Southern California's opening shot stands off about half a board span and
+ // is deliberately inside its subject. Pulling it back is a different
+ // photograph, not a correction.
+ assert.equal(chapterFraming({ aspect: 390 / 844, reach: 150, boardSpan: 308, orbitMax: 616 }), 1);
+ // As is a driving chapter on the state board.
+ assert.equal(chapterFraming({ aspect: 390 / 844, reach: 133, ...board }), 1);
+ });
+
+ it("never returns something a camera cannot use", () => {
+ for (const reach of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
+ assert.equal(chapterFraming({ aspect: 0.46, reach, ...board }), 1);
+ }
+ for (const aspect of [0, -1, Number.NaN]) {
+ const scale = chapterFraming({ aspect, reach: 600, ...board });
+ assert.ok(Number.isFinite(scale) && scale >= 1, `aspect ${aspect} gave ${scale}`);
+ }
+ });
+});
diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts
index 5c136d0..9abbac2 100644
--- a/src/test/integration/sceneWiring.test.ts
+++ b/src/test/integration/sceneWiring.test.ts
@@ -50,6 +50,7 @@ const LUMBRIDGE_HQ = (await import("../../offices/lumbridge-hq.ts")).default;
const MATEO_COURT = (await import("../../offices/mateo-court.ts")).default;
const { initialDeviceState } = await import("../../devices/types.ts");
const { createSimulatedVehicleTelemetry } = await import("../../transport/vehicleTelemetry.ts");
+const { arrivalGroundFor } = await import("../../transport/exteriorVehicle.ts");
type Office = typeof LUMBRIDGE_HQ;
@@ -277,7 +278,7 @@ test("a device reading reaches the indicator on the hardware", () => {
// ---- The exterior ----------------------------------------------------------
for (const [id, pack] of PACKS) {
- test(`${id}: a Model X is parked on the pack's arrival stall`, () => {
+ test(`${id}: the exterior matches the ground its site actually has`, () => {
const arrival = pack.site?.arrival;
assert.ok(arrival, `${id} authors no arrival anchor`);
const { scene, rig } = build(pack);
@@ -291,8 +292,19 @@ for (const [id, pack] of PACKS) {
exterior.traverse((object) => {
if (object.userData.vehicleModel === "model-x") car = object;
});
- assert.ok(car !== null, "the apron was built without a car on it");
+ // An apron is ground, and `lumbridge-hq`'s studio floor is 188 m above the
+ // pavement outside it. Built literally at the stall's own storey it is a
+ // paved pad and a car in open sky beside the west wall, which is what this
+ // shipped for one build. Read off the site rather than the id, because
+ // reading off the site is exactly what the layer under test must do.
+ if (arrivalGroundFor(pack.site?.elevation ?? 0) === "air") {
+ assert.equal(car, null, `${id} parked a car in the air`);
+ assert.equal(exterior.children.length, 0, "an off-ground site draws nothing");
+ return;
+ }
+
+ assert.ok(car !== null, "the apron was built without a car on it");
const at = (car as THREE.Object3D).getWorldPosition(new THREE.Vector3());
const floorY = scene.plan.level(arrival.levelId)?.floorY ?? 0;
assert.ok(
@@ -302,8 +314,8 @@ for (const [id, pack] of PACKS) {
);
assert.ok(
Math.abs(at.y - floorY) <= 1.5,
- `the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at the ` +
- `plan origin — a podium deck is the whole reason the stall names a storey`,
+ `the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at ` +
+ `the plan origin — the stall names a storey for a reason`,
);
} finally {
scene.dispose();
@@ -557,10 +569,31 @@ test("the water reflects rather than absorbing, now that there is a sky to refle
"Lambert has no specular term at all, which is why half the California board " +
"rendered as one flat blue value at every hour and from every angle",
);
+ // Assert the PROPERTY, not the formatting. The first draft of this test
+ // matched one exact source line — `MeshStandardMaterial({ color: pal.sea,
+ // roughness: 0.14, metalness: 0 })` — and the sea that shipped is a
+ // multi-line construction at roughness 0.2 with an `onBeforeCompile` patch
+ // that flattens it toward the horizon. The implementation was better than the
+ // literal and the test failed anyway, which is the failure mode of asserting
+ // on source text: it pins the author's first guess rather than the behaviour.
+ const build = water.slice(0, water.indexOf("\n}"));
+ assert.match(
+ build,
+ /MeshStandardMaterial\(/,
+ "the sea must be a dielectric that reads `scene.environment`",
+ );
+
+ const roughness = Number(/roughness:\s*([\d.]+)/.exec(build)?.[1]);
assert.ok(
- /MeshStandardMaterial\(\{ color: pal\.sea, roughness: 0\.14, metalness: 0 \}\)/.test(source),
- "the sea must be a low-roughness dielectric, so it takes both the sun's glint " +
- "and `scene.environment`",
+ Number.isFinite(roughness) && roughness > 0 && roughness <= 0.35,
+ `the sea's roughness is ${roughness}; it must be low enough to return a sun ` +
+ `glint and a sky reflection, and above zero so it is water and not a mirror`,
+ );
+ assert.match(
+ build,
+ /metalness:\s*0\b/,
+ "water is a dielectric: metalness 0 is what makes the reflection white " +
+ "rather than tinted by the sea colour",
);
});
diff --git a/src/test/integration/touchPick.test.ts b/src/test/integration/touchPick.test.ts
new file mode 100644
index 0000000..45b42f0
--- /dev/null
+++ b/src/test/integration/touchPick.test.ts
@@ -0,0 +1,228 @@
+/**
+ * Can a stranger on a phone read a detail card at all?
+ *
+ * The answer on the deployed build was **no**, for every card the product has —
+ * an aeroplane, a marker, a landmark — and nothing in the suite could see it.
+ * The pick was correct, `mount.ts` wrote the card's text into `#detail`, and
+ * about 32 ms later the card was blanked again. `#detail.textContent` still
+ * held `SIM 1 / Simulated track — no receiver involved / …` while
+ * `#detail.hidden` was `true`, continuously, for as long as you cared to watch.
+ *
+ * The cause is the one part of a tap that is not a touch. Chrome finishes a tap
+ * by replaying it as the legacy mouse events pages were written against before
+ * pointer events existed, and the tail of that replay — recorded off a real tap
+ * on the canvas — is:
+ *
+ * pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
+ * mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
+ *
+ * `onPointerLeave` filtered on `pointerType === "touch"`, which correctly
+ * ignored the fourth event and then honoured the eighth. A `pointerleave`
+ * calling itself a mouse is indistinguishable from a real one by type; the only
+ * thing that tells them apart is the clock.
+ *
+ * So this file drives that exact sequence through a real `SceneKit` and asserts
+ * the pick is still there afterwards — and, in the other direction, that a
+ * genuine mouse leaving the canvas long after any touch still clears it, which
+ * is the behaviour the desktop has always had and must keep.
+ *
+ * It is an integration test rather than a unit one because there is no seam to
+ * unit-test: the defect lives in the wiring between three listeners and a piece
+ * of module state, and the listeners are only reachable through the constructor.
+ * The fake DOM here therefore *records* its listeners, which is the one thing
+ * `sceneWiring.test.ts`'s fake deliberately does not do.
+ */
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import * as THREE from "three";
+
+// `scenekit.ts` reads `window.matchMedia` while constructing, so the stub has to
+// be installed before the module is imported. Hence the dynamic import below.
+(globalThis as unknown as { window: unknown }).window = {
+ matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
+ innerWidth: 390,
+ innerHeight: 844,
+ devicePixelRatio: 2,
+ addEventListener() {},
+ removeEventListener() {},
+};
+
+const { createSceneKit } = await import("../../engine/scenekit.ts");
+
+/** The canvas, plus a record of what was bound to it so a test can fire it. */
+interface RecordingDom {
+ el: HTMLElement;
+ fire(type: string, event: Record): void;
+}
+
+function recordingDom(): RecordingDom {
+ const listeners = new Map void>>();
+ const el = {
+ style: {} as Record,
+ clientWidth: 390,
+ clientHeight: 844,
+ addEventListener(type: string, fn: (event: unknown) => void) {
+ let set = listeners.get(type);
+ if (!set) listeners.set(type, (set = new Set()));
+ set.add(fn);
+ },
+ removeEventListener(type: string, fn: (event: unknown) => void) {
+ listeners.get(type)?.delete(fn);
+ },
+ setPointerCapture() {},
+ releasePointerCapture() {},
+ getBoundingClientRect: () => ({
+ left: 0, top: 0, width: 390, height: 844, right: 390, bottom: 844, x: 0, y: 0,
+ }),
+ getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }),
+ ownerDocument: { addEventListener() {}, removeEventListener() {} },
+ };
+ return {
+ el: el as unknown as HTMLElement,
+ fire(type, event) {
+ for (const fn of listeners.get(type) ?? []) fn(event);
+ },
+ };
+}
+
+/**
+ * A kit looking straight down the −Z axis at one box, with that box picked.
+ *
+ * The centre of the viewport is therefore a hit and anywhere else is a miss,
+ * which is all the geometry any test here needs.
+ */
+function board() {
+ const scene = new THREE.Scene();
+ const dom = recordingDom();
+ const target = new THREE.Mesh(new THREE.BoxGeometry(4, 4, 4), new THREE.MeshBasicMaterial());
+ target.userData.id = "sim-1";
+ scene.add(target);
+
+ const kit = createSceneKit({ scene, dom: dom.el });
+ kit.setPose({ position: new THREE.Vector3(0, 0, 40), target: new THREE.Vector3(0, 0, 0) });
+ kit.camera.updateMatrixWorld(true);
+
+ const changes: (string | null)[] = [];
+ kit.setPicking({
+ targets: [target],
+ resolve: (hit) => (hit.object.userData.id as string) ?? null,
+ onChange: (picked) => changes.push(picked),
+ });
+
+ return {
+ kit,
+ dom,
+ changes,
+ dispose() {
+ kit.dispose();
+ target.geometry.dispose();
+ target.material.dispose();
+ },
+ };
+}
+
+/** The four touch events a tap on the middle of the glass produces, in order. */
+function tapCentre(dom: RecordingDom, at: number) {
+ dom.fire("pointerdown", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at });
+ dom.fire("pointerup", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at + 60 });
+ dom.fire("pointerout", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
+ dom.fire("pointerleave", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
+}
+
+test("a tap raises a card and the compatibility mouse leave does not take it away", () => {
+ const { kit, dom, changes, dispose } = board();
+ try {
+ tapCentre(dom, 1_000);
+ kit.tick(1 / 60);
+ assert.deepEqual(changes, ["sim-1"], "the tap did not pick the thing under the finger");
+
+ // The replay. `pointerType` says mouse and it is not one — it is Chrome
+ // finishing the tap, 32 ms after the finger left.
+ dom.fire("pointerout", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
+ dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
+ kit.tick(1 / 60);
+
+ assert.deepEqual(
+ changes,
+ ["sim-1"],
+ "the card was cleared by the browser's own replay of the tap that raised it",
+ );
+ } finally {
+ dispose();
+ }
+});
+
+test("the card survives long enough to be read, over many frames", () => {
+ // The failure this is really about is not one event, it is a card that is
+ // never on the page long enough to look at. Two seconds of frames, with the
+ // replay in the middle of them, is the shape a person experiences.
+ const { kit, dom, changes, dispose } = board();
+ try {
+ tapCentre(dom, 0);
+ for (let frame = 0; frame < 120; frame += 1) {
+ if (frame === 2) {
+ dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 92 });
+ }
+ kit.tick(1 / 60);
+ }
+ assert.deepEqual(changes, ["sim-1"], "the card did not survive two seconds of frames");
+ } finally {
+ dispose();
+ }
+});
+
+test("a real mouse leaving the canvas still clears the pick", () => {
+ const { kit, dom, changes, dispose } = board();
+ try {
+ dom.fire("pointermove", { pointerType: "mouse", clientX: 195, clientY: 422, timeStamp: 500 });
+ kit.tick(1 / 60);
+ assert.deepEqual(changes, ["sim-1"], "hover did not pick on the desktop");
+
+ dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 900 });
+ assert.deepEqual(changes, ["sim-1", null], "the pointer left the canvas and the card stayed");
+ } finally {
+ dispose();
+ }
+});
+
+test("a hybrid laptop's mouse is only deferred to briefly, not disabled", () => {
+ // The cost of the fix, stated: a device with both a touchscreen and a mouse
+ // ignores a genuine mouse-leave for `COMPAT_MOUSE_MS` after a tap. Past that
+ // window it behaves exactly like a desktop again, and this pins the recovery
+ // rather than leaving it to be discovered.
+ const { kit, dom, changes, dispose } = board();
+ try {
+ tapCentre(dom, 0);
+ kit.tick(1 / 60);
+ assert.deepEqual(changes, ["sim-1"]);
+
+ // Inside the window: held.
+ dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 700 });
+ assert.deepEqual(changes, ["sim-1"]);
+
+ // Past it: a mouse is a mouse again.
+ dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 1_500 });
+ assert.deepEqual(changes, ["sim-1", null], "the mouse never got the canvas back");
+ } finally {
+ dispose();
+ }
+});
+
+test("tapping empty water dismisses the card", () => {
+ // The other half of the touch contract, asserted here because the fix above
+ // works by ignoring a clear and this is the clear that must still happen.
+ const { kit, dom, changes, dispose } = board();
+ try {
+ tapCentre(dom, 0);
+ kit.tick(1 / 60);
+ assert.deepEqual(changes, ["sim-1"]);
+
+ dom.fire("pointerdown", {
+ pointerType: "touch", pointerId: 3, clientX: 20, clientY: 60, timeStamp: 2_000,
+ });
+ assert.deepEqual(changes, ["sim-1", null], "a tap on nothing left the old card up");
+ } finally {
+ dispose();
+ }
+});
diff --git a/src/test/packs/californiaBoard.test.ts b/src/test/packs/californiaBoard.test.ts
new file mode 100644
index 0000000..4c74829
--- /dev/null
+++ b/src/test/packs/californiaBoard.test.ts
@@ -0,0 +1,270 @@
+/**
+ * The statewide California board — the default one, the first frame an
+ * anonymous visitor sees.
+ *
+ * Every defect this file guards against **typechecked, rendered without a
+ * console error, and met every performance budget.** They were only ever
+ * visible by looking at the board, which is why they survived for so long and
+ * why the assertions below are shaped the way they are: each one is the
+ * cheapest arithmetic statement of something a person found in a screenshot.
+ *
+ * 1. **A self-intersecting coastline.** San Francisco Bay was traced as a
+ * concavity in the landmass, and the polygon's closure edge ran across its
+ * head. `isLand` still answered correctly and the terrain grid still left
+ * the hole, but `ShapeGeometry` triangulated the slit shut and the shore
+ * plate paved the entire bay. Nothing threw. The bay was simply not there.
+ * 2. **A board with no relief.** At an exaggeration of 2.25 a 3,000 m range
+ * stood 1.6 units off a board 284 units tall — five tenths of one percent.
+ * Every hill was in the pack, every hill was in the heightfield, and the
+ * state looked like a beach.
+ * 3. **Cities that produce no buildings.** A district drawn inside a park
+ * envelope emits zero lots, because `createBlocks` skips every lot in a
+ * park. One did, silently, and read as an empty valley.
+ * 4. **A corridor that climbs a mountain nobody meant to put there.** Hill
+ * radii are in degrees and the routes are hand-traced; a range centred
+ * half a degree from US-101 puts a kilometre of climb into the Salinas
+ * Valley and neither the pack nor the renderer has an opinion about it.
+ *
+ * The blocks-scale assertions at the end are here for a different reason: they
+ * are the promise that making the state board legible did not disturb the two
+ * boards that already looked right.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import CALIFORNIA_CITY, { CALIFORNIA_I_5, CALIFORNIA_US_101 } from "../../cities/california.ts";
+import SF_CITY from "../../cities/sf.ts";
+import SOCAL_CITY from "../../cities/socal.ts";
+import { createBlocks } from "../../engine/blocks.ts";
+import type { City, LatLng } from "../../engine/types.ts";
+import { World } from "../../engine/world.ts";
+
+/** Do two closed segments cross, endpoints excluded? */
+function crosses(a: LatLng, b: LatLng, c: LatLng, d: LatLng): boolean {
+ const side = (p: LatLng, q: LatLng, r: LatLng): number =>
+ Math.sign((q[0] - p[0]) * (r[1] - q[1]) - (q[1] - p[1]) * (r[0] - q[0]));
+ return side(a, b, c) !== side(a, b, d) && side(c, d, a) !== side(c, d, b);
+}
+
+/**
+ * Every pair of non-adjacent edges in a ring, which is quadratic and does not
+ * matter: the biggest ring in the repo is a few hundred vertices and this runs
+ * in single-digit milliseconds.
+ */
+function selfIntersections(ring: readonly LatLng[]): Array<[number, number]> {
+ const hits: Array<[number, number]> = [];
+ const n = ring.length;
+ for (let i = 0; i < n; i += 1) {
+ for (let j = i + 2; j < n; j += 1) {
+ if (i === 0 && j === n - 1) continue; // the closing edge touches the first
+ const a = ring[i];
+ const b = ring[(i + 1) % n];
+ const c = ring[j];
+ const d = ring[(j + 1) % n];
+ if (!a || !b || !c || !d) continue;
+ if (crosses(a, b, c, d)) hits.push([i, j]);
+ }
+ }
+ return hits;
+}
+
+/**
+ * A `World` whose heightfield is already built.
+ *
+ * `ready()` waits on a paint that never comes under the Node test runner, so
+ * this takes the documented synchronous path instead: `lattice()` builds the
+ * field on the calling thread when nobody awaited `ready()`, which is the same
+ * fallback a browser with Workers blocked takes.
+ */
+function builtWorld(city: City): World {
+ const world = new World(city);
+ world.lattice();
+ return world;
+}
+
+describe("California board — geometry that only a picture used to catch", () => {
+ it("traces every coastline as a simple polygon", () => {
+ for (const city of [CALIFORNIA_CITY, SF_CITY, SOCAL_CITY]) {
+ for (const [index, ring] of city.landmasses.entries()) {
+ const hits = selfIntersections(ring);
+ assert.deepEqual(
+ hits,
+ [],
+ `${city.id} landmass ${index} crosses itself at ${JSON.stringify(hits)}; ` +
+ "ShapeGeometry will quietly triangulate the slit shut and pave whatever is inside it",
+ );
+ }
+ }
+ });
+
+ it("keeps San Francisco Bay as water joined to the Pacific", () => {
+ const world = new World(CALIFORNIA_CITY);
+ // Down the middle of the bay, from San Pablo to the south bay, plus the
+ // Golden Gate itself. Every one of these was dry land when the contour
+ // self-intersected.
+ for (const [lat, lng] of [
+ [38.0, -122.35],
+ [37.9, -122.35],
+ [37.8, -122.33],
+ [37.7, -122.25],
+ [37.6, -122.16],
+ [37.5, -122.05],
+ [37.83, -122.5],
+ ] as LatLng[]) {
+ assert.equal(world.isLand(lat, lng), false, `${lat},${lng} should be bay`);
+ }
+ // And the two shores are still land, so the bay is a strait and not a hole
+ // punched through the peninsula.
+ assert.equal(world.isLand(37.76, -122.44), true, "San Francisco");
+ assert.equal(world.isLand(37.8, -122.15), true, "the East Bay");
+ });
+
+ it("stands the ranges up far enough to be seen from the state camera", () => {
+ const world = builtWorld(CALIFORNIA_CITY);
+ const { bounds } = CALIFORNIA_CITY;
+ const boardUnits = (bounds.maxLat - bounds.minLat) * CALIFORNIA_CITY.latScale;
+
+ let peak = 0;
+ for (const metres of world.lattice().height) if (metres > peak) peak = metres;
+ const peakUnits = world.metres(peak);
+
+ assert.ok(peak > 4_000, `the highest ground is only ${Math.round(peak)} m`);
+ // 8% of the board's own height. Southern California's San Gabriels clear
+ // this comfortably; the old 2.25 exaggeration put this board at 0.6%.
+ assert.ok(
+ peakUnits / boardUnits > 0.08,
+ `relief is ${((peakUnits / boardUnits) * 100).toFixed(1)}% of the board — flat`,
+ );
+ });
+
+ it("leaves the Central Valley a genuine flat between two ranges", () => {
+ const world = builtWorld(CALIFORNIA_CITY);
+ // A line up the middle of the valley floor, and a matching line along the
+ // Sierra crest. Both axes lean west as they run north, which is why they are
+ // interpolated rather than held at one longitude: the valley at Bakersfield
+ // is at -119.2 and at Stockton it is at -121.4, and a straight line down one
+ // meridian walks out of the valley and up into the foothills.
+ for (let lat = 35.6; lat <= 37.8; lat += 0.2) {
+ const lng = -119.2 - (lat - 35.4) * 0.88;
+ const floor = world.elevationAt(lat, lng);
+ assert.ok(floor < 260, `the valley floor at ${lat.toFixed(1)}N is ${Math.round(floor)} m`);
+ }
+ // The crest is sampled at its own longitudes rather than off a straight
+ // line: the Sierra swings from -118.3 at Whitney to -119.6 at Sonora, and a
+ // meridian drawn through both ends misses the range in the middle.
+ for (const [lat, lng] of [
+ [36.2, -118.28],
+ [36.45, -118.28],
+ [36.62, -118.29],
+ [36.85, -118.38],
+ [37.05, -118.52],
+ [37.25, -118.72],
+ [37.45, -118.92],
+ [37.65, -119.12],
+ ] as LatLng[]) {
+ const crest = world.elevationAt(lat, lng);
+ assert.ok(crest > 2_000, `the Sierra at ${lat.toFixed(2)}N is only ${Math.round(crest)} m`);
+ }
+ });
+
+ it("keeps the two corridors in the valleys, and climbs only where a driver climbs", () => {
+ const world = builtWorld(CALIFORNIA_CITY);
+ /**
+ * The named passes, and nothing else, each with its own reach.
+ *
+ * Newhall is a single notch behind Santa Clarita and the Cuesta Grade is one
+ * climb out of San Luis Obispo, so both are tight. The Grapevine is not a
+ * pass in that sense at all: I-5 leaves the Los Angeles basin at Castaic and
+ * does not come down again until Wheeler Ridge forty kilometres later, over
+ * Gorman and Tejon, and a small circle round the summit would call most of
+ * that ascent an error.
+ */
+ const passes: Array<{ at: LatLng; reach: number }> = [
+ { at: [34.3917, -118.5426], reach: 0.16 }, // Newhall
+ { at: [34.75, -118.8], reach: 0.4 }, // the Grapevine: Castaic to Wheeler Ridge
+ { at: [35.2828, -120.6596], reach: 0.2 }, // the Cuesta Grade
+ ];
+ const nearAPass = (lat: number, lng: number): boolean =>
+ passes.some(({ at, reach }) => Math.hypot(lat - at[0], (lng - at[1]) * 0.81) < reach);
+
+ for (const [name, path] of [["US-101", CALIFORNIA_US_101], ["I-5", CALIFORNIA_I_5]] as const) {
+ for (let index = 0; index < path.length - 1; index += 1) {
+ const from = path[index];
+ const to = path[index + 1];
+ if (!from || !to) continue;
+ for (let step = 0; step <= 20; step += 1) {
+ const t = step / 20;
+ const lat = from[0] + (to[0] - from[0]) * t;
+ const lng = from[1] + (to[1] - from[1]) * t;
+ const metres = world.elevationAt(lat, lng);
+ const cap = nearAPass(lat, lng) ? 1_500 : 700;
+ assert.ok(
+ metres < cap,
+ `${name} climbs to ${Math.round(metres)} m at ${lat.toFixed(2)},${lng.toFixed(2)}`,
+ );
+ }
+ }
+ }
+ });
+});
+
+describe("California board — the built state", () => {
+ const world = builtWorld(CALIFORNIA_CITY);
+ const blocks = createBlocks(world);
+
+ it("builds every district it declares", () => {
+ // A district drawn inside a park envelope, or out in the bay, emits nothing
+ // at all and there is no warning anywhere. Each one is rebuilt alone so the
+ // empty one is named rather than hidden in the total.
+ for (const district of CALIFORNIA_CITY.districts) {
+ const alone = new World({ ...CALIFORNIA_CITY, districts: [district] });
+ alone.lattice();
+ const count = createBlocks(alone).count;
+ assert.ok(count > 20, `district "${district.id}" produced ${count} lots`);
+ }
+ });
+
+ it("makes Los Angeles and the Bay Area read as settlements, not as specks", () => {
+ const within = (minLat: number, maxLat: number, minLng: number, maxLng: number): number => {
+ let n = 0;
+ for (const district of CALIFORNIA_CITY.districts) {
+ const inside = district.polygon.every(
+ ([lat, lng]) => lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng,
+ );
+ if (!inside) continue;
+ const alone = new World({ ...CALIFORNIA_CITY, districts: [district] });
+ alone.lattice();
+ n += createBlocks(alone).count;
+ }
+ return n;
+ };
+ assert.ok(within(33.4, 34.4, -118.7, -117.6) > 3_000, "the Los Angeles basin is thin");
+ assert.ok(within(37.1, 38.1, -122.6, -121.7) > 1_500, "the Bay Area is thin");
+ });
+
+ it("stays inside the triangle budget it was sized against", () => {
+ // The California board is the tight one: 650 draw calls and 750,000
+ // triangles, shared with the aircraft layer. One instanced box is twelve
+ // triangles, so this ceiling is about 110k of them — roughly a seventh of
+ // the whole board. A district enlarged without checking is the easy way to
+ // blow the budget, and `scripts/performance-budget.mjs` needs a browser and
+ // a minute to say so.
+ assert.ok(blocks.count < 9_200, `${blocks.count} lots is over what the budget was sized for`);
+ assert.ok(blocks.count > 7_000, `${blocks.count} lots is thinner than the board was tuned to`);
+ });
+
+ it("drops the street lattice and the shadow pass only where a lot is a neighbourhood", () => {
+ // The rule in `blocks.ts` is about how much ground a lot covers, not about
+ // which board it is. Stated here as the fact it is derived from, so the two
+ // detailed boards are provably untouched by it.
+ const lotMetres = (city: City): number => 0.42 * (111_320 / city.latScale);
+ assert.ok(lotMetres(SF_CITY) < 260, "San Francisco must keep its street grid");
+ assert.ok(lotMetres(SOCAL_CITY) < 260, "Southern California must keep its street grid");
+ assert.ok(lotMetres(CALIFORNIA_CITY) > 260, "the state board must not draw 800 m streets");
+
+ assert.equal(blocks.castShadow, false, "state-scale lots cost a second pass for one pixel");
+ const socal = builtWorld(SOCAL_CITY);
+ assert.equal(createBlocks(socal).castShadow, true, "Southern California lost its shadows");
+ });
+});
diff --git a/src/test/packs/californiaCorridor.test.ts b/src/test/packs/californiaCorridor.test.ts
new file mode 100644
index 0000000..cd7dd46
--- /dev/null
+++ b/src/test/packs/californiaCorridor.test.ts
@@ -0,0 +1,102 @@
+/**
+ * The freeway corridor on the statewide board, which had two problems and only
+ * one of them was visible.
+ *
+ * **It read as a wireframe.** At 1,919 m to the scene unit the whole corridor is
+ * about eleven pixels wide from the default camera, and eleven pixels of flat
+ * mid-grey lying exactly on the ground is a line somebody drew on a map. It is
+ * now a graded crown with two batters, which gives it three value bands and a
+ * normal that is not straight up — see `createFreewayWorld`.
+ *
+ * **It cost a fifth of the board's triangle budget on things nobody can see.**
+ * Guardrails and median walls were tubes at two segments per draped sample on a
+ * corridor already sampled every kilometre, and the reflectors were 2,296 boxes
+ * eighteen millimetres across. Between them: 110,000 triangles on a board with
+ * 75,000 to spare, which is why the state had no mountains and no cities on it.
+ *
+ * The two assertions below are the ones that would have caught the two defects
+ * this cost a rebuild to find:
+ *
+ * - **Every batter faces the sky.** `deck` materials are `DoubleSide` and
+ * three.js negates the shading normal on a back face, so a strip whose two
+ * rails were emitted in the opposite order to its neighbours renders as an
+ * unlit black band. One did, the length of US-101, and it typechecked.
+ * - **The corridor stays under its triangle ceiling.** The real gate is
+ * `scripts/performance-budget.mjs`, which needs a browser and a minute; this
+ * runs in milliseconds and fails on the line that caused the regression.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+
+import CALIFORNIA_CITY from "../../cities/california.ts";
+import { createFreewayWorld } from "../../engine/structures.ts";
+import type { World } from "../../engine/world.ts";
+import CALIFORNIA_TRANSPORT from "../../transport/california.ts";
+
+/** Flat ground and a linear projection: the corridor's own shape, nothing else. */
+const flatWorld = {
+ city: CALIFORNIA_CITY,
+ project(lat: number, lng: number): [number, number] {
+ return [(lng + 121) * 47, -(lat - 36) * 58];
+ },
+ groundAt(): number {
+ return 0;
+ },
+} as unknown as World;
+
+function meshesIn(group: THREE.Object3D): THREE.Mesh[] {
+ const found: THREE.Mesh[] = [];
+ group.traverse((object) => {
+ if (object instanceof THREE.Mesh) found.push(object);
+ });
+ return found;
+}
+
+function triangles(mesh: THREE.Mesh): number {
+ const geometry = mesh.geometry;
+ const index = geometry.getIndex();
+ const per = index ? index.count / 3 : geometry.getAttribute("position").count / 3;
+ return per * (mesh instanceof THREE.InstancedMesh ? mesh.count : 1);
+}
+
+describe("California corridor", () => {
+ const group = createFreewayWorld(flatWorld, CALIFORNIA_TRANSPORT);
+ const all = meshesIn(group);
+
+ it("gives the earthwork a crown and two batters that both face the sky", () => {
+ const embankment = all.find((mesh) => mesh.name === "freeway:embankment");
+ assert.ok(embankment, "the corridor has no embankment; it is a flat ribbon again");
+
+ const normals = embankment.geometry.getAttribute("normal");
+ assert.ok(normals, "the embankment lost the normals mergeGeometries matches on");
+ let tilted = 0;
+ for (let index = 0; index < normals.count; index += 1) {
+ const y = normals.getY(index);
+ assert.ok(y > 0, `embankment normal ${index} points into the ground (y=${y.toFixed(3)})`);
+ if (y < 0.999) tilted += 1;
+ }
+ // And it is a batter, not another flat deck: an untilted strip would pass
+ // the test above and still be the thing this replaced.
+ assert.ok(tilted > normals.count * 0.9, "the embankment is flat; it will not catch the sun");
+
+ // All four spans — two carriageside batters on each of two corridors —
+ // merged into the one mesh. A dropped bucket looks like an efficient one.
+ assert.ok(
+ normals.count > 2_000,
+ `the embankment merged to only ${normals.count} vertices`,
+ );
+ });
+
+ it("keeps the whole corridor inside the triangle share it was budgeted", () => {
+ const total = all.reduce((sum, mesh) => sum + triangles(mesh), 0);
+ // 142,000 before the reclaim, on a board with a 750,000 cap that was already
+ // measuring 675,000. 95,000 is comfortably above what it emits and low
+ // enough to fail if anyone doubles a tube's tessellation again.
+ assert.ok(total < 95_000, `the corridor is ${Math.round(total)} triangles`);
+ // A floor as well, because the cheapest way to pass the line above is to
+ // stop drawing the corridor.
+ assert.ok(total > 50_000, `the corridor is only ${Math.round(total)} triangles`);
+ });
+});
diff --git a/src/test/render/seaAndTerrain.test.ts b/src/test/render/seaAndTerrain.test.ts
new file mode 100644
index 0000000..e7684dd
--- /dev/null
+++ b/src/test/render/seaAndTerrain.test.ts
@@ -0,0 +1,262 @@
+/**
+ * The two things `engine/terrain.ts` now does that a picture found and a test
+ * can keep: the sea has a surface, and the relief casts a shadow it can afford.
+ *
+ * Everything in this round was invisible to the type checker and to every
+ * existing test. What a test *can* hold is the handful of facts underneath the
+ * picture — that the sea reaches past the fog rather than stopping in a hard
+ * diamond, that its swell map tiles and is not one hard diagonal rib, that the
+ * terrain casts from a decimated copy of itself rather than from the mesh you
+ * are looking at. Each of those is a number, each was got wrong at least once
+ * on the way here, and each would go back to being wrong silently.
+ *
+ * The board below is synthetic and tiny — twenty cells a side — because none of
+ * these facts are about California. A real pack would make the file slow and
+ * 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 { createShorePlates, createTerrain, createWater, swellNormalData } from "../../engine/terrain.ts";
+import type { City } from "../../engine/types.ts";
+import { World } from "../../engine/world.ts";
+
+const BOARD: City = {
+ id: "test-board",
+ name: "Test 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,
+ // One square island with a hill on it: enough land for a terrain grid, and
+ // enough water around it for the sea to be the thing under everything.
+ landmasses: [
+ [
+ [36.7, -122.3],
+ [37.3, -122.3],
+ [37.3, -121.7],
+ [36.7, -121.7],
+ ],
+ ],
+ parks: [],
+ inlandWater: [],
+ hills: [{ name: "Test Hill", lat: 37, lng: -122, elevation: 400, radius: 0.15 }],
+ districts: [],
+ landmarks: [],
+ bridges: [],
+ roads: [],
+ chapters: [],
+};
+
+async function board(): Promise {
+ const world = new World(BOARD);
+ assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
+ return world;
+}
+
+function boardSpan(world: World): number {
+ const [westX, northZ] = world.project(BOARD.bounds.maxLat, BOARD.bounds.minLng);
+ const [eastX, southZ] = world.project(BOARD.bounds.minLat, BOARD.bounds.maxLng);
+ return Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
+}
+
+// ---- The swell map ---------------------------------------------------------
+
+/** Decode one texel back to the tangent-space normal it stands for. */
+function normalAt(data: Uint8Array, size: number, x: number, y: number): THREE.Vector3 {
+ const i = (((y + size) % size) * size + ((x + size) % size)) * 4;
+ return new THREE.Vector3(
+ ((data[i] as number) / 255) * 2 - 1,
+ ((data[i + 1] as number) / 255) * 2 - 1,
+ ((data[i + 2] as number) / 255) * 2 - 1,
+ );
+}
+
+test("the swell map is the same sea on every reload", () => {
+ const a = swellNormalData(64);
+ const b = swellNormalData(64);
+ assert.deepEqual(a, b, "two boards would show two different oceans");
+ // And it is a sea rather than a flat card: `Math.random` removed would pass
+ // the equality above just as happily as a seeded field does.
+ const flat = [...a].every((_, i) => i % 4 === 2 || i % 4 === 3 || a[i] === 128);
+ assert.equal(flat, false, "the swell map has no swell in it");
+});
+
+test("every texel of the swell map decodes to a unit normal facing up", () => {
+ const size = 64;
+ const data = swellNormalData(size);
+ let worst = 0;
+ let lowest = 1;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const n = normalAt(data, size, x, y);
+ worst = Math.max(worst, Math.abs(n.length() - 1));
+ lowest = Math.min(lowest, n.z);
+ }
+ }
+ // One byte of quantisation is 1/255 per channel, so a little over that is the
+ // whole tolerance a correctly encoded map needs.
+ assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`);
+ // A tangent-space normal map for a surface, not for an overhang: z is the
+ // surface's own axis and nothing may lean past horizontal.
+ assert.ok(lowest > 0.5, `a texel leaned to z=${lowest}, which is a cliff, not a wave`);
+});
+
+test("the swell map tiles: the wrap is no sharper than the interior", () => {
+ const size = 64;
+ const data = swellNormalData(size);
+ let interior = 0;
+ let seam = 0;
+ for (let y = 0; y < size; y++) {
+ for (let x = 1; x < size - 1; x++) {
+ interior = Math.max(interior, normalAt(data, size, x, y).distanceTo(normalAt(data, size, x + 1, y)));
+ }
+ seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y)));
+ }
+ // The sea is drawn as hundreds of copies of this map side by side, so a
+ // derivative that does not wrap paints a visible grid across the whole ocean.
+ assert.ok(
+ seam <= interior * 1.5,
+ `the wrap steps by ${seam} against an interior maximum of ${interior}`,
+ );
+});
+
+test("the swell runs in every direction rather than one", () => {
+ /*
+ * The failure this holds is a photographed one. With amplitude falling as
+ * 1/k every component of the sum carries the *same* slope — slope is
+ * amplitude times wave number — the shortest wave wins on sheer count of
+ * edges, and the ocean renders as one hard diagonal rib that reads as
+ * corduroy rather than water. At 1/k² the slope falls as 1/k and the eight
+ * headings stay spread.
+ */
+ const size = 64;
+ const data = swellNormalData(size);
+ const buckets = new Array(12).fill(0) as number[];
+ let total = 0;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const n = normalAt(data, size, x, y);
+ const slope = Math.hypot(n.x, n.y);
+ if (slope < 1e-4) continue;
+ // Folded to a half turn: a crest and its trough are one direction.
+ const angle = (Math.atan2(n.y, n.x) + Math.PI * 2) % Math.PI;
+ const at = Math.min(11, Math.floor((angle / Math.PI) * 12));
+ buckets[at] = (buckets[at] as number) + slope;
+ total += slope;
+ }
+ }
+ const dominant = Math.max(...buckets) / total;
+ assert.ok(dominant < 0.25, `${Math.round(dominant * 100)}% of the swell runs one way`);
+});
+
+// ---- The sea ---------------------------------------------------------------
+
+test("the sea reaches far enough out to fade instead of ending", () => {
+ return board().then((world) => {
+ const sea = createWater(world).children.find((child) => child.name === "sea") as
+ | THREE.Mesh
+ | undefined;
+ assert.ok(sea, "there is no sea in the water group");
+ /*
+ * Where the number comes from: `main.ts` gives the atmosphere a clear-day
+ * fog closing at 3.9 board spans, and `scene.ts` lets the orbit retreat to
+ * 2.0 spans from the middle of the board. So the furthest a fully-fogged
+ * horizon can be from the origin is about 5.9 spans, and a sea that stops
+ * anywhere nearer than that shows the viewer its own edge — which is
+ * exactly what the 1.8-span plane this replaced did, as a hard diamond with
+ * the state floating on it.
+ */
+ assert.ok(
+ sea.geometry.parameters.width / boardSpan(world) >= 12,
+ `the sea is only ${sea.geometry.parameters.width / boardSpan(world)} board spans across`,
+ );
+ });
+});
+
+test("the sea has a specular response, which a Lambert card cannot", () => {
+ return board().then((world) => {
+ const sea = createWater(world).children.find((child) => child.name === "sea") as THREE.Mesh;
+ const material = sea.material as THREE.MeshStandardMaterial;
+ // `MeshLambertMaterial` has no specular term at all, by construction, which
+ // is the whole reason the Pacific used to render as one flat blue value at
+ // every hour and from every angle.
+ assert.ok(material.isMeshStandardMaterial, "the sea went back to being unlit paint");
+ assert.equal(material.metalness, 0, "water is a dielectric");
+ assert.ok(material.roughness > 0 && material.roughness < 0.5, "the sun would have no path");
+ assert.ok(material.normalMap, "a mirror-flat plane has a specular point, not a glitter path");
+ assert.ok(material.normalScale.x > 0, "the swell is switched off");
+ });
+});
+
+// ---- The terrain's shadow --------------------------------------------------
+
+test("the relief casts, and from a decimated copy of itself", async () => {
+ const world = await board();
+ const terrain = createTerrain(world);
+ assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
+ assert.equal(terrain.receiveShadow, true);
+ const material = terrain.material as THREE.MeshLambertMaterial;
+ // The acne cure. Without it a constant bias has to cover a depth-per-texel
+ // that grows as 1/tan(sun elevation), and no single value is free of acne at
+ // a high sun and free of peter-panning at a low one.
+ assert.equal(material.shadowSide, THREE.BackSide);
+
+ const index = terrain.geometry.getIndex();
+ assert.ok(index, "the terrain lost its index");
+ const seen = terrain.geometry.drawRange.count;
+ const cast = index.count - seen;
+ assert.ok(seen > 0 && cast > 0, `nothing to draw: ${seen} seen, ${cast} cast`);
+ assert.equal(terrain.geometry.drawRange.start, 0, "the colour pass would skip the near edge");
+
+ /*
+ * A quarter, give or take the edge cells a stride of 2 cannot cover. The
+ * ratio is the whole reason this exists: submitting the visible surface to
+ * the depth pass draws every triangle on the board a second time, and
+ * `renderer.info` counts it — 65,566 triangles on California against about
+ * 28,000 of headroom in the board's budget.
+ */
+ assert.ok(cast / seen > 0.1 && cast / seen < 0.45, `the caster is ${cast / seen} of the surface`);
+});
+
+test("the shadow draw range swings onto the caster and back", async () => {
+ const world = await board();
+ const terrain = createTerrain(world);
+ const index = terrain.geometry.getIndex();
+ assert.ok(index);
+ const seen = terrain.geometry.drawRange.count;
+
+ // three fires these either side of the one `renderBufferDirect` the depth
+ // pass makes for this mesh, and the depth pass runs before the colour pass —
+ // so this pair is the whole mechanism that keeps the caster out of the
+ // picture without keeping it out of the shadow map.
+ const nothing = null as never;
+ terrain.onBeforeShadow(
+ nothing, nothing, nothing, nothing,
+ terrain.geometry, terrain.material as THREE.Material, nothing,
+ );
+ assert.equal(terrain.geometry.drawRange.start, seen, "the depth pass is still drawing the surface");
+ assert.equal(terrain.geometry.drawRange.count, index.count - seen);
+
+ terrain.onAfterShadow(
+ nothing, nothing, nothing, nothing,
+ terrain.geometry, terrain.material as THREE.Material, nothing,
+ );
+ assert.equal(terrain.geometry.drawRange.start, 0, "the caster leaked into the colour pass");
+ assert.equal(terrain.geometry.drawRange.count, seen);
+});
+
+test("the shore plate receives and does not cast", async () => {
+ const world = await board();
+ const plate = createShorePlates(world);
+ assert.equal(plate.receiveShadow, true);
+ // It is the landmass polygon lying flat six hundredths of a unit above the
+ // sea. A caster that thin has no volume: at a low sun it would throw the
+ // whole coastline out across the water as a hard slab.
+ assert.equal(plate.castShadow, false);
+});
diff --git a/src/test/render/toneMapping.test.ts b/src/test/render/toneMapping.test.ts
index 8cb701e..14f0b1b 100644
--- a/src/test/render/toneMapping.test.ts
+++ b/src/test/render/toneMapping.test.ts
@@ -203,8 +203,9 @@ test("the sun brightens monotonically as it rises", () => {
});
test("the sky colours were left alone, because they are not tone mapped", () => {
- // Three marks the background mesh `toneMapped = false` for an sRGB-transfer
- // texture and mixes fog after the tone map from an already-encoded uniform.
+ // The sky is a world-space `ShaderMaterial` dome marked `toneMapped = false`,
+ // so it writes its components straight out, and fog mixes after the tone map
+ // from an already-encoded uniform.
// So the one thing the re-tune must NOT have touched is the sky, and the noon
// stop still reproduces the city's own declared daylight colours.
const noon = rig(25);
diff --git a/src/test/vehicle/elevatedArrival.test.ts b/src/test/vehicle/elevatedArrival.test.ts
new file mode 100644
index 0000000..6790475
--- /dev/null
+++ b/src/test/vehicle/elevatedArrival.test.ts
@@ -0,0 +1,122 @@
+/**
+ * The apron is built from the site's elevation and from nothing else.
+ *
+ * `officeExterior.test.ts` asserts the outcome for the three shipped sites.
+ * This file asserts the *reason*, which is the part that rots: a rule that
+ * happens to produce the right answer for `lumbridge-hq` because it names
+ * `lumbridge-hq` passes every test in that file and is wrong the moment a
+ * self-hoster hands the same tower a different id, or an office moves.
+ *
+ * This repo has been here before. A door marker was once gated on
+ * `id === "sf"`, and had it shipped it would have pinned the Los Angeles
+ * building to San Francisco. So the sites below are the shipped ones with one
+ * number swapped and their identities kept: a `lumbridge-hq` that has come down
+ * to street level must get its car, and a `mateo-court` that has gone up a
+ * tower must lose it. Nothing but `elevation` moves between the two halves of
+ * each pair.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+import { MaterialRegistry } from "../../assets/materials.ts";
+import { kit } from "../../assets/kit.ts";
+import "../../assets/office/index.ts";
+import { createOfficeExterior } from "../../engine/officeExterior.ts";
+import { LUMBRIDGE_HQ_SITE, MATEO_COURT_SITE } from "../../offices/sites.ts";
+import type { OfficeSite } from "../../interiors/types.ts";
+import {
+ GROUND_ARRIVAL_MAX_ELEVATION_M,
+ arrivalGroundFor,
+} from "../../transport/exteriorVehicle.ts";
+
+/** A deterministic generator, so a failure is reproducible from the seed alone. */
+function seeded(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/** How many cars a given site gets outside it. */
+function carsOutside(site: OfficeSite): number {
+ assert.ok(site.arrival, "the site under test authors an arrival anchor");
+ const materials = new MaterialRegistry({ quality: "low" });
+ const exterior = createOfficeExterior({
+ site,
+ arrival: site.arrival,
+ assets: kit,
+ materials,
+ rand: seeded(4242),
+ detail: "corridor",
+ });
+ let cars = 0;
+ exterior.object.traverse((object: THREE.Object3D) => {
+ if (object.userData.vehicleModel === "model-x") cars += 1;
+ });
+ exterior.dispose();
+ return cars;
+}
+
+/** The same site, moved vertically and changed in no other way. */
+function at(site: OfficeSite, elevation: number): OfficeSite {
+ return { ...site, elevation };
+}
+
+describe("the apron follows the ground, not the office id", () => {
+ it("gives the tower's own site a car once it is standing on a street", () => {
+ // Same id, same lat/lng, same heading, same anchor — down at a kerb.
+ assert.equal(carsOutside(at(LUMBRIDGE_HQ_SITE, 1.2)), 1);
+ });
+
+ it("takes the street office's car away once its site is up a tower", () => {
+ assert.equal(carsOutside(at(MATEO_COURT_SITE, 188)), 0);
+ });
+
+ it("switches at the documented threshold and nowhere else", () => {
+ // Both sides of the constant, from both packs, so neither the number nor
+ // the comparison can drift without this failing.
+ for (const site of [LUMBRIDGE_HQ_SITE, MATEO_COURT_SITE]) {
+ assert.equal(carsOutside(at(site, GROUND_ARRIVAL_MAX_ELEVATION_M)), 1);
+ assert.equal(carsOutside(at(site, GROUND_ARRIVAL_MAX_ELEVATION_M + 0.01)), 0);
+ }
+ });
+
+ it("agrees with the pure rule at every elevation it is asked about", () => {
+ // `arrivalGroundFor` is what the UI, a verifier or a future pack tool would
+ // read to predict this without building a scene, so the two must not be
+ // able to disagree.
+ for (const elevation of [-3, 0, 1.2, 4, 12, 29.9, 30, 30.5, 61, 188, 400]) {
+ const expected = arrivalGroundFor(elevation) === "ground" ? 1 : 0;
+ assert.equal(
+ carsOutside(at(MATEO_COURT_SITE, elevation)),
+ expected,
+ `elevation ${elevation}`,
+ );
+ }
+ });
+
+ it("keeps the anchor readable even where nothing is drawn", () => {
+ // The suppressed exterior is still labelled with the stall it declined to
+ // build, because a caller that wants to caption "Podium kerb" should not
+ // have to reach back into the pack for it — and because a silently empty
+ // Group is indistinguishable from a bug.
+ const materials = new MaterialRegistry({ quality: "low" });
+ assert.ok(LUMBRIDGE_HQ_SITE.arrival);
+ const exterior = createOfficeExterior({
+ site: LUMBRIDGE_HQ_SITE,
+ arrival: LUMBRIDGE_HQ_SITE.arrival,
+ assets: kit,
+ materials,
+ rand: seeded(1),
+ detail: "corridor",
+ });
+ assert.equal(exterior.object.userData.suppressed, "no-ground");
+ assert.equal(exterior.object.userData.label, "Podium kerb");
+ assert.equal(exterior.object.userData.arrivalKind, "vehicle-stall");
+ exterior.dispose();
+ });
+});
diff --git a/src/test/vehicle/metreScale.test.ts b/src/test/vehicle/metreScale.test.ts
index 1c2c030..fd1be55 100644
--- a/src/test/vehicle/metreScale.test.ts
+++ b/src/test/vehicle/metreScale.test.ts
@@ -13,8 +13,9 @@ import {
import { CALIFORNIA_TRANSPORT } from "../../transport/california.ts";
import {
METRE_SCALE_VEHICLE_OPTIONS,
- apronKindFor,
apronMetrics,
+ arrivalGroundFor,
+ GROUND_ARRIVAL_MAX_ELEVATION_M,
metreScaleVehicleOptions,
PARK_JITTER,
parkPose,
@@ -157,7 +158,7 @@ describe("metre-scale controller options", () => {
describe("the bay is sized from the car", () => {
it("fits the vehicle with room to open a door", () => {
- const metrics = apronMetrics(MODEL_X_METRICS, "street");
+ const metrics = apronMetrics(MODEL_X_METRICS);
assert.ok(metrics.stallWidth > MODEL_X_METRICS.width);
assert.ok(metrics.stallLength > MODEL_X_METRICS.length);
// Enough clearance each side to actually get out, which is the number a
@@ -172,21 +173,25 @@ describe("the bay is sized from the car", () => {
});
it("grows with the vehicle rather than being authored twice", () => {
- const small = apronMetrics({ length: 3.6, width: 1.6 }, "street");
- const large = apronMetrics({ length: 5.6, width: 2.4 }, "street");
+ const small = apronMetrics({ length: 3.6, width: 1.6 });
+ const large = apronMetrics({ length: 5.6, width: 2.4 });
assert.ok(large.stallWidth > small.stallWidth);
assert.ok(large.stallLength > small.stallLength);
assert.ok(large.padWidth > small.padWidth);
});
- it("reads a tower as a deck and everything else as a street", () => {
- assert.equal(apronKindFor(188), "deck");
- assert.equal(apronKindFor(4), "street");
- assert.equal(apronKindFor(1.2), "street");
- assert.equal(apronKindFor(0), "street");
- assert.equal(apronKindFor(Number.NaN), "street");
- assert.ok(apronMetrics(MODEL_X_METRICS, "deck").kerbHeight <
- apronMetrics(MODEL_X_METRICS, "street").kerbHeight);
+ it("reads a tower as air and everything with a kerb as ground", () => {
+ // The shipped elevations, and both edges of the threshold. See
+ // `ArrivalGround`: this decides *whether* there is an apron, not which kind.
+ assert.equal(arrivalGroundFor(188), "air");
+ assert.equal(arrivalGroundFor(4), "ground");
+ assert.equal(arrivalGroundFor(1.2), "ground");
+ assert.equal(arrivalGroundFor(0), "ground");
+ assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M), "ground");
+ assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M + 0.01), "air");
+ // A pack that failed to state its elevation gets a kerb, because a bay
+ // nobody walks past is a cheaper mistake than a car in mid-air.
+ assert.equal(arrivalGroundFor(Number.NaN), "ground");
});
});
diff --git a/src/test/vehicle/officeExterior.test.ts b/src/test/vehicle/officeExterior.test.ts
index 552bb90..96415fb 100644
--- a/src/test/vehicle/officeExterior.test.ts
+++ b/src/test/vehicle/officeExterior.test.ts
@@ -11,7 +11,7 @@ import {
MATEO_COURT_SITE,
} from "../../offices/sites.ts";
import type { ExteriorArrival, OfficeSite } from "../../interiors/types.ts";
-import { PARK_JITTER } from "../../transport/exteriorVehicle.ts";
+import { PARK_JITTER, arrivalGroundFor } from "../../transport/exteriorVehicle.ts";
import {
createSimulatedVehicleTelemetry,
type VehicleTelemetryState,
@@ -23,6 +23,17 @@ const SITES: readonly (readonly [string, OfficeSite])[] = [
["mateo-court", MATEO_COURT_SITE],
];
+/**
+ * The sites whose arrival storey is actually on the ground, and so get an apron.
+ *
+ * Selected by the rule rather than listed by id, on purpose: an id list here
+ * would pass just as happily against a `createOfficeExterior` that hard-coded
+ * `lumbridge-hq`, which is the bug class this repo already hit once when a door
+ * marker was gated on `id === "sf"`.
+ */
+const GROUNDED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "ground");
+const ELEVATED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "air");
+
/** A deterministic generator, so a failure is reproducible from the seed alone. */
function seeded(seed: number): () => number {
let a = seed >>> 0;
@@ -85,7 +96,7 @@ function angleDelta(a: number, b: number): number {
}
describe("office exterior placement", () => {
- for (const [id, site] of SITES) {
+ for (const [id, site] of GROUNDED) {
it(`stands one Model X on ${id}'s authored arrival anchor`, () => {
const { exterior } = build(site);
const arrival = arrivalOf(site);
@@ -133,22 +144,42 @@ describe("office exterior placement", () => {
}
});
- it("gives a tower a podium deck and a street-level site a kerb", () => {
- // 188 m up a Transbay tower there is no pavement outside the west wall, and
- // `offices/sites.ts` explicitly left the question of what that means to this
- // layer. See `apronKindFor`.
- const tower = build(LUMBRIDGE_HQ_SITE);
+ it("builds nothing at all for a site whose arrival storey is not on ground", () => {
+ // The defect this is here for renders, typechecks and passes a budget: an
+ // apron built at `lumbridge-hq`'s floor is a paved pad and a Model X 188 m
+ // up, in open sky beside the studio's west wall, and from the arrival
+ // viewpoint it reads as a car standing on top of a wall. See
+ // `ArrivalGround` for why the answer is no apron rather than a lower kerb.
+ assert.ok(ELEVATED.length > 0, "a shipped site is off the ground; keep testing it");
+ for (const [id, site] of ELEVATED) {
+ const { exterior } = build(site);
+ assert.equal(meshes(exterior.object).length, 0, `${id} drew geometry in mid-air`);
+ assert.equal(vehicleRoots(exterior.object).length, 0, `${id} parked a car in the sky`);
+ exterior.dispose();
+ }
+ });
+
+ it("stays inert rather than throwing when telemetry arrives anyway", () => {
+ // The caller does not know about architecture and should not have to: it
+ // adds the object to the level group and publishes telemetry into it every
+ // time a feed ticks. See the note on the early return in `officeExterior.ts`.
+ const { exterior } = build(LUMBRIDGE_HQ_SITE);
+ exterior.apply(stateOf({ pluggedIn: true, socPct: 40, locked: false }));
+ exterior.apply(stateOf({ pluggedIn: false, socPct: 100, locked: true }));
+ assert.equal(exterior.object.children.length, 0);
+ exterior.dispose();
+ });
+
+ it("still gives a street-level site its kerb", () => {
const street = build(MATEO_COURT_SITE);
- const kerbHeight = (exterior: OfficeExterior): number => {
- const kerb = meshes(exterior.object).find((mesh) => mesh.name.includes("skirting"));
- assert.ok(kerb, "the apron has a kerb");
- kerb.geometry.computeBoundingBox();
- const box = kerb.geometry.boundingBox;
- assert.ok(box);
- return box.max.y - box.min.y;
- };
- assert.ok(kerbHeight(tower.exterior) < kerbHeight(street.exterior));
- tower.exterior.dispose();
+ const kerb = meshes(street.exterior.object).find((mesh) => mesh.name.includes("skirting"));
+ assert.ok(kerb, "the apron has a kerb");
+ kerb.geometry.computeBoundingBox();
+ const box = kerb.geometry.boundingBox;
+ assert.ok(box);
+ // A kerb you could trip over, not a painted line: this is the height that
+ // makes the pad read as pavement rather than as a decal on the ground.
+ assert.ok(box.max.y - box.min.y > 0.1, `kerb ${box.max.y - box.min.y} m high`);
street.exterior.dispose();
});
diff --git a/src/transport/exteriorVehicle.ts b/src/transport/exteriorVehicle.ts
index 215c08c..cd08590 100644
--- a/src/transport/exteriorVehicle.ts
+++ b/src/transport/exteriorVehicle.ts
@@ -79,45 +79,78 @@ export function metreScaleVehicleOptions(
return { routeId, ...METRE_SCALE_VEHICLE_OPTIONS, ...overrides };
}
-// ---- The apron ------------------------------------------------------------
+// ---- Is there ground outside the door? ------------------------------------
/**
- * What kind of ground the building's front door opens onto.
+ * What the arrival storey's floor actually stands on.
*
- * `street` is a kerb, a marked bay and a strip of carriageway: the normal case,
- * and what `mateo-court` (1.2 m above Mateo Street) and `frontier-valley` (4 m
- * above an airfield apron) both have.
+ * `ground` is the normal case and the only one an apron can be built on:
+ * `mateo-court`'s floor is 1.2 m above Mateo Street and `frontier-valley`'s is
+ * 4 m above an airfield, and both of those are a kerb and a ramp — a car can be
+ * driven to them, so a marked bay a few metres outside the façade is a bay on
+ * the pavement.
*
- * `deck` is the answer to the question `offices/sites.ts` deliberately left
- * open. `lumbridge-hq` is authored 188 m up a Transbay tower and its arrival
- * anchor is "the kerb of the podium", because the pack frame is the only frame
- * a pack has. Drawing a public street there would be a lie about a building
- * that has none at that height, so an elevated site gets a podium deck instead:
- * the same marked bay and the same charge post, standing on a paved deck with a
- * low upstand and no carriageway running off it.
+ * `air` is `lumbridge-hq`. Its floor is 188 m above the ground outside, which is
+ * what `OfficeSite.elevation` measures and says so in as many words: "how far
+ * this pack's level-0 floor sits above **the ground outside**". There is no
+ * pavement out there to park on, and there is no version of a paved pad, a kerb,
+ * a charge post and a two-and-a-half-tonne car at that height that is not a
+ * claim about ground that is not there.
*
- * The vertical question that note deferred is settled the same way, and by
- * `ExteriorArrival`'s own wording rather than by a new rule: `levelId` names
- * "the storey whose floor this stall is measured from", so the apron stands on
- * that floor. The tower's car is on the podium at level 1, not on Folsom Street
- * 188 m below it.
+ * ### Why this is a *whether*, not a *which*
+ *
+ * An earlier version of this file made the same distinction and spent it on
+ * style: an elevated site got a "podium deck", the same bay and the same car on
+ * a slab with a lower upstand. That reasoning went wrong at the vertical.
+ * `ExteriorArrival.levelId` names "the storey whose floor this stall is measured
+ * from", so the deck was built at the *studio's* floor — and a studio 188 m up a
+ * Transbay tower put a paved pad and a Model X in open sky beside a wall. From
+ * the arrival viewpoint it read as a car standing on top of a wall, which is
+ * exactly what it was. Nothing about the kerb height fixed that, because the
+ * defect was never the kerb.
+ *
+ * The two repairs that are not this one, and why:
+ *
+ * - **Drop the apron to the ground plane** (a sited office gets one at
+ * `-site.elevation`, CONTRACT §4) and park the car on the real podium. But the
+ * anchor's XZ is authored against the *studio's* walls — 12 × 9 m of floor
+ * plate — and the tower's footprint at street level is 48 × 42 m, so the two
+ * frames have no relationship at all below the storey the stall was measured
+ * from. It would also be thirty-odd draw calls of geometry four pixels tall.
+ * - **Delete the anchor from the pack.** The anchor is not wrong; a podium kerb
+ * does exist at the foot of that tower. What is wrong is rendering it 188 m
+ * above itself. The pack keeps saying the true thing and this layer stops
+ * drawing the false one.
+ *
+ * Derived from `site.elevation` and nothing else, deliberately. The last time a
+ * rule like this was written against an office id — a door marker gated on
+ * `id === "sf"` — it would have pinned the Los Angeles building to San
+ * Francisco. A pack that arrives over HTTP from a self-hoster gets the same
+ * answer as a shipped one, for the same reason.
*/
-export type ApronKind = "street" | "deck";
+export type ArrivalGround = "ground" | "air";
/**
- * Above this site elevation there is no street outside the door.
+ * Above this floor elevation there is nothing outside the door to park on.
*
- * Thirty metres is about ten storeys — comfortably above anything with a kerb
- * and comfortably below anything that could be mistaken for one. Both numbers
- * either side of it in the shipped packs (4 m and 188 m) are nowhere near it,
- * which is the property a threshold like this wants.
+ * Thirty metres is about ten storeys — comfortably above anything a vehicle can
+ * be driven to and comfortably below anything that could be mistaken for it.
+ * Both numbers either side of it in the shipped packs (4 m and 188 m) are
+ * nowhere near it, which is the property a threshold like this wants: it is not
+ * doing fine discrimination, it is separating "there is a kerb here" from "this
+ * is the sky".
+ *
+ * A non-finite elevation reads as `ground`, because a pack that failed to say
+ * how high it is is far more likely to be at street level than up a tower, and
+ * the failure mode of guessing wrong that way is a bay nobody walks past rather
+ * than a car in mid-air.
*/
-export const APRON_STREET_MAX_ELEVATION_M = 30;
+export const GROUND_ARRIVAL_MAX_ELEVATION_M = 30;
-export function apronKindFor(siteElevationM: number): ApronKind {
- return Number.isFinite(siteElevationM) && siteElevationM > APRON_STREET_MAX_ELEVATION_M
- ? "deck"
- : "street";
+export function arrivalGroundFor(siteElevationM: number): ArrivalGround {
+ return Number.isFinite(siteElevationM) && siteElevationM > GROUND_ARRIVAL_MAX_ELEVATION_M
+ ? "air"
+ : "ground";
}
/** Just enough of a vehicle to size a bay for it. */
@@ -128,9 +161,15 @@ export interface VehicleFootprint {
width: number;
}
-/** Every dimension `officeExterior.ts` needs, in metres, all derived. */
+/**
+ * Every dimension `officeExterior.ts` needs, in metres, all derived.
+ *
+ * There is one apron and it is a kerbside one, because the only sites that get
+ * an apron at all are the ones with a kerb — see `ArrivalGround`. The second
+ * variant this interface used to carry was a podium deck for an elevated site,
+ * and it was answering the wrong question.
+ */
export interface ApronMetrics {
- kind: ApronKind;
/** Thickness of the paved slab. The vehicle stands on top of it. */
padThickness: number;
padWidth: number;
@@ -162,24 +201,24 @@ export interface ApronMetrics {
* measured off those two numbers, so there is exactly one place to change if
* the vehicle changes.
*/
-export function apronMetrics(vehicle: VehicleFootprint, kind: ApronKind): ApronMetrics {
+export function apronMetrics(vehicle: VehicleFootprint): ApronMetrics {
const width = Math.max(1.2, vehicle.width);
const length = Math.max(2.4, vehicle.length);
const stallWidth = width + 0.9;
const stallLength = length + 1.0;
return {
- kind,
padThickness: 0.06,
// A shoulder wide enough to walk round the car on, and deep enough that the
// bay is not floating in the middle of nothing at an oblique camera.
padWidth: stallWidth + 1.8,
- padDepth: stallLength + (kind === "street" ? 3.4 : 1.6),
+ // The extra 3.4 m at the open end is the strip of carriageway the car drove
+ // in over, which is what makes the bay read as being *on* a street.
+ padDepth: stallLength + 3.4,
stallWidth,
stallLength,
lineWidth: 0.1,
- // A street kerb is a full 135 mm step; a podium deck gets a low upstand,
- // because nothing is going to drive up onto a deck 188 m in the air.
- kerbHeight: kind === "street" ? 0.135 : 0.09,
+ // A full 135 mm step, which is what a kerb is.
+ kerbHeight: 0.135,
kerbDepth: 0.3,
postWidth: 0.3,
postDepth: 0.2,