/**
 * Every surface texture in the library, drawn at runtime on a 2D canvas.
 *
 * Nothing here is fetched, imported or base64'd, and that is a licensing
 * decision before it is a graphics one (CONTRACT.md §3): a repo that ships no
 * binary art has nothing in it whose provenance anyone has to take on trust.
 *
 * Three properties are load-bearing and easy to lose:
 *
 *   1. **Textures are neutral, not coloured.** Each one is drawn near-white and
 *      modulates *downward*, so `material.color` supplies the hue and the map
 *      supplies only the grain. One `carpetLoop` texture therefore serves every
 *      palette. Baking the colour in would have made the cache key
 *      `kind × colour` and given a self-hoster who recolours their world eight
 *      new uploads to the GPU for no visible gain. **`screenUI` is the one
 *      declared exception** — a display is content rather than a finish, and the
 *      reasoning is written out at the drawing itself.
 *   2. **They tile.** Value noise is not periodic, so a naively-drawn 2 m tile
 *      shows a hard seam every 2 m across a floor. `tileableNoise` blends the
 *      four wrapped samples so the edges match; see the comment there.
 *   3. **They are cheap to build.** The noise is evaluated on a coarse grid and
 *      bilinearly upsampled rather than per-pixel — four `fbm` calls per pixel
 *      at 512² is about sixteen million `Math.sin` calls and roughly a second
 *      of blocked main thread, which is not a price a floor is worth.
 *
 * There is a **second channel**. Six of these kinds describe a surface with real
 * relief — a carpet has pile, a plank floor has seams, a tiled floor has grout —
 * and until now every one of those was drawn as a *darker line* and nothing
 * else. A darker line does not move when the sun does, so a tiled floor at ten
 * in the morning looked exactly like a tiled floor at six in the evening, which
 * is the flat, printed-on look the whole office had. `NORMAL_RECIPES` below
 * restates each of those six as a height field in **metres**, and `TextureBin`
 * Sobel-differentiates it into a tangent-space normal map. See the comment on
 * `HeightRecipe` for why the height field is authored rather than read back off
 * the drawn canvas.
 *
 * The noise itself is the engine's — `fbm` and `seededRandom` are imported from
 * `engine/world.ts` rather than reimplemented, so the city and the office are
 * grained by the same function.
 */

import * as THREE from "three";
import { fbm, seededRandom } from "../engine/world.ts";

/**
 * How many metres one repeat of a texture covers.
 *
 * Geometry that carries a textured surface must therefore generate UVs in
 * metres divided by this — `PartBin.metricQuad` and `PartBin.wallRun` do, which
 * is why a 12 m wall and a 3 m wall show the same size of grain. Unit
 * primitives with 0..1 UVs will stretch, and are only used for roles that carry
 * no map.
 */
export const TEXTURE_TILE_METRES = 2;

export type TextureKind =
  | "carpetLoop"
  | "woodPlank"
  | "polishedConcrete"
  | "ceilingTile"
  | "plasterPaint"
  | "fabricWeave"
  | "tileGrid"
  | "whiteboard"
  | "screenUI"
  | "leafAlpha";

export type TextureQuality = "low" | "medium" | "high";

/**
 * How many parameterised layouts `screenUI` can draw.
 *
 * Published as a number rather than left implicit because `furnish.ts` batches
 * props **per kind** and draws `ctx.rand` once for the whole batch, so a screen
 * asset cannot pick a layout per instance — it has to be handed one, from a
 * separate colour-key or parameter batch. Whoever authors those batches needs to
 * know how many there are to choose between, and `variant` is taken modulo this
 * so an out-of-range index wraps rather than throwing.
 */
export const SCREEN_UI_VARIANTS = 6;

/** `low` draws nothing at all: the materials fall back to flat colour. */
const RESOLUTION: Record<TextureQuality, number> = { low: 0, medium: 256, high: 512 };

// ---- Noise ----------------------------------------------------------------

/**
 * How coarse the noise lattice is.
 *
 * Shared by the colour drawings and by the height fields the normal maps are
 * differentiated from, and that sharing is the point rather than a tidy-up: a
 * height field evaluated on a *different* lattice would put the bumps somewhere
 * other than the grain you can see, and the surface would light as though it
 * were embossed with a second, invisible pattern.
 */
const NOISE_RES = 64;

/**
 * A tileable noise field, sampled on a coarse grid.
 *
 * `fbm` is not periodic, so the four-way blend below is what makes the left
 * edge equal the right edge: each point is mixed with its wrapped neighbours
 * weighted by how close it is to them, which is exactly zero contribution in
 * the middle of the tile and a perfect match at the seam.
 *
 * Exported because the ground uses it too. `engine/terrain.ts` builds one
 * tiling detail normal map for the land out of these three functions — the
 * blend, the bilinear read and the Sobel below — rather than reimplementing
 * them at a 440 m repeat, for the same reason this file takes `fbm` from
 * `engine/world.ts` rather than writing its own: a second noise is a second
 * thing to keep tiling, and a second Sobel is a second place for the
 * flipY/perUv reasoning to be got wrong. Nothing here touches the DOM, so the
 * land's map is as testable under `node --test` as the office's are.
 */
export function tileableNoise(res: number, scale: number, offset: number): Float32Array {
  const field = new Float32Array(res * res);
  for (let y = 0; y < res; y++) {
    const v = y / res;
    for (let x = 0; x < res; x++) {
      const u = x / res;
      const a = fbm(offset + u * scale, offset + v * scale);
      const b = fbm(offset + (u - 1) * scale, offset + v * scale);
      const c = fbm(offset + u * scale, offset + (v - 1) * scale);
      const d = fbm(offset + (u - 1) * scale, offset + (v - 1) * scale);
      const top = a * (1 - u) + b * u;
      const bottom = c * (1 - u) + d * u;
      field[y * res + x] = top * (1 - v) + bottom * v;
    }
  }
  return field;
}

/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
export function sampleField(field: Float32Array, res: number, u: number, v: number): number {
  const fx = u * res;
  const fy = v * res;
  const x0 = Math.floor(fx);
  const y0 = Math.floor(fy);
  const tx = fx - x0;
  const ty = fy - y0;
  const xa = ((x0 % res) + res) % res;
  const ya = ((y0 % res) + res) % res;
  const xb = (xa + 1) % res;
  const yb = (ya + 1) % res;
  const a = field[ya * res + xa] ?? 0;
  const b = field[ya * res + xb] ?? 0;
  const c = field[yb * res + xa] ?? 0;
  const d = field[yb * res + xb] ?? 0;
  return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
}

