aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tiles.py
diff options
context:
space:
mode:
Diffstat (limited to 'tiles.py')
-rw-r--r--tiles.py1160
1 files changed, 1160 insertions, 0 deletions
diff --git a/tiles.py b/tiles.py
new file mode 100644
index 0000000..2b54a26
--- /dev/null
+++ b/tiles.py
@@ -0,0 +1,1160 @@
+"""Deep-zoom tiles for the map viewer: procedural detail below the build's resolution.
+
+Deterministic, seeded and continuous across tiles and zoom levels, but invented rather than data. Lives outside
+mapgen/ so changing it never invalidates the build cache. Zoom z has 2^(z+1) × 2^z tiles of 256 px (x east from
+−180°, y south from +90°); pixel size = 360° / (2^(z+1)·256).
+"""
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import multiprocessing
+import os
+import queue
+import shutil
+import signal
+import tempfile
+import threading
+from collections import OrderedDict
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+from scipy.ndimage import binary_dilation, map_coordinates
+from scipy.spatial import cKDTree
+
+import worldgen_path # noqa: F401 (mapgen on sys.path)
+from mapgen import render as RN
+from mapgen.noise import value_noise
+from mapgen.sphere import east_north
+from mapgen.sphere import latlon_to_xyz
+
+import refine
+import rivers as RV
+import servecache
+
+TILE = 256
+MAX_Z = 16
+VERSION = 6 # 2: river valleys; 3: per-valley surfaces; 4: 129² heights;
+ # 5: meshes with true heights + water, era sharing; 6: erosion gullies
+DRAW = 5 # how tiles draw a build, apart from VERSION (which refine's area keys hash): bump → tiles re-render,
+ # refined areas stay; 1: river banks never below their water; 2: micro-relief + land colour variation;
+ # 3: stronger (visible from the ground view), lush patches green not teal; 4: drawn from the water
+ # surface raster, world lakes flat at their level (no pits in 3D); 5: land colour = terrain shading +
+ # vegetation mosaic (author 2026-10-03)
+TOP_KM, MIN_KM, GAIN = 8.0, 0.005, 0.55 # detail octaves: 8 km wavelength down to 5 m
+MICRO_KM, MICRO_GAIN = 2.0, 0.75 # drawn-only micro-relief (not in relief_at): 2 km down to 5 m, rougher
+MICRO_M = (10.0, 0.05) # its 2-km amplitude (m) = a + b × landform amplitude: swells on plains
+TINT_KM, TINT = 4.0, (0.18, 22.0) # land colour variation: 4 km down to the pixel; ± brightness, ± tint
+WARP_KM, WARP_TOP_KM = 12.0, 48.0 # ≈ ⅓ of a res-5 cell: organic borders between cells
+LAND_BORDER = 8 # px around a relief tile for its land colour (slope, water)
+CREST_M = 40.0 # crests/hollows: height above the smooth base surface, in units of
+ # max(this, ½ × the landform's detail amplitude)
+STEEP = 0.35 # slope (m/m) ≈ full "steep"
+RIPARIAN_KM = 0.4 # woods and green along water fade over this distance
+VEG_KM, VEG_WARP = 16.0, 0.8 # vegetation patches: noise from 16 km down, domain-warped (Quilez)
+VEG_SD = 0.455 # the mosaic noise is ≈ normal with this SD at every zoom (measured on
+ # r4, 2026-10-03): woods where it is below SD·Φ⁻¹(cover)
+VEG_COVER = (("polar desert", 0.0), ("dry tundra", 0.02), ("moist tundra", 0.06), ("wet tundra", 0.1),
+ ("rain tundra", 0.12), ("desert scrub", 0.08), ("desert", 0.02), ("dry scrub", 0.12),
+ ("thorn steppe", 0.12), ("steppe", 0.12), ("thorn woodland", 0.3), ("very dry forest", 0.4),
+ ("dry forest", 0.5), ("moist forest", 0.65), ("wet forest", 0.75), ("rain forest", 0.82))
+GROUND_COVER = {"salt flat": 0.0, "mangrove": 0.9} # wooded share of open land by Holdridge zone (first suffix match)
+GULLY_KM, GULLY_GAIN, GULLY_AMP = 4.0, 0.5, 0.55 # erosion gullies: waves 4 km down, × detail amplitude, halving
+GULLY_SLOPE = 0.15 # slope (m/m) where gullies are ≈ ¾ on (tanh)
+GULLY_SHARE = 0.8 # on full slopes, this share of the finer fractal detail gives way
+PH_JIT = 0.8 # gully lattice points jitter over this share of a cell
+GULLY_STEP_KM = 0.5 # finite-difference step for the downhill direction
+RIDGE_MEAN = 0.394 # E[1 − 2|n|] of value noise: keeps ridged detail unbiased
+DEEP = {"ocean": (150, 0.0), "plain": (25, 0.0), "hills": (120, 0.0), "mountains": (450, 1.0), "plateau": (60, 0.0),
+ "rift valley": (150, 0.3), "escarpment": (250, 0.5), "volcanic arc": (350, 1.0), "volcanic massif": (350, 1.0),
+ "basalt plateau": (60, 0.0), "dunes": (30, 0.0), "badlands": (120, 0.3)} # 8-km amplitude (m), ridged share
+CONT = {"temperature": "T_mean", "rainfall": "P_ann", "o2": "po2", "gravity": "gravity", "pressure": "pressure",
+ "fire": "fire", "bottom_temp": "bottom_temp", "sediment": "sediment", "vent_potential": "vent_potential",
+ "currents": "current_speed", "sst": "sst", "productivity": "productivity"}
+CAT = {"biomes": ("holdridge", "holdridge"), "seasonality": ("seasonality", "seasonality"), "landform": ("landform", "landform"),
+ "ground": ("ground", "ground"), "ice": ("ice", "ice"), "plates": ("plate", "plates"),
+ "seabed": ("seabed_type", "seabed_type"), "minerals": ("seabed_mineral", "seabed_mineral"),
+ "deposits": ("deposit_main", "deposits")}
+LAYERS = ("relief", "seafloor", "elevation", *CONT, *CAT)
+MEM_TILES = 512
+REGION_WARP = 0.4 # refined areas: the organic warp at ≈ their 5 km cell scale:
+REGION_FREQ = 7.0 # 0.4 × the displacement, waves 7× shorter
+MESH_N = 129 # heights per tile side for 3D patches (128 segments, corners included)
+MESH_BYTES = MESH_N * MESH_N * 5 # float32 heights + uint8 water
+EXT = {"height": "png", "mesh": "bin"} # every other layer is a JPEG image
+CAT_KEYS = ("holdridge", "ground", "ice", "landform", "seasonality", "plate", "lake", "seabed_type", "seabed_mineral", "deposit_main")
+SEABED_RGB = np.array([[0, 0, 0], [150, 120, 80], [225, 225, 205], [150, 110, 95], [70, 70, 80], [95, 60, 70],
+ [120, 130, 110], [230, 110, 40], [40, 40, 60]], dtype=np.float64)
+# by mapgen.seabed code: none, terrigenous, carbonate, clay, basalt, volcanic, continental, vents, trench
+FLOOR_RAMP = [[5, 15, 45], [20, 60, 120], [90, 150, 190], [170, 215, 235]] # −8,000 m … 0 m
+
+
+def tile_ok(z: int, x: int, y: int) -> bool:
+ return 0 <= z <= MAX_Z and 0 <= x < 2 ** (z + 1) and 0 <= y < 2 ** z
+
+
+def seafloor_rgb(z, hs, seabed, land):
+ """The sea floor as if the water were gone: depth ramp tinted 35 % by sea-floor type, hillshaded like land;
+ land as the relief layer's colours would shade it (here: a plain earth tone, hillshaded)."""
+ z = np.asarray(z, dtype=np.float64)
+ depth = RN._ramp(z, -8000.0, 0.0, FLOOR_RAMP).astype(np.float64)
+ tint = SEABED_RGB[np.clip(np.asarray(seabed), 0, len(SEABED_RGB) - 1)]
+ sea = 0.65 * depth + 0.35 * tint
+ earth = RN._ramp(z, 0.0, 4000.0, [[110, 125, 80], [150, 135, 100], [225, 225, 225]]).astype(np.float64)
+ rgb = np.where(np.asarray(land)[..., None], earth, sea)
+ return np.clip(rgb * (0.45 + 0.55 * np.asarray(hs))[..., None], 0, 255).astype(np.uint8)
+
+
+def _ll(p):
+ """(lat, lon) in degrees of unit vectors (N, 3)."""
+ return np.degrees(np.arcsin(np.clip(p[:, 2], -1.0, 1.0))), np.degrees(np.arctan2(p[:, 1], p[:, 0]))
+
+
+def _hash32(x, y, z, seed: int) -> np.ndarray:
+ """Integer lattice coordinates → 30 well-mixed bits (int64), deterministic."""
+ h = (x * 73856093) ^ (y * 19349663) ^ (z * 83492791) ^ (seed * 2654435761)
+ h = h & 0xFFFFFFFF
+ h = ((h ^ (h >> 15)) * 0x2C1B3C6D) & 0xFFFFFFFF
+ h = ((h ^ (h >> 12)) * 0x297A2D39) & 0xFFFFFFFF
+ return (h ^ (h >> 15)) & 0x3FFFFFFF
+
+
+def _make_phacelle_jit():
+ """TileSource._phacelle's lattice blend compiled (numba optional; WORLDGEN_NO_JIT=1 turns it off): the same
+ hash, weights and products per point. exp may differ from numpy's in the last bit (≪ 1e-9 m of height)."""
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ jit = PH_JIT
+
+ @numba.njit(cache=True, error_model="numpy")
+ def phacelle(q, a, seed):
+ n = q.shape[0]
+ cos_out, sin_out = np.empty(n), np.empty(n)
+ two_pi = 2.0 * np.pi
+ st = np.empty((3, 2))
+ pw = np.empty((3, 4, 2))
+ sd = np.int64(seed) * np.int64(2654435761)
+ for k in range(n):
+ ax, ay, az = a[k, 0], a[k, 1], a[k, 2]
+ fx, fy, fz = np.floor(q[k, 0]), np.floor(q[k, 1]), np.floor(q[k, 2])
+ ix, iy, iz = np.int64(fx), np.int64(fy), np.int64(fz)
+ tx, ty, tz = q[k, 0] - fx, q[k, 1] - fy, q[k, 2] - fz
+ tx = tx * tx * (3.0 - 2.0 * tx)
+ ty = ty * ty * (3.0 - 2.0 * ty)
+ tz = tz * tz * (3.0 - 2.0 * tz)
+ ph0 = two_pi * ((fx * ax + fy * ay + fz * az) - 0.5 * jit * (ax + ay + az))
+ e0r, e0i = np.cos(ph0), np.sin(ph0)
+ for d in range(3): # step phasors: corner step, jitter step and its powers
+ ang = two_pi * a[k, d]
+ st[d, 0], st[d, 1] = np.cos(ang), np.sin(ang)
+ ang = two_pi * jit / 3.0 * a[k, d]
+ jr, ji = np.cos(ang), np.sin(ang)
+ pw[d, 0, 0], pw[d, 0, 1] = 1.0, 0.0
+ for m in range(1, 4):
+ pw[d, m, 0] = pw[d, m - 1, 0] * jr - pw[d, m - 1, 1] * ji
+ pw[d, m, 1] = pw[d, m - 1, 0] * ji + pw[d, m - 1, 1] * jr
+ re, im = 0.0, 0.0
+ for dx in range(2):
+ wx = tx if dx else 1.0 - tx
+ for dy in range(2):
+ wy = wx * (ty if dy else 1.0 - ty)
+ for dz in range(2):
+ w = wy * (tz if dz else 1.0 - tz)
+ h = ((ix + dx) * 73856093) ^ ((iy + dy) * 19349663) ^ ((iz + dz) * 83492791) ^ sd
+ h = h & 0xFFFFFFFF
+ h = ((h ^ (h >> 15)) * 0x2C1B3C6D) & 0xFFFFFFFF
+ h = ((h ^ (h >> 12)) * 0x297A2D39) & 0xFFFFFFFF
+ h = (h ^ (h >> 15)) & 0x3FFFFFFF
+ zr, zi = e0r, e0i
+ if dx:
+ zr, zi = zr * st[0, 0] - zi * st[0, 1], zr * st[0, 1] + zi * st[0, 0]
+ if dy:
+ zr, zi = zr * st[1, 0] - zi * st[1, 1], zr * st[1, 1] + zi * st[1, 0]
+ if dz:
+ zr, zi = zr * st[2, 0] - zi * st[2, 1], zr * st[2, 1] + zi * st[2, 0]
+ m0, m1, m2 = h & 3, (h >> 2) & 3, (h >> 4) & 3
+ zr, zi = zr * pw[0, m0, 0] - zi * pw[0, m0, 1], zr * pw[0, m0, 1] + zi * pw[0, m0, 0]
+ zr, zi = zr * pw[1, m1, 0] - zi * pw[1, m1, 1], zr * pw[1, m1, 1] + zi * pw[1, m1, 0]
+ zr, zi = zr * pw[2, m2, 0] - zi * pw[2, m2, 1], zr * pw[2, m2, 1] + zi * pw[2, m2, 0]
+ re += w * zr
+ im += w * zi
+ A = two_pi * (q[k, 0] * ax + q[k, 1] * ay + q[k, 2] * az)
+ ca, sa = np.cos(A), np.sin(A)
+ r = max(np.hypot(re, im), 1e-12)
+ re, im = re / r, im / r
+ cos_out[k] = ca * re + sa * im
+ sin_out[k] = sa * re - ca * im
+ return cos_out, sin_out
+ return phacelle
+
+
+_phacelle_jit = _make_phacelle_jit()
+
+
+def _resolved(wave_km, px_km) -> float:
+ """0 → 1 as a pattern of this wavelength grows from 4 to 8 pixels: too small to read → drawn as its mean."""
+ return float(np.clip(np.log2(wave_km / (4.0 * px_km)), 0.0, 1.0))
+
+
+def veg_cover(zones) -> np.ndarray:
+ """Wooded share of open land per Holdridge zone name (VEG_COVER: the first suffix that matches)."""
+ return np.array([next((c for k, c in VEG_COVER if n.endswith(k)), 0.0) for n in zones], dtype=np.float64)
+
+
+def coast_water(z, sea, a: float = 20.0):
+ """Water where the detailed height is below a threshold that runs smoothly from −∞ (no sea nearby) through ≈0
+ (mixed coast: the build's own shoreline, z = 0) to +∞ (open sea): fractal coasts, no hard cut where a sea cell
+ leaves the neighbourhood, no inland seas in dry depressions."""
+ s = np.clip(sea, 1e-6, 1.0 - 1e-6)
+ return np.asarray(z) < a * (1.0 / (1.0 - s) - 1.0 / s)
+
+
+def tile_lat_lon(z, x, y, idx):
+ """Lat/lon (deg) of tile-local pixel-centre indices (negative or ≥ TILE reach into the neighbours)."""
+ f = (np.asarray(idx, dtype=np.float64) + 0.5) / TILE
+ span = 180.0 / 2 ** z
+ return 90.0 - (y + f) * span, -180.0 + (x + f) * span
+
+
+def _render_worker(conn, sources, parent: int) -> None:
+ """A forked render process: renders what it is sent for the source it names; a job for an older region set is
+ answered "stale" (the parent renders it itself while it forks fresh workers)."""
+ signal.signal(signal.SIGINT, signal.SIG_IGN) # Ctrl-C is for the server; when it dies, the pipe
+ if os.getppid() != parent: # ends (EOF below) and so does this worker
+ os._exit(0) # (not PR_SET_PDEATHSIG: that follows the forking
+ for s in sources.values(): # thread, which may be a short-lived one)
+ s.lock = threading.Lock() # forked while a server thread may have held one
+ fd = conn.fileno() # inherited: the server's listening socket, the other
+ os.closerange(3, fd) # workers' pipes… — holding them would keep a dead
+ os.closerange(fd + 1, os.sysconf("SC_OPEN_MAX")) # server's port and workers alive
+ while True:
+ try:
+ name, layer, z, x, y, fp = conn.recv()
+ except (EOFError, OSError):
+ return
+ try:
+ src = sources[name]
+ if src.regions.fingerprint != fp:
+ conn.send(("stale", None))
+ continue
+ if layer == EXPORT_JOB:
+ import export
+ conn.send(("ok", export.tile_data(src, z, x, y)))
+ else:
+ conn.send(("ok", src.render(layer, z, x, y)))
+ except Exception as e: # noqa: BLE001 — reported to the parent
+ conn.send(("err", f"{type(e).__name__}: {e}"))
+
+
+RENDER_TIMEOUT_S = 300.0 # one job; a worker slower than this is taken as hung and stopped
+
+
+def fingerprint(out: Path, seed: int) -> str:
+ """A build's identity for its tiles and refined areas (a copy must keep the files' mtimes: rsync -a does)."""
+ out = Path(out)
+ h = hashlib.sha1(f"{VERSION}:{seed}".encode())
+ for name in ("fields.json", "cells_meta.json"):
+ h.update((out / name).read_bytes())
+ for name in ("cells.npz", "raster/elevation.png"):
+ st = (out / name).stat()
+ h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode())
+ return h.hexdigest()[:10]
+
+
+class Busy(Exception):
+ """Too many tiles already wait for a render (a public server's limit): ask again later."""
+
+
+class RenderPool:
+ """Worker processes forked from a warmed TileSource (the loaded world is shared copy-on-write), so tiles render
+ in parallel on several cores. Each job names the regions fingerprint it expects."""
+ def __init__(self, sources, n: int):
+ sources = sources if isinstance(sources, dict) else {sources.name: sources}
+ self.sources = sources
+ import export # noqa: F401 — loaded before forking: no import in a child (a parent thread may hold its lock)
+ ctx = multiprocessing.get_context("fork")
+ self.idle, self.procs, self.alive, self.lock = queue.Queue(), [], 0, threading.Lock()
+ self.proc_of = {}
+ for _ in range(n):
+ a, b = ctx.Pipe()
+ p = ctx.Process(target=_render_worker, args=(b, sources, os.getpid()), daemon=True)
+ p.start()
+ b.close()
+ self.procs.append(p)
+ self.proc_of[a] = p
+ self.idle.put(a)
+ self.alive += 1
+
+ def render(self, name, layer, z, x, y, fp) -> bytes:
+ while True: # wait for a free worker, unless none are left
+ if self.alive <= 0:
+ raise RuntimeError("no render workers left")
+ try:
+ conn = self.idle.get(timeout=2.0)
+ break
+ except queue.Empty:
+ continue
+ try:
+ conn.send((name, layer, z, x, y, fp))
+ if not conn.poll(RENDER_TIMEOUT_S):
+ self.proc_of[conn].kill()
+ with self.lock:
+ self.alive -= 1
+ raise RuntimeError(f"a render worker timed out ({layer} {z}/{x}/{y})")
+ kind, data = conn.recv()
+ except (EOFError, OSError):
+ with self.lock:
+ self.alive -= 1 # a dead worker is not handed out again
+ raise RuntimeError("a render worker died")
+ self.idle.put(conn)
+ if kind != "ok":
+ raise RuntimeError(data or kind)
+ return data
+
+ def close(self) -> None:
+ for p in self.procs:
+ p.terminate()
+ for p in self.procs:
+ p.join(timeout=5)
+ self.alive = 0
+
+
+REGION_VERSION = 4 # how tiles draw refined areas (bump: their saved tiles re-render); 2: lakes end at their bed;
+ # 3: judged on the cells' smooth ground (no dry pits); 4: that ground against the lowest bed of
+ # the lake's cells it mixes (no dry pits where a lake deepens), shores follow the ground
+EXPORT_JOB = "__export__" # a render-pool job: a tile's export layers (export.tile_data) instead of an image
+CARRY_KM = 100.0 # a region change keeps saved tiles farther than this from the areas that changed
+MANIFEST = "areas.npz" # in a region set's tile folder: its areas (name, key, coarse points) for a later carry-over
+MANIFEST_KM = 25.0 # the coarse points' grid
+
+
+class TileSource:
+ def __init__(self, world, seed: int, cache_dir: Path | None = None, regions_dir: Path | None = None,
+ with_regions: bool = True, name: str = "base", share=None):
+ self.w, self.seed = world, int(seed)
+ self.out = world.out
+ self.R = float(world.cells_meta["radius_km"])
+ self.enc = json.loads((self.out / "fields.json").read_text())["continuous"]
+ self.fingerprint = self._fingerprint()
+ self.regions_dir = Path(regions_dir or (self.out / "regions"))
+ self.regions = (refine.RegionSet(self.regions_dir, self.fingerprint, world.legends, self.R) if with_regions
+ else refine.RegionSet.none()) # refined areas (a build needs none)
+ self.base_tag = f"v{VERSION}.{DRAW}-{self.fingerprint}"
+ self.tag = self.base_tag + self._region_tag(self.regions) # new build or new refined
+ # areas: never serve old tiles. On disk, tiles away from the areas stay in the world's own cache (base_tag).
+ self.url = f"/tiles/{self.tag}/{{layer}}/{{z}}/{{x}}/{{y}}.jpg"
+ self.own_cache = cache_dir is None
+ self.cache_root = cache_dir or (self.out / "tiles")
+ self.cache_dir = self.cache_root / self.tag
+ self.lock = threading.Lock()
+ self._rasters, self._tree, self._net, self.mem = {}, None, None, OrderedDict()
+ self.name, self.share = name, share
+ self.peers = {name: self}
+ self._mask_tree = None # an era: its influence mask (cells.npz era_mask)
+ if share is not None:
+ with np.load(self.out / "cells.npz") as a:
+ m = a["era_mask"].astype(bool) if "era_mask" in a.files else np.ones(len(a["g_ids"]), bool)
+ self._mask_tree = cKDTree(np.asarray(a["g_xyz"], dtype=np.float64)[m]) if m.any() else None
+ self._spacing_km = float(np.sqrt(4.0 * np.pi * self.R ** 2 / len(self.w.ids)))
+ self.pool = None # RenderPool (start_workers), else render here
+ self.cap = None # tilecap.TileCap: a size cap on the saved tiles
+ names = world.legends["landform"]
+ self.amp = np.array([DEEP.get(n, (60, 0.0))[0] for n in names], dtype=np.float64)
+ self.ridge = np.array([DEEP.get(n, (60, 0.0))[1] for n in names], dtype=np.float64)
+
+ def _fingerprint(self) -> str:
+ return fingerprint(self.out, self.seed)
+
+ @staticmethod
+ def _region_tag(rs) -> str:
+ """The refined areas' part of the tile tag: their results and how tiles draw them (REGION_VERSION)."""
+ return "" if rs.empty else hashlib.sha1(f"{rs.fingerprint}:{REGION_VERSION}".encode()).hexdigest()[:6]
+
+ def reload_regions(self, cleanup: bool = True, refork: bool = True) -> None:
+ """A region build finished: new refined areas, new tile URLs (old ones stop answering), fresh memory cache.
+ Saved tiles of areas that did not change (and are far from the ones that did) move to the new set."""
+ rs = refine.RegionSet(self.regions_dir, self.fingerprint, self.w.legends, self.R)
+ tag = self.base_tag + self._region_tag(rs)
+ with self.lock:
+ old, old_rs = self.tag, self.regions
+ self.regions, self.tag = rs, tag
+ self.url = f"/tiles/{tag}/{{layer}}/{{z}}/{{x}}/{{y}}.jpg"
+ self.cache_dir = self.cache_root / tag
+ self.mem.clear()
+ gone = [h for h in old_rs.area_names if h not in rs.area_names]
+ new = [h for h in rs.area_names if old_rs.area_keys.get(h) != rs.area_keys[h]] # new or rebuilt
+ gone_trees = {h: old_rs.area_trees[h] for h in gone if h in old_rs.area_trees} # all the carry-over needs
+ old_spacing = getattr(old_rs, "spacing_km", 0.0)
+ del old_rs # the old set's memory goes before the fork
+ rs.warm() # before the fresh workers are forked
+ if cleanup:
+ self.prune_region_caches() # the previous set's (also when no area is left)
+ if cleanup and refork:
+ self.refork()
+ if not cleanup or old in (tag, self.base_tag):
+ return
+ self._carry(self.cache_root / old, rs, tag, list(gone_trees.values()), old_spacing, new)
+ self._save_manifest(rs, tag)
+
+ def _carry(self, src_dir: Path, rs, tag: str, gone_trees: list, gone_spacing: float, new: list) -> None:
+ """Move src_dir's saved tiles away from the gone and the new (or rebuilt) areas to region set `tag`; the
+ rest of src_dir goes."""
+ if not rs.empty and src_dir.is_dir():
+ for f in src_dir.rglob("*"):
+ if not f.is_file() or f.name.endswith(".part"):
+ continue
+ rel = f.relative_to(src_dir) # layer/z/x/y.ext
+ try:
+ z, x, y = int(rel.parts[1]), int(rel.parts[2]), int(rel.stem)
+ except (IndexError, ValueError):
+ continue
+ if (refine.touches_trees(gone_trees, gone_spacing, self.R, z, x, y, CARRY_KM)
+ or rs.touches(z, x, y, new, CARRY_KM)):
+ continue
+ dst = self.cache_root / tag / rel
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ os.replace(f, dst)
+ shutil.rmtree(src_dir, ignore_errors=True)
+
+ def _save_manifest(self, rs, tag: str) -> None:
+ """Region set `tag`'s areas (name, key, points on a MANIFEST_KM grid) next to its tiles: a later start with
+ other areas carries the tiles over (carry_stale) though these areas' results are gone by then."""
+ f = self.cache_root / tag / MANIFEST
+ if rs.empty or f.exists():
+ return
+ xyz, area, q = np.asarray(rs.arrays["g_xyz"]), np.asarray(rs.area), MANIFEST_KM / self.R
+ pts = {f"p{k}": (np.unique(np.round(xyz[area == k] / q).astype(np.int32), axis=0) * q).astype(np.float32)
+ for k in range(len(rs.area_names))}
+ f.parent.mkdir(parents=True, exist_ok=True)
+ tmp = f.with_name(f".{MANIFEST}-{os.getpid()}.npz")
+ np.savez(tmp, names=np.array(rs.area_names), keys=np.array([rs.area_keys[h] for h in rs.area_names]),
+ version=REGION_VERSION, **pts)
+ os.replace(tmp, f)
+
+ def carry_stale(self) -> None:
+ """At a start: saved tiles of this world's earlier region sets (regions rebuilt while the server was stopped)
+ move to the current set where their areas did not change, as a reload does; then the current set's
+ manifest. Sets without a manifest, or drawn by another REGION_VERSION, are left to warm()'s cleanup."""
+ rs = self.regions
+ if rs.empty:
+ return
+ for d in sorted(self.cache_root.glob(self.base_tag + "?*")):
+ if d.name == self.tag or not (d / MANIFEST).is_file():
+ continue
+ try:
+ with np.load(d / MANIFEST) as m:
+ if int(m["version"]) != REGION_VERSION: # drawn the old way: left to warm()'s cleanup
+ continue
+ names = [str(n) for n in m["names"]]
+ keys = dict(zip(names, (str(k) for k in m["keys"])))
+ gone = [cKDTree(m[f"p{k}"].astype(np.float64)) for k, h in enumerate(names)
+ if rs.area_keys.get(h) != keys[h]]
+ except (OSError, ValueError, KeyError):
+ continue
+ new = [h for h in rs.area_names if keys.get(h) != rs.area_keys[h]]
+ self._carry(d, rs, self.tag, gone, MANIFEST_KM, new)
+ self._save_manifest(rs, self.tag)
+
+ def refork(self) -> None:
+ """Fresh workers forked from the new state share its memory (loading it in each worker: six copies)."""
+ if self.pool is None:
+ return
+ import gc
+ gc.collect()
+ refine.trim_memory()
+ retired = self.pool
+ fresh = RenderPool(self.peers, len(retired.procs))
+ for s in self.peers.values():
+ s.pool = fresh
+ retired.close()
+
+ def prune_region_caches(self) -> None:
+ """Remove this world's region serve caches other than the current set's (all of them when it has no areas)."""
+ keep = [self.regions.cache] if self.regions.cache is not None else []
+ servecache.prune(refine.results_dir(self.regions_dir, self.fingerprint), keep)
+
+ def meta(self) -> dict:
+ return {"url": self.url, "mesh_url": f"/tiles/{self.tag}/mesh/{{z}}/{{x}}/{{y}}.bin", "min_z": 5, "max_z": MAX_Z,
+ "rivers": True}
+
+ # --- data -------------------------------------------------------------------------------------------------
+ def raster(self, name: str) -> np.ndarray:
+ with self.lock:
+ if name not in self._rasters:
+ e = self.enc[name]
+ a = np.asarray(Image.open(self.out / "raster" / e["file"]), dtype=np.float32)
+ self._rasters[name] = a * np.float32(e["scale"]) + np.float32(e["offset"])
+ return self._rasters[name]
+
+ def surface(self) -> np.ndarray:
+ """The raster the tiles draw from: the water surface (lakes at their level) where the build has one."""
+ return self.raster("surface" if "surface" in self.enc else "elevation")
+
+ def _flat_lakes(self, z, lat, lon, e, nn):
+ """The world's heights with its lakes flat at their level (refined areas blend their own in over them)."""
+ a = self.w.arrays
+ if "lake_level_m" not in a:
+ return z
+ lev = np.asarray(a["lake_level_m"], dtype=np.float64)[self._warped_cells(lat, lon, e, nn).reshape(np.shape(z))]
+ return np.where(np.isfinite(lev), lev, z)
+
+ def tree(self) -> cKDTree:
+ with self.lock:
+ if self._tree is None:
+ self._tree = cKDTree(np.asarray(self.w.arrays["g_xyz"], dtype=np.float64))
+ return self._tree
+
+ @property
+ def river_net(self) -> RV.RiverNet:
+ with self.lock:
+ if self._net is None:
+ self._net = RV.RiverNet(self.w.arrays, self.w.legends, self.R)
+ return self._net
+
+ def warm(self) -> None:
+ self.tree()
+ self.river_net
+ self.regions.warm()
+ self.surface()
+ if self.own_cache: # tiles of older builds / models are dead weight; other
+ for d in (self.out / "tiles").glob("v*"): # region sets of this world only when we have our own
+ if not d.is_dir() or d.name in (self.tag, self.base_tag): # (a smoke server has none: it keeps them)
+ continue
+ if not d.name.startswith(self.base_tag) or not self.regions.empty:
+ shutil.rmtree(d, ignore_errors=True)
+
+ def bilinear(self, a, lat, lon):
+ H, W = a.shape
+ col = (np.asarray(lon) + 180.0) / 360.0 * W - 0.5
+ row = np.clip((90.0 - np.asarray(lat)) / 180.0 * H - 0.5, 0, H - 1)
+ c0 = np.floor(col).astype(np.int64)
+ r0 = np.minimum(np.floor(row).astype(np.int64), H - 2)
+ fc, fr = col - c0, row - r0
+ ca, cb = c0 % W, (c0 + 1) % W
+ top = a[r0, ca] * (1 - fc) + a[r0, cb] * fc
+ bot = a[r0 + 1, ca] * (1 - fc) + a[r0 + 1, cb] * fc
+ return top * (1 - fr) + bot * fr
+
+ # --- procedural detail ------------------------------------------------------------------------------------
+ def _octaves(self, xyz, px_km, top_km, min_km, gain, seed, ridge=None):
+ """Σ gainᵏ·noiseₖ for wavelengths top_km/2ᵏ ≥ max(px_km, min_km); the finest ones fade in over 1–4 px."""
+ out = np.zeros(len(xyz))
+ a, lam, k = 1.0, top_km, 0
+ while lam >= max(px_km, min_km):
+ w = min(1.0, 0.5 * float(np.log2(lam / px_km))) # fade in over 1–4 px: no pixel-scale grain
+ v = value_noise(xyz * (self.R / lam), seed + 101 * k)
+ if ridge is not None:
+ v = (1.0 - ridge) * v + ridge * (1.0 - 2.0 * np.abs(v) - RIDGE_MEAN)
+ out += w * a * v
+ a *= gain
+ lam /= 2.0
+ k += 1
+ return out
+
+ def _detail(self, xyz, px_km, amp, ridge, lat, lon, base=None):
+ """Procedural relief (m) at points: the fractal detail, and on slopes erosion gullies in its place — stripes
+ running downhill, octave by octave, each finer one following the slopes the coarser ones made, so they
+ branch (after Rune Skovbo Johansen's erosion filter and Phacelle noise, 2025). The downhill direction is that
+ of the bedrock plus the two coarsest detail octaves, the same at every zoom. base(lat, lon): bedrock height
+ (m), default the build's elevation raster. Each gully octave fades in as its waves grow from 4 to 8 px."""
+ amp, ridge = np.asarray(amp, dtype=np.float64), np.asarray(ridge, dtype=np.float64)
+ det = self._octaves(xyz, px_km, TOP_KM, MIN_KM, GAIN, self.seed + 7001, ridge)
+ fz = _resolved(GULLY_KM, px_km)
+ if fz == 0.0:
+ return amp * det
+ base = base or (lambda la, lo: self.bilinear(self.raster("elevation"), la, lo))
+ ref = min(px_km, 0.25) # zoom-independent coarse octaves
+
+ def coarse(p):
+ return self._octaves(p, ref, TOP_KM, TOP_KM / 2, GAIN, self.seed + 7001, ridge)
+
+ e, n = east_north(xyz)
+ h = GULLY_STEP_KM / self.R
+ c0 = coarse(xyz)
+ z0 = base(lat, lon) + amp * c0
+ grad = []
+ for t in (e, n): # forward differences, m per m
+ hi = xyz + h * t
+ hi = hi / np.linalg.norm(hi, axis=1, keepdims=True)
+ grad.append((base(*_ll(hi)) + amp * coarse(hi) - z0) / (GULLY_STEP_KM * 1000.0))
+ ge, gn = grad
+ on = fz * np.tanh(np.hypot(ge, gn) / GULLY_SLOPE)
+ out = amp * (det + GULLY_SHARE * on * (c0 - det)) # finer fractal detail gives way on slopes
+ lam, a, k = GULLY_KM, GULLY_AMP, 0
+ while lam >= max(4.0 * px_km, MIN_KM):
+ w = fz * _resolved(lam, px_km)
+ sl = np.hypot(ge, gn)
+ ue, un = ge / np.maximum(sl, 1e-12), gn / np.maximum(sl, 1e-12)
+ across = -un[:, None] * e + ue[:, None] * n # tangent, across the slope
+ c, sn, dphase = self._phacelle(xyz, across, lam, self.seed + 7801 + 31 * k)
+ prof = 1.0 - 2.0 * (1.0 - np.abs(c)) ** 1.5 # sharp gully floors, rounded spurs
+ m = np.tanh(sl / GULLY_SLOPE)
+ height = w * a * amp * m
+ out += height * prof
+ dprof = -3.0 * np.sqrt(np.maximum(1.0 - np.abs(c), 0.0)) * np.sign(c) * sn * dphase
+ ge = ge + height * dprof * (-un) # the slope this octave made (m/m) steers
+ gn = gn + height * dprof * ue # the next
+ lam /= 2.0
+ a *= GULLY_GAIN
+ k += 1
+ return out
+
+ def _phacelle(self, xyz, across, lam_km, seed):
+ """Stripes of wavelength lam_km across `across` at points: the phases from the 8 jittered lattice points
+ around each point, blended as unit phasors (Johansen's Phacelle noise, in 3-D on the sphere) so the
+ stripes stay sharp and continuous. Returns (cos, sin) of the blended phase and its rate across (rad per m).
+ Phase at a lattice point c: 2π (q − c)·across = 2π q·a − 2π (i·a + corner·a + jitter·a). The jitter takes
+ 4 steps per axis (PH_JIT wide), so every corner's phasor is a product of a few per-point ones: 7 sin/cos
+ per point instead of 18."""
+ q = xyz * (self.R / lam_km)
+ if _phacelle_jit is not None and q.dtype == np.float64:
+ c, sn = _phacelle_jit(np.ascontiguousarray(q), np.ascontiguousarray(across, dtype=np.float64), int(seed))
+ return c, sn, 2.0 * np.pi / (lam_km * 1000.0)
+ i = np.floor(q)
+ t = q - i
+ t = t * t * (3.0 - 2.0 * t)
+ a = across
+ ii = i.astype(np.int64)
+ tp = 2.0 * np.pi
+ e0 = np.exp(1j * tp * ((i * a).sum(1) - 0.5 * PH_JIT * a.sum(1))) # lattice corner (0,0,0), jitter −½
+ ex, ey, ez = (np.exp(1j * tp * a[:, d]) for d in range(3)) # one corner step along x, y, z
+ jx, jy, jz = (np.exp(1j * tp * PH_JIT / 3.0 * a[:, d]) for d in range(3)) # one jitter step (of 3)
+ jpow = [[np.ones(len(q), complex), j, j * j, j * j * j] for j in (jx, jy, jz)]
+ acc = np.zeros(len(q), complex)
+ for dx in (0, 1):
+ wx = t[:, 0] if dx else 1.0 - t[:, 0]
+ for dy in (0, 1):
+ wy = wx * (t[:, 1] if dy else 1.0 - t[:, 1])
+ for dz in (0, 1):
+ w = wy * (t[:, 2] if dz else 1.0 - t[:, 2])
+ h = _hash32(ii[:, 0] + dx, ii[:, 1] + dy, ii[:, 2] + dz, seed)
+ z = e0 * (ex if dx else 1.0) * (ey if dy else 1.0) * (ez if dz else 1.0)
+ z = z * np.choose(h & 3, jpow[0]) * np.choose((h >> 2) & 3, jpow[1]) * np.choose((h >> 4) & 3, jpow[2])
+ acc += w * z
+ re, im = acc.real, acc.imag
+ A = tp * (q * a).sum(1)
+ ca, sa = np.cos(A), np.sin(A)
+ r = np.maximum(np.hypot(re, im), 1e-12)
+ re, im = re / r, im / r # unit phasor of −(blended lattice phase)
+ return ca * re + sa * im, sa * re - ca * im, 2.0 * np.pi / (lam_km * 1000.0)
+
+ def _amp(self, xyz):
+ """Landform amplitude, ridged share and sea fraction blended over the 4 nearest cells. The kernel falls to 0 at
+ the 5th-nearest distance, so the blend stays continuous when the set of neighbours changes."""
+ d, k = self.tree().query(xyz, k=5)
+ w = np.clip(1.0 - d[:, :4] / np.maximum(d[:, 4:5], 1e-12), 0.0, None) ** 2
+ s = w.sum(axis=1, keepdims=True)
+ w = np.where(s > 0, w / np.maximum(s, 1e-300), 0.25)
+ k4 = k[:, :4]
+ lf = np.asarray(self.w.arrays["landform"])[k4]
+ sea = np.asarray(self.w.arrays["ocean"])[k4].astype(np.float64)
+ return (w * self.amp[lf]).sum(1), (w * self.ridge[lf]).sum(1), (w * sea).sum(1)
+
+ def _micro(self, xyz, px_km, amp):
+ """Micro-relief (m) the tiles draw on top of relief_at: low swells that keep plains from looking poured."""
+ return (MICRO_M[0] + MICRO_M[1] * amp) * self._octaves(xyz, px_km, MICRO_KM, MIN_KM, MICRO_GAIN, self.seed + 7301)
+
+ @staticmethod
+ def _roughen(t, m, V, base):
+ """Heights t + micro-relief m with valleys V cut in, never digging dry ground below a valley's water (its
+ base − 1 m) that t alone kept above it. base: +inf / ≥ 1e8 where no valley reaches."""
+ z0 = np.minimum(t, V)
+ lo = np.where(base < 1e8, base - 1.0, -np.inf)
+ return np.maximum(np.minimum(t + m, V), np.minimum(z0, lo))
+
+ def _warp_offsets(self, xyz, px_km):
+ """East/north warp noise (≈ ±1) for the organic cell borders; octaves down to px_km."""
+ return (self._octaves(xyz, px_km, WARP_TOP_KM, 0.05, 0.5, self.seed + 9001),
+ self._octaves(xyz, px_km, WARP_TOP_KM, 0.05, 0.5, self.seed + 9377))
+
+ def _region_warp(self, xyz, px_km): # the organic warp for refined areas (waves at their cell scale)
+ return self._warp_offsets(np.asarray(xyz) * REGION_FREQ, 4 * px_km * REGION_FREQ)
+
+ def _warped_cells(self, lat, lon, e, nn):
+ return self.tree().query(latlon_to_xyz(*self._warp_ll(lat, lon, e, nn)).reshape(-1, 3))[1]
+
+ def _refined(self, xyz, lat, lon, px_km, rough, warp, lattice=None):
+ """Heights inside refined areas: the fine cells' valley-shoulder heights (compact 4-of-5 kernel) + procedural
+ detail below 5 km; the refined rivers' valleys cut in with their rock/climate walls; lakes wherever the ground
+ lies below a lake's surface. The organic warp (REGION_WARP of the world's) shapes rivers and lake shores.
+ Computed only where an area shows (weight > 0). Returns (z, weight, channel, draw, lake, sea fraction):
+ channels wherever the area shows (they meet the world's rivers in the blend band), lakes where it dominates."""
+ rs = self.regions
+ w = rs.weight(xyz)
+ n = len(xyz)
+ z, ch, dr, lake, sea = np.zeros(n), np.zeros(n, bool), np.zeros(n, bool), np.zeros(n, bool), np.zeros(n)
+ on = np.where(w > 0)[0]
+ if not len(on):
+ return z, w, ch, dr, lake, sea
+ q = xyz[on]
+ idx5, d5 = rs.nearest(q, k=5)
+ wt = np.clip(1.0 - d5[:, :4] / np.maximum(d5[:, 4:5], 1e-12), 0.0, None) ** 2
+ s = wt.sum(axis=1, keepdims=True)
+ wt = np.where(s > 0, wt / np.maximum(s, 1e-300), 0.25)
+ idx = idx5[:, :4]
+ zq = (rs.z_env[idx] * wt).sum(1)
+ smooth = zq.copy() # the cells' ground, before detail and valleys
+ sea[on] = (rs.arrays["ocean"][idx].astype(np.float64) * wt).sum(1)
+ amp, ridge, _ = self._amp(q)
+ zq = zq + amp * 0.55 * self._octaves(q, px_km, TOP_KM / 2, MIN_KM, GAIN, self.seed + 7001, ridge)
+ m = self._micro(q, px_km, amp)
+ wla, wlo = self._warp_ll(np.asarray(lat)[on], np.asarray(lon)[on], warp[0][on], warp[1][on], scale=REGION_WARP)
+ wxyz = latlon_to_xyz(wla, wlo).reshape(-1, 3)
+ if rs.net is not None and rs.net.tree is not None:
+ rg = 0.0 if rough is None else np.asarray(rough)[on] if np.ndim(rough) else rough
+ if lattice is None:
+ V, c, d, base = rs.net.valleys(wxyz, px_km, rg, with_base=True)
+ else: # tiles: valley surfaces on the aligned 4-px lattice (seams agree, fast), channels per pixel
+ cxyz_w, up, near = lattice
+ base, wall_up, rf = (np.full(len(cxyz_w), np.inf), np.zeros(len(cxyz_w)), np.zeros(len(cxyz_w)))
+ if near.any():
+ base[near], wall_up[near], rf[near] = rs.net.parts(cxyz_w[near], px_km, 4 * px_km)
+ base = up(np.where(np.isfinite(base), base, 1e9)).ravel()[on]
+ V = base + np.maximum(0.0, up(wall_up).ravel()[on] + up(rf).ravel()[on] * rg)
+ c, d, lev = rs.net.channel(wxyz, px_km)
+ V = np.where(c, lev, V)
+ ch[on], dr[on] = c, d
+ zq = self._roughen(zq, m, V, base)
+ else:
+ zq = zq + m
+ near = rs.nearest(wxyz)[0]
+ level, floor = rs.lake_level[near], rs.lake_floor[near]
+ kl = rs.lake_level[idx] # a dry cell's point by a lake: the nearest
+ first = np.argmax(np.isfinite(kl), axis=1) # lake cell of its kernel (its shore follows
+ level = np.where(np.isfinite(level), level, kl[np.arange(len(kl)), first]) # the ground, not cell borders)
+ same = np.abs(kl - level[:, None]) < 0.5 # smooth mixes the kernel's cells: their
+ floor = np.fmin(floor, np.where(same, rs.lake_floor[idx], np.inf).min(1)) # beds count too
+ # water below the lake's surface, unless the cells' own ground (not the fine detail, which would punch dry
+ # pits) lies far below the lake's bed: then this is past a dam or a cliff, not under the lake
+ lk = (np.isfinite(level) & (zq <= np.nan_to_num(level, nan=-1e9))
+ & (smooth >= np.nan_to_num(floor, nan=1e9)))
+ z[on] = np.where(lk, level, zq)
+ lake[on] = lk & (w[on] > 0.5)
+ return z, w, ch, dr, lake, sea
+
+ def _z_sea(self, lat, lon, px_km):
+ """Detailed height (m) with river valleys cut in, neighbourhood sea fraction, unit vectors and the river
+ channel at points (1-D float64 arrays)."""
+ xyz = latlon_to_xyz(np.clip(lat, -90, 90), lon).reshape(-1, 3)
+ amp, ridge, sea = self._amp(xyz)
+ D = self._detail(xyz, px_km, amp, ridge, lat, lon)
+ wla, wlo = self._warp_ll(lat, lon, *self._warp_offsets(xyz, 4 * px_km))
+ z, channel, _ = self._valleys(wla, wlo, self.bilinear(self.surface(), lat, lon) + D, px_km,
+ D, self._micro(xyz, px_km, amp))
+ z = self._flat_lakes(z, lat, lon, *self._warp_offsets(xyz, 4 * px_km))
+ if not self.regions.empty: # refined areas: their own heights, rivers, lakes and sea, blended in at the edge
+ zr, w, chr_, _, lk, sear = self._refined(xyz, lat, lon, px_km, D, self._region_warp(xyz, px_km))
+ z = (1 - w) * z + w * zr
+ channel = (channel & (w < 1)) | chr_ | lk # the world's rivers meet the refined ones in the band
+ sea = np.where(w > 0.5, sear, sea)
+ return z, sea, xyz, channel
+
+ def _warp_ll(self, lat, lon, e, nn, scale=1.0): # the organic warp as lat/lon (same as the cell borders)
+ km_deg = self.R * np.pi / 180.0
+ return (np.clip(lat + scale * WARP_KM * nn / km_deg, -90, 90),
+ lon + scale * WARP_KM * e / (km_deg * np.maximum(np.cos(np.radians(lat)), 0.05)))
+
+ def _valleys(self, lat, lon, z, px_km, rough=None, micro=0.0):
+ """Cut river valleys into heights (+ micro-relief) at (already warped) points: min(z, the lowest
+ valley surface reaching each point), fading in as a valley grows from 1 to 2 px wide. Returns (z, channel,
+ drawn channel)."""
+ xyz = latlon_to_xyz(np.clip(lat, -90, 90), lon).reshape(-1, 3)
+ V, channel, draw, base = self.river_net.valleys(xyz, px_km, rough, with_base=True)
+ return self._roughen(z, micro, V, base), channel, draw
+
+ def surface_at(self, lat, lon, px_km: float = MIN_KM):
+ """Detailed height and water at points, as the tiles draw them: sea by the coast rule, lakes from warped cells."""
+ lat = np.atleast_1d(np.asarray(lat, dtype=np.float64))
+ lon = np.atleast_1d(np.asarray(lon, dtype=np.float64))
+ z, sea, xyz, channel = self._z_sea(lat, lon, px_km)
+ idx = self._warped_cells(lat, lon, *self._warp_offsets(xyz, 4 * px_km))
+ lake = np.asarray(self.w.arrays["lake"])[idx].astype(bool) if "lake" in self.w.arrays else np.zeros(len(lat), bool)
+ if not self.regions.empty: # inside refined areas: their own lakes (in channel)
+ lake &= self.regions.weight(xyz) <= 0.5
+ return z, coast_water(z, sea) | lake | channel
+
+ def z_at(self, lat, lon, px_km: float = MIN_KM) -> np.ndarray:
+ lat = np.atleast_1d(np.asarray(lat, dtype=np.float64))
+ lon = np.atleast_1d(np.asarray(lon, dtype=np.float64))
+ return self._z_sea(lat, lon, px_km)[0]
+
+ def mesh(self, z, x, y):
+ """(heights m, water) at a 3D patch's MESH_N × MESH_N vertices: rows north → south, columns west → east, tile
+ corners included, detail only down to the vertex spacing. True heights (the sea floor below 0 m); water = sea
+ by the coast rule (the client draws its surface at 0 m); lakes flat at their level."""
+ span = 180.0 / 2 ** z
+ f = np.arange(MESH_N) / (MESH_N - 1)
+ LA, LO = np.meshgrid(90.0 - (y + f) * span, -180.0 + (x + f) * span, indexing="ij")
+ h, sea, _, _ = self._z_sea(LA.ravel(), LO.ravel(), span / (MESH_N - 1) * np.pi / 180.0 * self.R)
+ return h.reshape(MESH_N, MESH_N), coast_water(h, sea).reshape(MESH_N, MESH_N)
+
+ def relief_at(self, lat, lon, px_km: float) -> np.ndarray:
+ """World surface + procedural relief down to px_km, without river valleys (regional refinement's start)."""
+ lat = np.atleast_1d(np.asarray(lat, dtype=np.float64))
+ lon = np.atleast_1d(np.asarray(lon, dtype=np.float64))
+ xyz = latlon_to_xyz(np.clip(lat, -90, 90), lon).reshape(-1, 3)
+ amp, ridge, _ = self._amp(xyz)
+ return self.bilinear(self.raster("elevation"), lat, lon) + self._detail(xyz, px_km, amp, ridge, lat, lon)
+
+ def fields(self, z, x, y, border=0, need=("z",), valleys=True) -> dict:
+ n = TILE + 2 * border
+ j = np.arange(n) - border
+ la, _ = tile_lat_lon(z, x, y, j)
+ _, lo = tile_lat_lon(z, x, y, j)
+ LA, LO = np.meshgrid(la, lo, indexing="ij")
+ px_km = 180.0 / 2 ** z / TILE * np.pi / 180.0 * self.R
+ ext = 4 * max(0, -(-(border - 4) // 4)) # wide borders: the lattice reaches past them too
+ C = np.arange(-4 - ext, TILE + 5 + ext, 4) # coarse lattice every 4 px, aligned across tiles
+ m = len(C)
+ cla, _ = tile_lat_lon(z, x, y, C)
+ _, clo = tile_lat_lon(z, x, y, C)
+ CLA, CLO = np.meshgrid(cla, clo, indexing="ij")
+ cxyz = latlon_to_xyz(np.clip(CLA, -90, 90), CLO).reshape(-1, 3)
+ rc = (j + 4 + ext) / 4.0
+ RR, CC = np.meshgrid(rc, rc, indexing="ij")
+
+ def up(a):
+ return map_coordinates(np.asarray(a, dtype=np.float64).reshape(m, m), [RR, CC], order=1, mode="nearest")
+
+ out = {"lat": LA, "lon": LO, "px_km": px_km}
+ if "z" in need or "water" in need:
+ amp, ridge, sea = self._amp(cxyz)
+ amp, ridge, out["sea"] = up(amp), up(ridge), up(sea)
+ out["amp"] = amp
+ xyz = latlon_to_xyz(np.clip(LA, -90, 90), LO).reshape(-1, 3)
+ D = self._detail(xyz, px_km, amp.ravel(), ridge.ravel(), LA.ravel(), LO.ravel()).reshape(n, n)
+ out["base"] = self.bilinear(self.surface(), LA, LO)
+ out["z"] = out["base"] + D
+ micro = self._micro(xyz, px_km, amp.ravel()).reshape(n, n)
+ out["river"] = out["river_draw"] = np.zeros((n, n), bool)
+ if valleys: # valley surfaces on the aligned 4-px lattice (seams agree), channels per pixel
+ e, nn = self._warp_offsets(cxyz, 4 * px_km)
+ cla_w, clo_w = self._warp_ll(CLA.ravel(), CLO.ravel(), e, nn)
+ base, wall_up, rf = self.river_net.parts(latlon_to_xyz(cla_w, clo_w).reshape(-1, 3), px_km, 4 * px_km)
+ base = up(np.where(np.isfinite(base), base, 1e9))
+ V = base + np.maximum(0.0, up(wall_up) + up(rf) * D)
+ wla, wlo = self._warp_ll(LA, LO, up(e), up(nn))
+ ch, dr, lev = self.river_net.channel(latlon_to_xyz(wla, wlo).reshape(-1, 3), px_km)
+ ch, dr = ch.reshape(n, n), dr.reshape(n, n)
+ V = np.where(ch, lev.reshape(n, n), V)
+ out["z"], out["river"], out["river_draw"] = self._roughen(out["z"], micro, V, base), ch, dr
+ else:
+ out["z"] = out["z"] + micro
+ e, nn = (up(v) for v in self._warp_offsets(cxyz, 4 * px_km))
+ out["z"] = self._flat_lakes(out["z"], LA, LO, e, nn)
+ if not self.regions.empty and self.regions.touches(z, x, y): # refined areas: their own heights, rivers,
+ e, nn = self._region_warp(cxyz, px_km) # lakes and sea, blended in at the edge
+ cla_w, clo_w = self._warp_ll(CLA.ravel(), CLO.ravel(), e, nn, scale=REGION_WARP)
+ near = binary_dilation((self.regions.weight(cxyz) > 0).reshape(m, m), iterations=1).ravel()
+ zr, w, chr_, drr, lk, sear = self._refined(
+ xyz, LA.ravel(), LO.ravel(), px_km, D.ravel(), (up(e).ravel(), up(nn).ravel()),
+ lattice=(latlon_to_xyz(cla_w, clo_w).reshape(-1, 3), up, near))
+ if w.any():
+ w, inside = w.reshape(n, n), (w > 0.5).reshape(n, n)
+ out["z"] = (1 - w) * out["z"] + w * zr.reshape(n, n)
+ world = w < 1 # the world's rivers meet the refined ones in the band
+ out["river"] = (out["river"] & world) | chr_.reshape(n, n)
+ out["river_draw"] = (out["river_draw"] & world) | drr.reshape(n, n)
+ out["sea"] = np.where(inside, sear.reshape(n, n), out["sea"])
+ out["_region"] = (inside, lk.reshape(n, n))
+ out["water"] = coast_water(out["z"], out["sea"])
+ if "cat" in need:
+ e, nn = (up(v) for v in self._warp_offsets(cxyz, 4 * px_km))
+ idx = self._warped_cells(LA, LO, e, nn).reshape(n, n)
+ for k in CAT_KEYS:
+ if k in self.w.arrays:
+ out[k] = np.asarray(self.w.arrays[k])[idx]
+ rs = self.regions
+ if rs.touches(z, x, y): # refined areas: categories of the fine cells (organic borders at their own scale)
+ xyz = latlon_to_xyz(np.clip(LA, -90, 90), LO).reshape(-1, 3)
+ inside = (rs.weight(xyz) > 0.5).reshape(n, n)
+ if inside.any():
+ re_, rn_ = (up(v) for v in self._region_warp(cxyz, px_km))
+ wla, wlo = self._warp_ll(LA, LO, re_, rn_, scale=REGION_WARP)
+ fi = rs.nearest(latlon_to_xyz(wla, wlo).reshape(-1, 3))[0].reshape(n, n)
+ for k in CAT_KEYS:
+ if k in out and k in rs.arrays:
+ out[k] = np.where(inside, np.asarray(rs.arrays[k])[fi], out[k])
+ if "_region" in out: # lakes: wherever the ground lies below a lake's surface (organic shores)
+ out["lake"] = np.where(out["_region"][0], out["_region"][1], out["lake"])
+ return out
+
+ # --- images -----------------------------------------------------------------------------------------------
+ def _hillshade(self, z, lat, px_km, zoom):
+ exag = float(np.clip(2.5 - 0.15 * (zoom - 5), 1.2, 2.5)) # gentler than the base's 4×: detail, not grain
+ dy = px_km * 1000.0
+ dx = dy * np.maximum(np.cos(np.radians(lat)), 0.01)
+ gy, gx = np.gradient(z)
+ dzdx, dzdn = gx / dx * exag, -gy / dy * exag
+ a, b = np.radians(315.0), np.radians(45.0)
+ L = (np.sin(a) * np.cos(b), np.cos(a) * np.cos(b), np.sin(b))
+ hs = np.clip((-dzdx * L[0] - dzdn * L[1] + L[2]) / np.sqrt(dzdx**2 + dzdn**2 + 1.0), 0.0, 1.0)
+ return hs[1:-1, 1:-1]
+
+ def _warped(self, xyz, px_km, top_km, gain, seed, s):
+ """Domain-warped octaves (Inigo Quilez, "Domain warping", 2002): noise at p + s·q(p), q three octave sums;
+ s in units of top_km. Swirled, flowing patch shapes instead of round blobs. The warp itself is smooth: its
+ octaves stop at top_km / 8."""
+ q = np.stack([self._octaves(xyz, px_km, top_km, top_km / 8, 0.5, seed + 11 + i) for i in range(3)], 1)
+ p = xyz + s * q * (top_km / self.R)
+ return self._octaves(p / np.linalg.norm(p, axis=1, keepdims=True), px_km, top_km, MIN_KM, gain, seed)
+
+ def _cover(self) -> tuple[np.ndarray, dict]:
+ if getattr(self, "_cover_cache", None) is None:
+ g = list(self.w.legends.get("ground", ()))
+ self._cover_cache = (veg_cover(self.w.legends.get("holdridge", ())),
+ {g.index(k): c for k, c in GROUND_COVER.items() if k in g})
+ return self._cover_cache
+
+ def _veg(self, f, B) -> dict:
+ """Land colour of a tile from its fields with a border of B px (B ≥ LAND_BORDER reaches everything it uses,
+ so tiles meet without seams; f from fields(…, ("z", "water", "cat"))): terrain shading (steep darker and drier, crests lighter and drier, green along
+ water) and a vegetation mosaic — woods vs open ground, wooded share by Holdridge zone, woods in hollows and
+ along water, not on crests or steep ground. Returns wood (0–1), bright (×) and tint (+ drier, − lusher) for
+ the tile's own pixels."""
+ from scipy.ndimage import distance_transform_edt
+ from scipy.special import ndtri
+ px, zz = f["px_km"], np.asarray(f["z"], dtype=np.float64)
+ R = min(B, LAND_BORDER)
+ c = lambda a: a[B:-B, B:-B]
+ gy, gx = np.gradient(zz, px * 1000.0)
+ steep = c(np.tanh(np.hypot(gx, gy) / STEEP))
+ ridge = c(np.tanh((zz - f["base"]) / np.maximum(CREST_M, 0.5 * f["amp"]))) # + crest, − hollow or valley
+ ridge = ridge * _resolved(TOP_KM, px)
+ wet = np.asarray(f["water"], bool) | (np.asarray(f["lake"]) > 0) | np.asarray(f["river_draw"], bool)
+ d = np.minimum(distance_transform_edt(~wet), R) if wet.any() else np.full(zz.shape, float(R))
+ near = c(np.where(d < R, np.exp(-d / min(RIPARIAN_KM / px, R / 3.0)), 0.0))
+ cover_z, cover_g = self._cover()
+ zone = c(np.asarray(f["holdridge"]))
+ cover = cover_z[np.clip(zone, 0, len(cover_z) - 1)] if len(cover_z) else np.zeros(zone.shape)
+ ground = c(np.asarray(f["ground"]))
+ for code, v in cover_g.items():
+ cover = np.where(ground == code, v, cover)
+ lat, lon = c(f["lat"]), c(f["lon"])
+ xyz = latlon_to_xyz(np.clip(lat, -90, 90), lon).reshape(-1, 3)
+ m = self._warped(xyz, px, VEG_KM, 0.6, self.seed + 7601, VEG_WARP).reshape(lat.shape)
+ m = m + 0.25 * ridge - 0.5 * near + 0.3 * steep
+ edge = max(0.04, px / 8.0) # patch edges ≈ a pixel soft at any zoom
+ wood = np.where(cover > 0, 1.0 / (1.0 + np.exp(np.clip((m - VEG_SD * ndtri(np.clip(cover, 1e-3, 1 - 1e-3))) / edge, -60, 60))), 0.0)
+ wood = cover + _resolved(VEG_KM, px) * (wood - cover) # far out: the zone's mean, not a few-pixel speckle
+ n1 = self._octaves(xyz, px, TINT_KM, MIN_KM, 0.6, self.seed + 7501).reshape(lat.shape)
+ n2 = self._octaves(xyz, px, TINT_KM, TINT_KM / 8, 0.6, self.seed + 7502).reshape(lat.shape)
+ terrain_b = 1.0 - 0.10 * steep + 0.06 * ridge - 0.05 * near
+ terrain_t = 0.6 * (14.0 * steep + 10.0 * ridge - 26.0 * near)
+ wood_b, wood_t = 0.9 - 0.08 * cover, -6.0 - 12.0 * cover # scrub grey-olive … dark forest green
+ open_t = np.minimum(16.0, -wood_t * cover / np.maximum(1.0 - cover, 0.05)) # clearings: lighter, the mean kept
+ bright = terrain_b * (1.0 + (wood_b - 1.0) * wood) + 0.3 * TINT[0] * n1
+ tint = terrain_t + wood * wood_t + (1.0 - wood) * open_t + 0.3 * TINT[1] * n2
+ return {"wood": wood, "bright": bright, "tint": tint}
+
+ def rgb(self, layer, z, x, y) -> np.ndarray:
+ if layer == "relief":
+ B = LAND_BORDER
+ f = self.fields(z, x, y, B, ("z", "water", "cat"))
+ c = lambda a: a[B:-B, B:-B]
+ o = lambda a: a[B - 1:1 - B, B - 1:1 - B] # the hillshade's one-pixel border
+ hs = self._hillshade(o(f["z"]), o(f["lat"]), f["px_km"], z)
+ v = self._veg(f, B)
+ return RN.relief_rgb(c(f["z"]), hs, c(f["holdridge"]), c(f["ground"]), c(f["ice"]), c(f["lake"]) | c(f["river_draw"]), ~c(f["water"]), # rivers: water
+ vary=(v["bright"], v["tint"]), style=self.w.cells_meta.get("style"))
+ if layer == "seafloor":
+ f = self.fields(z, x, y, 1, ("z", "water", "cat"))
+ c = lambda a: a[1:-1, 1:-1]
+ hs = self._hillshade(f["z"], f["lat"], f["px_km"], z)
+ seabed = c(f["seabed_type"]) if "seabed_type" in f else np.zeros(hs.shape, np.int64)
+ return seafloor_rgb(c(f["z"]), hs, seabed, ~c(f["water"]))
+ if layer == "elevation":
+ return RN._colorize("elevation", self.fields(z, x, y, 0, ("z",))["z"])
+ if layer in CONT:
+ la, lo = tile_lat_lon(z, x, y, np.arange(TILE))
+ LA, LO = np.meshgrid(la, lo, indexing="ij")
+ return RN._colorize(CONT[layer], self.bilinear(self.raster(CONT[layer]), LA, LO))
+ key, legend = CAT[layer]
+ f = self.fields(z, x, y, 0, ("cat",))
+ pal = RN.holdridge_palette() if key == "holdridge" else RN.category_palette(len(self.w.legends[legend]))
+ return np.asarray(pal)[np.clip(f[key], 0, len(pal) - 1)].astype(np.uint8)
+
+ def render(self, layer, z, x, y) -> bytes:
+ if layer == "mesh":
+ h, water = self.mesh(z, x, y)
+ return h.astype("<f4").tobytes() + water.astype(np.uint8).tobytes()
+ buf = io.BytesIO()
+ if layer == "height":
+ Image.fromarray(RN.encode(self.fields(z, x, y, 0, ("z",))["z"], 0.5, -12000.0)).save(buf, "PNG")
+ else:
+ Image.fromarray(np.ascontiguousarray(self.rgb(layer, z, x, y), dtype=np.uint8)).save(buf, "JPEG", quality=90)
+ return buf.getvalue()
+
+ def start_workers(self, n: int) -> None:
+ """Fork n render processes for this source and its peers (call before other threads start): every world is
+ loaded first, then shared copy-on-write."""
+ for s in self.peers.values():
+ s.tree()
+ s.river_net
+ s.raster("elevation")
+ s.regions.warm() # search trees in the parent: workers share them
+ pool = RenderPool(self.peers, n) if n > 0 else None
+ for s in self.peers.values():
+ s.pool = pool
+
+ def prerender(self, max_z: int = 11, layers=("relief", "mesh"), progress=None, threads: int = 4,
+ stop=lambda: False, max_tiles: int | None = None) -> int:
+ """Render and save every tile of the refined areas from z5 to max_z (coarse first; saved tiles are skipped),
+ leaving out the deeper zooms that would take the job count over max_tiles. progress(done, total).
+ Returns the number rendered."""
+ rs = self.regions
+ if rs.empty:
+ return 0
+ lat, lon = np.asarray(rs.arrays["g_lat"]), np.asarray(rs.arrays["g_lon"])
+ jobs = []
+ for z in range(5, max_z + 1):
+ span = 180.0 / 2 ** z
+ xs = np.floor((lon + 180.0) / span).astype(np.int64)
+ ys = np.floor((90.0 - lat) / span).astype(np.int64)
+ cand = {(int(x + dx) % 2 ** (z + 1), int(y + dy)) for x, y in set(zip(xs.tolist(), ys.tolist()))
+ for dx in (-1, 0, 1) for dy in (-1, 0, 1)}
+ level = [(layer, z, x, y) for x, y in sorted(cand) if 0 <= y < 2 ** z and rs.touches(z, x, y)
+ for layer in layers]
+ if max_tiles is not None and jobs and len(jobs) + len(level) > max_tiles:
+ break # huge areas: deeper zooms render when viewed
+ jobs += level
+ done, rendered, lock, todo = 0, 0, threading.Lock(), iter(jobs)
+
+ def run(): # daemon threads taking one job at a time: a stop
+ nonlocal done, rendered # or the server's exit ends them at once
+ while not stop() and self.regions is rs:
+ with lock:
+ job = next(todo, None)
+ if job is None:
+ return
+ fresh = not self.cache_path(*job).exists()
+ if fresh:
+ self.tile(*job, remember=False)
+ with lock:
+ done += 1
+ rendered += fresh
+ if progress:
+ progress(done, len(jobs))
+
+ ts = [threading.Thread(target=run, daemon=True) for _ in range(max(1, threads))]
+ for t in ts:
+ t.start()
+ for t in ts:
+ t.join()
+ return rendered
+
+ def export_tile(self, z, x, y) -> dict:
+ """A tile's export layers (export.tile_data), on a render worker when there are some."""
+ import export
+ with self.lock:
+ rs = self.regions
+ if self.pool is not None:
+ try:
+ return self.pool.render(self.name, EXPORT_JOB, z, x, y, rs.fingerprint)
+ except RuntimeError:
+ pass
+ return export.tile_data(self, z, x, y)
+
+ def _own_areas(self) -> list:
+ """Refined areas of this era whose content differs from the base's (new, or a different key)."""
+ mine, theirs = self.regions.area_keys, self.share.regions.area_keys
+ return [h for h, k in mine.items() if theirs.get(h) != k]
+
+ def shared(self, z, x, y) -> bool:
+ """An era's tile that is exactly the base's: away from the influence mask (+ CARRY_KM) and from refined
+ areas the era changed; the base renders and saves it once for every era."""
+ if self.share is None:
+ return False
+ if self._mask_tree is not None and refine.touches_trees([self._mask_tree], self._spacing_km, self.R, z, x, y,
+ CARRY_KM):
+ return False
+ own = self._own_areas()
+ return not (own and self.regions.touches(z, x, y, own, CARRY_KM))
+
+ def cache_path(self, layer, z, x, y) -> Path:
+ if self.shared(z, x, y):
+ return self.share.cache_path(layer, z, x, y)
+ with self.lock:
+ rs, tag = self.regions, self.tag
+ return self._path(rs, tag, layer, z, x, y)
+
+ def _path(self, rs, tag, layer, z, x, y) -> Path:
+ return self.cache_root / (tag if rs.touches(z, x, y) else self.base_tag) / layer / str(z) / str(x) / \
+ f"{y}.{EXT.get(layer, 'jpg')}"
+
+ def _render_for(self, rs, layer, z, x, y) -> bytes:
+ if self.pool is not None:
+ try:
+ return self.pool.render(self.name, layer, z, x, y, rs.fingerprint)
+ except RuntimeError:
+ pass # a worker failed or lags behind: render here
+ return self.render(layer, z, x, y)
+
+ def tile(self, layer, z, x, y, remember: bool = True, gate=None) -> bytes:
+ """gate: a semaphore of render slots (saved tiles never wait for one); none free → Busy."""
+ if (layer not in LAYERS and layer not in EXT) or not tile_ok(z, x, y):
+ raise KeyError(f"no tile {layer}/{z}/{x}/{y}")
+ if self.shared(z, x, y):
+ return self.share.tile(layer, z, x, y, remember, gate)
+ data = b""
+ for _ in range(3): # a region set swapped mid-render: render again
+ with self.lock:
+ rs, tag = self.regions, self.tag
+ key = (tag, layer, z, x, y)
+ if key in self.mem:
+ self.mem.move_to_end(key)
+ return self.mem[key]
+ f = self._path(rs, tag, layer, z, x, y)
+ if f.exists() and (layer != "mesh" or f.stat().st_size == MESH_BYTES): # an old mesh format: again
+ data = f.read_bytes()
+ if self.cap is not None:
+ self.cap.used(f)
+ else:
+ if gate is not None and not gate.acquire(blocking=False):
+ raise Busy(f"{layer} {z}/{x}/{y}")
+ try:
+ data = self._render_for(rs, layer, z, x, y)
+ finally:
+ if gate is not None:
+ gate.release()
+ with self.lock:
+ swapped = self.tag != tag
+ if swapped:
+ continue # may mix both sets: never save it
+ f.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=f.parent, suffix=".part")
+ with os.fdopen(fd, "wb") as fh:
+ fh.write(data)
+ os.replace(tmp, f)
+ if self.cap is not None:
+ self.cap.saved(f, len(data))
+ if remember:
+ with self.lock:
+ if self.tag == tag:
+ self.mem[key] = data
+ while len(self.mem) > MEM_TILES:
+ self.mem.popitem(last=False)
+ return data
+ return data
+
+
+def reload_all(sources: list) -> None:
+ """A region build finished: every source reloads, then the shared pool forks once."""
+ for s in sources:
+ s.reload_regions(refork=False)
+ if sources:
+ sources[0].refork()
+
+
+def join(sources: list) -> None:
+ """Sources that share one render pool (the eras of a server): each knows the others by name."""
+ peers = {s.name: s for s in sources}
+ for s in sources:
+ s.peers = peers