OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / server.py

795 lines · python ref:
#!/usr/bin/env python3
"""
LOOKDEV SERVER — our erosion filter and terrain-diffusion, side by side, at a
matched scale, with the knobs live.

hyper-games #46. §4 measured that the two generators differ in roughness and
said nothing about which is better to look at. That is the open question and it
is aesthetic, so the only instrument that can answer it is a pair of eyes with
both images at the same scale.

  EROSION panel   -> shells out to erosion_render, which compiles
                     erosion_filter.inc off disk and the wrapper from
                     erosion_wrapper_glsl.h. Nothing is reimplemented here.
  DIFFUSION panel -> proxies the terrain_diffusion API.

Both are shaded by the SAME code below, because otherwise you are comparing two
renderers rather than two terrains.

  python server.py --port 8818
"""
import argparse, hashlib, json, os, subprocess, sys, urllib.request
from collections import OrderedDict

import numpy as np
from flask import Flask, Response, request, send_from_directory

HERE = os.path.dirname(os.path.abspath(__file__))
# WHERE TILES LIVE, and why it is not ~/.cache.
#
# Generated tiles are GAME STATE, not a cache: each is ~131 s of inference and
# they are the only copy. They used to default under ~/.cache/, which
# rsync-excludes.txt excludes (".cache/" matches any directory of that name at
# any depth) -- so the generator was writing its only copy into the one tree the
# backup deliberately skips. Verified both ways with the real exclude list.
#
# They now live under Godot's user:// for hyper-tps, which IS in the backup set
# and is where every other thing the game generates already goes.
#
# THE SEED IS THE PARTITION AXIS -- and the only one. A room is WHO MAY SEE a
# tile, not what it is: two rooms on one seed have byte-identical terrain, so
# nesting by room would duplicate it (the same reason content_overlay.gd keys
# user://content by hash: "two rooms asking for the same asset share one copy").
# A map SELECTS a seed and generator config; it is not an identity axis.
# Room/map scoping belongs to KeetTileIndex and StateBase, not the filesystem.
#
# LOOKDEV_TILE_CACHE overrides the whole path.
TILE_CACHE = os.environ.get("LOOKDEV_TILE_CACHE",
                            os.path.expanduser(
                           "~/.local/share/godot/app_userdata/HyperTPS/tile_cache/1234"))
# Where the ENGINE reads. map_dc_diffusion.gd's TILE_DIR is
# user://tile_cache/1234_hblk, and user:// for this project is
# ~/.local/share/godot/app_userdata/HyperTPS -- so the default is the .npy
# cache path with _hblk appended, and the two stay together if either moves.
HBLK_CACHE = os.environ.get("LOOKDEV_HBLK_CACHE", TILE_CACHE + "_hblk")

DC_TERRAIN = os.path.abspath(os.path.join(HERE, "..", "dc-terrain"))
INC = os.path.abspath(os.path.join(HERE, "..", "proc-nodes", "src", "shared",
                                   "erosion_filter.inc"))

app = Flask(__name__, static_folder=None)
_tiles = None            # TileStore, built lazily so importing this is cheap

# Bump when anything that changes a tile's PIXELS changes: the apron, the
# shading, the ramp, the tile size. Tile PNGs are cacheable (they must be, or
# panning re-encodes constantly), so the URL has to change or browsers keep
# serving images made by code that no longer exists. That is exactly what
# happened after the apron fix: the server had 2 tiles and the page was full of
# them, all rendered by the previous build, all mis-joined.
TILE_VERSION = 3

# 1 coarse px = 256 detail px = 7.68 km for the 30 m model.
COARSE_RATIO = 256

# Channel 0 of the coarse map is elevation under a SIGNED-SQUARE encoding --
# the paper's "compact Laplacian encoding to stabilise outputs across
# Earth-scale dynamic ranges". Calibrated against 254 cached detail tiles:
#
#     metres = 0.9953 * sign(c0)*c0^2 + 11.8      R2 = 0.9952
#
# against R2 0.9126 for a linear fit. The coefficient being 1.0 and the offset
# 12 m says the encoding is exactly signed-sqrt of metres, so it is used as
# such rather than with the fitted constants.
def coarse_to_metres(c0):
    return np.sign(c0) * c0 * c0


# Terrain classes by LOCAL RELIEF over a 23 km window (3 coarse px), which is
# what separates rolling hills from ranges far better than elevation does.
# Measured over 4.6 M km2 of this seed: 4.0% plains, 21.9% rolling, 42.1% hill
# country, 28.2% rugged, 3.8% alpine, with peaks to 6407 m. The world already
# contains the whole range; it just had no way to be found.
TERRAIN_CLASSES = [
    ("plains",       0,    60, (0.42, 0.55, 0.33)),
    ("rolling",     60,   180, (0.55, 0.66, 0.32)),
    ("hill country",180,   400, (0.72, 0.66, 0.35)),
    ("rugged",     400,   800, (0.75, 0.50, 0.32)),
    ("alpine",     800, 1e18, (0.88, 0.86, 0.90)),
]


