/**
 * The ground: water, coastline, and relief.
 *
 * Drawn as two meshes rather than one, deliberately:
 *
 *   1. A **shore plate** per landmass — the smooth polygon from the city pack,
 *      triangulated flat at y=0. This is what gives the coastline a crisp edge
 *      at any zoom.
 *   2. A **terrain grid** on top, clipped to land and displaced. Its edge is
 *      necessarily stair-stepped at cell size, which is why the world's coastal
 *      falloff ramps every height to zero near the water: the grid's blocky rim
 *      ends up flat, at y≈0, exactly where the smooth plate already is and in
 *      the same colour. The steps vanish.
 *
 * One mesh would have to choose between a crisp coastline and cheap relief.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { normalMapData, sampleField, tileableNoise } from "../assets/textures.ts";
import { deviceProfile } from "./stage.ts";
import type { ScenePalette } from "./types.ts";
import type { World } from "./world.ts";

export const DEFAULT_PALETTE: ScenePalette = {
  skyTop: 0x8fb8d8,
  skyHorizon: 0xd9e6ee,
  sea: 0x4a7a99,
  lake: 0x527f9c,
  shore: 0xa8a495,
  sand: 0xc4b79b,
  flats: 0x9d9c93,
  upland: 0x8f9084,
  park: 0x6f8a5c,
  parkHigh: 0x5d7a4c,
};

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.
 *
 * An earlier version ramped green with elevation, which turned every hill into
 * a meadow — and in San Francisco the hills are the most thoroughly built-on
 * part of the city. Nob Hill, Pacific Heights, Bernal and Potrero are houses to
 * the summit; the genuinely green high ground is Twin Peaks, Sutro, Davidson
 * and McLaren, all of which are parks and get their green from being in
 * `city.parks`. Everywhere else stays city-coloured however high it goes, and
 * the buildings do the rest of the talking.
 *
 * `inPark` arrives as an argument rather than being worked out here. This used
 * to call `world.pointInAny(lat, lng, world.city.parks)` itself, once per
 * emitted vertex, which on the Bay Area is 294k walks of twenty-four park
 * polygons — 72 ms of main thread, on a desktop, recomputing a fact the Worker
 * had already established at exactly these points on its way past. The lattice
 * now carries it (`Field.park`), and the caller has the index in hand.
 */
