85be0b13f8
The owner asked for one map, with detail arriving in the background rather than
behind a wait. The interface already said one place (4bd8481: one ladder, no tab
strip, no boot card). This is the world catching up: on a desktop both metros are
now resident within seconds of landing, and the switch that used to be a build
becomes a fog dip over a cache hit.
**Measured, three runs each, California -> The Bay after an 8 s dwell:**
the longest blocking task falls from **521 ms to 58 ms** (before 700/501/521,
after 58/58/61) and long tasks from 2 to 1.
**And the cost, stated rather than omitted.** Over ten seconds sitting on the
state board: 601 frames -> ~571, p95 frame interval 18.0 -> 18.7 ms, and **one
long task of ~500 ms that did not exist before**. The build is not chunked yet,
so this moves a freeze from the moment of interaction to a moment nobody asked
for. That is a real trade and the follow-on that removes it is chunking the
build. `PREFETCH_ENABLED` in main.ts turns the whole lane off in one line.
A parked board draws nothing: 371 draws per frame before the lane lands a board
and 371 after, patched at the GL entry points. The performance budget reproduces
every cell to the digit.
**Three defects found on the way, two of them latent for a while.**
1. `environmentRig` had no idea what off-stage meant. `createScene` calls
`apply()` unconditionally, two hundred lines above the `present` check, and
the rig holds one probe per kind: on a key miss it convolves a new one,
disposes the one the visible board is using, and repoints every applied scene
at the replacement. A board built ahead of the camera is observed at its own
centre — about forty minutes of apparent solar time across the state — so its
key differs by construction. The board on screen would have changed colour
because something invisible finished loading. Harmless until today only
because every build was followed within ~800 ms by the swap that presented it.
Fixed with `ApplyOptions.offstage`; the regression test was written first and
failed first.
2. `mounting` was assigned and never cleared. Harmless while the only reader was
the next `mountCity` wanting something to abort. It stopped being harmless the
moment a second lane asked "is a foreground build in flight?", because the
honest answer after the first mount of the session was permanently yes — the
background lane armed exactly never, and the only symptom was a feature that
silently did nothing. Found by measuring, not by reading.
3. `buildBoard`'s `onProgress` ends in `bootProgress`, which raises the switch
pill. A prefetch would have put "Building The Bay Area... terrain 42%" over a
visitor who did nothing — the exact chrome that removing the tab strip was
for. `quiet` closes it.
**The policy is pure and lives with the eviction policy it has to agree with.**
`prefetchTarget` and `canAdmit` in boards.ts take scalars, never a camera, so
boards.ts keeps CONTRACT section 1's no-DOM/no-WebGL/no-three promise and
prefetchPolicy.test.ts can assert against the real pack bounds. Two regimes,
because collapsing them was the first version's mistake: with free room the gate
is simply "is there a slot", since the common path is to land on the state at
1,551 km and click a metro, and on that path a proximity trigger fires never;
with the cache full — the handheld case, capacity two with California pinned —
proximity is the only thing that justifies an eviction. The discs are asserted
non-overlapping against the shipped bounds (92 + 146 km of reach across a 314 km
gap), which is the covering-set argument residentCapacity already rests on.
**Also fixed, and separately load-bearing: reconcile's flag parser lied.** It
prefix-matched, so `?reconcile=palette` selected nothing and produced an empty
set — indistinguishable from the flag being absent. Every photograph taken to
judge a rule could have been a photograph of the unreconciled board with no way
to tell. Exact names now, with a warning that says "the flag is NOT off".
And two consumers were reading the raw packs beside a reconciled World:
`buildLadder` derives every rung's stand-off from `focus`, and `createMinimap`
was handed `entry.city` next to `handle.world`. Both now read `world.city`.
Latent with the flag off — `reconciledCity` returns by identity — which is what
kept it alive: it corrupts the measurement rather than announcing itself.
**What I did NOT ship, having tried it.** `roads` on by default. The rule exists
to stop California drawing 1,919 m freeways, and `city.roads` is not what draws
them: California is the one board with `roadTraffic`, so `scene.ts:816` takes the
`createFreewayWorld` branch and `createRoads` — the only reader of `Road.width` —
is never called for it. The visible corridor is a deliberate atlas glyph sized so
DRIVE mode can drive down it. Photographed at three chapters the rule moved
Downtown LA not at all (empty diff bbox), FiDi by RMSE 0.0006, California by
0.001% of pixels; the only measurable effect anywhere was -112 triangles on
Southern California. See DEFAULT_RULES for the whole argument.
1,688 + 295 tests, every gate, chapter-identity OK against the unmodified
fixture, ui-smoke PASS, budget PASS with private-request checks clean on all ten
cells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
575 lines
25 KiB
TypeScript
575 lines
25 KiB
TypeScript
/**
|
||
* 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<Rule> | 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<Rule> {
|
||
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<Rule>();
|
||
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<Rule> {
|
||
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<Rule>): 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<City, Map<string, City>>();
|
||
|
||
/**
|
||
* 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),
|
||
};
|
||
}
|