def local_relief(elev, win=3):
    """Std of coarse elevation in a win x win window, edge-padded."""
    r = win // 2
    p = np.pad(elev, r, mode="edge")
    acc = np.zeros_like(elev); acc2 = np.zeros_like(elev)
    for dy in range(win):
        for dx in range(win):
            b = p[dy:dy + elev.shape[0], dx:dx + elev.shape[1]]
            acc += b; acc2 += b * b
    n = win * win
    return np.sqrt(np.maximum(acc2 / n - (acc / n) ** 2, 0.0))


def colourise_terrain_class(elev):
    """Colour by terrain class so a region can be FOUND, not just rendered."""
    rel = local_relief(elev)
    rgb = np.zeros(elev.shape + (3,))
    for _nm, lo, hi, col in TERRAIN_CLASSES:
        m = (rel >= lo) & (rel < hi)
        for k in range(3):
            rgb[..., k] = np.where(m, col[k], rgb[..., k])
    # shade it so the classes read as terrain rather than a choropleth
    sh = hillshade(elev, 7680.0)
    rgb *= (0.45 + 0.55 * sh[..., None])
    sea = elev <= 0
    d = np.clip(-elev / 6000.0, 0, 1)
    for k, c in enumerate((0.10, 0.16, 0.34)):
        rgb[..., k] = np.where(sea, c * (1 - 0.45 * d), rgb[..., k])
    return (np.clip(rgb, 0, 1) * 255).astype(np.uint8)


def tiles():
    global _tiles
    if _tiles is None:
        from tiles import TileStore
        def _fetch(i1, j1, n):
            return fetch_diffusion(app.config["TD_URL"], i1, j1, n)[:2]
        _tiles = TileStore(_fetch, TILE_CACHE,
                           tile_px=int(app.config.get("TILE_PX", 256)),
                           hblk_dir=HBLK_CACHE)
        print(f"tile cache: {TILE_CACHE}  tile {_tiles.tile_px} px "
              f"= {_tiles.tile_px*30/1000:.2f} km")
        print(f"hblk out:   {HBLK_CACHE}")
        n = _tiles.backfill_hblk()
        if n:
            print(f"backfilled {n} .hblk from the existing cache")
    return _tiles

# Every knob the renderer will accept, with its type and a sane range. The
# server shells out, so this allowlist is also the injection boundary: a key
# not in here never reaches argv, and a value is always re-emitted from a
# parsed float/int, never passed through as text.
KNOBS = {
    "terrain_height":    ("f", 1000.0, 200000.0),
    "mountain_scale":    ("f", 0.05, 20.0),
    "mountain_height":   ("f", 0.0, 2.0),
    "mountain_gain":     ("f", 0.2, 0.9),
    "mountain_variance": ("f", 0.0, 1.0),
    "mountain_octaves":  ("i", 1, 12),
    "relief_scale":      ("f", 0.0, 8.0),
    "relief_floor":      ("f", 0.0, 1.0),
    "detail_scale":      ("f", 0.0, 5000.0),
    "detail_height":     ("f", 0.0, 500.0),
    "detail_gain":       ("f", 0.2, 0.9),
    "detail_octaves":    ("i", 1, 10),
    "noise_lacunarity":  ("f", 1.2, 3.0),
    "voxel_size":        ("f", 0.25, 512.0),
    "gn_scale":          ("f", 0.1, 40.0),
    "gn_gain":           ("f", 0.0, 0.9),
    "gn_detail":         ("i", 1, 8),
    "en_height_scale":   ("f", 0.1, 40.0),
    "en_strength":       ("f", 0.0, 1.5),
    "en_cell_scale":     ("f", 0.05, 4.0),
    "en_gain":           ("f", 0.2, 0.9),
    "en_octaves":        ("i", 1, 12),
    "en_erosion_scale":  ("f", 0.002, 0.5),
    # Baked on the engine path (7-float push-constant cap); reachable here
    # because this path emits GLSL text. rounding_input is runevision's
    # input-feature-size control.
    "en_fade_div":         ("f", 0.5, 40.0),
    "en_height_amp":       ("f", 0.02, 2.0),
    "en_erosion_lac":      ("f", 1.2, 4.0),
    "en_gully_weight":     ("f", 0.0, 2.0),
    "en_detail":           ("f", 0.2, 5.0),
    "en_normalization":    ("f", 0.0, 1.0),
    "en_height_offset_a":  ("f", -2.0, 1.0),
    "en_height_offset_b":  ("f", 0.0, 1.0),
    "rounding_ridges":     ("f", 0.0, 2.0),
    "rounding_creases":    ("f", 0.0, 2.0),
    "rounding_input":      ("f", 0.005, 4.0),
    "rounding_per_oct":    ("f", 0.25, 6.0),
    "onset_input":         ("f", 0.0, 6.0),
    "onset_per_oct":       ("f", 0.0, 6.0),
    "onset_ridge_input":   ("f", 0.0, 8.0),
    "onset_ridge_oct":     ("f", 0.0, 6.0),
    "assumed_slope_mag":   ("f", 0.0, 3.0),
    "assumed_slope_mix":   ("f", 0.0, 1.0),
}