/**
 * Multiply the canvas down by a noise field.
 *
 * `amount` is the depth of the darkest dip, as a fraction. Everything stays at
 * or below the colour already on the canvas, which is what keeps the map a
 * tint-preserving multiplier rather than something that lightens a dark
 * palette back toward white.
 */
function grain(
  ctx: CanvasRenderingContext2D,
  width: number,
  height: number,
  scale: number,
  amount: number,
  offset: number,
): void {
  const res = NOISE_RES;
  const field = tileableNoise(res, scale, offset);
  const image = ctx.getImageData(0, 0, width, height);
  const data = image.data;
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const n = sampleField(field, res, x / width, y / height);
      // fbm's four octaves land in roughly 0..0.94 with a mean near 0.47.
      const k = 1 - amount * Math.min(1, Math.max(0, n / 0.94));
      const i = (y * width + x) * 4;
      data[i] = (data[i] ?? 0) * k;
      data[i + 1] = (data[i + 1] ?? 0) * k;
      data[i + 2] = (data[i + 2] ?? 0) * k;
    }
  }
  ctx.putImageData(image, 0, 0);
}

// ---- The drawings ---------------------------------------------------------

/**
 * What a drawing is told about the canvas it is drawing on.
 *
 * `width` and `height` are separate because two of these kinds are not square:
 * a display is 16:9 and drawing a dashboard on a square then stretching it puts
 * every rule and every tile in the wrong proportion. Everything that *tiles* is
 * square and reads `width` for both axes — a repeat is defined in metres by
 * `TEXTURE_TILE_METRES` and a non-square repeat would need a per-axis one.
 */
interface DrawContext {
  readonly width: number;
  readonly height: number;
  /** Which parameterised layout to draw. Already reduced into range. */
  readonly variant: number;
}

type Draw = (ctx: CanvasRenderingContext2D, d: DrawContext) => void;

