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,