NO_SURFACE = -3.0e38

# The erosion filter applied to a heightmap that came from somewhere else --
# #46 §4b option 1. Allowlisted and clamped exactly like KNOBS: these reach a
# command line.
ERODE_KNOBS = {
    "e_gully_metres":   ("f", 30.0, 20000.0),
    "e_strength":       ("f", 0.0, 1.0),
    "e_octaves":        ("i", 1, 10),
    "e_gain":           ("f", 0.2, 0.9),
    "e_cell_scale":     ("f", 0.05, 4.0),
    "e_rounding_input": ("f", 0.005, 4.0),
    "e_offset":         ("f", -2.0, 1.0),
    "e_vscale":         ("f", 0.0, 20000.0),
    # Run the filter on a FINER grid than the model's. The model has nothing
    # below its 60 m Nyquist, so upsampling adds no information -- it is a
    # carrier. The filter is analytic and point-evaluable, so it genuinely
    # generates detail there. Without this the erosion band is floored at 60 m
    # and competes with the model instead of extending it.
    "e_upsample":       ("i", 1, 4),
    # depth:width. Scale-invariant, unlike e_strength which is a fraction of the
    # tile's own relief. Real valleys run 0.05-0.25; runevision's strength 0.22
    # lands at 2.19 on our input, which is why it had to be tuned down by eye.
    "e_depth_ratio":    ("f", 0.0, 1.0),
}


def bilinear_up(h, f):
    """Nearest-honest upsample: no information is claimed, only carried."""
    if f <= 1:
        return h
    n = h.shape[0]
    t = np.linspace(0, n - 1, n * f)
    i0 = np.clip(t.astype(int), 0, n - 2)
    fr = (t - i0)
    a = h[np.ix_(i0, i0)]; b = h[np.ix_(i0 + 1, i0)]
    c = h[np.ix_(i0, i0 + 1)]; d = h[np.ix_(i0 + 1, i0 + 1)]
    fy = fr[:, None]; fx = fr[None, :]
    return a * (1 - fy) * (1 - fx) + b * fy * (1 - fx) + c * (1 - fy) * fx + d * fy * fx


def erode_field(h, mpp, args):
    """Run erode_heightmap on an in-memory field. Returns (height, ridge)."""
    import tempfile
    n = h.shape[0]
    def gv(k):
        kind, lo, hi = ERODE_KNOBS[k]
        v = args.get(k)
        if v is None or v == "":
            return None
        v = _clamp(float(v), lo, hi)
        return int(round(v)) if kind == "i" else v
    with tempfile.TemporaryDirectory() as td:
        fin, fout, frg = f"{td}/in.f32", f"{td}/out.f32", f"{td}/ridge.f32"
        np.nan_to_num(h, nan=float(np.nanmin(h))).astype("<f4").tofile(fin)
        cmd = ["/tmp/erode_heightmap", "--in", fin, "--out", fout, "--ridge", frg,
               "--n", str(n), "--mpp", repr(float(mpp))]
        for k, flag in (("e_gully_metres", "--gully-metres"), ("e_strength", "--strength"),
                        ("e_octaves", "--octaves"), ("e_gain", "--gain"),
                        ("e_cell_scale", "--cell-scale"),
                        ("e_rounding_input", "--rounding-input"),
                        ("e_offset", "--offset"), ("e_vscale", "--vscale"),
                        ("e_depth_ratio", "--depth-ratio")):
            if k == "e_upsample":
                continue
            v = gv(k)
            if v is not None:
                cmd += [flag, repr(v)]
        pr = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
        if pr.returncode != 0:
            raise RuntimeError((pr.stderr or pr.stdout or "erode failed").strip()[:400])
        out = np.fromfile(fout, dtype="<f4").reshape(n, n).astype(np.float64)
        rg = np.fromfile(frg, dtype="<f4").reshape(n, n).astype(np.float64)
    return out, rg