const DRAW: Record<TextureKind, Draw> = {
  /** Loop pile: dense fine speckle, plus the faint rows a loop carpet lays in. */
  carpetLoop(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 26, 0.16, 3.1);
    grain(ctx, size, size, 90, 0.1, 11.7);
    const rand = seededRandom(0x9e11);
    ctx.strokeStyle = "rgba(0,0,0,0.035)";
    ctx.lineWidth = 1;
    for (let y = 0; y < size; y += 4) {
      ctx.beginPath();
      ctx.moveTo(0, y + rand() * 1.5);
      ctx.lineTo(size, y + rand() * 1.5);
      ctx.stroke();
    }
  },

  /** Boards along +U, with grain stretched hard along the board. */
  woodPlank(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 4, 0.1, 21.4);
    const rand = seededRandom(0x7a03);
    const boards = 5;
    const pitch = size / boards;
    // Grain lines run along the board, which is the whole reason wood reads as
    // wood; a rotationally symmetric noise reads as stone.
    ctx.lineWidth = 1;
    for (let b = 0; b < boards; b++) {
      const y0 = b * pitch;
      for (let i = 0; i < 26; i++) {
        const y = y0 + rand() * pitch;
        ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.05})`;
        ctx.beginPath();
        ctx.moveTo(0, y);
        for (let x = 0; x <= size; x += size / 8) {
          ctx.lineTo(x, y + Math.sin(x / 37 + b * 2.3) * 1.6);
        }
        ctx.stroke();
      }
      ctx.strokeStyle = "rgba(0,0,0,0.12)";
      ctx.beginPath();
      ctx.moveTo(0, y0);
      ctx.lineTo(size, y0);
      ctx.stroke();
    }
  },

  /** Power-floated slab: broad mottle and a scatter of exposed aggregate. */
  polishedConcrete(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 6, 0.09, 5.9);
    grain(ctx, size, size, 40, 0.05, 31.2);
    const rand = seededRandom(0x51c0);
    for (let i = 0; i < size * 1.5; i++) {
      const r = 0.5 + rand() * 1.4;
      ctx.fillStyle = `rgba(0,0,0,${0.03 + rand() * 0.06})`;
      ctx.beginPath();
      ctx.arc(rand() * size, rand() * size, r, 0, Math.PI * 2);
      ctx.fill();
    }
  },

  /** Mineral fibre tile: a 600 mm grid — one tile per 600 mm at a 2 m repeat. */
  ceilingTile(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 70, 0.07, 13.3);
    const rand = seededRandom(0x0ce1);
    for (let i = 0; i < size * 3; i++) {
      ctx.fillStyle = `rgba(0,0,0,${0.05 + rand() * 0.08})`;
      ctx.fillRect(rand() * size, rand() * size, 1, 1);
    }
    // 2 m of repeat covers a little over three 600 mm tiles; three is the
    // number that tiles cleanly, and nobody counts ceiling tiles.
    const cells = 3;
    const pitch = size / cells;
    ctx.strokeStyle = "rgba(0,0,0,0.16)";
    ctx.lineWidth = Math.max(1, size / 256);
    for (let i = 0; i < cells; i++) {
      ctx.beginPath();
      ctx.moveTo(i * pitch, 0);
      ctx.lineTo(i * pitch, size);
      ctx.moveTo(0, i * pitch);
      ctx.lineTo(size, i * pitch);
      ctx.stroke();
    }
  },

  /** Emulsion over plasterboard: almost nothing, which is the point. */
  plasterPaint(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 9, 0.045, 8.8);
    grain(ctx, size, size, 120, 0.03, 27.6);
  },

  /** Upholstery weave: two crossed sets of threads, low contrast. */
  fabricWeave(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 34, 0.1, 17.2);
    ctx.lineWidth = 1;
    const pitch = Math.max(2, Math.round(size / 128));
    ctx.strokeStyle = "rgba(0,0,0,0.05)";
    for (let x = 0; x < size; x += pitch) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, size);
      ctx.stroke();
    }
    ctx.strokeStyle = "rgba(0,0,0,0.07)";
    for (let y = 0; y < size; y += pitch) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(size, y);
      ctx.stroke();
    }
  },

  /** Square tile with a grout line — kitchens, WCs, entrance mats. */
  tileGrid(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 14, 0.05, 4.4);
    const cells = 4;
    const pitch = size / cells;
    ctx.strokeStyle = "rgba(0,0,0,0.2)";
    ctx.lineWidth = Math.max(2, size / 128);
    for (let i = 0; i < cells; i++) {
      ctx.beginPath();
      ctx.moveTo(i * pitch, 0);
      ctx.lineTo(i * pitch, size);
      ctx.moveTo(0, i * pitch);
      ctx.lineTo(size, i * pitch);
      ctx.stroke();
    }
  },

  /** A wiped-down board: faint ghosting, no writing. */
  whiteboard(ctx, d) {
    const size = d.width;
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, size, size);
    grain(ctx, size, size, 5, 0.03, 9.1);
    const rand = seededRandom(0x0b0a);
    ctx.lineCap = "round";
    for (let i = 0; i < 14; i++) {
      ctx.strokeStyle = `rgba(0,0,0,${0.012 + rand() * 0.018})`;
      ctx.lineWidth = 4 + rand() * 10;
      const y = rand() * size;
      ctx.beginPath();
      ctx.moveTo(rand() * size * 0.4, y);
      ctx.lineTo(size * 0.5 + rand() * size * 0.5, y + (rand() - 0.5) * 20);
      ctx.stroke();
    }
  },

  /**
   * What is on a screen. The one drawing in this file that is not neutral.
   *
   * Property 1 at the top of the module — textures are near-white and modulate
   * downward so `material.color` carries the hue — is deliberately broken here,
   * and it is worth being explicit about why rather than letting the next reader
   * discover it as an inconsistency. A carpet is a *finish*: it has one colour
   * and a grain, and the grain is the only thing a texture needs to supply. A
   * display is not a finish, it is content: it is dark where nothing is lit and
   * bright in four different hues where something is, and there is no colour
   * `material.color` could carry that would produce that from a greyscale map.
   *
   * So this one draws its own colour, and `screenContent`'s palette entry is
   * pushed to near-white (see `ROLE_SHIFTS`) specifically to get out of its way.
   * It is also bound as `emissiveMap` as well as `map`, which is what makes a
   * screen the brightest thing in a room without the whole panel glowing: the
   * lit pixels emit and the dark chrome does not. That only reads correctly
   * because `stage.ts` runs a tone curve with a shoulder — under the old
   * clipping renderer every screen was one flat white rectangle, which is
   * exactly what the live site shows today.
   *
   * Nothing here is legible and nothing here is meant to be. There are no
   * glyphs, no words and no logos: every "line of text" is a rounded rectangle,
   * every chart is abstract, and the whole thing is read from three metres away
   * as *a screen with work on it*. That is also the only version of this that
   * ARCHITECTURE.md §3.1 permits — a screen drawing a recognisable interface is
   * a screen drawing somebody's trademark.
   */
  screenUI(ctx, d) {
    const layout = SCREEN_LAYOUTS[d.variant % SCREEN_LAYOUTS.length];
    // Unreachable — `variant` arrives already reduced — but the index signature
    // is what TypeScript sees, and a placeholder beats a non-null assertion.
    if (!layout) return;
    layout(ctx, d, seededRandom(0x5c00 + d.variant * 0x9d));
  },

  /**
   * One leaf, in coverage rather than colour: white where the leaf is, black
   * where it is not.
   *
   * This exists because of the single worst-looking asset in the product.
   * `greenery.ts` builds every leaf as a bare `P.panel()` — an untextured
   * rectangle — so the plants in the LA studio render as giant flat green shards
   * filling the bottom corners of the frame. No amount of lighting work fixes a
   * rectangle. An `alphaMap` with `alphaTest` cuts the silhouette out of it for
   * one texture and no extra draw calls, and the cutout is what turns a shard
   * into a leaf.
   *
   * Drawn hard-edged and without grain, on purpose. `alphaTest` compares against
   * a threshold and throws the fragment away, so a soft edge does not buy a soft
   * leaf — it buys a leaf whose outline moves as the mip level changes. The
   * shape carries a midrib, a base that narrows to a stem and a shallow
   * serration on each flank, because those three things are what the eye uses to
   * decide something is foliage at a glance.
   *
   * Note the resolution floor in `KIND_SPECS`: this is the one kind that draws
   * even at `low` quality. `low` means "no maps", and that is a statement about
   * *shading* cost — an alpha cutout is one texture fetch and a discard, and the
   * alternative at low quality is not a cheaper plant, it is the shard.
   */
  leafAlpha(ctx, d) {
    const w = d.width;
    const h = d.height;
    ctx.fillStyle = "#000000";
    ctx.fillRect(0, 0, w, h);

    // Tip at the top, stem at the bottom, so a quad's +V runs along the leaf.
    const cx = w * 0.5;
    const tipY = h * 0.045;
    const baseY = h * 0.86;
    const half = w * 0.29;

    ctx.fillStyle = "#ffffff";
    ctx.beginPath();
    ctx.moveTo(cx, tipY);
    // Right flank: wide at a third of the way down, tucked back in at the base.
    ctx.bezierCurveTo(cx + half * 0.85, h * 0.2, cx + half, h * 0.52, cx, baseY);
    ctx.bezierCurveTo(cx - half, h * 0.52, cx - half * 0.85, h * 0.2, cx, tipY);
    ctx.fill();

    // Serrations: shallow scallops bitten out of each flank. Cut rather than
    // added, so they cannot push the silhouette outside the quad's own UVs.
    ctx.globalCompositeOperation = "destination-out";
    ctx.fillStyle = "#ffffff";
    const teeth = 5;
    for (let i = 0; i < teeth; i++) {
      const t = 0.18 + (i / (teeth - 1)) * 0.6;
      const y = tipY + (baseY - tipY) * t;
      const reach = half * (0.9 - Math.abs(t - 0.45)) * 1.05;
      const r = w * 0.045;
      ctx.beginPath();
      ctx.arc(cx + reach, y, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.beginPath();
      ctx.arc(cx - reach, y + (baseY - tipY) * 0.06, r, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalCompositeOperation = "source-over";

    // The stem. Narrow enough that `alphaTest` keeps it at the mip levels a
    // plant three metres away is actually sampled at.
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(cx - w * 0.022, baseY - h * 0.02, w * 0.044, h * 0.13);
  },
};

// ---- Screen layouts -------------------------------------------------------

/**
 * The chrome every screen layout shares, and the body rectangle left over.
 *
 * A window bar at the top costs four rectangles and is the single cheapest thing
 * that makes a lit panel read as a computer rather than as a light box.
 */
function screenChrome(
  ctx: CanvasRenderingContext2D,
  d: DrawContext,
  rand: () => number,
  accent: string,
): { x: number; y: number; w: number; h: number } {
  const w = d.width;
  const h = d.height;
  ctx.fillStyle = SCREEN_BACKDROP;
  ctx.fillRect(0, 0, w, h);

  const bar = h * 0.09;
  ctx.fillStyle = SCREEN_CHROME;
  ctx.fillRect(0, 0, w, bar);
  ctx.fillStyle = accent;
  ctx.fillRect(0, bar - Math.max(1, h * 0.006), w * (0.25 + rand() * 0.3), h * 0.006);

  // Three window dots, then a run of tab-shaped blocks.
  for (let i = 0; i < 3; i++) {
    ctx.fillStyle = SCREEN_DIM;
    ctx.beginPath();
    ctx.arc(w * 0.03 + i * w * 0.028, bar * 0.5, h * 0.014, 0, Math.PI * 2);
    ctx.fill();
  }
  let tab = w * 0.16;
  for (let i = 0; i < 4 && tab < w * 0.9; i++) {
    const tw = w * (0.07 + rand() * 0.09);
    ctx.fillStyle = i === 0 ? SCREEN_PANEL : SCREEN_CHROME_HI;
    roundRect(ctx, tab, bar * 0.22, tw, bar * 0.56, bar * 0.16);
    ctx.fill();
    tab += tw + w * 0.012;
  }

  const pad = w * 0.03;
  return { x: pad, y: bar + pad, w: w - pad * 2, h: h - bar - pad * 2 };
}

/** A run of "text": rounded bars of varying length, never a glyph. */
function textLines(
  ctx: CanvasRenderingContext2D,
  rand: () => number,
  x: number,
  y: number,
  w: number,
  lines: number,
  lineH: number,
  gap: number,
  color: string,
): void {
  ctx.fillStyle = color;
  for (let i = 0; i < lines; i++) {
    const len = w * (0.45 + rand() * 0.55);
    roundRect(ctx, x, y + i * (lineH + gap), len, lineH, lineH * 0.4);
    ctx.fill();
  }
}

function roundRect(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number,
): void {
  const k = Math.min(r, w * 0.5, h * 0.5);
  ctx.beginPath();
  ctx.moveTo(x + k, y);
  ctx.arcTo(x + w, y, x + w, y + h, k);
  ctx.arcTo(x + w, y + h, x, y + h, k);
  ctx.arcTo(x, y + h, x, y, k);
  ctx.arcTo(x, y, x + w, y, k);
  ctx.closePath();
}

const SCREEN_BACKDROP = "#0d121a";
const SCREEN_CHROME = "#161d27";
const SCREEN_CHROME_HI = "#1e2733";
const SCREEN_PANEL = "#141c26";
const SCREEN_DIM = "#2d3947";
const SCREEN_INK = "#61748a";
const SCREEN_INK_HI = "#93a7bd";
const SCREEN_ACCENTS = ["#4c9cff", "#38cf94", "#ffb44f", "#ff6f8d", "#a98bff"] as const;

type ScreenLayout = (ctx: CanvasRenderingContext2D, d: DrawContext, rand: () => number) => void;

/**
 * Six layouts, because six is what it takes to fill a room.
 *
 * `furnish.ts` batches per kind, so the only way a studio gets two different
 * screens is for the pack to author two different parameter batches — and a
 * variety of *kinds of work* is what makes a floor of desks read as occupied.
 * Four would satisfy the requirement; six covers what is actually on the
 * monitors in a studio: dashboards, documents, code, data, a terminal, and
 * footage.
 */
const SCREEN_LAYOUTS: readonly ScreenLayout[] = [
  // 0 — dashboard: tiles, bars, a donut.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[0] ?? "#4c9cff";
    const b = screenChrome(ctx, d, rand, accent);
    const tileH = b.h * 0.26;
    const tileW = (b.w - b.w * 0.04) / 3;
    for (let i = 0; i < 3; i++) {
      const x = b.x + i * (tileW + b.w * 0.02);
      ctx.fillStyle = SCREEN_PANEL;
      roundRect(ctx, x, b.y, tileW, tileH, tileH * 0.12);
      ctx.fill();
      ctx.fillStyle = SCREEN_INK;
      roundRect(ctx, x + tileW * 0.08, b.y + tileH * 0.16, tileW * 0.42, tileH * 0.12, tileH * 0.06);
      ctx.fill();
      ctx.fillStyle = SCREEN_ACCENTS[i % SCREEN_ACCENTS.length] ?? accent;
      roundRect(ctx, x + tileW * 0.08, b.y + tileH * 0.42, tileW * 0.55, tileH * 0.26, tileH * 0.08);
      ctx.fill();
    }

    // A column chart, from the same seeded stream so the same variant is always
    // the same picture.
    const chartY = b.y + tileH + b.h * 0.05;
    const chartH = b.h * 0.42;
    ctx.fillStyle = SCREEN_PANEL;
    roundRect(ctx, b.x, chartY, b.w * 0.62, chartH, chartH * 0.06);
    ctx.fill();
    const bars = 11;
    const bw = (b.w * 0.62 - b.w * 0.08) / bars;
    for (let i = 0; i < bars; i++) {
      const v = 0.25 + rand() * 0.72;
      ctx.fillStyle = i === bars - 1 ? accent : SCREEN_DIM;
      const hgt = chartH * 0.72 * v;
      roundRect(
        ctx,
        b.x + b.w * 0.04 + i * bw,
        chartY + chartH * 0.86 - hgt,
        bw * 0.62,
        hgt,
        bw * 0.2,
      );
      ctx.fill();
    }

    // A donut, drawn as two arcs so there is no radial gradient to sample.
    const cx = b.x + b.w * 0.82;
    const cy = chartY + chartH * 0.5;
    const r = Math.min(b.w * 0.14, chartH * 0.42);
    ctx.lineWidth = r * 0.34;
    ctx.strokeStyle = SCREEN_DIM;
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.stroke();
    ctx.strokeStyle = SCREEN_ACCENTS[1] ?? accent;
    ctx.beginPath();
    ctx.arc(cx, cy, r, -Math.PI * 0.5, -Math.PI * 0.5 + Math.PI * (0.7 + rand() * 0.9));
    ctx.stroke();

    textLines(ctx, rand, b.x, b.y + b.h * 0.88, b.w * 0.5, 2, b.h * 0.035, b.h * 0.02, SCREEN_DIM);
  },

  // 1 — a document. A light page, which is what makes it the brightest screen
  // in the room and the one that lights a desk.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[4] ?? "#a98bff";
    const b = screenChrome(ctx, d, rand, accent);
    ctx.fillStyle = "#e9edf2";
    roundRect(ctx, b.x + b.w * 0.12, b.y, b.w * 0.76, b.h, b.w * 0.01);
    ctx.fill();
    const px = b.x + b.w * 0.18;
    const pw = b.w * 0.64;
    ctx.fillStyle = "#2b3440";
    roundRect(ctx, px, b.y + b.h * 0.08, pw * 0.55, b.h * 0.07, b.h * 0.02);
    ctx.fill();
    ctx.fillStyle = accent;
    roundRect(ctx, px, b.y + b.h * 0.2, pw * 0.22, b.h * 0.022, b.h * 0.011);
    ctx.fill();
    textLines(ctx, rand, px, b.y + b.h * 0.28, pw, 5, b.h * 0.032, b.h * 0.028, "#9aa5b3");
    textLines(ctx, rand, px, b.y + b.h * 0.62, pw, 4, b.h * 0.032, b.h * 0.028, "#9aa5b3");
  },

  // 2 — code. Indentation is the whole read: a wall of equal-length lines is a
  // document, and a staircase is source.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[1] ?? "#38cf94";
    const b = screenChrome(ctx, d, rand, accent);
    const rows = 17;
    const rowH = b.h / rows;
    const gutter = b.w * 0.05;
    ctx.fillStyle = SCREEN_PANEL;
    ctx.fillRect(b.x, b.y, gutter, b.h);
    let indent = 0;
    for (let i = 0; i < rows; i++) {
      const y = b.y + i * rowH + rowH * 0.22;
      ctx.fillStyle = SCREEN_DIM;
      roundRect(ctx, b.x + gutter * 0.3, y, gutter * 0.4, rowH * 0.4, rowH * 0.2);
      ctx.fill();

      const r = rand();
      if (r < 0.16 && indent > 0) indent--;
      else if (r > 0.78 && indent < 3) indent++;
      let x = b.x + gutter + b.w * 0.03 + indent * b.w * 0.045;
      // Two to four tokens a line, coloured like syntax rather than like text.
      const tokens = 2 + Math.floor(rand() * 3);
      for (let t = 0; t < tokens && x < b.x + b.w * 0.9; t++) {
        const tw = b.w * (0.05 + rand() * 0.16);
        ctx.fillStyle =
          t === 0
            ? (SCREEN_ACCENTS[4] ?? accent)
            : rand() > 0.66
              ? accent
              : rand() > 0.5
                ? SCREEN_INK_HI
                : SCREEN_INK;
        roundRect(ctx, x, y, tw, rowH * 0.4, rowH * 0.2);
        ctx.fill();
        x += tw + b.w * 0.014;
      }
    }
  },

  // 3 — a table. Alternating row tint plus two highlighted cells, which is what
  // a spreadsheet looks like from across a room.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[2] ?? "#ffb44f";
    const b = screenChrome(ctx, d, rand, accent);
    const cols = 6;
    const rows = 12;
    const cw = b.w / cols;
    const rh = b.h / rows;
    ctx.fillStyle = SCREEN_CHROME_HI;
    ctx.fillRect(b.x, b.y, b.w, rh);
    for (let c = 0; c < cols; c++) {
      ctx.fillStyle = SCREEN_INK_HI;
      roundRect(ctx, b.x + c * cw + cw * 0.12, b.y + rh * 0.32, cw * 0.5, rh * 0.34, rh * 0.17);
      ctx.fill();
    }
    for (let r = 1; r < rows; r++) {
      if (r % 2 === 0) {
        ctx.fillStyle = SCREEN_PANEL;
        ctx.fillRect(b.x, b.y + r * rh, b.w, rh);
      }
      for (let c = 0; c < cols; c++) {
        const hot = rand() > 0.9;
        ctx.fillStyle = hot ? (SCREEN_ACCENTS[(r + c) % SCREEN_ACCENTS.length] ?? accent) : SCREEN_INK;
        const len = cw * (0.3 + rand() * 0.5);
        roundRect(ctx, b.x + c * cw + cw * 0.12, b.y + r * rh + rh * 0.34, len, rh * 0.3, rh * 0.15);
        ctx.fill();
      }
    }
  },

  // 4 — a terminal. One prompt colour, ragged output, a status strip.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[1] ?? "#38cf94";
    const b = screenChrome(ctx, d, rand, accent);
    ctx.fillStyle = "#080c12";
    ctx.fillRect(b.x, b.y, b.w, b.h);
    const rows = 20;
    const rowH = b.h / rows;
    for (let i = 0; i < rows - 1; i++) {
      const y = b.y + i * rowH + rowH * 0.24;
      const prompt = rand() > 0.72;
      let x = b.x + b.w * 0.02;
      if (prompt) {
        ctx.fillStyle = accent;
        roundRect(ctx, x, y, b.w * 0.02, rowH * 0.42, rowH * 0.2);
        ctx.fill();
        x += b.w * 0.035;
      }
      ctx.fillStyle = prompt ? SCREEN_INK_HI : SCREEN_INK;
      roundRect(ctx, x, y, b.w * (0.12 + rand() * 0.72), rowH * 0.42, rowH * 0.2);
      ctx.fill();
    }
    ctx.fillStyle = accent;
    ctx.fillRect(b.x, b.y + b.h - rowH * 0.9, b.w, rowH * 0.9);
    ctx.fillStyle = "#0b1017";
    roundRect(ctx, b.x + b.w * 0.02, b.y + b.h - rowH * 0.7, b.w * 0.2, rowH * 0.5, rowH * 0.2);
    ctx.fill();
  },

  // 5 — footage. A hero frame over a strip of thumbnails; the only layout with
  // large flat areas, which is what an editor's screen throws onto a wall.
  (ctx, d, rand) => {
    const accent = SCREEN_ACCENTS[3] ?? "#ff6f8d";
    const b = screenChrome(ctx, d, rand, accent);
    const heroH = b.h * 0.62;
    ctx.fillStyle = "#1b2530";
    roundRect(ctx, b.x, b.y, b.w * 0.72, heroH, b.h * 0.02);
    ctx.fill();
    // A horizon and a light source: two rectangles that read as a shot.
    ctx.fillStyle = "#2f4257";
    ctx.fillRect(b.x, b.y + heroH * 0.58, b.w * 0.72, heroH * 0.42);
    ctx.fillStyle = "#d8c39a";
    ctx.beginPath();
    ctx.arc(b.x + b.w * 0.52, b.y + heroH * 0.44, heroH * 0.12, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = SCREEN_PANEL;
    roundRect(ctx, b.x + b.w * 0.75, b.y, b.w * 0.25, heroH, b.h * 0.02);
    ctx.fill();
    textLines(
      ctx,
      rand,
      b.x + b.w * 0.78,
      b.y + b.h * 0.05,
      b.w * 0.19,
      6,
      b.h * 0.03,
      b.h * 0.03,
      SCREEN_DIM,
    );

    const strip = b.y + heroH + b.h * 0.06;
    const thumbs = 6;
    const tw = (b.w - (thumbs - 1) * b.w * 0.014) / thumbs;
    for (let i = 0; i < thumbs; i++) {
      ctx.fillStyle = i === 2 ? accent : SCREEN_DIM;
      roundRect(ctx, b.x + i * (tw + b.w * 0.014), strip, tw, b.h * 0.24, b.h * 0.015);
      ctx.fill();
      ctx.fillStyle = "#151d26";
      roundRect(
        ctx,
        b.x + i * (tw + b.w * 0.014) + tw * 0.06,
        strip + b.h * 0.02,
        tw * 0.88,
        b.h * 0.16 * (0.7 + rand() * 0.3),
        b.h * 0.01,
      );
      ctx.fill();
    }
  },
];

// ---- What each kind is ----------------------------------------------------

/**
 * The facts about a kind that are not its drawing.
 *
 * Two of these matter enough to be worth stating rather than defaulting.
 *
 * `clamp` is the difference between a *finish* and an *image*. A carpet repeats
 * every `TEXTURE_TILE_METRES` and must wrap or it seams; a screen and a leaf are
 * one picture on one quad, and wrapping them means a UV that overshoots by a
 * hair draws the far edge of the leaf against the near one.
 *
 * `linear` is the one that is silently wrong if you get it backwards. Three
 * reads a single channel out of an `alphaMap` and uses it as coverage. Coverage
 * is not a colour and has no transfer function, so decoding it as sRGB shifts
 * every alpha value — a cutout authored at 0.5 arrives at 0.21, and an
 * `alphaTest` tuned against the drawing eats half the leaf.
 */
interface KindSpec {
  /** Canvas width divided by height. Omitted means square. */
  aspect?: number;
  /** How many parameterised layouts the drawing produces. Omitted means one. */
  variants?: number;
  /** A single image rather than a repeat: clamp both axes. */
  clamp?: boolean;
  /** Coverage rather than colour: no sRGB transfer on the way in. */
  linear?: boolean;
  /** Draw at least this big, even where the quality says draw nothing. */
  minSize?: number;
}

const KIND_SPECS: Partial<Record<TextureKind, KindSpec>> = {
  screenUI: { aspect: 16 / 9, variants: SCREEN_UI_VARIANTS, clamp: true },
  leafAlpha: { clamp: true, linear: true, minSize: 128 },
};

// ---- Relief ---------------------------------------------------------------

/**
 * Which channel of a kind is being asked for.
 *
 * `"color"` is the drawing above. `"normal"` is the same surface expressed as
 * relief, and only the six kinds in `NORMAL_RECIPES` have one — a whiteboard, a
 * screen and a leaf cutout are flat, and binding a normal map to them would be
 * inventing texture that is not there.
 */
export type TextureChannel = "color" | "normal";

/**
 * A surface's relief, in metres, as a sum of terms.
 *
 * **Why this is authored rather than read back off the canvas.** The obvious
 * implementation is to draw the colour map, read the pixels, take the luminance
 * as a height and differentiate that. It is wrong here for two reasons, and the
 * second one is fatal. Luminance is not height — a dark wood plank and a pale
 * one are the same flatness, so a stain would emboss itself — and, more simply,
 * there is no canvas at all in Node, which is where the tests and the server
 * typecheck run. An authored recipe restates the *same* noise layers the drawing
 * uses (same `scale`, same `offset`, therefore the same lattice) plus the relief
 * the drawing could only imply, and it is pure arithmetic, so the normal map is
 * the one texture in this file that exists identically in a browser and under
 * `node --test`.
 *
 * Everything is in metres over a `TEXTURE_TILE_METRES` tile, which is what makes
 * the numbers arguable: carpet pile is about a millimetre, a grout line is three
 * deep and twenty wide, a plank seam is a knife cut. Authoring in metres also
 * makes the map resolution-independent — the gradient is taken per unit UV and
 * divided by the tile size, so `medium` and `high` differ in sharpness and not
 * in how steep the floor looks.
 *
 * **The depths are exaggerated, deliberately and by different amounts.** A 512²
 * map over a 2 m tile is 4 mm to the texel, so every finish here is standing in
 * for structure the map cannot resolve, and authored at its true depth most of
 * it encodes to within a texel of flat — which costs a texture fetch per
 * fragment and returns a surface that still looks printed. The rule applied
 * below is: exaggerate until the relief reads at a grazing sun and no further.
 * `plasterPaint` is the extreme (roughly ten times life) because a painted wall
 * genuinely is almost flat; `tileGrid` and `ceilingTile` are close to true,
 * because a grout line and a ceiling shadow gap are millimetres deep across
 * centimetres and survive on their own.
 */
interface HeightRecipe {
  /**
   * Noise layers, each mirroring one `grain()` call in the kind's drawing.
   * `scale` and `offset` **must** match the drawing or the bumps and the grain
   * come apart; `metres` is the peak-to-trough depth this layer contributes.
   */
  readonly noise: readonly { scale: number; offset: number; metres: number }[];
  /**
   * Cut lines — plank seams, grout, a ceiling grid. `along` is the axis the
   * lines *run* along, so plank seams that run across the tile are `"u"`.
   * `width` is the full width of the cut in UV, `metres` how deep it goes.
   */
  readonly lines?: {
    readonly along: "u" | "v" | "both";
    readonly count: number;
    readonly width: number;
    readonly metres: number;
  };
  /**
   * Corrugation — carpet rows, the over-and-under of a weave. A cosine rather
   * than a cut, because that is what a woven surface actually is and because a
   * smooth term has a smooth derivative at every mip level.
   */
  readonly ribs?: {
    readonly along: "u" | "v" | "both";
    readonly count: number;
    readonly metres: number;
  };
}

/**
 * The six kinds that have relief, and how much.
 *
 * The list is exactly the spec's: carpet, plank, weave, paint, tile, ceiling.
 * The other four kinds are deliberately absent — `polishedConcrete` is a power
 * float and is meant to be flat, and a whiteboard, a display and an alpha cutout
 * have no surface of their own to speak of.
 */
const NORMAL_RECIPES: Partial<Record<TextureKind, HeightRecipe>> = {
  // Pile, then the rows a loop carpet is laid in. The rows are the only thing on
  // a carpet you can see the light move across; they are about half a millimetre
  // proud in life and are carried at 1.2 mm here, which is the mildest of the
  // exaggerations in this table and the one a raking wall-washer earns.
  carpetLoop: {
    noise: [
      { scale: 26, offset: 3.1, metres: 0.002 },
      { scale: 90, offset: 11.7, metres: 0.001 },
    ],
    ribs: { along: "u", count: 32, metres: 0.0012 },
  },

  // A board floor is flat; the seams between boards are not. Five boards per
  // 2 m tile, matching the drawing, cut 1.2 mm deep and 12 mm wide — wider than
  // a real board joint, because a 2 mm joint is half a texel and would alias
  // into a dotted line. What has to be right is that it is a groove and not a
  // dark stripe: a groove catches a low sun along one flank and shades the other.
  woodPlank: {
    noise: [{ scale: 4, offset: 21.4, metres: 0.0004 }],
    lines: { along: "u", count: 5, width: 0.006, metres: 0.0012 },
  },

  // Warp and weft. The drawing rules them at `size/128`, so 128 each way here.
  fabricWeave: {
    noise: [{ scale: 34, offset: 17.2, metres: 0.0012 }],
    ribs: { along: "both", count: 128, metres: 0.0008 },
  },

  // Roller-laid emulsion over skimmed plasterboard. The long layer is trowel
  // undulation — which is what makes a wall catch a wall-washer unevenly — and
  // the short one is roller stipple.
  //
  // This is where the exaggeration declared at `HeightRecipe` shows most: both
  // layers are carried at roughly ten times life, because a painted wall really
  // is almost flat and at its true depth the normals peaked two values off
  // (128, 128, 255). That costs a texture fetch on every wall in the building
  // and returns a wall that still looks printed.
  plasterPaint: {
    noise: [
      { scale: 9, offset: 8.8, metres: 0.013 },
      { scale: 120, offset: 27.6, metres: 0.005 },
    ],
  },

  // The one kind where the relief is louder than the colour, and the one closest
  // to life: grout really does sit about 3 mm below the tile face. The 24 mm
  // width (0.012 UV) is generous for a joint and is set by the map rather than
  // by the tiler — three texels at 512 is the narrowest a smooth groove can be
  // before mipping turns it into a crawling line.
  tileGrid: {
    noise: [{ scale: 14, offset: 4.4, metres: 0.0003 }],
    lines: { along: "both", count: 4, width: 0.012, metres: 0.003 },
  },

  // Mineral fibre, and the shadow gap the tile sits in — 4 mm deep and 16 mm
  // wide, which is close to a real exposed-tee grid. Three tiles per repeat,
  // matching the drawing's `cells = 3`.
  ceilingTile: {
    noise: [{ scale: 70, offset: 13.3, metres: 0.0009 }],
    lines: { along: "both", count: 3, width: 0.008, metres: 0.004 },
  },
};

/** Which kinds `TextureBin.normal()` can answer for. Exported for the tests. */
export const NORMAL_MAP_KINDS = Object.keys(NORMAL_RECIPES) as readonly TextureKind[];

/** A smooth, symmetric dip of unit depth. Zero slope at the middle and at the rim. */
function dip(t: number): number {
  if (t >= 1) return 0;
  return 0.5 * (1 + Math.cos(Math.PI * t));
}

/** The sum of `dip`s for one axis of a `lines` term, in metres. */
function lineDepth(coord: number, count: number, width: number, metres: number): number {
  if (count <= 0 || width <= 0) return 0;
  const pitch = 1 / count;
  // Lines sit at 0, 1/count, 2/count …, exactly where the drawings put them.
  const offset = coord - Math.round(coord / pitch) * pitch;
  return dip(Math.abs(offset) / (width * 0.5)) * metres;
}

/**
 * Evaluate a recipe onto a `size × size` grid of metres.
 *
 * Sampled at `x / size`, **not** at `(x + 0.5) / size`. Sampling on the texel
 * corners is what makes the field exactly periodic — row `size` is row `0` — so
 * the wrapped Sobel below sees a genuinely seamless surface rather than a step
 * one texel wide all the way down the left edge.
 *
 * **Row `y` of the field is row `y` of the drawing, not of the texture.** A
 * `CanvasTexture` has `flipY` on, so canvas row `y` is sampled at `v = 1 − y/h`;
 * a `DataTexture` has it off, so data row `y` is sampled at `v = y/h`. The two
 * channels therefore only line up if the height field reads the drawing's own
 * row coordinate *backwards* — `s` below — and that is cheaper and far more
 * legible than flipping one of the two buffers and hoping the unpack flag
 * survives a three.js upgrade.
 */
function heightField(recipe: HeightRecipe, size: number): Float32Array {
  const height = new Float32Array(size * size);

  for (const layer of recipe.noise) {
    const field = tileableNoise(NOISE_RES, layer.scale, layer.offset);
    for (let y = 0; y < size; y++) {
      const s = (size - y) / size;
      for (let x = 0; x < size; x++) {
        // `/ 0.94` matches `grain()`: four octaves of `fbm` land in 0..0.94.
        const n = Math.min(1, Math.max(0, sampleField(field, NOISE_RES, x / size, s) / 0.94));
        height[y * size + x] = (height[y * size + x] ?? 0) + n * layer.metres;
      }
    }
  }

  const lines = recipe.lines;
  const ribs = recipe.ribs;
  if (lines || ribs) {
    for (let y = 0; y < size; y++) {
      const s = (size - y) / size;
      for (let x = 0; x < size; x++) {
        const u = x / size;
        let cut = 0;
        if (lines) {
          // A line running along `u` repeats down the other axis, and vice versa.
          if (lines.along !== "v") cut += lineDepth(s, lines.count, lines.width, lines.metres);
          if (lines.along !== "u") cut += lineDepth(u, lines.count, lines.width, lines.metres);
        }
        if (ribs) {
          if (ribs.along !== "v") {
            cut += 0.5 * (1 - Math.cos(2 * Math.PI * ribs.count * s)) * ribs.metres;
          }
          if (ribs.along !== "u") {
            cut += 0.5 * (1 - Math.cos(2 * Math.PI * ribs.count * u)) * ribs.metres;
          }
        }
        height[y * size + x] = (height[y * size + x] ?? 0) - cut;
      }
    }
  }

  return height;
}

/**
 * Sobel-differentiate a height field into an RGBA tangent-space normal map.
 *
 * Sobel rather than a plain central difference because the field is bilinearly
 * upsampled off a 64² lattice: a two-tap difference on a bilinear surface
 * reproduces the lattice as a faint grid of creases, and averaging the three
 * neighbouring rows is enough to bury it.
 *
 * The gradient is converted to a **slope** before it is encoded — `× size` puts
 * it per unit UV, `/ tileMetres` puts it per metre — which is why the same
 * recipe drawn at 256 and at 512 gives the same steepness rather than the
 * higher-resolution floor looking twice as smooth.
 *
 * `tileMetres` is a parameter rather than the constant it used to read, because
 * the ground's detail map repeats every 440 m and the office's finishes every
 * 2 m, and the whole point of the conversion is that the number is the tile's
 * own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what
 * the default preserves.
 */
export function normalMapData(
  height: Float32Array,
  size: number,
  tileMetres: number = TEXTURE_TILE_METRES,
): Uint8Array {
  const data = new Uint8Array(size * size * 4);
  const at = (x: number, y: number): number =>
    height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0;

  // Per-texel Sobel sums weight 4 either side across a 2-texel baseline.
  const perUv = size / 8 / tileMetres;

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const gu =
        (at(x + 1, y - 1) + 2 * at(x + 1, y) + at(x + 1, y + 1) -
          at(x - 1, y - 1) - 2 * at(x - 1, y) - at(x - 1, y + 1)) * perUv;
      // Data row `y` is texture coordinate `v = y / size` — the backwards read
      // happened in `heightField`, so this one is a plain forward difference.
      const gv =
        (at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1) -
          at(x - 1, y - 1) - 2 * at(x, y - 1) - at(x + 1, y - 1)) * perUv;

      // The surface normal of a height field is (-∂h/∂u, -∂h/∂v, 1), normalised.
      const nx = -gu;
      const ny = -gv;
      const inv = 1 / Math.hypot(nx, ny, 1);
      const i = (y * size + x) * 4;
      data[i] = Math.round((nx * inv * 0.5 + 0.5) * 255);
      data[i + 1] = Math.round((ny * inv * 0.5 + 0.5) * 255);
      data[i + 2] = Math.round((inv * 0.5 + 0.5) * 255);
      data[i + 3] = 255;
    }
  }

  return data;
}

// ---- The bin --------------------------------------------------------------

/**
 * Draws each texture at most once and hands out the same `THREE.Texture` to
 * every material that wants it.
 *
 * `get` returns `null` rather than throwing when there is no canvas to draw on.
 * That happens for real: the server workspace and the CI typecheck run under
 * Node, and an asset module that explodes on import there would make the
 * zero-config boot in CONTRACT.md §5.1 impossible to test.
 */
export class TextureBin {
  readonly quality: TextureQuality;
  private readonly cache = new Map<string, THREE.Texture | null>();

  constructor(quality: TextureQuality = "high") {
    this.quality = quality;
  }

  /**
   * The texture for a kind, and for parameterised kinds a layout within it.
   *
   * `variant` is reduced modulo the kind's own count, so a caller may hand it a
   * running index — a desk number, a prop ordinal — without knowing or caring
   * how many layouts exist. Kinds that have only one ignore it entirely, which
   * is why every existing call site still reads `get("carpetLoop")`.
   */
  get(kind: TextureKind, variant = 0): THREE.Texture | null {
    const count = KIND_SPECS[kind]?.variants ?? 1;
    // `%` keeps a negative index negative, and a negative cache key would draw
    // the same picture under two names.
    const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
    const key = index === 0 ? kind : `${kind}#${index}`;
    const hit = this.cache.get(key);
    if (hit !== undefined) return hit;
    const texture = this.draw(kind, "color", index);
    this.cache.set(key, texture);
    return texture;
  }

  /**
   * The relief for a kind, or `null` where the kind has none.
   *
   * Not parameterised by variant: the six kinds with relief have one layout
   * each, and a screen — the only kind with layouts — is flat.
   */
  normal(kind: TextureKind): THREE.Texture | null {
    const key = `${kind}!normal`;
    const hit = this.cache.get(key);
    if (hit !== undefined) return hit;
    const texture = this.draw(kind, "normal");
    this.cache.set(key, texture);
    return texture;
  }

  /** How many distinct layouts `get` can return for a kind. */
  variants(kind: TextureKind): number {
    return KIND_SPECS[kind]?.variants ?? 1;
  }

  /**
   * Build one texture, **uncached**.
   *
   * Public because the tests need to look at a channel without the cache in the
   * way, and because a capture pass occasionally wants a texture it is free to
   * dispose. Everything in the product goes through `get` or `normal`; calling
   * this in a loop is a new GPU upload every time round.
   */
  draw(kind: TextureKind, channel: TextureChannel = "color", variant = 0): THREE.Texture | null {
    return channel === "normal" ? this.drawNormal(kind) : this.drawColor(kind, variant);
  }

  /**
   * Relief needs no canvas — it is arithmetic over an authored height field —
   * which is what lets a normal map exist under `node --test` where the colour
   * channel cannot. See `HeightRecipe`.
   */
  private drawNormal(kind: TextureKind): THREE.Texture | null {
    const recipe = NORMAL_RECIPES[kind];
    const size = RESOLUTION[this.quality];
    // `low` means "no maps"; a normal map is a fetch and a matrix multiply per
    // fragment, which is exactly the cost `low` exists to refuse.
    if (!recipe || size === 0) return null;

    const data = normalMapData(heightField(recipe, size), size);
    const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
    texture.name = `${kind}!normal`;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    // A normal map is a direction, not a colour: an sRGB decode on the way in
    // would bend every one of them toward the surface.
    texture.colorSpace = THREE.NoColorSpace;
    // `DataTexture` defaults to nearest filtering and no mipmaps, which on a
    // floor running to the horizon is a field of shimmering static.
    texture.magFilter = THREE.LinearFilter;
    texture.minFilter = THREE.LinearMipmapLinearFilter;
    texture.generateMipmaps = true;
    texture.anisotropy = 4;
    texture.needsUpdate = true;
    return texture;
  }

  private drawColor(kind: TextureKind, variant: number): THREE.Texture | null {
    const spec = KIND_SPECS[kind] ?? {};
    const size = Math.max(RESOLUTION[this.quality], spec.minSize ?? 0);
    if (size === 0 || typeof document === "undefined") return null;

    const width = size;
    const height = Math.max(1, Math.round(size / (spec.aspect ?? 1)));

    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    // Every material passes through `grain()`, which reads the full canvas back
    // before uploading it to Three.js. Declare that workload so Chromium keeps
    // the 2D surface on its read-optimised path instead of warning once per
    // generated office material.
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    if (!ctx) return null;

    DRAW[kind](ctx, { width, height, variant });

    const texture = new THREE.CanvasTexture(canvas);
    texture.name = variant === 0 ? kind : `${kind}#${variant}`;
    const wrap = spec.clamp ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
    texture.wrapS = wrap;
    texture.wrapT = wrap;
    texture.colorSpace = spec.linear ? THREE.NoColorSpace : THREE.SRGBColorSpace;
    texture.anisotropy = 4;
    texture.needsUpdate = true;
    return texture;
  }

  dispose(): void {
    for (const texture of this.cache.values()) texture?.dispose();
    this.cache.clear();
  }
}
