From 8fb85cd2e5933d85cfddc7b4388638d13bd49068 Mon Sep 17 00:00:00 2001 From: Kartios Date: Fri, 21 Aug 2026 23:43:14 -0700 Subject: [PATCH] feat: give the boards a horizon, a sea that reflects, and a state worth flying over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wide shot, which is what an anonymous visitor actually lands on. **The sea was `MeshLambertMaterial`** — a material with no specular term at all, by construction — on a board where water is half the frame. It is now a low-roughness dielectric that reads `scene.environment`, with a runtime-generated tiling swell normal map sampled twice per fragment at two scales and two headings, so the sun breaks into a moving glitter path instead of a mirror point. An `onBeforeCompile` patch takes the body colour toward the deep value looking straight down and leaves it to the reflection at grazing, and walks roughness up past 1.6 board spans so the far water cannot shimmer. The swell spectrum is 1/k^2 and not 1/k because the first attempt was photographed: at 1/k every component carries the same slope, the shortest wave wins, and the sea renders as hard diagonal corduroy. A test holds it now. **The board no longer ends in a diamond.** The sea plane went from 1.8 board spans to 18, past the fog's far plane from anywhere the orbit reaches, and the sky is a world-space dome rather than a screen-space gradient. That gradient was wrong in a way dusk made obvious: the sunset band was painted along the *bottom* of the picture, under the board, while the true horizon at the top of frame stayed zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the seam was a symptom of it. **Terrain casts shadows.** Left off before because double-sided terrain against a ~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is a stride-2 decimation appended to the same index buffer and swapped in by `onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of the depth cost, and indistinguishable from the full-resolution caster in a side-by-side crop. Stride 1 was measured at +65,566 triangles and would have missed the budget by ~47,000, so it was not shipped. **California reads as California.** It was a beige kite: the eastern edge one ruled line for five degrees of latitude, the south closing in a diagonal V, the whole south-east a featureless tan wedge. Now the coast runs to the Mexican border with San Diego on it, the eastern edge follows the Colorado and the Nevada diagonal, and the south-east is the Basin and Range — forty parallel desert ridges throwing shadows east, Death Valley as a white pan between the Panamints and the Black Mountains, the Salton Sea the one cool value for two hundred kilometres. The opening pose is retuned to the bigger board; the old 452/392 stand-off left a slab of empty ocean where the state should be. **The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to find one at all — indistinguishable from a dead pixel, on a board whose entire claim is that the sky is live. They are airliners now, with planform and trail, and clicking one raises its card for a signed-out visitor. **The Model X is off the wall.** It stood at floor level outside a studio 188 m up a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen from `site.elevation`, which the pack already carries — not from an office id, which is the bug class this repo already hit once when a door marker gated on `id === "sf"` and would have pinned the Los Angeles building to San Francisco. Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground on the Bay Area and SoCal boards. And one test asserted an exact source line for the water material, so the better multi-line implementation failed it — it now asserts the property (dielectric, metalness 0, low roughness) rather than the author's first guess at formatting. Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000 triangles — 2.8% of triangle headroom left, which is the number the next person should check first. No budget was raised. Co-Authored-By: Claude Opus 5 (1M context) --- index.html | 47 +- scripts/look.mjs | 107 ++ src/arena/sourceHashes.ts | 2 +- src/assets/palette.ts | 16 +- src/cities/california.ts | 1478 ++++++++++++++++- src/engine/aircraftGeometry.ts | 16 + src/engine/blocks.ts | 63 +- src/engine/flights.ts | 257 ++- src/engine/officeExterior.ts | 58 +- src/engine/scene.ts | 93 +- src/engine/scenekit.ts | 258 ++- src/engine/stage.ts | 16 +- src/engine/structures.ts | 178 +- src/engine/terrain.ts | 580 ++++++- src/engine/types.ts | 17 + src/interiors/officeScene.ts | 5 +- src/offices/sites.ts | 36 +- src/test/californiaCity.test.ts | 7 +- src/test/data/skyTraffic.test.ts | 247 +++ .../integration/californiaSilhouette.test.ts | 316 ++++ src/test/integration/sceneWiring.test.ts | 47 +- src/test/integration/touchPick.test.ts | 228 +++ src/test/packs/californiaBoard.test.ts | 270 +++ src/test/packs/californiaCorridor.test.ts | 102 ++ src/test/render/seaAndTerrain.test.ts | 262 +++ src/test/render/toneMapping.test.ts | 5 +- src/test/vehicle/elevatedArrival.test.ts | 122 ++ src/test/vehicle/metreScale.test.ts | 29 +- src/test/vehicle/officeExterior.test.ts | 65 +- src/transport/exteriorVehicle.ts | 111 +- 30 files changed, 4781 insertions(+), 257 deletions(-) create mode 100644 scripts/look.mjs create mode 100644 src/test/data/skyTraffic.test.ts create mode 100644 src/test/integration/californiaSilhouette.test.ts create mode 100644 src/test/integration/touchPick.test.ts create mode 100644 src/test/packs/californiaBoard.test.ts create mode 100644 src/test/packs/californiaCorridor.test.ts create mode 100644 src/test/render/seaAndTerrain.test.ts create mode 100644 src/test/vehicle/elevatedArrival.test.ts 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,