_cache = OrderedDict()
CACHE_MAX = 64


def _clamp(v, lo, hi):
    return lo if v < lo else (hi if v > hi else v)


def parse_knobs(args):
    """Allowlist + clamp. Nothing else is ever put on the command line."""
    out = {}
    for k, (kind, lo, hi) in KNOBS.items():
        raw = args.get(k)
        if raw is None or raw == "":
            continue
        try:
            v = float(raw)
        except ValueError:
            raise ValueError(f"{k}: not a number")
        if v != v or v in (float("inf"), float("-inf")):
            raise ValueError(f"{k}: not finite")
        v = _clamp(v, lo, hi)
        out[k] = int(round(v)) if kind == "i" else v
    return out


def render_erosion(ox, oz, n, mpp, knobs, steps, bisect):
    key = hashlib.sha1(json.dumps(
        [ox, oz, n, mpp, sorted(knobs.items()), steps, bisect]).encode()).hexdigest()
    if key in _cache:
        _cache.move_to_end(key)
        return _cache[key]

    out = f"/tmp/lookdev-{key}.f32"
    cmd = ["/tmp/erosion_render", "--inc", INC, "--out", out,
           "--n", str(int(n)), "--mpp", repr(float(mpp)),
           "--ox", repr(float(ox)), "--oz", repr(float(oz)),
           "--steps", str(int(steps)), "--bisect", str(int(bisect))]
    for k, v in knobs.items():
        cmd += ["--set", k, repr(v)]
    p = subprocess.run(cmd, cwd=DC_TERRAIN, capture_output=True, text=True, timeout=300)
    if p.returncode != 0:
        raise RuntimeError((p.stderr or p.stdout or "render failed").strip()[:500])

    h = np.fromfile(out, dtype="<f4").reshape(int(n), int(n)).astype(np.float64)
    os.unlink(out)
    h = np.where(h <= NO_SURFACE / 2, np.nan, h)

    _cache[key] = h
    while len(_cache) > CACHE_MAX:
        _cache.popitem(last=False)
    return h


def fetch_diffusion(url, i1, j1, n, seed=None):
    q = (f"{url}/terrain?i1={int(i1)}&j1={int(j1)}"
         f"&i2={int(i1)+int(n)}&j2={int(j1)+int(n)}&scale=1")
    # A new seed is a whole new world. NOTE: the upstream handler calls
    # change_seed(), which clears every cached tile and rebuilds the pipeline --
    # so changing seed is far more expensive than moving, and it invalidates the
    # cache for everyone. Move with i1/j1 to browse; change seed to start over.
    if seed not in (None, ""):
        q += f"&seed={int(seed)}"
    with urllib.request.urlopen(q, timeout=600) as r:
        hh = int(r.headers["X-Height"]); ww = int(r.headers["X-Width"])
        body = r.read()
    nelev = hh * ww * 2
    elev = np.frombuffer(body[:nelev], dtype="<i2").reshape(hh, ww).astype(np.float64)
    climate = None
    if len(body) > nelev:
        climate = np.frombuffer(body[nelev:], dtype="<f4").reshape(hh, ww, 4)
    return elev, climate


# ---------------------------------------------------------------------------
# Shading. ONE implementation, used for both panels — the whole point is that
# a difference on screen is a difference in the terrain.
# ---------------------------------------------------------------------------
def hillshade(h, mpp, azimuth=315.0, altitude=45.0, exaggeration=1.0):
    hh = np.nan_to_num(h, nan=float(np.nanmean(h)) if np.isfinite(h).any() else 0.0)
    gy, gx = np.gradient(hh * exaggeration, mpp)
    slope = np.arctan(np.hypot(gx, gy))
    aspect = np.arctan2(-gy, gx)
    az = np.radians(360.0 - azimuth + 90.0)
    alt = np.radians(altitude)
    shade = (np.sin(alt) * np.cos(slope) +
             np.cos(alt) * np.sin(slope) * np.cos(az - aspect))
    return np.clip(shade, 0.0, 1.0)


def slope_map(h, mpp):
    hh = np.nan_to_num(h, nan=float(np.nanmean(h)) if np.isfinite(h).any() else 0.0)
    gy, gx = np.gradient(hh, mpp)
    return np.degrees(np.arctan(np.hypot(gx, gy)))


def to_png(rgb):
    from PIL import Image
    import io
    im = Image.fromarray(rgb, mode="RGB")
    buf = io.BytesIO()
    im.save(buf, format="PNG", optimize=False, compress_level=1)
    return buf.getvalue()


