/** * One California, from three packs — behind a flag, so both answers can be * photographed and the owner can choose between them. * * ## The complaint this exists for * * The three boards feel like three boards, and the measured reason is *not* * that the horizontal scale jumps 20× at the seam. `World.project` is a uniform * scale in x/z with no vertical term, and a uniform scale leaves a perspective * image identical: a camera carried across the seam on matched true-metre * offsets draws a pixel-identical horizontal frame. 1,919 → 94 m per unit costs * nothing to look at. * * What is visible is that **the three packs draw three different Californias**, * and every one of those differences is a number in a data file: * * | | california | sf | socal | spread | * | --- | --- | --- | --- | --- | * | `verticalExaggeration` | 15 | 3.6 | 3.4 | 4.41× at a matched pose | * | freeway width, true metres | 1,919–2,034 | 23–30 | 43–55 | 88× | * | longitude squashed by cos | 37.30° | 37.77° | 33.82° | up to ~4 km of drift | * | `coastFalloff` | 0.0250° | 0.0018° | 0.0045° | 14× in metres | * | palette stops declared | 11 | 0 | 11 (different) | — | * * So this module is four rules, each replacing one column of that table with a * single authored constant plus facts the pack already states. It reads as data * because it *is* data: nothing here is an engine change, and the whole of the * blast radius in `world.ts` is that the constructor asks for a reconciled pack * and reads `lngScale` off it. * * ## The flag, and why it defaults off * * San Francisco and Southern California carry every marketing still on * lumbridgecorp.com. A reconciliation that leaves a board *worse* is a bad trade * however good the argument for it, and the only instrument that can tell is a * photograph. So: `?reconcile=1` in the browser, `setReconcile()` from a node * script, **off** everywhere else, and each of the four rules can be turned on * alone — `?reconcile=exaggeration,roads` — so a rule that loses its photograph * is reverted rather than argued for. * * With the flag off `reconciledCity` returns the pack it was handed, by * identity. Not a copy with the same numbers in it: the same object. That is * what makes "no pixel moves with the flag off" a fact about the code rather * than a claim about a diff. * * ## Why the reconciliation rewrites the pack rather than the engine * * `World` posts `this.city` to the terrain worker as a structured clone, and the * worker builds a second `World` from it. A flag read from `location.search` is * not visible in there — a worker's `location` is the worker script's URL — so a * reconciliation that lived in the engine would produce a *reconciled* board on * the main thread standing on an *unreconciled* heightfield. Rewriting the pack * puts the answer in the thing that crosses the wire. `reconciled: true` on the * way out is what makes the second pass in the worker a no-op, and the rule * idempotent. */ import type { City, Road, ScenePalette } from "../engine/types.ts"; // ---- The flag -------------------------------------------------------------- /** The four rules, each independently switchable. */ export type Rule = "exaggeration" | "roads" | "ground" | "projection"; export const RULES: readonly Rule[] = ["exaggeration", "roads", "ground", "projection"]; /** * Set from a node script; wins over the query string. * * `null` hands the decision back to the URL, which is what a test wants in its * teardown. Deliberately not memoised: a test that flips the flag between two * assertions must see two answers. */ let forced: ReadonlySet | null = null; export function setReconcile(rules: boolean | readonly Rule[] | null): void { if (rules === null) forced = null; else if (rules === true) forced = new Set(RULES); else if (rules === false) forced = new Set(); else forced = new Set(rules); } /** * Read the rule set out of a flag value. * * ## Exact names, and a warning for anything else * * This used to prefix-match: `RULES.filter((rule) => wanted.some((part) => * rule.startsWith(part)))`. Three things were wrong with that, and all three * corrupt measurements rather than breaking anything loudly. * * - **A misspelling reads as "off".** `?reconcile=palette` selected nothing, * because no rule name begins with "palette" — and an empty set is exactly * what the flag being absent produces. So a photograph taken to judge a rule * was a photograph of the unreconciled board, captioned as the rule. There * is no way to tell those two apart from the picture. * - **One letter selected a rule.** `?reconcile=p` was `projection`, `r` was * `roads`, `g` was `ground`. Convenient until a second rule shares a letter, * at which point the same URL silently means something else. * - **It is order-dependent in a way nobody would guess.** The result is * built by filtering `RULES`, so the set is in declaration order however the * caller wrote it, which is fine — but a caller who writes two prefixes that * both hit one rule gets one rule and no complaint. * * Whatever replaces it has to fail *loudly*, because every number anyone takes * from this module downstream — every RMSE, every pixel-diff bbox, every "the * metro boards are unmoved to the digit" — is only as good as the flag having * meant what the person typing it thought it meant. */ function parse(value: string | null): ReadonlySet { if (value === null || value === "" || value === "0" || value === "false") return new Set(); if (value === "1" || value === "true" || value === "all") return new Set(RULES); const wanted = value.split(/[,+\s]+/).filter((part) => part !== ""); const known = new Set(); for (const part of wanted) { const rule = RULES.find((candidate) => candidate === part); if (rule === undefined) { // Not an exception. A bad rule name must not be able to stop a page from // booting — this flag is read at module load on every visit, including // from a URL somebody pasted — but it must never pass silently either. warn( `reconcile: unknown rule "${part}". Known rules: ${RULES.join(", ")}. ` + "This rule is being ignored; the flag is NOT off.", ); continue; } known.add(rule); } return known; } /** * Where the warning goes. * * Split out so a test can assert the warning happened without owning a console, * and because this module is imported by the terrain worker, where `console` is * present but nobody is reading it. */ let warned: (message: string) => void = (message) => { (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message); }; /** Redirect the unknown-rule warning. Pass `null` to restore the console. */ export function setReconcileWarn(sink: ((message: string) => void) | null): void { warned = sink ?? ((message) => { (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message); }); } function warn(message: string): void { warned(message); } /** * The rules that are on when nobody has said otherwise. * * **Empty, and `roads` was tried here and taken back out.** The reason is worth * keeping, because the argument for turning it on is persuasive and wrong: * * `roads` exists to stop California drawing US-101 and I-5 as 1,919 m and * 2,034 m black ribbons — wider than the cities they join. That complaint is * real and you can see it in any state frame. But `city.roads` **is not what * draws them**. `scene.ts` reads * * scene.add(options.roadTraffic ? createFreewayWorld(world, pack) : createRoads(world)) * * and California is the one board that has `roadTraffic`, so it takes the first * branch and `createRoads` — the only consumer of `Road.width` — is never called * for it at all. The corridor you can see is `createFreewayWorld`, built from a * `TransportPack` at hard-coded scene units (carriageways 1.03 wide at ±0.64, * shoulders 1.18), which at 1,919 m to the unit is a corridor about 4.7 km * across. That is deliberate: `main.ts` says so where it sets the corridor's * altitude — "an atlas glyph just like the black cars: literal metres would put * the chase camera inside the coarse hills" — because DRIVE mode has to be able * to drive down it on a board where a real freeway is a fifth of a pixel. * * So on California the rule rewrites a column nothing reads, and on the metros * `reconciledRoadWidth` returns the same number the pack already authored, to * within float noise. Turned on and photographed at three chapters: Downtown LA * pixel-identical (empty diff bbox), FiDi RMSE 0.0006 with zero pixels past * 8/255, California 0.001%. The one measurable consequence anywhere was **−112 * triangles on Southern California**, from a ribbon width recomputing to a float * that differs in its last bits. * * A default that changes no pixel and 112 triangles is not a feature; it is a * second baseline for everyone who measures the other three rules afterwards. * The rule stays available — `?reconcile=roads` — and the real fix for the state * board's freeway lives in `structures.ts`, not here. */ export const DEFAULT_RULES: readonly Rule[] = []; /** * Which rules are on right now. * * Order of precedence: an explicit `setReconcile()` from a node script, then the * query string, then `DEFAULT_RULES`. The middle one includes `?reconcile=0`, * which is how a measurement asks for the unreconciled board now that "no flag" * no longer means that. */ export function activeRules(): ReadonlySet { if (forced !== null) return forced; const search = (globalThis as { location?: { search?: string } }).location?.search; if (search === undefined) return new Set(DEFAULT_RULES); const value = new URLSearchParams(search).get("reconcile"); if (value === null) return new Set(DEFAULT_RULES); return parse(value); } export function reconcileEnabled(rule?: Rule): boolean { const on = activeRules(); return rule === undefined ? on.size > 0 : on.has(rule); } // ---- The four authored constants ------------------------------------------- /** * Relief in the frame: peak scene units over board span, the rule * ARCHITECTURE §12.1 already argues and already tuned California against. * * §12.1 moved California from 13 to 15 precisely to hold this number while the * board grew, and recorded that 15 "puts this board on Southern California's * number exactly, which is the calibration that matters — the two are meant to * read as the same landscape at two zooms". This constant is that number, taken * off California so the board the photograph tuned does not move at all: * * | board | today | one rule | relief today | relief reconciled | * | --- | --- | --- | --- | --- | * | california | 15 | 15.00 | 7.24% | 7.24% | * | socal | 3.4 | 3.41 | 6.92% | 7.24% | * | sf | 3.6 | 5.78 | 4.51% | 7.24% | * * **So the rule's verdict is that San Francisco is the board that is wrong**, * by 1.6×, and the other two were already on one number. That is a real claim * about a board that carries most of this product's imagery, which is exactly * why it is behind a flag with a photograph attached. * * What the rule does **not** do is remove the deflation at the seam, and that * is worth stating because it is easy to assume otherwise. Apparent relief at a * *matched* pose — same true-metre stand-off on both boards — is proportional * to the exaggeration itself, not to relief-in-frame, so the 4.17× step from * California to the Bay Area becomes 2.60× and the 4.41× step to Southern * California becomes 4.22×. Making it 1.0× would mean one exaggeration on every * board, which flattens the state to 1.7% of its own frame. Relief-in-frame and * matched-pose relief are the same quantity only when two boards have the same * span, and these do not. */ export const RELIEF_IN_FRAME = 0.0724; /** * The narrowest a road may be drawn, as a fraction of the board's span. * * Measured rather than chosen. The two metro packs' narrowest roads sit at * 0.00019940 (San Francisco, a 19 m residential street) and 0.00020356 * (Southern California, a 31 m boulevard) of their own board spans — two packs * authored a year apart agreeing to within 2% about how thin a line may get * before it stops being a line. The floor is set just under the tighter of the * two, so **neither metro board moves**, and California's freeways fall onto it. * * California's US-101 and I-5 are authored 1 and 1.06 scene units wide, which at * 1,919 m to the unit is 1,919 m and 2,034 m — black ribbons wider than the * cities they join, and the most prominent marks on the state board. Read as * true metres instead (44 m and 47 m, `Road.widthM`) they are 0.023 units and * would vanish, so the floor is what draws them: 0.110 units, or 212 m. A 9.1× * narrowing, and a symbol at the same fraction of its board that the Bay Area * already draws a side street at. */ export const LEGIBLE_SPAN_FRACTION = 0.000199; /** * The coastal ramp, in coarse ground cells. * * `coastFalloff` exists for one mechanical reason: the terrain grid is clipped * to land, so its rim is stair-stepped at cell size, and the ramp takes every * height to zero across that rim so the steps land flat on the shore plate and * disappear (`terrain.ts` header). The size of the thing being hidden is the * **coarse** cell — the fine cell only exists inside a focus region, and coasts * run outside them — so the ramp is a multiple of that and of nothing else. * * 0.8 is California's own ratio, and it reproduces its authored 0.025° to * within 0.2%. The metro packs both come out slightly wider than they are * today: San Francisco 0.0018° → 0.0032° (200 m → 360 m) and Southern * California 0.0045° → 0.0058° (500 m → 641 m), which is the direction that * *removes* stair-stepping rather than adding it. */ export const COAST_RAMP_CELLS = 0.8; /** * The one latitude every board squashes longitude by. * * `World.lngScale` is `latScale × cos(centre.lat)`, and each pack uses its own * centre — 37.30°, 37.77°, 33.82°. In true metres a degree of longitude is then * 88,551 m on California and 92,484 m on Southern California, so the two boards * disagree about where Riverside is by 3,435 m and about their shared north-east * corner by 4,025 m. At a 109 km stand-off that is about 3% of the frame — a * visible sideways slide in any transition that shows both boards at once. * * 37.3° is California's centre: the state board is the root of every descent and * the only board whose rectangle is a place rather than a crop. The price is * local aspect, and it is small and one-directional — Southern California draws * 4.25% narrower in x than a projection taken at its own latitude would, San * Francisco 0.64% wider — against a registration error that goes to zero by * construction. */ export const REFERENCE_LAT = 37.3; /** * The palette anchors: one dry inland-southern end, one cool coastal-northern * end, mixed by where the board is. * * The south anchor is California's authored palette, unchanged, so the state * board renders exactly as it does today. The north anchor is the engine's * `DEFAULT_PALETTE` — which is to say San Francisco's, since it declares no * override — plus an `alpine` stop it never needed. * * **The two authored palettes are not on one line and no rule can put them * there.** Southern California's `flats` is 0xa9a291; the point 76.8% of the way * from the default to California's 0xb49b57 is 0xaf9b65. Its green channel is * *below* both anchors, so it is off the family in a direction a one-parameter * mix cannot reach. Something had to move, and the choice made here is that * California — the board §12 tuned against a photograph, and the board whose * desert Southern California is drawing a corner of — is the one that does not. * This is the riskiest of the four rules for that reason. */ const SOUTH_ANCHOR: ScenePalette = { skyTop: 0x7da6c9, skyHorizon: 0xe9d8bb, sea: 0x3c6d8b, lake: 0x4a7d93, shore: 0xc4b184, sand: 0xceba8c, flats: 0xb49b57, upland: 0x9d8a63, alpine: 0xb9b3a4, park: 0x76854e, parkHigh: 0x3d5739, }; /** * `terrain.ts`'s `DEFAULT_PALETTE`, copied rather than imported, plus an * `alpine` stop it never needed. * * Copied because `world.ts` reaches this module and `terrain.worker.ts` reaches * `world.ts`: importing `terrain.ts` here would drag three.js into the * heightfield worker, which is a worker that exists precisely so that half a * million samples of noise cost the page nothing. `reconcile.test.ts` asserts * the ten stops still agree, so the copy cannot drift silently. */ const NORTH_ANCHOR: ScenePalette = { skyTop: 0x8fb8d8, skyHorizon: 0xd9e6ee, sea: 0x4a7a99, lake: 0x527f9c, shore: 0xa8a495, sand: 0xc4b79b, flats: 0x9d9c93, upland: 0x8f9084, park: 0x6f8a5c, parkHigh: 0x5d7a4c, alpine: 0xa6a8a2, }; /** Metres above sea level at which `terrain.ts` starts painting `alpine`. */ const ALPINE_FROM = 1_900; // ---- The rules ------------------------------------------------------------- /** Scene units across the board, the larger of the two axes. See §12.1. */ function spanUnits(city: City, lngScale: number): number { const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale; const lng = (city.bounds.maxLng - city.bounds.minLng) * lngScale; return Math.max(lat, lng); } function lngScaleOf(city: City, on: ReadonlySet): number { const lat = on.has("projection") ? REFERENCE_LAT : city.center.lat; return city.latScale * Math.cos((lat * Math.PI) / 180); } /** * The tallest ground the pack can produce, in metres, by the same blend * `World.elevationAt` uses — tallest hill plus 35% of the rest — evaluated at * every hill's own summit. * * A property of the pack and not of the field, which is what lets the * exaggeration be derived at construction time. The heightfield's true maximum * is 2.7–8.1% higher because the roughness multiplier peaks at 1.18 somewhere * near a summit; that bias is in the same direction on all three boards and * divides out of a ratio, and the alternative — half a million samples of * four-octave noise before the first triangle — is the cost this whole engine is * arranged around avoiding. * * O(hills²): 469² on California, and 7 ms. Only ever run with the flag on. */ export function blendedPeakMetres(city: City): number { const squash = Math.cos((city.center.lat * Math.PI) / 180); let best = 0; for (const at of city.hills) { let peak = 0; let total = 0; for (const hill of city.hills) { const dLat = at.lat - hill.lat; const dLng = (at.lng - hill.lng) * squash; const d = Math.hypot(dLat, dLng) / hill.radius; if (d >= 1) continue; const f = (1 - d * d) ** 2; const h = hill.elevation * f; total += h; if (h > peak) peak = h; } const here = peak === 0 ? 0 : peak + (total - peak) * 0.35; if (here > best) best = here; } return best; } /** (a) One relief-in-frame constant instead of three unrelated exaggerations. */ export function reconciledExaggeration(city: City, lngScale = lngScaleOf(city, new Set())): number { const metresPerUnit = 111_320 / city.latScale; const peak = blendedPeakMetres(city); if (peak <= 0) return city.verticalExaggeration; return (RELIEF_IN_FRAME * spanUnits(city, lngScale) * metresPerUnit) / peak; } /** * (b) A road's width in true metres, floored at the narrowest legible fraction * of the board. * * `widthM` is the pack's own statement of what the road really is; a pack that * does not declare one is read as having authored true metres already, which * both metro packs did. */ export function reconciledRoadWidth(city: City, road: Road, lngScale?: number): number { const metresPerUnit = 111_320 / city.latScale; const trueMetres = road.widthM ?? road.width * metresPerUnit; const floor = LEGIBLE_SPAN_FRACTION * spanUnits(city, lngScale ?? lngScaleOf(city, new Set())); return Math.max(trueMetres / metresPerUnit, floor); } /** (c) The coastal ramp as a multiple of the coarse ground cell. */ export function reconciledCoastFalloff(city: City): number { return COAST_RAMP_CELLS * city.cellLat * (city.coarseFactor ?? 1); } /** * (c) One palette, mixed by how far south and how far inland the board reaches. * * The two terms are the two things that separate a golden Central Valley from a * grey-green Bay Area, and both are already in `bounds`: 37.5°N to 33.0°N for * the southern term, and the coast at -122.0° to the Colorado at -114.0° for the * inland one. California lands on 1.00 and gets its own palette back exactly; * Southern California on 0.77; San Francisco on 0.05, which is the engine * default it already renders with. */ export function reconciledPaletteMix(city: City): number { const south = (37.5 - city.bounds.minLat) / (37.5 - 33.0); const inland = (city.bounds.maxLng + 122.0) / 8.0; return Math.min(1, Math.max(0, 0.5 * south + 0.5 * inland)); } const CHANNELS = ["skyTop", "skyHorizon", "sea", "lake", "shore", "sand", "flats", "upland", "park", "parkHigh"] as const; function mixChannel(a: number, b: number, t: number): number { const r = Math.round((((a >> 16) & 0xff) * (1 - t) + ((b >> 16) & 0xff) * t)); const g = Math.round((((a >> 8) & 0xff) * (1 - t) + ((b >> 8) & 0xff) * t)); const bl = Math.round(((a & 0xff) * (1 - t) + (b & 0xff) * t)); return (r << 16) | (g << 8) | bl; } export function reconciledPalette(city: City): ScenePalette { const t = reconciledPaletteMix(city); const out = {} as ScenePalette; for (const key of CHANNELS) out[key] = mixChannel(NORTH_ANCHOR[key], SOUTH_ANCHOR[key], t); // A board gets the alpine stop when it has ground above the snow line, which // is the fact `alpine` is about. San Francisco's tallest is 1,186 m and does // not; the other two do. Today that is authored, one pack at a time. if (blendedPeakMetres(city) > ALPINE_FROM) { out.alpine = mixChannel(NORTH_ANCHOR.alpine ?? 0, SOUTH_ANCHOR.alpine ?? 0, t); } return out; } // ---- Putting it back into a pack ------------------------------------------- const cache = new WeakMap>(); /** * The pack the engine should actually draw. * * Returns the argument **by identity** when nothing is on, and the same object * for the same pack and the same rule set when something is, so a `World` and * the `World` its worker rebuilds agree without either of them knowing about the * other. */ export function reconciledCity(city: City): City { if (city.reconciled === true) return city; const on = activeRules(); if (on.size === 0) return city; const key = RULES.filter((rule) => on.has(rule)).join(","); let byRules = cache.get(city); if (byRules === undefined) cache.set(city, (byRules = new Map())); const hit = byRules.get(key); if (hit !== undefined) return hit; const lngScale = lngScaleOf(city, on); const next: City = { ...city, reconciled: true }; if (on.has("projection")) next.lngScale = lngScale; if (on.has("exaggeration")) next.verticalExaggeration = reconciledExaggeration(city, lngScale); if (on.has("roads")) { next.roads = city.roads.map((road) => ({ ...road, width: reconciledRoadWidth(city, road, lngScale), })); } if (on.has("ground")) { next.coastFalloff = reconciledCoastFalloff(city); next.palette = reconciledPalette(city); } byRules.set(key, next); return next; } /** * What the reconciliation did to a pack, for a script that wants to print it. * * Here rather than in the script so the numbers a report quotes and the numbers * the engine draws come from one place. */ export interface Reconciliation { id: string; spanUnits: number; peakMetres: number; exaggeration: { before: number; after: number }; reliefInFrame: { before: number; after: number }; roadMetres: { before: [number, number]; after: [number, number] }; coastFalloff: { before: number; after: number }; lngMetresPerDegree: { before: number; after: number }; paletteMix: number; } export function describe(city: City): Reconciliation { const metresPerUnit = 111_320 / city.latScale; const before = lngScaleOf(city, new Set()); const after = lngScaleOf(city, new Set(RULES)); const peak = blendedPeakMetres(city); const spanBefore = spanUnits(city, before); const spanAfter = spanUnits(city, after); const exAfter = reconciledExaggeration(city, after); const widths = (pack: City, scale: number, use: boolean): [number, number] => { const metres = pack.roads.map((road) => use ? reconciledRoadWidth(pack, road, scale) * metresPerUnit : road.width * metresPerUnit, ); return [Math.min(...metres), Math.max(...metres)]; }; return { id: city.id, spanUnits: spanAfter, peakMetres: peak, exaggeration: { before: city.verticalExaggeration, after: exAfter }, reliefInFrame: { before: ((peak / metresPerUnit) * city.verticalExaggeration) / spanBefore, after: ((peak / metresPerUnit) * exAfter) / spanAfter, }, roadMetres: { before: widths(city, before, false), after: widths(city, after, true) }, coastFalloff: { before: city.coastFalloff, after: reconciledCoastFalloff(city) }, lngMetresPerDegree: { before: (before / city.latScale) * 111_320, after: (after / city.latScale) * 111_320, }, paletteMix: reconciledPaletteMix(city), }; }