function groundColor(
  pal: ScenePalette,
  scratch: THREE.Color,
  inPark: boolean,
  elevation: number,
): THREE.Color {
  if (inPark) {
    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(RAMP_TO.setHex(pal.flats), elevation / 3);
  }
  const ground = scratch
    .setHex(pal.flats)
    .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 ground's surface --------------------------------------------------

/**
 * How rough the land is, for the physically-shaded ground below.
 *
 * The land is not the sea and must not glint like it. At `SEA_*`'s 0.2 a GGX
 * lobe is a few degrees wide and returns the sun as a hard streak; at 0.92 it
 * is most of a hemisphere, which is what dry ground, scrub, tarmac and roof
 * tile all are — the specular term contributes a low, wide sheen and nothing
 * that reads as a highlight. What the number is really buying is not the
 * specular at all but the *diffuse* environment term, which is what a Lambert
 * material has no slot for; see the note on the material in `createTerrain`.
 */
const GROUND_ROUGHNESS = 0.92;

/**
 * How many metres one repeat of the ground's detail normal map covers, and the
 * whole reason it is not `TEXTURE_TILE_METRES`.
 *
 * The office's constant is 2 m, which is correct for a carpet and meaningless
 * here. Work out what a repeat is worth in pixels before choosing one. The
 * desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so at
 * a camera-to-ground distance of `D` scene units one device pixel covers
 * `D · 2·tan(21°) / 900` scene units — 1.64 m of California's 1,919 m per unit.
 * Against the distances this board can actually be put at, quoted the way
 * `TODO.md` and `cost-at.mjs` quote them, as `D × metresPerUnit`:
 *
 * | camera distance | m/px | a 2 m repeat | a 440 m repeat |
 * |---|---|---|---|
 * | 400 km — the whole state, and where the budget measures | 341 | 0.006 px | 1.3 px |
 * | 110 km — Los Angeles wide | 94 | 0.02 px | 4.7 px |
 * | 45 km — the Bay rung | 38.4 | 0.05 px | 11 px |
 * | 7.7 km | 6.6 | 0.3 px | 67 px |
 * | 2.5 km | 2.13 | 0.9 px | 207 px |
 * | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 272 px |
 *
 * **A 2 m repeat is sub-pixel at every distance the board can reach**, right
 * down to the orbit floor, so reusing the office's number would have bought a
 * texture fetch per ground fragment in exchange for a map the mip chain has
 * already flattened.
 *
 * **440 m, and it was 220 m first, and the difference was a photograph.** The
 * arithmetic says a repeat wants to be a few hundred metres and stops there; it
 * cannot tell you that at 220 m, with `tileableNoise` at a `scale` of 2, the
 * blend's own four-way cross-fade is the largest thing in the tile and the flat
 * Central Valley comes out as a **regular grid of little four-pointed stars**,
 * one per repeat, marching off to the horizon. That was rendered and looked at.
 * At 440 m with the layers below there are eight or so features across a repeat
 * instead of one, so nothing in the tile is identifiable and the repetition
 * stops being legible as repetition.
 *
 * **Not 300 m, and not 600.** The obvious choice is one repeat per terrain cell,
 * and it is the one number to avoid: the lattice is very nearly axis-aligned, so
 * a repeat the size of a cell paints the *same* pattern on every cell and lands
 * its seam exactly on the facet edges it exists to hide. 440 beats against them.
 */
const GROUND_DETAIL_TILE_METRES = 440;

/**
 * The detail map's resolution, and the lattice its height field is built on.
 *
 * Four texels per lattice cell, which is the ratio that matters. `textures.ts`
 * records why the noise is evaluated on a coarse lattice and bilinearly
 * upsampled — four `fbm` calls per pixel at 512² is about sixteen million
 * `Math.sin`es — and why `normalMapData` uses a Sobel rather than a two-tap
 * difference: the upsample reproduces the lattice as a faint grid of creases in
 * the derivative, and averaging three rows buries it. 128 over a 440 m repeat
 * puts the finest thing the height field can hold at 3.4 m, the same floor the
 * office's 64-over-2 m gives at its own scale.
 *
 * 512 rather than 256 because of the *bottom* of the descent, not the top. At
 * 256 a texel is 1.72 m, which is exactly one device pixel at the 1.9 km orbit
 * floor — no margin at all, and a grazing look has none to give. At 512 it is
 * 0.86 m and the map is minified everywhere in the reachable range, so the mip
 * chain decides everything and the magnifier decides nothing. The price is
 * 1.05 MB, 1.4 MB with its mips, against a board whose whole resident geometry
 * `boards.ts` measures at about 40 MB — and one of it, shared by all 18 ground
 * objects. It costs about 50 ms of the board build, against `createTerrain`'s
 * own 159 ms on the Bay Area.
 *
 * The Sobel converts gradient to slope per metre, so the two resolutions light
 * identically — `textureMaps.test.ts` pins that property for the office's maps
 * and it is the same arithmetic here, checked again below for this one.
 */
const GROUND_DETAIL_LATTICE = 128;
const GROUND_DETAIL_SIZE = 512;

/**
 * The height field the ground's normal map is differentiated from, in metres of
 * **drawn** relief.
 *
 * Drawn, not real, and the distinction is the one trap in this whole file.
 * `world.metres` multiplies every elevation by `verticalExaggeration` — 15 on
 * the merged California board — while a horizontal metre is divided by
 * `metresPerUnit` alone. A normal map encodes a slope in scene units per scene
 * unit, so the numbers below are already on the drawn side of that
 * multiplication: the ±5 m of relief these three layers sum to stands for about
 * ±0.35 m of real ground, which is a sub-1% grade over 440 m and is about right
 * for field, scrub and alluvium.
 *
 * **They are deliberately not exaggerated to match the geography.** Carrying the
 * real relief through the board's own 15× would put the map's mean slope near
 * 45° and its peaks past 70°, and that is the wrong answer for a reason that is
 * not about taste: the grain would out-shout the geography, the sun's N·L would
 * swing by ±0.6 across two texels, and a state whose whole subject is its
 * landforms would read as crumpled foil. The mesh carries the exaggeration; the
 * finish on it does not.
 *
 * Measured on the field these three produce: **mean slope 9.9°, peak 39.4°.**
 * The three are about a factor of three apart each — roughly 55 m of swell, 20 m
 * of gully and 7 m of grain — because a single octave at any of those reads as a
 * pattern and three read as a surface. The `scale`s are all comfortably above 4
 * for the reason `GROUND_DETAIL_TILE_METRES` records: below that,
 * `tileableNoise`'s wrapped cross-fade is itself the biggest feature in the tile.
 */
const GROUND_DETAIL_LAYERS: readonly { scale: number; offset: number; metres: number }[] = [
  { scale: 8, offset: 5.7, metres: 21 },
  { scale: 22, offset: 19.3, metres: 8 },
  { scale: 60, offset: 41.1, metres: 2.6 },
];

/**
 * Where the detail gives up, in **device pixels of one repeat**.
 *
 * The same argument as `SEA_CALM_NEAR` and a different measurement, because the
 * first attempt at this used the sea's and it was wrong by a factor of nine —
 * measured, in a photograph of the Central Valley where the map was doing
 * literally nothing and a five-fold amplitude produced a byte-identical PNG.
 *
 * The sea fades on `length( vViewPosition )` converted from real metres by
 * `metresPerUnit`, which quietly assumes the camera's distance is a *plan*
 * distance. On this board it is not: `verticalExaggeration` is 15, so a camera
 * lifted to 0.6 of its stand-off sits `sqrt(1 + (0.6 x 15)²) = 9.06` stand-offs
 * away in scene units. A window written as "25 km to 70 km" was therefore
 * closing at about 2.8 km of stand-off and the detail was off everywhere a
 * viewer could actually go.
 *
 * The fix is to stop inferring the quantity and measure it. `fwidth` of the
 * detail UV is exactly how much of one repeat a pixel covers, so its reciprocal
 * is the repeat's width in pixels — independent of the board's scale, the
 * exaggeration, the field of view, the viewport and the device pixel ratio, all
 * five of which the old form was implicitly guessing at. Two thresholds then say
 * what the study of stand-offs already concluded: **under 2 px the map is noise
 * the mip chain has already flattened, over 8 px it is legible.** For a 440 m
 * repeat that is the same window as "from about 110 km down to about 45 km",
 * arrived at without any of the assumptions.
 *
 * It matters more here than it does for the water, because the board's own
 * budget poses live at the far end: `california-one` is measured at whole-board
 * framing, 400 km out, where a 440 m repeat is 1.3 px. Without this the ground
 * would be paying a fetch and a tangent frame at the one pose the product is
 * judged on, to perturb a normal by an amount that has already averaged to zero.
 *
 * Two derivative pairs, which is the whole cost, and the normal-map path is
 * already taking three for `getTangentFrame`.
 */
const GROUND_DETAIL_NOISE_PX = 2;
const GROUND_DETAIL_LEGIBLE_PX = 8;

/**
 * A tiling tangent-space normal map for the land, as raw RGBA bytes.
 *
 * The land's answer to `swellNormalData`, and built out of `textures.ts`'
 * machinery rather than its own: `tileableNoise` for the four-way wrapped blend
 * that makes the edges match, `sampleField` for the bilinear read, and
 * `normalMapData` for the Sobel that turns metres into an encoded slope. The
 * only thing this adds is the recipe and the tile size, which is the whole
 * difference between a carpet and a hillside.
 *
 * No canvas, no `document`, no image: the field is arithmetic, so this function
 * exists identically in a browser and under `node --test`, and
 * `scripts/check-no-binaries.mjs` has nothing to find.
 */
export function groundDetailData(size: number = GROUND_DETAIL_SIZE): Uint8Array {
  const height = new Float32Array(size * size);
  for (const layer of GROUND_DETAIL_LAYERS) {
    const field = tileableNoise(GROUND_DETAIL_LATTICE, layer.scale, layer.offset);
    for (let y = 0; y < size; y++) {
      for (let x = 0; x < size; x++) {
        // `/ 0.94` is `grain()`'s: four octaves of `fbm` land in 0..0.94. The
        // half is what centres the layer, so the summed field has no drift in
        // it and `metres` is a peak-to-trough rather than a ceiling.
        const n = Math.min(
          1,
          Math.max(0, sampleField(field, GROUND_DETAIL_LATTICE, x / size, y / size) / 0.94),
        );
        height[y * size + x] = (height[y * size + x] as number) + (n - 0.5) * layer.metres;
      }
    }
  }
  return normalMapData(height, size, GROUND_DETAIL_TILE_METRES);
}

/**
 * The bytes, built once for the life of the page.
 *
 * Two materials want this map — the terrain and the shore plate under it — and
 * the field is a pure function of the constants above, so building it twice is
 * 50 ms of identical arithmetic. Only the *bytes* are shared: each material gets
 * its own `DataTexture`, because `scene.ts` disposes a board by traversing it
 * and disposing what it finds, and a `Texture` shared across boards would be
 * disposed by the first board to leave. A `Uint8Array` has no GPU resource to
 * free and is never written to after this line.
 */
let groundDetailBytes: Uint8Array | null = null;

function groundDetailTexture(): THREE.DataTexture {
  const size = GROUND_DETAIL_SIZE;
  groundDetailBytes ??= groundDetailData(size);
  const texture = new THREE.DataTexture(groundDetailBytes, size, size);
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.magFilter = THREE.LinearFilter;
  // Mipmapped for the reason `swellNormalTexture` states: the mip chain of a
  // normal map converges on flat, so the ground a long way off stops perturbing
  // its normal without anything having to decide when — and the fade at
  // `GROUND_DETAIL_NOISE_PX` then finishes the job by taking the fetch's
  // *result* to zero rather than leaving a residue of the coarsest mip on the
  // horizon.
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.generateMipmaps = true;
  // Eight, as the sea has: from a map pose the ground is seen at a grazing
  // angle across most of the frame, and that is exactly where an isotropic
  // filter picks a mip several levels too coarse and the detail vanishes
  // halfway up the screen for no reason the viewer can see.
  texture.anisotropy = 8;
  texture.needsUpdate = true;
  texture.name = "groundDetailNormal";
  return texture;
}

/**
 * Whether this device should pay for the ground's detail relief.
 *
 * The precedent is `MaterialQuality`'s `low` rung in `assets/materials.ts`,
 * which is flat Lambert with no maps — a tier's "off" is *no map*, not a
 * smaller one, because the cost being avoided is per fragment and a smaller map
 * costs exactly the same fetch. `deviceProfile()` is the only device knob a
 * city board reads and it is asked here rather than re-derived, so the
 * renderer's idea of "phone" cannot drift from the stylesheet's.
 *
 * What a handheld keeps is the half of this change that reads at every
 * distance: the standard material, its specular and its environment term. What
 * it gives up is the half that only reads inside the last decade of the descent
 * — one texture fetch and `getTangentFrame`'s three derivative pairs per ground
 * fragment, on a viewport that is 1.3 Mpx of them, at a whole-board pose where
 * one repeat of the map is 1.3 px wide.
 *
 * `typeof window` because `createTerrain` runs under `node --test` as well,
 * where there is no device to profile and no fill rate to protect.
 */
function groundDetailWanted(): boolean {
  if (typeof window === "undefined") return true;
  return !deviceProfile().handheld;
}

/**
 * The one material the ground is shaded by, built for the terrain and again for
 * the shore plate under it.
 *
 * Two materials rather than one because the terrain is `vertexColors` and the
 * plate is a flat `pal.shore`, and three compiles those to different programs
 * whatever this returns. **One material per mesh either way**, so the draw call
 * count is exactly what it was: 17 chunks, one zero-triangle caster and one
 * plate, sharing two programs between them. The plate is here rather than left
 * on its own flat colour because a textured terrain against a smooth plate makes
 * the grid's stair-stepped rim *legible* — which is the one thing
 * `createShorePlates` exists to hide — and because `WebGLRenderer` forwards
 * `scene.environment` only to `isMeshStandardMaterial`, so a Lambert plate would
 * also sit a fifth of the hemisphere's diffuse contribution darker than the
 * ground it abuts, all the way around the coastline.
 */
function groundMaterial(
  world: World,
  detail: boolean,
  paint: Pick<THREE.MeshStandardMaterialParameters, "color" | "vertexColors">,
): THREE.MeshStandardMaterial {
  const material = new THREE.MeshStandardMaterial({
    ...paint,
    side: THREE.DoubleSide,
    roughness: GROUND_ROUGHNESS,
    // Stated rather than defaulted: ground is a dielectric, and a metallic
    // surface has no diffuse term at all, so a stray metalness here would take
    // `groundColor`'s whole ramp off the board.
    metalness: 0,
    ...(detail ? { normalMap: groundDetailTexture() } : {}),
  });
  /*
   * The map follows its owner into disposal, which it did not before.
   *
   * `Material.dispose()` frees the material and **not its maps** — the same
   * arithmetic `stage.ts` records for `WebGLRenderer.dispose()`, one level
   * down — and `scene.ts`'s teardown traverse frees geometry and material and
   * nothing else. So every board build was orphaning a 512-square RGBA texture
   * per ground material on the GL context, about 2.8 MB with mips, for the life
   * of the page. Two of them per board, since the shore plate wears the same
   * surface, on a board cache that retains up to three boards and rebuilds on
   * every switch.
   *
   * `structures.ts` already has this exact idiom for its road canvases and
   * states the reason: three's materials are event dispatchers and emit
   * `dispose`, so a texture can simply follow its owner rather than needing a
   * disposal path of its own. Copied deliberately rather than reinvented.
   */
  if (material.normalMap !== null) {
    const map = material.normalMap;
    material.addEventListener("dispose", () => map.dispose());
  }
  /**
   * `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°.
   *
   * It survives the move to a standard material unchanged, and it has to: the
   * depth pass does not use this material at all — `WebGLShadowMap` swaps in
   * its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
   * the displacement slots and nothing else — so the patch below is invisible
   * to it and this flag is the only thing it reads.
   */
  material.shadowSide = THREE.BackSide;

  if (detail) {
    /**
     * Three edits to the standard shader. The ground has no `uv` attribute and
     * no `tangent` attribute, and both of those are decisions rather than
     * omissions.
     *
     * 1. **The detail UV is derived from world XZ in the vertex shader.** The
     *    alternative is a real `uv` attribute, and it was costed: 46,144
     *    vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
     *    colour and normal the ground already uploads — a 22% growth of the
     *    ground's vertex data to store a number that is `position.xz` times a
     *    constant. It would also have to be added as *one* attribute object
     *    assigned to all 18 geometries, or the shared-buffer arrangement in
     *    `createTerrain` quietly duplicates it seventeen times. The plan
     *    projection is in any case the natural parameterisation of a
     *    heightfield, which is the other half of why there is no attribute to
     *    interpolate. `position.xz` and not the world matrix's, deliberately:
     *    `scene.ts` scales this group in **Y** to move `verticalExaggeration`
     *    without rebuilding a board, and a UV taken off the plan is the one
     *    that does not slide when it does.
     *
     * 2. **The slope is scaled by the cosine of the drawn slope, which is the
     *    fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
     *    board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
     *    face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
     *    of surface along the fall line — 2.8x on that hillside, 8.7x on that
     *    face — so the map's encoded slope describes bumps far shorter than the
     *    ones it is actually being stretched over, and the Sierra escarpment
     *    comes out as a smear of vertical stripes. Multiplying the tangent-space
     *    slope by cos θ restores the geometry exactly: the bumps get longer, so
     *    they get shallower by the same factor. It costs no fetch — the cosine
     *    is a dot product against world up, which `viewMatrix`' second column
     *    already is — and it is why there is no triplanar projection here
     *    tripling the sampler traffic to solve the same problem. The
     *    approximation in it, stated because it is real: the stretch is along
     *    the fall line only and this scales both tangent axes, so the detail is
     *    slightly over-flattened across a steep slope. At the angles the
     *    exaggeration produces that is a rounding error next to the stripes.
     *
     * 3. **It fades out once one repeat is worth a couple of pixels**, which is
     *    the sea's `SEA_CALM_NEAR` idea measured rather than inferred. See
     *    `GROUND_DETAIL_NOISE_PX` — that note also records the nine-fold error
     *    the distance form made on a board with a vertical exaggeration on it.
     *
     * A patch rather than a `ShaderMaterial`, for the reason `createWater`
     * gives at length: the lighting this surface has to obey is a sun, a
     * hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
     * owned elsewhere and all changing with the hour, and that is exactly what
     * `MeshStandardMaterial` already implements correctly.
     */
    const uniforms = {
      /** Repeats of the detail map per scene unit, so the grain is the same
       *  size in metres on a 94 m board and on a 1,919 m one. */
      uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
      /** The fade window, in device pixels of one repeat. See above. */
      uGroundFade: {
        value: new THREE.Vector2(GROUND_DETAIL_NOISE_PX, GROUND_DETAIL_LEGIBLE_PX),
      },
    };
    material.onBeforeCompile = (shader) => {
      Object.assign(shader.uniforms, uniforms);
      shader.vertexShader = shader.vertexShader
        .replace(
          "#include <common>",
          `#include <common>
uniform float uGroundTile;`,
        )
        // `uv_vertex` has already written `vNormalMapUv` from the `uv`
        // attribute, which this geometry does not have — WebGL feeds a constant
        // for a missing attribute, so every fragment would sample texel 0. This
        // overwrites it with the plan coordinate, which is the only place the
        // varying is ever set.
        .replace(
          "#include <uv_vertex>",
          `#include <uv_vertex>
vNormalMapUv = position.xz * uGroundTile;`,
        );
      shader.fragmentShader = shader.fragmentShader
        .replace(
          "#include <common>",
          `#include <common>
uniform vec2 uGroundFade;`,
        )
        .replace(
          "#include <normal_fragment_maps>",
          `vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
// One repeat of the map spans 1.0 in UV, so the reciprocal of the pixel's UV
// footprint is the repeat's width in pixels. The geometric mean of the two axes
// rather than the larger of them, floored at an eighth of the larger: that is
// what \`anisotropy = 8\` actually resolves, and the isotropic maximum would
// give the detail up across most of an oblique frame — which is most of a map
// pose — while the minimum would keep it on ground stretched to nothing.
vec2 groundStep = fwidth( vNormalMapUv );
float groundWide = max( groundStep.x, groundStep.y );
float groundFoot = max( sqrt( groundStep.x * groundStep.y ), groundWide / 8.0 );
float groundSeen = smoothstep( uGroundFade.x, uGroundFade.y, 1.0 / max( groundFoot, 1e-6 ) );
mapN.xy *= normalScale * groundFlat * groundSeen;
normal = normalize( tbn * mapN );`,
        );
    };
    /*
     * `onBeforeCompile` is invisible to three's default key, so a patched
     * material that does not declare one gets handed somebody else's program.
     *
     * The terrain and the shore plate both answer `"tera:ground"` and that is
     * correct rather than a collision: `WebGLPrograms.getProgramCacheKey`
     * appends this string to the *whole* parameter list, and `vertexColors`
     * is in that list — so the two still hash apart, and two boards of the same
     * kind still share. Set only on the patched branch: an unpatched ground is
     * an ordinary standard material and must go on sharing three's own key.
     */
    material.customProgramCacheKey = () => "tera:ground";
  }
  return material;
}

/** The smooth flat polygon under each landmass — the crisp coastline. */
export function createShorePlates(world: World, options: TerrainOptions = {}): THREE.Mesh {
  const pal = paletteFor(world);
  const positions: number[] = [];

  for (const poly of world.city.landmasses) {
    const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
    const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
    geo.rotateX(Math.PI / 2); // the shape's XY plane onto the scene's XZ ground
    const pos = geo.getAttribute("position");
    const index = geo.getIndex();
    if (index) {
      for (let i = 0; i < index.count; i++) {
        const k = index.getX(i);
        positions.push(pos.getX(k), pos.getY(k), pos.getZ(k));
      }
    } else {
      for (let i = 0; i < pos.count; i++) positions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
    }
    geo.dispose();
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
  geo.computeVertexNormals();

  /**
   * The same ground material the relief above it wears, in a flat colour.
   *
   * It used to be `MeshLambertMaterial({ color: pal.shore })`, and both halves
   * of `groundMaterial` matter to it. Without the standard class it would get no
   * `scene.environment` — `WebGLRenderer` forwards that only to
   * `isMeshStandardMaterial` — and would sit about a fifth of the hemisphere's
   * diffuse contribution darker than the terrain, all the way around the
   * coastline: the one place on the board where two materials meet edge to edge
   * in the same colour, where a step reads as a seam rather than as a different
   * object. Without the detail map it would be *smooth* against a grained
   * terrain, and that was photographed at San Francisco — the grid's
   * stair-stepped rim, which this plate exists to hide, came back as a legible
   * zigzag between a textured surface and a flat one. The plate is flat at y=0,
   * so the plan projection the map is applied through is exact on it.
   *
   * One mesh, one material, one draw call, exactly as before.
   */
  const mesh = new THREE.Mesh(
    geo,
    groundMaterial(world, options.surfaceDetail ?? groundDetailWanted(), { color: pal.shore }),
  );
  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;
}

/**
 * Patch sizes the visible surface is allowed to collapse to, largest first, in
 * lattice cells per side.
 *
 * Powers of two and aligned to their own multiple, which is what makes the
 * bookkeeping in `lodPatches` a single lookup: a level-2 patch is either wholly
 * inside a level-4 patch or wholly outside one, so a patch is rejected by
 * testing its first cell rather than all of them.
 *
 * Stopping at 8 is a measurement, not a limit of the method. Each level costs a
 * pass over the lattice, and the levels pay less as they get coarser: against
 * the cell-by-cell surface, going to 4 takes the Bay Area from 581,008
 * triangles to 171,124 and going to 8 takes it to 157,378, while a fifth level
 * would be scanning for flat ground that levels 4 and 8 have already claimed.
 * The whole pass is *cheaper* than the surface it replaces, because the
 * vertices it stops emitting cost more than the flatness test costs to run:
 * `createTerrain` on the Bay Area went from 260 ms to 159 ms.
 */
const LOD_LEVELS = [8, 4, 2] as const;

/**
 * The same, for the shadow caster appended to the visible surface.
 *
 * It ends one level coarser and begins one level coarser, because the caster
 * already starts at `SHADOW_CASTER_STRIDE` rather than at a single cell — the
 * levels here are the *merges above* that stride, not a second decimation of
 * the same ground. A 16 was measured and left out: it takes the Bay Area's
 * caster from 53,806 triangles to 51,238 and California's from 17,632 to
 * 17,602, which is not worth a fourth pass over the lattice.
 */
const LOD_CASTER_LEVELS = [8, 4] as const;

/**
 * How wide a terrain chunk wants to be, in metres of ground — and the two
 * numbers that stop a future pack spending a draw-call budget it cannot see.
 *
 * **What this buys.** three frustum-culls per *object*. One mesh holding the
 * whole state has a bounding sphere containing the whole state, so it is
 * submitted at every pose: standing 2.5 km over San Francisco the merged board
 * drew all 86,400 of its visible triangles, most of them in the Mojave. Cutting
 * the same geometry into a grid of meshes — the *same* triangles, at the *same*
 * resolution, re-indexed and nothing else — lets the renderer reject the ones
 * that are off screen.
 *
 * **What it costs, and why the grid is this coarse.** A draw call is the scarce
 * resource on this board, not a triangle: the merged board reads 420 draws
 * against a 460 cap and 352,927 triangles against 400,000. Every chunk is a
 * draw at the whole-board pose, where the split saves exactly nothing. Measured
 * over `city.bounds` — chunks that hold no land never become objects, and the
 * triangle columns are what survives three's own sphere test at 2.5 km of
 * stand-off:
 *
 * | grid | cell | non-empty | SF close | LA close | tris per added draw |
 * |---|---|---|---|---|---|
 * | 3 x 3 | 310 x 354 km | 8 | 37,462 | 30,290 | 6,117 |
 * | 4 x 4 | 232 x 266 km | 12 | 22,600 | 27,828 | 5,317 |
 * | 4 x 5 | 232 x 213 km | 15 | 22,902 | 22,668 | 4,233 |
 * | **5 x 5** | **186 x 213 km** | **17** | **16,370** | **18,404** | **4,119** |
 * | 5 x 6 | 186 x 177 km | 22 | 15,196 | 22,454 | 3,236 |
 * | 6 x 6 | 155 x 177 km | 23 | 12,934 | 15,214 | 3,194 |
 * | 8 x 8 | 116 x 133 km | 38 | 12,012 | 13,624 | 1,957 |
 *
 * Marginally: 4x4 to 5x5 buys 6,230 triangles for 5 draws — 1,246 each; 5x5 to
 * 6x6 buys 3,436 for 6 — 573 each; 6x6 to 8x8 buys 922 for 15 — 61 each. 5 x 5
 * is the last grid on which a draw call is worth more than a thousand
 * triangles, and 200 km is what produces it on California's 930 x 1,063 km
 * bounds.
 *
 * The number is metres of ground rather than a count for the same reason
 * `NEIGHBOURHOOD_LOT_METRES` is: a self-hoster's pack of a single valley gets
 * one chunk and pays nothing, without having to declare anything.
 *
 * `MAX_TERRAIN_CHUNKS` is the draw-call bound stated as a number. On this board
 * 17 non-empty cells is under it and it never fires; it exists so a pack twice
 * California's size cannot quietly cost fifty draws. When it does fire the
 * longer axis coarsens and the grid is rebuilt, which is the cheap direction to
 * be wrong in — a coarser grid culls less and costs less.
 */
const TERRAIN_CHUNK_METRES = 200_000;
const MAX_TERRAIN_CHUNK_AXIS = 5;
const MAX_TERRAIN_CHUNKS = 20;

/**
 * How far the collapsed surface may sit from the one it replaces, in **scene
 * units** of height.
 *
 * Scene units rather than metres deliberately: the boards disagree about metres
 * by a factor of twenty — one unit of height is 148 m on California, 65 m in
 * Southern California and 26 m on the Bay Area — and what has to stay invisible
 * is a number of *pixels*, which is a scene-unit quantity. The three boards are
 * 1003, 308 and 284 units across and are all looked at from a standoff that
 * puts the board across most of a 1440-pixel viewport, so a unit is roughly
 * 1.4, 4.5 and 4.9 pixels. 0.1 units is therefore a seventh of a pixel on the
 * board with the loosest scale and half a pixel on the tightest.
 *
 * It is deliberately not tuned per board. A self-hoster's pack gets the same
 * guarantee without having to declare anything, for the same reason
 * `NEIGHBOURHOOD_LOT_METRES` in `blocks.ts` is a measurement of the ground
 * rather than a list of city ids.
 */
export const LOD_HEIGHT_TOLERANCE = 0.1;
//         ^ exported, because it is a floor under anything laid flat on the
// ground. `airports.ts` derives its field lift from it: a plate laid closer to
// the surface than this can be pierced by the collapsed quad that replaced the
// lattice under it, which showed as a wedge of bare ground through the middle
// of Van Nuys. Two constants that must not drift apart are one constant.

/**
 * How far the collapsed surface's colour may sit from the one it replaces, as a
 * distance in linear RGB.
 *
 * Height is not enough on its own and the coast is where that shows.
 * `groundColor` ramps `sand` into `flats` over the **first three metres** of
 * elevation — a swing of 0.15 in red across a band the coastal falloff makes
 * tens of kilometres wide — so a height tolerance loose enough to be free
 * everywhere else is loose enough to walk the beach inland. Bounding the colour
 * as well keeps the shoreline's pale rim exactly where it was and leaves the
 * ramp free to be as steep as a pack likes.
 *
 * 0.012 is three parts in 255, which is under one step of an 8-bit framebuffer
 * once the tone map has been through it.
 */
const LOD_COLOUR_TOLERANCE = 0.012;

/**
 * Which lattice cells of the visible surface are drawn, and at what size.
 *
 * Returns `[i, j, s]` triples: the patch rooted at lattice corner `(i, j)`,
 * `s` cells on a side, drawn as one quad.
 *
 * **Why this exists.** The lattice is sized for the roughest ground on the
 * board, and most of a board is not that. California's Central Valley is four
 * hundred kilometres of ground that never leaves a hundred-metre band, and it
 * was being spent at the same triangle density as the Sierra crest. Southern
 * California's terrain measured 213,968 triangles and the Bay Area's 581,008
 * for exactly that reason: the floor of the LA basin and the floor of the bay's
 * south valley are planes, and they were being tessellated like mountains.
 *
 * **Why it cannot be seen.** A patch is only collapsed when every lattice point
 * inside it lies within `LOD_HEIGHT_TOLERANCE` of the quad that would replace
 * it *and* within `LOD_COLOUR_TOLERANCE` of the colour that quad interpolates —
 * both measured against the very diagonal `createTerrain` draws, so the test
 * and the mesh agree. Flat ground collapses; a ridge cannot, because a ridge is
 * exactly the case the bilinear surface gets wrong.
 *
 * **Why the coastline is untouched.** A patch is also only collapsed when every
 * one of its lattice points is on land and all of them agree about `park`. Any
 * patch straddling either boundary falls back to `base` cells, which are
 * emitted by the same four-corner test the surface used before this existed. So
 * the *set of ground covered* is identical to the cell-by-cell version, down to
 * the last stair-step, and only the interior of a flat run is redrawn.
 *
 * The seam between a collapsed patch and its finer neighbour is a T-junction,
 * and it is bounded by the same tolerance: the two surfaces share the patch's
 * corners, cover the same footprint in x/z — the projection is linear in
 * latitude and longitude, so a lattice rectangle stays a rectangle — and differ
 * along the shared edge by at most `LOD_HEIGHT_TOLERANCE`. There is no hole,
 * only a sliver thinner than a pixel.
 */
function lodPatches(
  world: World,
  pal: ScenePalette,
  levels: readonly number[],
  base: number,
  matchColour: boolean,
): Int32Array {
  const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice();
  const w = lngSteps + 1;
  const tolerance = world.unitsToMetres(LOD_HEIGHT_TOLERANCE);
  const taken = new Uint8Array(latSteps * lngSteps);
  const out: number[] = [];

  // Held rather than allocated: `fits` runs a few million times on the Bay
  // Area, and `groundColor` wants somewhere to put its answer.
  const c00 = new THREE.Color();
  const c01 = new THREE.Color();
  const c10 = new THREE.Color();
  const c11 = new THREE.Color();
  const here = new THREE.Color();

  const fits = (i0: number, j0: number, s: number): boolean => {
    const parkAt = park[i0 * w + j0];
    for (let i = i0; i <= i0 + s; i++) {
      const row = i * w;
      for (let j = j0; j <= j0 + s; j++) {
        const k = row + j;
        if (!land[k]) return false;
        // Park membership is a *colour* boundary and nothing else, so the depth
        // pass has no opinion about it.
        if (matchColour && park[k] !== parkAt) return false;
      }
    }

    const inPark = parkAt === 1;
    const h00 = height[i0 * w + j0] as number;
    const h01 = height[i0 * w + j0 + s] as number;
    const h10 = height[(i0 + s) * w + j0] as number;
    const h11 = height[(i0 + s) * w + j0 + s] as number;
    if (matchColour) {
      groundColor(pal, c00, inPark, h00);
      groundColor(pal, c01, inPark, h01);
      groundColor(pal, c10, inPark, h10);
      groundColor(pal, c11, inPark, h11);
    }

    const lat0 = lats[i0] as number;
    const lat1 = lats[i0 + s] as number;
    const lng0 = lngs[j0] as number;
    const lng1 = lngs[j0 + s] as number;

    for (let i = i0; i <= i0 + s; i++) {
      // The axes are not uniformly spaced — `buildAxis` runs a focus region
      // fine and the rest coarse — so the interpolant comes off the coordinate
      // rather than off the index.
      const u = ((lats[i] as number) - lat0) / (lat1 - lat0);
      const row = i * w;
      for (let j = j0; j <= j0 + s; j++) {
        const v = ((lngs[j] as number) - lng0) / (lng1 - lng0);
        // Which of the patch's two triangles this point lands in. The diagonal
        // joins (i0+s, j0) to (i0, j0+s), so u + v = 1 is the seam.
        const near = u + v <= 1;
        const a = near ? 1 - u - v : 1 - u;
        const b = near ? u : 1 - v;
        const cc = 1 - a - b;
        const ha = near ? h00 : h01;
        const hc = near ? h01 : h11;
        const flat = a * ha + b * h10 + cc * hc;
        const k = row + j;
        const real = height[k] as number;
        if (Math.abs(flat - real) > tolerance) return false;
        if (!matchColour) continue;
        const ca = near ? c00 : c01;
        const ccol = near ? c01 : c11;
        groundColor(pal, here, inPark, real);
        const dr = a * ca.r + b * c10.r + cc * ccol.r - here.r;
        const dg = a * ca.g + b * c10.g + cc * ccol.g - here.g;
        const db = a * ca.b + b * c10.b + cc * ccol.b - here.b;
        if (dr * dr + dg * dg + db * db > LOD_COLOUR_TOLERANCE * LOD_COLOUR_TOLERANCE) {
          return false;
        }
      }
    }
    return true;
  };

  for (const s of levels) {
    for (let i = 0; i + s <= latSteps; i += s) {
      for (let j = 0; j + s <= lngSteps; j += s) {
        if (taken[i * lngSteps + j]) continue; // inside a coarser patch already
        if (!fits(i, j, s)) continue;
        for (let a = i; a < i + s; a++) {
          for (let b = j; b < j + s; b++) taken[a * lngSteps + b] = 1;
        }
        out.push(i, j, s);
      }
    }
  }

  // Everything a patch did not claim, at `base`, by the original four-corner
  // land test — which is what keeps the covered ground identical to the version
  // before any of this existed.
  for (let i = 0; i + base <= latSteps; i += base) {
    for (let j = 0; j + base <= lngSteps; j += base) {
      if (taken[i * lngSteps + j]) continue;
      const a = i * w + j;
      if (
        !land[a] ||
        !land[a + base] ||
        !land[a + base * w] ||
        !land[a + base * w + base]
      ) {
        continue;
      }
      out.push(i, j, base);
    }
  }
  return Int32Array.from(out);
}

/**
 * The displaced ground. Indexed, and holding only the cells that are fully on
 * land — a partial cell would poke a stair-step out over the water that the
 * shore plate cannot hide.
 *
 * Returns a group, named `terrain`, holding either one mesh — every board that
 * is not the merged one, byte for byte the geometry this used to return — or
 * `TERRAIN_CHUNK_METRES`' grid of chunk meshes plus one caster. See the note on
 * the chunking below for why the split exists and why the caster is not part
 * of it.
 */
export interface TerrainOptions {
  /**
   * Whether the ground carries its detail normal map.
   *
   * Defaults to `groundDetailWanted()`, which is "not on a phone". Passed
   * explicitly by the tests, which want the map on a board that has no
   * `window` to profile and want to be able to ask for it off.
   *
   * **Named `surfaceDetail` and not `detail`**, which is what it was called
   * first. `createTerrain` reads `district.detail` nineteen lines below this —
   * a completely unrelated flag meaning "metro lots, revealed by stand-off" —
   * and the two are the gate for two different level-of-detail systems in one
   * function. One of them decides whether the terrain is chunked at all. A
   * reader who conflates them gets the wrong mental model of the whole file,
   * and the cost of not conflating them is nine characters.
   */
  readonly surfaceDetail?: boolean;
}

export function createTerrain(world: World, options: TerrainOptions = {}): THREE.Group {
  const pal = paletteFor(world);
  // Which cells are drawn, and how big, is `lodPatches`' answer; this function
  // only turns a corner into a vertex.
  const { latSteps, lngSteps, lats, lngs, height, park } = world.lattice();

  const positions: number[] = [];
  const colors: number[] = [];
  const indices: number[] = [];
  const scratch = new THREE.Color();

  // Lattice corner -> emitted vertex, so the four cells sharing a corner share
  // its vertex. Non-indexed, SF's terrain was 724k vertices for 241k triangles.
  const vertexAt = new Int32Array((latSteps + 1) * (lngSteps + 1)).fill(-1);

  const vertex = (i: number, j: number): number => {
    const k = i * (lngSteps + 1) + j;
    const existing = vertexAt[k];
    if (existing !== undefined && existing >= 0) return existing;
    const lat = lats[i] as number;
    const lng = lngs[j] as number;
    const e = height[k] ?? 0;
    const [x, z] = world.project(lat, lng);
    positions.push(x, world.metres(e) + 0.012, z);
    const c = groundColor(pal, scratch, park[k] === 1, e);
    colors.push(c.r, c.g, c.b);
    const id = positions.length / 3 - 1;
    vertexAt[k] = id;
    return id;
  };

  const patches = lodPatches(world, pal, LOD_LEVELS, 1, true);
  /*
   * Where each visible patch sits on the ground, so it can be filed into a
   * chunk further down without projecting anything a second time. Two floats a
   * patch, thrown away on any board that is not chunked.
   */
  const patchCentres = new Float64Array((patches.length / 3) * 2);
  for (let p = 0; p < patches.length; p += 3) {
    const i = patches[p] as number;
    const j = patches[p + 1] as number;
    const s = patches[p + 2] as number;
    // The diagonal runs from (i+s, j) to (i, j+s). `lodPatches` splits its
    // error test along the same one, so what it measured is what is drawn.
    // The four calls are in the order they always were, and that is
    // load-bearing: `vertex` allocates an id the first time it sees a corner,
    // so a reordering here renumbers the whole board.
    const a = vertex(i, j);
    const b = vertex(i + s, j);
    const c = vertex(i, j + s);
    const d = vertex(i + s, j + s);
    indices.push(a, b, c);
    indices.push(c, b, d);
    patchCentres[(p / 3) * 2] = (((positions[a * 3] as number) + (positions[d * 3] as number)) / 2);
    patchCentres[(p / 3) * 2 + 1] =
      (((positions[a * 3 + 2] as number) + (positions[d * 3 + 2] as number)) / 2);
  }

  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();

  /**
   * 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.
   *
   * **A third alternative exists and the chunked branch below uses it**, for
   * the reason stated there: once the visible surface is several objects the
   * caster cannot be their tail, so it becomes its own mesh at `drawRange(0, 0)`
   * — which `WebGLRenderer.renderBufferDirect` does *not* early-return on,
   * unlike `< 0` and `Infinity`, so it costs one draw call and no triangles.
   * That is a draw call more than this arrangement, which is why this one stays
   * for every board that is not chunked.
   */
  const seen = indices.length;
  /*
   * The caster is `lodPatches` again, one level coarser at both ends.
   *
   * `SHADOW_CASTER_STRIDE` is its *floor* rather than its spacing: rugged
   * ground still writes a facet every two lattice cells, which is the
   * resolution the round before this one measured against full resolution and
   * shipped. What changes is the flat ground, which used to pay the same
   * stride and cannot record a shadow edge at any resolution — the Central
   * Valley's depth is a plane, and a plane is two triangles whether it is
   * drawn as two or as eight thousand.
   *
   * It runs with `matchColour` off. A depth pass reads no colour, so bounding
   * one would only stop the caster merging across a park boundary that the
   * shadow map cannot see. The height tolerance still applies, and it is the
   * one that decides whether a shadow lands where it did.
   */
  const casterPatches = lodPatches(world, pal, LOD_CASTER_LEVELS, SHADOW_CASTER_STRIDE, false);
  for (let p = 0; p < casterPatches.length; p += 3) {
    const i = casterPatches[p] as number;
    const j = casterPatches[p + 1] as number;
    const s = casterPatches[p + 2] as number;
    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.
  const positionAttribute = new THREE.Float32BufferAttribute(positions, 3);
  const colorAttribute = new THREE.Float32BufferAttribute(colors, 3);
  const normal = geo.getAttribute("normal") as THREE.BufferAttribute;
  let normalAttribute = normal;
  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;
    normalAttribute = new THREE.BufferAttribute(grown, 3);
  }

  /**
   * Standard rather than Lambert, and the land had the better half of the
   * argument `createWater` already won.
   *
   * The sea below this is a `MeshStandardMaterial` with a swell map on it; the
   * state beside it was `MeshLambertMaterial` with a vertex colour and **no
   * maps of any kind** — no albedo, no normal, no roughness — which is most of
   * why the board read as a painted relief model with a real ocean next to it.
   *
   * Two things change, and only one of them is the texture.
   *
   * **The environment.** `environmentRig.ts` builds a PMREM of the sky the
   * atmosphere decided on — a sun lobe at radiance 1.2, `SKY_RADIANCE_SHARE`
   * 0.12 of the dome, `GROUND_RADIANCE_SHARE` 0.09 bouncing back — and
   * `scene.ts` applies it to every city scene. `WebGLRenderer` then forwards
   * `scene.environment` to `material.isMeshStandardMaterial` **and to nothing
   * else** (`WebGLRenderer.js`, twice, at the `materialProperties.environment`
   * assignment). So until this line the whole sky probe was being delivered to
   * the water, the guardrails, the ports and the vessels, and not one photon of
   * it reached the land. That is not a subtle loss: `environmentRig.ts` records
   * that the day stops in `atmosphere.ts` gave up about a tenth of
   * `hemiIntensity` and a quarter of `ambientIntensity` to make room for the
   * environment, so the ground has been paying the counterweight for a term it
   * could not receive. It receives it now, for no second pass, no new uniform
   * and no new draw call.
   *
   * **The relief.** `GROUND_DETAIL_TILE_METRES` explains the repeat, and
   * `groundMaterial` explains the three shader edits it needs. `lodPatches` collapses flat ground
   * into patches up to eight 300 m cells across, so at a close pose the state is
   * kilometre-wide triangles with one normal each, and a tiling detail normal is
   * the cheapest thing in computer graphics that hides that.
   *
   * **What does not change: the draw call count, which has 22 spare of 460.**
   * This is the same one material object handed to all 17 chunk meshes and to
   * the caster, so it is the same 18 objects, the same one program switch and
   * the same one vertex buffer — `terrainLod.test.ts` asserts every part of
   * that. A texture is not a draw call. A second mesh, a decal layer or a
   * second pass would have been, which is why none of those is here.
   */
  const material = groundMaterial(world, options.surfaceDetail ?? groundDetailWanted(), {
    vertexColors: true,
  });

  const group = new THREE.Group();
  group.name = "terrain";
  //     ^ the group carries the name the mesh used to. `godmode.ts` matches its
  // layer chips by `Object3D.name` against the scene root, so its terrain chip
  // keeps working and now hides the whole subtree.

  /*
   * Chunked only where a camera descends on a board that stays statewide, which
   * is exactly the merged board and is exactly what `detail` districts mark.
   * `cities/unify.ts` is the one place in the repo that writes `detail: true`.
   *
   * Everything else — `?city=sf`, `?city=socal`, the un-merged `california`, the
   * office — takes the branch below and gets the geometry it has always had,
   * inside a group. One extra `Object3D`, no extra draw call, no extra byte.
   */
  const chunked = world.city.districts.some((d) => d.detail === true);

  if (!chunked) {
    geo.setAttribute("position", positionAttribute);
    geo.setAttribute("color", colorAttribute);
    geo.setAttribute("normal", normalAttribute);
    geo.setIndex(indices);
    geo.setDrawRange(0, seen);
    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 = "terrainSurface";
    group.add(mesh);
    return group;
  }

  /**
   * The same triangles, filed into a grid of meshes so three can reject the
   * ones that are off screen — plus one un-chunked caster.
   *
   * **Why this is not level of detail.** Nothing is decimated, nothing moves,
   * no ground is re-derived. The vertex data is the array the branch above
   * would have uploaded; only the *index* is cut up. That is deliberate and it
   * is what makes this a small change rather than a streaming quadtree: a
   * quadtree re-derives ground under objects that were placed against the old
   * ground, and everything on this board — buildings, bridges, airports, ports,
   * markers, road ribbons — was placed once, at scene build, against
   * `groundAt`. Re-deriving is what moves them. Re-indexing cannot.
   *
   * **Why the attributes are shared.** All 18 geometries hold the *same*
   * `position`, `color` and `normal` `BufferAttribute` objects. Zero duplicated
   * vertices, one GPU upload of exactly today's bytes — and, the part that is
   * easy to miss, the normals were averaged over the whole board before the
   * partition existed, so there is no lighting crease down any of the sixteen
   * seam lines. A compact-per-chunk split would have to work to avoid one.
   *
   * The price is a rule: **no chunk may be disposed independently of its
   * siblings.** `scene.ts` disposes by traversing the scene, which sweeps them
   * together and is fine — `WebGLAttributes.remove` is a no-op the second time
   * and `update` re-creates on demand, so the worst a double dispose can do is
   * re-upload. Anything that later rebuilds one chunk lazily must dispose the
   * shared attributes exactly once, not once per geometry. Note also that
   * `renderer.info.memory.geometries` reads 18 per merged board rather than 1,
   * at zero extra bytes of vertex data; that is not a leak.
   *
   * **Why the bounding volume is assigned by hand.** `computeBoundingSphere`
   * walks the *position attribute*, not the index. A chunk sharing California's
   * position pool would therefore compute California's sphere, be culled by
   * nothing, and quietly restore today's cost with seventeen extra draw calls —
   * a regression that is invisible in a picture. The box comes off the chunk's
   * own vertices as it is filed. Never call `computeBoundingSphere()` on one.
   *
   * **Why the caster is one object with `frustumCulled = false`.**
   * `WebGLShadowMap.renderObject` gates the depth pass on
   * `!object.frustumCulled || _frustum.intersectsObject(object)` — the same
   * machinery as the colour pass, against the *shadow* camera. Chunk the caster
   * and a ridge that is off screen but legitimately shadows into frame stops
   * being drawn, so the shadow it casts disappears the moment the camera turns.
   * Selected independently at the shadow map's own texel error a quadtree
   * caster measured 826,206 triangles against today's 53,806. So it stays one
   * object, submitted at every pose, and 22,414 triangles of depth is the price
   * of shadows that do not depend on where you are looking.
   *
   * **How it costs nothing in the colour pass.** Its `drawRange` is `(0, 0)`
   * and the hooks swing the *whole* index in for the shadow pass and back out
   * after — the inverse of the single-mesh trick above, where the caster is the
   * tail. `WebGLRenderer.renderBufferDirect` early-returns on a draw count of
   * `< 0` or `Infinity` and **not** on 0, so the caster costs exactly one
   * zero-triangle draw call per frame at every pose. That is the third
   * alternative, after the two the note above already records as tried and
   * failed: a shadow-only rendering layer casts nothing at all, and a second
   * mesh with `colorWrite` off is counted twice by `renderer.info`.
   */
  // `geo` was the thing `computeVertexNormals` ran on and it is dropped here.
  // Nothing has ever uploaded it, so there is no GPU resource to dispose; what
  // survives it is `normalAttribute`, which every chunk below shares.
  const chunks = chunkTerrain(world, patchCentres, indices, seen, positions);
  for (const chunk of chunks) {
    const g = new THREE.BufferGeometry();
    g.setAttribute("position", positionAttribute);
    g.setAttribute("color", colorAttribute);
    g.setAttribute("normal", normalAttribute);
    // The index *is* the range; `drawRange` stays (0, Infinity).
    g.setIndex(new THREE.BufferAttribute(chunk.index, 1));
    g.boundingBox = chunk.box.clone();
    g.boundingSphere = chunk.box.getBoundingSphere(new THREE.Sphere());
    const mesh = new THREE.Mesh(g, material);
    mesh.castShadow = false;
    mesh.receiveShadow = true;
    mesh.frustumCulled = true; // the default, stated because it is the point
    mesh.name = "terrainChunk";
    group.add(mesh);
  }

  const casterGeo = new THREE.BufferGeometry();
  casterGeo.setAttribute("position", positionAttribute);
  casterGeo.setAttribute("color", colorAttribute);
  casterGeo.setAttribute("normal", normalAttribute);
  casterGeo.setIndex(new THREE.BufferAttribute(Uint32Array.from(indices.slice(seen)), 1));
  casterGeo.setDrawRange(0, 0);
  const board = new THREE.Box3();
  for (const chunk of chunks) board.union(chunk.box);
  casterGeo.boundingBox = board.clone();
  casterGeo.boundingSphere = board.getBoundingSphere(new THREE.Sphere());
  const caster = new THREE.Mesh(casterGeo, material);
  caster.castShadow = true;
  caster.receiveShadow = false;
  caster.frustumCulled = false;
  caster.onBeforeShadow = () => casterGeo.setDrawRange(0, cast);
  caster.onAfterShadow = () => casterGeo.setDrawRange(0, 0);
  caster.name = "terrainCaster";
  group.add(caster);

  return group;
}

/**
 * File the visible patches into a grid of index buffers over `city.bounds`.
 *
 * By the centre of each patch's own quad rather than by triangle, so a patch is
 * never split and the covered ground is preserved exactly — `terrainLod.test.ts`
 * asserts that as an area, to one part in a million, which is the assertion a
 * dropped or double-filed patch fails first.
 *
 * Cells that hold no land never become objects. On California eight of the
 * twenty-five are Pacific, Nevada or Arizona, which is why a 5 x 5 grid is 17
 * draw calls and not 25.
 */
function chunkTerrain(
  world: World,
  centres: Float64Array,
  indices: number[],
  seen: number,
  positions: number[],
): { index: Uint32Array; box: THREE.Box3 }[] {
  const { minLat, maxLat, minLng, maxLng } = world.city.bounds;
  const [x0, z0] = world.project(minLat, minLng);
  const [x1, z1] = world.project(maxLat, maxLng);
  const minX = Math.min(x0, x1);
  const maxX = Math.max(x0, x1);
  const minZ = Math.min(z0, z1);
  const maxZ = Math.max(z0, z1);
  const axis = (span: number): number =>
    Math.min(
      MAX_TERRAIN_CHUNK_AXIS,
      Math.max(1, Math.round((span * world.metresPerUnit) / TERRAIN_CHUNK_METRES)),
    );

  let cols = axis(maxX - minX);
  let rows = axis(maxZ - minZ);
  for (;;) {
    const buckets = new Map<number, number[]>();
    const cellW = (maxX - minX) / cols;
    const cellD = (maxZ - minZ) / rows;
    for (let at = 0; at < seen; at += 6) {
      const cx = centres[(at / 6) * 2] as number;
      const cz = centres[(at / 6) * 2 + 1] as number;
      // Clamped: a pack whose land runs a hair outside its own declared bounds
      // files into the edge cell rather than into a cell that does not exist.
      const col = Math.min(cols - 1, Math.max(0, Math.floor((cx - minX) / cellW)));
      const row = Math.min(rows - 1, Math.max(0, Math.floor((cz - minZ) / cellD)));
      const key = row * cols + col;
      let bucket = buckets.get(key);
      if (bucket === undefined) {
        bucket = [];
        buckets.set(key, bucket);
      }
      for (let k = 0; k < 6; k++) bucket.push(indices[at + k] as number);
    }
    if (buckets.size > MAX_TERRAIN_CHUNKS && cols + rows > 2) {
      if (cols >= rows) cols--;
      else rows--;
      continue;
    }
    const out: { index: Uint32Array; box: THREE.Box3 }[] = [];
    for (const bucket of buckets.values()) {
      const box = new THREE.Box3();
      const point = new THREE.Vector3();
      for (const v of bucket) {
        point.set(
          positions[v * 3] as number,
          positions[v * 3 + 1] as number,
          positions[v * 3 + 2] as number,
        );
        box.expandByPoint(point);
      }
      out.push({ index: Uint32Array.from(bucket), box });
    }
    return out;
  }
}

/**
 * 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.
 *
 * Since `lodPatches` arrived this is a **floor** rather than a spacing: rugged
 * ground still writes a facet every two cells, and flat ground merges above it.
 * The numbers above are what a uniform stride cost, and they are why the floor
 * is 2 rather than 1; what the merge changed is that California's caster came
 * down from 20,716 triangles to 17,632 and the Bay Area's from 143,704 to
 * 53,806 without touching the resolution anywhere a shadow edge can exist.
 */
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);
  const group = new THREE.Group();
  group.name = "water";

  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
   * ocean and a blue card.
   *
   * `MeshLambertMaterial` has no specular term at all — none, by construction —
   * so the Pacific, which is between a third and a half of the California
   * board's frame, rendered as one flat value at every hour and from every
   * angle. A low roughness gives it the sun's glint back, and now that the
   * scene carries an environment map (`engine/environmentRig.ts`) it also gives
   * it the sky: `MeshStandardMaterial` reads `scene.environment`, so the water
   * reflects whatever the atmosphere decided the sky is, for free and with no
   * second pass.
   *
   * `metalness: 0` is stated rather than defaulted because water is a
   * 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 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 <common>",
        `#include <common>
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 <roughnessmap_fragment>",
        `#include <roughnessmap_fragment>
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 <normal_fragment_maps>",
        `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);

  /*
   * **Every lake on the board is one mesh, and it used to be one mesh each.**
   *
   * A `Mesh` per polygon with a `MeshStandardMaterial` per polygon is a draw
   * call per polygon, and the packs that have a lot of inland water are
   * precisely the ones that can least afford them. It went unnoticed while the
   * boards were separate — California draws one lake, the Salton Sea — and
   * showed up the moment `cities/unify.ts` folded three packs into one: a
   * scene census on a phone read **water=20 against water=1**, nineteen draw
   * calls of the fifty-eight that put the merged board over the mobile cap.
   *
   * They differ in nothing but shape: same colour, same roughness, same
   * polygon offset, same y. So this merges the geometries and shares one
   * material, which is 1 draw instead of N on every board in the product —
   * San Francisco and the Southland included, where it was never free either.
   *
   * The 0.05 lift is baked into each geometry before the merge rather than set
   * on a mesh transform, because after the merge there is no per-lake mesh left
   * to carry it.
   */
  const lakeGeometries: THREE.BufferGeometry[] = [];
  for (const poly of world.city.inlandWater) {
    const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
    const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
    geo.rotateX(Math.PI / 2);
    geo.translate(0, 0.05, 0);
    lakeGeometries.push(geo);
  }
  if (lakeGeometries.length > 0) {
    const merged = mergeGeometries(lakeGeometries, false);
    for (const geo of lakeGeometries) geo.dispose();
    if (merged !== null) {
      const lake = new THREE.Mesh(
        merged,
        new THREE.MeshStandardMaterial({
          color: pal.lake,
          // Slightly rougher than the sea: an inland lake is sheltered, and a
          // mirror-smooth bay next to a wind-roughened ocean reads as the wrong
          // way round.
          roughness: 0.24,
          metalness: 0,
          side: THREE.DoubleSide,
          /**
           * The five-hundredths above 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.name = "inlandWater";
      group.add(lake);
    }
  }

  return group;
}