# HYPSOMETRIC / BATHYMETRIC TINTING.
#
# A single ramp across the whole range is right for consistency between tiles
# and wrong for contrast within one. Seafloor at -2200..-1000 m occupies about
# 15% of a +/-4000 m ramp, so it arrives on screen almost flat -- which read as
# "the model has no detail down there" when the measurement says it has 5x less
# detail, not none (land 8.41 vs seafloor 1.65, and the drop lands exactly on
# the 0 m contour, not on any tile boundary).
#
# Real maps solve this by tinting land and sea on SEPARATE scales. Both then get
# their full range, and sea level is a hard, readable edge instead of the middle
# of a gradient. The scales are absolute, so tiles still agree with each other.
LAND_STOPS = [
    (0,     (0.29, 0.48, 0.25)),   # coastal green
    (300,   (0.49, 0.60, 0.29)),
    (800,   (0.72, 0.63, 0.35)),   # tan
    (1600,  (0.66, 0.46, 0.25)),   # brown
    (2600,  (0.55, 0.44, 0.38)),   # bare rock
    (3600,  (0.91, 0.91, 0.94)),   # snow
    (6500,  (1.00, 1.00, 1.00)),
]
SEA_STOPS = [
    (0,     (0.47, 0.68, 0.74)),   # shore
    (-200,  (0.31, 0.53, 0.63)),   # shelf
    (-1000, (0.19, 0.37, 0.52)),
    (-2500, (0.11, 0.24, 0.38)),
    (-4500, (0.05, 0.13, 0.24)),
    (-9000, (0.02, 0.06, 0.14)),   # trench
]


def _ramp(h, stops):
    xs = np.array([s[0] for s in stops], dtype=np.float64)
    cs = np.array([s[1] for s in stops], dtype=np.float64)
    if xs[0] > xs[-1]:
        xs = xs[::-1]; cs = cs[::-1]
    out = np.empty(h.shape + (3,))
    for k in range(3):
        out[..., k] = np.interp(h, xs, cs[:, k])
    return out


def hypsometric(h):
    """Land and sea tinted on separate scales, so each gets its full range."""
    rgb = _ramp(h, LAND_STOPS)
    sea = _ramp(h, SEA_STOPS)
    m = (h <= 0)[..., None]
    return np.where(m, sea, rgb)


def colourise_array(h, mpp, view, vmin, vmax, exag=1.0):
    """The colour computation without the PNG encode, so a caller can crop an
    apron off before encoding."""
    finite = h[np.isfinite(h)]
    lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0)
    hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0)
    if hi <= lo:
        hi = lo + 1.0
    if view == "slope":
        s = np.clip(slope_map(h, mpp) / 60.0, 0, 1)
        rgb = np.stack([s, 1.0 - s * 0.65, 1.0 - s], axis=-1)
    elif view == "height":
        t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
        rgb = np.stack([0.15 + 0.85 * t, 0.25 + 0.6 * t, 0.35 + 0.3 * (1 - t)], axis=-1)
    elif view == "hypsometric":
        # Exaggeration affects the SHADING only, never the colour -- the tint
        # keeps telling the truth about elevation while the relief becomes
        # legible. Submarine relief needs 5-10x before it reads at all.
        sh = hillshade(h, mpp, exaggeration=exag)
        rgb = hypsometric(h) * (0.30 + 0.70 * sh[..., None])
    else:
        sh = hillshade(h, mpp, exaggeration=exag)
        if view == "shaded-height":
            t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
            base = np.stack([0.20 + 0.75 * t, 0.30 + 0.55 * t,
                             0.38 + 0.22 * (1 - t)], axis=-1)
            rgb = base * (0.35 + 0.65 * sh[..., None])
        else:
            rgb = np.repeat(sh[..., None], 3, axis=-1)
    rgb = np.where(np.isfinite(h)[..., None], rgb, 0.0)
    return (np.clip(rgb, 0, 1) * 255).astype(np.uint8)


