feat: the boards stop being three products
The owner asked twice why there are still three separate boards. The honest answer, and what this round executes: **it feels like three boards, but not because the scale jumps 20x — because the three packs draw three different Californias, and the loudest difference is that the mountains are four times taller on one of them.** **THE 20x HORIZONTAL SCALE JUMP IS INVISIBLE**, and measuring that collapsed the cost of this whole round. `World.project` is a uniform scale in x/z with no vertical term, and a uniform scale leaves a perspective image identical — so a camera carried across the seam on matched true-metre offsets draws a pixel-identical horizontal frame. 1,919 -> 94 m/unit costs nothing to look at. Rescaling was never the problem. A boot card, a tab strip and a 4.17x vertical deflation were. **THE PAUSE WAS MOSTLY FAKE.** A switch covered the screen for 1,715 ms but only 608 ms blocked the main thread; the page drew 46 of 69 frames with nothing to show, because the outgoing board had already been disposed. `mountCity` now retains it: the incoming board builds BEHIND a live, interactive picture, and `stage.setScene` fires only on completion. Measured across all six directions, three runs each — boot card yes -> **no**, opaque cover 726-1,415 ms -> **0**, blank frames 21-46 -> **exactly 1**, wall clock down 12-29%, blocked main thread down 15-47%. A return to a board already seen links **zero** shader programs and blocks **zero** milliseconds: 298-312 ms of camera flight where it was ~1,600 ms behind a card. Disposal had been throwing away the shader cache too — linkProgram ran 38, 59, 78, 109, 127 across five mounts and never reused one. **The transition is a fog dip, not a crossfade**, through the `setAerialFog` seam built last round. Every both-boards-live crossfade breaks a budget — ca+sf is 2,640,307 triangles against bay-area's 2,600,000 cap — and a fade never lands inside the harness's sample window, which is the "a cap you do not measure is a cap you do not have" failure this repo already argues against. The dip costs zero triangles and zero draw calls, and it hides the 4.17x deflation, the 4,025 m projection disagreement and the vanishing 2 km freeway symbols at once, because all three happen at maximum obscuration. It is also diegetic: a descent through haze. The first dip was wrong and the photograph caught it: collapsing to 6% of board SPAN turned the whole night frame into one flat field — the exact "turns the map off" failure the risk list named. Re-anchored to 70% of camera STAND-OFF, so the coastline survives and only the relief melts. **One ladder, one places list.** 26 authored chapters become 24 rungs sorted descending by STAND-OFF, not altitude — by altitude they interleave badly and altitude cannot tell a low oblique from a high plan. The three-board tab strip is off by default; the left column is now one scrolling list of all 24 rungs under three region headings that does not change when the board does. Only which row is lit changes. Label collisions are resolved in the ladder and never in a pack, so the 29 index-aimed capture guards are untouched. The minimap stops turning through 90 degrees between boards: every board is pinned to a rectangle with California's proportions. **SF and SoCal are not regressed**, and that was the acceptance that mattered: 95.9-98.8% of board pixels are delta-0 against a baseline hash-verified identical to what the live site serves, and every one of the 34-70 surviving pixels per frame is an aircraft or a hull. **A real defect found only by photograph:** `minimap.setMarkers()` had zero call sites. Every marker on every board was gone — the LA studio's door dot, the Bay Area's eight company markers — dropped when the minimap went per-board. Typecheck, tests, budgets and the console were all green with that bug in. Also fixed: two capture presets that lied. `look.mjs`'s `glyph-la` and `glyph-sf` claimed California chapter closeups and returned SoCal and Bay Area frames, because they aimed by chapter index and the indices had moved. Aiming is now by identity, with a guard test. NOT SHIPPED, DELIBERATELY: the pack merge. At Bay density it is 34.04M triangles, 13x the highest budget — dead, not a trade. At SoCal density it is 1.99M and fits today, and the price is San Francisco rendering at 164 m lots instead of 40 m, i.e. SF looking the way SoCal looks now. SF and SoCal carry every marketing still on the site. That is the owner's decision and it is worthless as an argument and decisive as a photograph, so it ships as a measurement artifact with a side-by-side still and is wired into nothing. The four data reconciliations that would make one world honest — one exaggeration rule, roads in metres, one projection centre, one coastline convention — are behind TERA_RECONCILE, default OFF. Tests 1,570 -> 1,651, server 295. All ten budget cells pass, no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1837,9 +1837,28 @@ export const CALIFORNIA_CITY: City = {
|
||||
{ name: "San Francisco", lat: 37.7749, lng: -122.4194, height: 720, footprint: 0.026, shape: "tower", color: 0x9fb2c2, label: true },
|
||||
],
|
||||
bridges: [],
|
||||
/**
|
||||
* The two corridors, authored twice: once as the symbol this board draws and
|
||||
* once as the road it actually is.
|
||||
*
|
||||
* `width` is 1 and 1.06 scene units, which at 1,919 m to the unit is 1,919 m
|
||||
* and 2,034 m of asphalt — wider, by a factor of two, than the widest thing in
|
||||
* downtown Los Angeles, and the most prominent marks on the state board. That
|
||||
* was a deliberate choice and it is defensible on its own terms: a 47 m
|
||||
* freeway on a board 554 units across is a fortieth of a unit and would not
|
||||
* exist at all. It is not defensible at the seam, where the same road is
|
||||
* 43–55 m on Southern California and 23–30 m on the Bay Area, because a 2 km
|
||||
* ribbon becoming a 30 m line is a change of drawing convention rather than an
|
||||
* LOD transition.
|
||||
*
|
||||
* `widthM` is US-101 and I-5 as they are on the ground — six lanes plus
|
||||
* shoulders through the Salinas Valley, eight through the San Joaquin — and it
|
||||
* is read by `cities/reconcile.ts` and by nothing else. The engine draws
|
||||
* `width`, always.
|
||||
*/
|
||||
roads: [
|
||||
{ path: CALIFORNIA_US_101, width: 1, kind: "freeway" },
|
||||
{ path: CALIFORNIA_I_5, width: 1.06, kind: "freeway" },
|
||||
{ path: CALIFORNIA_US_101, width: 1, widthM: 44, kind: "freeway" },
|
||||
{ path: CALIFORNIA_I_5, width: 1.06, widthM: 47, kind: "freeway" },
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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 !== "");
|
||||
return new Set(RULES.filter((rule) => wanted.some((part) => rule.startsWith(part))));
|
||||
}
|
||||
|
||||
/** Which rules are on right now. */
|
||||
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();
|
||||
return parse(new URLSearchParams(search).get("reconcile"));
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user