def colourise(h, mpp, view, vmin=None, vmax=None):
    """view: shade | height | slope | shaded-height

    vmin/vmax PIN the ramp. Without them the ramp is fitted to this tile, so
    panning one step re-normalises and the colours jump even though the terrain
    is identical -- which is exactly what terrain-diffusion issue #19 reported
    as "tile inconsistencies" before the author identified it as the colour map.
    An instrument that changes its scale between readings is not an instrument.
    """
    if vmin in ("", None): vmin = None
    if vmax in ("", None): vmax = None
    vmin = float(vmin) if vmin is not None else None
    vmax = float(vmax) if vmax is not None else None
    finite = h[np.isfinite(h)]
    lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0)
    hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0)
    if hi <= lo:
        hi = lo + 1.0

    if view == "slope":
        s = np.clip(slope_map(h, mpp) / 60.0, 0, 1)
        rgb = np.stack([s, 1.0 - s * 0.65, 1.0 - s], axis=-1)
    elif view == "height":
        t = np.clip((h - lo) / (hi - lo), 0, 1)
        t = np.nan_to_num(t)
        rgb = np.stack([0.15 + 0.85 * t, 0.25 + 0.6 * t, 0.35 + 0.3 * (1 - t)], axis=-1)
    else:
        sh = hillshade(h, mpp)
        if view == "shaded-height":
            t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
            base = np.stack([0.20 + 0.75 * t, 0.30 + 0.55 * t,
                             0.38 + 0.22 * (1 - t)], axis=-1)
            rgb = base * (0.35 + 0.65 * sh[..., None])
        else:
            rgb = np.repeat(sh[..., None], 3, axis=-1)

    rgb = np.where(np.isfinite(h)[..., None], rgb, 0.0)
    return to_png((np.clip(rgb, 0, 1) * 255).astype(np.uint8)), lo, hi


def stats_headers(resp, h, lo, hi, extra=None):
    finite = h[np.isfinite(h)]
    resp.headers["X-Min"] = f"{finite.min():.1f}" if finite.size else "nan"
    resp.headers["X-Max"] = f"{finite.max():.1f}" if finite.size else "nan"
    resp.headers["X-Mean"] = f"{finite.mean():.1f}" if finite.size else "nan"
    resp.headers["X-Sd"] = f"{finite.std():.1f}" if finite.size else "nan"
    resp.headers["X-Ramp"] = f"{lo:.1f},{hi:.1f}"
    if extra:
        for k, v in extra.items():
            resp.headers[k] = v
    return resp


# ---------------------------------------------------------------------------
@app.get("/")
def index():
    # The page is edited constantly during a session, and a cached copy is not a
    # cosmetic problem: an older index.html kept sending the seed field on every
    # request, so the browser silently drove the world to a different seed while
    # the server was verifiably on 1234. Cost an hour of chasing a model bug that
    # did not exist. Never let this file cache.
    r = send_from_directory(HERE, "index.html")
    r.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    r.headers["Pragma"] = "no-cache"
    return r


@app.get("/api/erosion.png")
def api_erosion():
    try:
        a = request.args
        n = int(_clamp(float(a.get("n", 384)), 64, 1024))
        mpp = float(_clamp(float(a.get("mpp", 30)), 0.05, 20000))
        ox = float(a.get("ox", 0)); oz = float(a.get("oz", 0))
        steps = int(_clamp(float(a.get("steps", 256)), 32, 1024))
        knobs = parse_knobs(a)
        h = render_erosion(ox, oz, n, mpp, knobs, steps, 40)
        png, lo, hi = colourise(h, mpp, a.get("view", "shaded-height"),
                                a.get("vmin"), a.get("vmax"))
        return stats_headers(Response(png, mimetype="image/png"), h, lo, hi,
                             {"X-Source": "erosion_render"})
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/diffusion.png")
def api_diffusion():
    try:
        a = request.args
        n = int(_clamp(float(a.get("n", 384)), 64, 1024))
        i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
        h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
        extra = {"X-Source": "terrain-diffusion", "X-Mpp": "30"}
        mpp_d = 30.0
        if a.get("erode") in ("1", "true", "on"):
            f = int(_clamp(float(a.get("e_upsample", 1)), 1, 4))
            h = bilinear_up(h, f); mpp_d = 30.0 / f
            h, _ridge = erode_field(h, mpp_d, a)
            extra["X-Source"] = "terrain-diffusion + erosion_filter"
            extra["X-Mpp"] = repr(mpp_d)
        png, lo, hi = colourise(h, mpp_d, a.get("view", "shaded-height"),
                                a.get("vmin"), a.get("vmax"))
        if climate is not None:
            extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
                                  f"{climate[...,1].mean():.0f},"
                                  f"{climate[...,2].mean():.0f}mm,"
                                  f"{climate[...,3].mean():.0f}%")
        return stats_headers(Response(png, mimetype="image/png"), h, lo, hi, extra)
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


# ---------------------------------------------------------------------------
# Raw heightfields, for the 3D tab. Same render path as the PNG endpoints --
# the 3D view must be a different VIEW of the same field, never a different
# field, or it stops being evidence about the thing on the left.
# ---------------------------------------------------------------------------
def _raw_response(h, mpp, extra=None):
    finite = h[np.isfinite(h)]
    lo = float(finite.min()) if finite.size else 0.0
    hi = float(finite.max()) if finite.size else 1.0
    # NaN does not survive a Float32Array cleanly in every browser path, so
    # holes are sent as the floor and flagged by count.
    filled = np.where(np.isfinite(h), h, lo).astype("<f4")
    resp = Response(filled.tobytes(), mimetype="application/octet-stream")
    resp.headers["X-N"] = str(h.shape[0])
    resp.headers["X-Mpp"] = repr(float(mpp))
    resp.headers["X-Min"] = f"{lo:.3f}"
    resp.headers["X-Max"] = f"{hi:.3f}"
    resp.headers["X-Mean"] = f"{finite.mean():.1f}" if finite.size else "nan"
    resp.headers["X-Sd"] = f"{finite.std():.1f}" if finite.size else "nan"
    resp.headers["X-Holes"] = str(int((~np.isfinite(h)).sum()))
    if extra:
        for k, v in extra.items():
            resp.headers[k] = v
    return resp


@app.get("/api/erosion.bin")
def api_erosion_bin():
    try:
        a = request.args
        n = int(_clamp(float(a.get("n", 384)), 64, 1024))
        mpp = float(_clamp(float(a.get("mpp", 30)), 0.05, 20000))
        ox = float(a.get("ox", 0)); oz = float(a.get("oz", 0))
        steps = int(_clamp(float(a.get("steps", 256)), 32, 1024))
        h = render_erosion(ox, oz, n, mpp, parse_knobs(a), steps, 40)
        return _raw_response(h, mpp, {"X-Source": "erosion_render"})
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/diffusion.bin")
def api_diffusion_bin():
    try:
        a = request.args
        n = int(_clamp(float(a.get("n", 384)), 64, 1024))
        i1 = int(float(a.get("i1", 0))); j1 = int(float(a.get("j1", 2048)))
        h, climate = fetch_diffusion(app.config["TD_URL"], i1, j1, n, a.get("seed"))
        extra = {"X-Source": "terrain-diffusion"}
        mpp_d = 30.0
        if a.get("erode") in ("1", "true", "on"):
            f = int(_clamp(float(a.get("e_upsample", 1)), 1, 4))
            h = bilinear_up(h, f); mpp_d = 30.0 / f
            h, _ridge = erode_field(h, mpp_d, a)
            extra["X-Source"] = "terrain-diffusion + erosion_filter"
        if climate is not None:
            extra["X-Climate"] = (f"{climate[...,0].mean():.1f}C,"
                                  f"{climate[...,1].mean():.0f},"
                                  f"{climate[...,2].mean():.0f}mm,"
                                  f"{climate[...,3].mean():.0f}%")
        return _raw_response(h, mpp_d, extra)
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


# ---------------------------------------------------------------------------
# Explore: an infinite pannable map, streamed.
# ---------------------------------------------------------------------------
@app.get("/explore")
def explore():
    r = send_from_directory(HERE, "explore.html")
    r.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    return r


@app.get("/api/tile.png")
def api_tile():
    """Return a tile IF it is already available, else 204 and queue it.

    This endpoint never blocks on the GPU. A request path that waits for
    generation would stall panning for seconds at a time, and the point of the
    page is to find out what streaming actually feels like."""
    try:
        tx = int(request.args["tx"]); ty = int(request.args["ty"])
        ts = tiles()
        a = ts.get(tx, ty)
        if a is None:
            ts.request([(tx, ty)], (tx, ty))
            return Response(status=204)
        # ONE ramp for every tile. A per-tile ramp makes the map a patchwork and
        # reads as terrain discontinuity -- the same misread as upstream #19.
        vmin = float(request.args.get("vmin", -4000))
        vmax = float(request.args.get("vmax", 4000))
        # Shade WITH the apron, then crop it. Shading the bare tile is what put
        # a seam on every join even though the elevation is continuous.
        full = a.astype(np.float64)
        shaded = colourise_array(full, 30.0, request.args.get("view", "hypsometric"),
                                 vmin, vmax,
                                 float(request.args.get("exag", 1.0)))
        ap = tiles().APRON
        png = to_png(shaded[ap:-ap, ap:-ap] if ap else shaded)
        r = Response(png, mimetype="image/png")
        # Safe to cache only because the URL carries TILE_VERSION.
        r.headers["Cache-Control"] = "public, max-age=3600"
        r.headers["X-Tile"] = f"{tx},{ty}"
        r.headers["X-Tile-Version"] = str(TILE_VERSION)
        return r
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/coarse.png")
def api_coarse():
    """The coarse stage as a backdrop — 7.68 km/px, ~700x cheaper per km2.

    Measured: 246 x 246 km (60,516 km2) in 1.78 s = ~34,000 km2/s, against the
    detail stage's ~50 km2/s. This is what a zoomed-out view should be made of;
    generating 30 m/px to draw it at 132 m per screen pixel is the waste the
    explore page was built to expose."""
    try:
        ci1 = int(request.args["ci1"]); cj1 = int(request.args["cj1"])
        ci2 = int(request.args["ci2"]); cj2 = int(request.args["cj2"])
        if (ci2 - ci1) * (cj2 - cj1) > 90000:
            raise ValueError("coarse region too large")
        # np.gradient needs >= 2 elements per axis, so a 1-px-thin request dies
        # inside hillshade with a confusing message. The app always asks for a
        # large box, but a deep zoom could produce a thin one. Grow it here and
        # crop after shading, the same trick the tile apron uses.
        pad_i = max(0, 2 - (ci2 - ci1))
        pad_j = max(0, 2 - (cj2 - cj1))
        ci2 += pad_i
        cj2 += pad_j
        q = (f"{app.config['TD_URL']}/coarse?i1={ci1}&j1={cj1}&i2={ci2}&j2={cj2}")
        with urllib.request.urlopen(q, timeout=1800) as r:
            h = int(r.headers["X-Height"]); w = int(r.headers["X-Width"])
            c = int(r.headers["X-Channels"]); b = r.read()
        a = np.frombuffer(b, dtype="<f4").reshape(h, w, c)
        elev = coarse_to_metres(a[..., 0].astype(np.float64))
        vmin = float(request.args.get("vmin", -4000))
        vmax = float(request.args.get("vmax", 4000))
        view = request.args.get("view", "hypsometric")
        if view == "terrain-class":
            rgb = colourise_terrain_class(elev)
        else:
            rgb = colourise_array(elev, 7680.0, view, vmin, vmax,
                                  float(request.args.get("exag", 1.0)))
        if pad_i or pad_j:
            rgb = rgb[:rgb.shape[0] - pad_i or None, :rgb.shape[1] - pad_j or None]
        resp = Response(to_png(rgb), mimetype="image/png")
        resp.headers["Cache-Control"] = "public, max-age=3600"
        resp.headers["X-Height"] = str(rgb.shape[0]); resp.headers["X-Width"] = str(rgb.shape[1])
        resp.headers["X-Elev"] = f"{elev.min():.0f},{elev.max():.0f}"
        return resp
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/tiles/request")
def api_tiles_request():
    """Queue a batch, prioritised by distance from the view centre."""
    try:
        raw = request.args.get("tiles", "")
        want = []
        for pair in raw.split(";"):
            if not pair.strip():
                continue
            x, y = pair.split(",")
            want.append((int(x), int(y)))
        cx = int(request.args.get("cx", 0)); cy = int(request.args.get("cy", 0))
        ts = tiles()
        # The client caps what it ASKS for per poll, but polls repeat -- which is
        # how a 12-per-poll cap became a 376-deep queue. Refuse to grow past a
        # depth that can actually be delivered while the view is still current.
        MAX_DEPTH = 16
        room = max(0, MAX_DEPTH - ts.queue_depth())
        ts.request(want[:room], (cx, cy))
        return Response(json.dumps({"queued": min(len(want), room),
                                    "depth": ts.queue_depth()}),
                        mimetype="application/json")
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/tiles/state")
def api_tiles_state():
    """Per-tile availability for the overlay, plus streaming stats."""
    try:
        ts = tiles()
        out = {}
        raw = request.args.get("tiles", "")
        for pair in raw.split(";"):
            if not pair.strip():
                continue
            x, y = [int(v) for v in pair.split(",")]
            out[f"{x},{y}"] = ts.state(x, y)
        return Response(json.dumps({"state": out, "stats": ts.summary(),
                                    "tile_version": TILE_VERSION,
                                    "tile_px": ts.tile_px}),
                        mimetype="application/json")
    except Exception as e:
        return Response(str(e), status=400, mimetype="text/plain")


@app.get("/api/knobs")
def api_knobs():
    return Response(json.dumps({k: {"kind": v[0], "min": v[1], "max": v[2]}
                                for k, v in KNOBS.items()}),
                    mimetype="application/json")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8818)
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--td-url", default="http://127.0.0.1:8817")
    args = ap.parse_args()
    if not os.path.exists("/tmp/erosion_render"):
        sys.exit("build /tmp/erosion_render first — see erosion_render.cpp's header, "
                 "and run it with --verify before believing any picture it makes.")
    app.config["TD_URL"] = args.td_url
    print(f"lookdev on http://{args.host}:{args.port}  (diffusion at {args.td_url})")
    app.run(host=args.host, port=args.port, threaded=True)